Compare commits

..

36 Commits

Author SHA1 Message Date
sagit 7a40ddb1ef fix(diagnosis): tighten timeout handling and clarify timeout messaging (#233)
## Summary
- shorten per-item diagnosis command timeout from 2 minutes to 30
seconds while keeping overall request timeout at 2 minutes
- centralize timeout messages in backend constants and apply consistent
timeout fallback handling in diagnosis result assembly
- update frontend forward/tunnel diagnosis timeout copy to clearly
explain single-item and overall timeout limits

## Notes
- includes workspace tool config files under `.claude/` and `.entire/`
as part of this commit
2026-03-01 18:39:19 +08:00
sagitchu d33814e18c fix(diagnosis): tighten timeout handling and clarify timeout messaging 2026-03-01 18:37:53 +08:00
sagit cf51b305b0 fix(diagnosis): prevent progress stream blocking and refine tunnel type chips (#230)
* fix(diagnosis): avoid result channel deadlock in progress stream

Close the diagnosis result channel asynchronously after workers complete so progress can stream without blocking, and improve tunnel card type chip contrast for clearer protocol distinction.

* feat(diagnosis): stream pending items and render in-progress states

Pre-populate diagnosis stream with pending targets so tunnel and forward dialogs can show per-item diagnosing status immediately. Update result typing and UI states to distinguish in-progress, success, and failure rows/cards consistently.
2026-03-01 14:49:38 +08:00
sagit 9ffeb83753 fix(forward): align table columns and fix card layout (#228) 2026-03-01 11:14:12 +08:00
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
sagit 6684a3426b fix(frontend): use placeholder for IP preference select (#217)
## Summary
- Replace empty key SelectItem with proper placeholder for IP preference
dropdown
- Improves UX consistency with other select components
2026-02-27 08:39:26 +08:00
sagitchu 7a8595ec87 fix(frontend): use placeholder for IP preference select instead of empty key item 2026-02-27 08:33:10 +08:00
sagitchu 06f76d918f fix(frontend): unify speed rule placeholders in selects 2026-02-27 08:33:10 +08:00
sagit feb357ff17 fix: tunnel chain order and speed limit UI improvements (#216)
* fix(backend): use correct chain order index for tunnel nodes

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(frontend): filter '不限速' from speed limit dropdowns and preserve node order

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-26 21:30:25 +08:00
sagit 34581e0d18 feat: decouple speed limits from tunnels and add forward-level rate limiting (#214)
## Summary

This PR refactors the speed limiting functionality to decouple it from
tunnel-specific binding and adds support for forward-level rate
limiting.

### Key Changes

**Backend (Go)**:
- `SpeedLimit.TunnelID` and `SpeedLimit.TunnelName` are now nullable,
allowing speed limits to be created without binding to a specific tunnel
- `Forward` model now has `SpeedID sql.NullInt64` field for
forward-level rate limiting
- `ForwardRecord` updated to include `SpeedID` for control plane use
- Repository methods updated to handle optional tunnel binding in CRUD
operations
- Control plane now prioritizes `Forward.SpeedID` over
`UserTunnel.SpeedID`

**Frontend (React/TypeScript)**:
- Updated `limit.tsx` to support creating speed limits without tunnel
binding
- Tunnel selection is now optional in the speed limit form
- Updated TypeScript types for optional `tunnelId` and new `speedId`
fields

### Behavior

**Speed Limit Application Priority**:
1. `Forward.SpeedID` - Forward-level rate limiting (highest priority)
2. `UserTunnel.SpeedID` - User tunnel permission-level rate limiting
(fallback)

### Migration Notes

Database schema changes will be handled automatically by GORM
AutoMigrate:
- `speed_limit.tunnel_id` and `speed_limit.tunnel_name` become nullable
- `forward.speed_id` column added (nullable)

### Docker Images

Built and pushed:
- `ghcr.io/sagit-chu/vite-frontend:beta`
- `ghcr.io/sagit-chu/vite-frontend:latest`
- `ghcr.io/sagit-chu/flux-panel-backend:beta`
- `ghcr.io/sagit-chu/flux-panel-backend:latest`

closes #201 #155
2026-02-26 20:20:11 +08:00
sagitchu 61c5b5e759 feat: add speed limit contract tests and refine limit/user UI 2026-02-26 20:18:29 +08:00
sagitchu c8eb780c67 feat: decouple speed limits from tunnels and add forward-level rate limiting
- Make SpeedLimit.TunnelID and TunnelName nullable (optional binding)
- Add SpeedID field to Forward model for forward-level rate limiting
- Update ForwardRecord to include SpeedID for control plane
- Update repository methods to handle optional tunnel binding
- Update handlers to accept optional tunnelId in create/update
- Modify control plane to prioritize Forward.SpeedID over UserTunnel speed limit
- Update frontend limit.tsx to support creating speed limits without tunnel binding
- Update TypeScript types for optional tunnelId and new speedId fields

This allows speed limits to be created as reusable rules that can be applied
to either tunnels (via UserTunnel.SpeedID) or individual forwards (via Forward.SpeedID).
2026-02-26 13:08:35 +08:00
sagit 4bdfa50b0c docs: update AGENTS.md files with current project state (#213)
- Update root AGENTS.md to commit 21008cc / tag 2.1.5-rc15
- Add CI workflows info (ci-build.yml, docker-build.yml, deploy-docs.yml)
- Add Repository Layer and Contract Tests to WHERE TO LOOK
- Add websocket_reporter to CODE MAP
- Add Go version conventions (1.24/1.23/1.22)
- Add PostgreSQL migration support note
- Update go-backend AGENTS.md with PostgreSQL support and contract tests
- Update go-gost AGENTS.md with CI build conventions
- Update vite-frontend AGENTS.md with component counts
- Update go-gost/x AGENTS.md with file counts and registry reference
- Update handler AGENTS.md with LOC estimates
2026-02-26 09:52:11 +08:00
91 changed files with 11493 additions and 1067 deletions
+84
View File
@@ -0,0 +1,84 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code post-task"
}
]
},
{
"matcher": "TodoWrite",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code post-todo"
}
]
}
],
"PreToolUse": [
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code pre-task"
}
]
}
],
"SessionEnd": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code session-end"
}
]
}
],
"SessionStart": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code session-start"
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code stop"
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code user-prompt-submit"
}
]
}
]
},
"permissions": {
"deny": [
"Read(./.entire/metadata/**)"
]
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"enabled": true,
"telemetry": false
}
+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
+29 -11
View File
@@ -1,12 +1,12 @@
# PROJECT KNOWLEDGE BASE
**Generated:** Thu Feb 19 2026
**Commit:** 137c34e
**Generated:** Thu Feb 26 2026
**Commit:** 21008cc
**Branch:** main
**Tag:** 2.1.4-rc2
**Tag:** 2.1.5-rc15
## OVERVIEW
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite/PostgreSQL) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
## STRUCTURE
```
@@ -14,12 +14,14 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
├── go-gost/ # Go forwarding agent (forked gost + local x/)
│ └── x/ # Local fork of github.com/go-gost/x (replace => ./x)
├── go-backend/ # Go Admin API (GORM + SQLite/PostgreSQL, net/http)
│ └── tests/contract/ # Integration/contract tests
├── vite-frontend/ # React/Vite dashboard (shadcn bridge + Tailwind v4)
│ └── src/shadcn-bridge/heroui/ # HeroUI-compatible facade
├── docker-compose-v4.yml # Panel deploy (IPv4-only bridge)
├── docker-compose-v6.yml # Panel deploy (IPv6-enabled bridge)
├── panel_install.sh # Panel installer/upgrader (downloads compose)
├── install.sh # Node installer/upgrader (downloads gost binary)
└── .github/workflows/ # CI: build/push images + release artifacts
└── .github/workflows/ # CI: build/test + Docker push + release artifacts
```
## WHERE TO LOOK
@@ -29,12 +31,15 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
| **Deploy (IPv6)** | `docker-compose-v6.yml` | Same as v4 + IPv6-enabled bridge |
| **Panel install** | `panel_install.sh` | Picks v4/v6, generates `JWT_SECRET`, downloads compose |
| **Node install** | `install.sh` | Installs `/etc/flux_agent/flux_agent` + writes `config.json`/`gost.json` + systemd `flux_agent.service` |
| **Admin API** | `go-backend/` | Go Admin API (SQLite) |
| **Admin API** | `go-backend/` | Go Admin API (SQLite/PostgreSQL) |
| **Web UI** | `vite-frontend/` | React/Vite dashboard (shadcn bridge + Tailwind v4) |
| **UI Compatibility** | `vite-frontend/src/shadcn-bridge/heroui/` | HeroUI-compatible API wrappers backed by shadcn/radix |
| **Theme Tokens** | `vite-frontend/src/styles/tailwind-theme.pcss` | Tailwind v4 `@theme inline` semantic color mapping |
| **Go Agent** | `go-gost/` | Forwarding agent (forked gost + local x/) |
| **Go Core** | `go-gost/x/` | Handlers/listeners/dialers + management API |
| **Repository Layer** | `go-backend/internal/store/repo/` | GORM data access (repository.go 83k LOC) |
| **Contract Tests** | `go-backend/tests/contract/` | Integration tests for auth, federation, tunnels |
| **CI Workflows** | `.github/workflows/` | ci-build.yml, docker-build.yml, deploy-docs.yml |
## CODE MAP
| Symbol | Type | Location | Role |
@@ -43,7 +48,9 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
| `main` | Func | `go-backend/cmd/paneld/main.go` | Backend Entry |
| `App` | Component | `vite-frontend/src/App.tsx` | Frontend Entry |
| `main` | Func | `go-gost/main.go` | Agent Entry |
| `Repository` | Struct | `go-backend/internal/store/repo/repository.go` | Data Access Layer |
| `Handler` | Struct | `go-backend/internal/http/handler/handler.go` | HTTP Handlers |
| `websocket_reporter` | Func | `go-gost/x/socket/websocket_reporter.go` | Panel Telemetry |
## CONVENTIONS
- **Auth**: `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `go-backend/`.
@@ -52,6 +59,7 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
- **API Envelope**: All REST responses follow `{code, msg, data, ts}` structure (code 0 = success).
- **Frontend UI Layer**: Import UI primitives from `src/shadcn-bridge/heroui/*` (legacy-compatible facade), not direct `@heroui/*` packages.
- **Tailwind v4 Semantic Colors**: `src/styles/globals.css` must import `src/styles/tailwind-theme.pcss`; removing it breaks semantic classes like `bg-primary`, `text-foreground`, and `border-input`.
- **Go Versions**: `go-backend` uses Go 1.24, `go-gost` uses Go 1.23, `go-gost/x` uses Go 1.22.
## ANTI-PATTERNS (THIS PROJECT)
- **DO NOT EDIT** generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
@@ -60,6 +68,8 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
- **DO NOT** let backend handlers call `repo.DB()` directly — add a Repository method instead.
- **DO NOT ADD** frontend tests - project has no test infrastructure (Vitest/Jest not configured).
- **DO NOT REINTRODUCE** `@heroui/*` or `@nextui-org/*` dependencies; migration is now shadcn bridge-based.
- **DO NOT** use `type:jsonb` or `type:serial` in GORM tags (SQLite incompatible).
- **DO NOT** omit `TableName()` on new models — GORM pluralizes by default.
## COMMANDS
```bash
@@ -75,15 +85,21 @@ docker compose -f docker-compose-v6.yml up -d
(cd go-backend && make build)
(cd vite-frontend && npm run dev)
(cd go-gost && go run .)
# Testing
(cd go-backend && go test ./...)
(cd go-backend && go test ./tests/contract/...)
```
## UNIQUE STYLES
- **Flat Monorepo**: Language-prefixed dirs (`go-backend`, `go-gost`, `vite-frontend`) instead of `apps/`/`libs/`.
- **Asymmetric Go Layout**: `go-backend` follows `cmd/<app>/main.go` while `go-gost` uses `root/main.go`.
- **Frontend Hybrid Mode**: `App.tsx` detects "H5 mode" (mobile WebView) vs desktop, dictating layout strategy.
- **Experimental Bundler**: `vite-frontend` uses `rolldown-vite` (Rust-based) instead of standard Vite.
- **Non-minified Builds**: `vite.config.ts` sets `minify: false`, `treeshake: false` for debugging.
## NOTES
- LSP servers are not installed in this environment (gopls/jdtls/typescript-language-server); rely on grep-based navigation.
- LSP servers are not installed in this environment (gopls/typescript-language-server); rely on grep-based navigation.
- `vite-frontend/vite.config.ts` sets `minify: false` and disables treeshake; expect larger bundles.
- `vite-frontend` uses `rolldown-vite` (experimental Rust bundler) instead of standard Vite.
- Install scripts (`install.sh`, `panel_install.sh`) self-delete after execution - common pattern in one-liner installs.
@@ -91,7 +107,9 @@ docker compose -f docker-compose-v6.yml up -d
- CI dynamically injects `PINNED_VERSION` into install scripts and docker-compose files during releases.
- `panel_install.sh` auto-detects IPv6 and modifies `/etc/docker/daemon.json` to enable IPv6 bridge.
- Download proxy `https://gcode.hostcentral.cc/` used for GitHub downloads in China/restricted environments.
- Backend has contract tests in `go-backend/tests/contract/` - frontend has no test infrastructure (Vitest/Jest not configured).
- Backend has contract tests in `go-backend/tests/contract/` - frontend has no test infrastructure.
- `analysis/3x-ui/` contains a separate git repo for reference/comparison - not part of FLVX core.
- PR `#144` (shadcn migration) and PR `#142` (user-group binding) are merged into `main`; release tag `2.1.4-rc2` points to commit `137c34e`.
- Button visual parity relies on `vite-frontend/src/shadcn-bridge/heroui/button.tsx` color mapping + `vite-frontend/src/styles/tailwind-theme.pcss` token export.
- CI workflows: `ci-build.yml` (build check), `docker-build.yml` (multi-arch images + release), `deploy-docs.yml` (MkDocs).
- PostgreSQL migration supported via `panel_install.sh` menu option using pgloader.
- Repository layer is large: `repository.go` (83k LOC), `repository_mutations.go` (43k LOC).
- Button visual parity relies on `vite-frontend/src/shadcn-bridge/heroui/button.tsx` color mapping + `vite-frontend/src/styles/tailwind-theme.pcss` token export.
+148
View File
@@ -0,0 +1,148 @@
# 限速功能重构实施计划
## 一、需求概述
**原始需求**: 限速功能当前绑定到具体隧道,需要改为不绑定隧道,创建限速后可以自由在隧道上限速,也可以在转发上限速。
**核心变更**:
1. 限速规则(SpeedLimit)与隧道的绑定关系改为可选
2. 转发(Forward)支持独立的限速规则
---
## 二、实施计划清单
### 2.0 计划状态(审计更新:2026-02-26)
- 总体状态:**进行中(未验收通过)**
- 已完成:模型、仓储查询、限速 CRUD、控制面优先级、限速页与类型改造、编译与测试通过
- 未完成:**Forward 独立限速写入链路**(前端表单 -> API handler -> repository 落库 `forward.speed_id`)
### 2.1 后端模型层 (Model)
| 序号 | 任务 | 文件 | 状态 |
|------|------|------|------|
| M1 | SpeedLimit.TunnelID 改为 sql.NullInt64 (可空) | `go-backend/internal/store/model/model.go` | ✅ 完成 |
| M2 | SpeedLimit.TunnelName 改为 sql.NullString (可空) | `go-backend/internal/store/model/model.go` | ✅ 完成 |
| M3 | Forward 添加 SpeedID sql.NullInt64 字段 | `go-backend/internal/store/model/model.go` | ✅ 完成 |
| M4 | ForwardRecord 添加 SpeedID sql.NullInt64 字段 | `go-backend/internal/store/model/model.go` | ✅ 完成 |
| M5 | SpeedLimitBackup.TunnelID 改为指针类型 | `go-backend/internal/store/model/model.go` | ✅ 完成 |
| M6 | ForwardBackup 添加 SpeedID *int64 字段 | `go-backend/internal/store/model/model.go` | ✅ 完成 |
### 2.2 后端仓储层 (Repository)
| 序号 | 任务 | 文件 | 状态 |
|------|------|------|------|
| R1 | ListSpeedLimits() 返回可空 tunnelId/tunnelName | `go-backend/internal/store/repo/repository.go` | ✅ 完成 |
| R2 | ListForwards() 返回 speedId 字段 | `go-backend/internal/store/repo/repository.go` | ✅ 完成 |
| R3 | CreateSpeedLimit() 参数 tunnelID 改为 *int64 | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
| R4 | UpdateSpeedLimit() 参数 tunnelID 改为 *int64 | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
| R5 | GetSpeedLimitTunnelID() 返回 sql.NullInt64 | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
| R6 | exportSpeedLimits() 处理可空字段 | `go-backend/internal/store/repo/repository.go` | ✅ 完成 |
| R7 | importSpeedLimits() 处理可空字段 | `go-backend/internal/store/repo/repository.go` | ✅ 完成 |
| R8 | GetSpeedLimitSpeed() 新增方法 | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 |
| R9 | ListForwardsByTunnel() 返回 SpeedID | `go-backend/internal/store/repo/repository_control.go` | ✅ 完成 |
| R10 | ListActiveForwardsByUser() 返回 SpeedID | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 |
| R11 | ListActiveForwardsByUserTunnel() 返回 SpeedID | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 |
| R12 | GetForwardRecord() 返回 SpeedID | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 |
### 2.3 后端处理器层 (Handler)
| 序号 | 任务 | 文件 | 状态 |
|------|------|------|------|
| H1 | speedLimitCreate 处理可选 tunnelId | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
| H2 | speedLimitUpdate 处理可选 tunnelId | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
| H3 | speedLimitDelete 处理可空 tunnelID | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
### 2.4 后端控制平面 (Control Plane)
| 序号 | 任务 | 文件 | 状态 |
|------|------|------|------|
| C1 | syncForwardServices 优先使用 Forward.SpeedID | `go-backend/internal/http/handler/control_plane.go` | ✅ 完成 |
| C2 | 回退到 UserTunnel 的 speed limit | `go-backend/internal/http/handler/control_plane.go` | ✅ 完成 |
### 2.5 前端类型定义 (TypeScript Types)
| 序号 | 任务 | 文件 | 状态 |
|------|------|------|------|
| T1 | SpeedLimitApiItem.tunnelId 改为可选 | `vite-frontend/src/api/types.ts` | ✅ 完成 |
| T2 | ForwardApiItem 添加 speedId 字段 | `vite-frontend/src/api/types.ts` | ✅ 完成 |
| T3 | ForwardMutationPayload 添加 speedId 字段 | `vite-frontend/src/api/types.ts` | ✅ 完成 |
| T4 | SpeedLimitMutationPayload.tunnelId 改为可选 | `vite-frontend/src/api/types.ts` | ✅ 完成 |
### 2.6 前端页面组件
| 序号 | 任务 | 文件 | 状态 |
|------|------|------|------|
| F1 | SpeedLimitRule 接口更新 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
| F2 | SpeedLimitForm 接口更新 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
| F3 | validateForm 移除 tunnelId 必填校验 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
| F4 | Select 组件改为可选 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
| F5 | 显示"未绑定"状态 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
### 2.7 编译验证
| 序号 | 任务 | 状态 |
|------|------|------|
| B1 | Go 后端编译通过 | ✅ 完成 |
| B2 | TypeScript 类型检查通过 | ✅ 完成 |
| B3 | `go test ./...` 全量通过 | ✅ 完成 |
| B4 | `go test ./tests/contract/... -run SpeedLimit` 通过 | ✅ 完成 |
### 2.8 Forward 独立限速写入链路补全(新增)
| 序号 | 任务 | 文件 | 状态 |
|------|------|------|------|
| N1 | forwardCreate 支持接收并校验可选 speedId,写入 Forward.SpeedID | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
| N2 | forwardUpdate 支持更新/清空 speedId,并触发服务重下发 | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
| N3 | CreateForwardTx 支持落库 speed_id | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
| N4 | UpdateForward 支持更新 speed_id | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
| N5 | Forward 页面新增限速选择并透传 speedId | `vite-frontend/src/pages/forward.tsx` | ✅ 完成 |
| N6 | Forward 相关契约测试补充 speedId 写入/清空断言 | `go-backend/tests/contract/forward_contract_test.go` | ✅ 完成 |
---
## 三、优先级说明
限速规则应用优先级:
1. **Forward.SpeedID** - 转发级别的限速 (最高优先)
2. **UserTunnel.SpeedID** - 用户隧道权限级别的限速 (回退)
---
## 四、数据库兼容性
- SpeedLimit 表: `tunnel_id` 和 `tunnel_name` 字段改为可空 (GORM AutoMigrate 自动处理)
- Forward 表: 新增 `speed_id` 可空字段 (GORM AutoMigrate 自动处理)
---
## 五、验证检查项
### 5.1 功能验证(审计后)
- [x] 创建不限速规则的限速 (不绑定隧道)
- [x] 创建绑定隧道的限速 (兼容旧逻辑)
- [x] 编辑限速规则,切换隧道绑定状态
- [ ] 删除限速规则
- [ ] 转发列表正确显示 speedId
### 5.2 API 验证(审计后)
- [x] GET /api/speed-limit/list 返回可选 tunnelId
- [x] POST /api/speed-limit/create 接受可选 tunnelId
- [x] POST /api/speed-limit/update 接受可选 tunnelId
- [ ] GET /api/forward/list 返回 speedId
### 5.3 兼容性验证(审计后)
- [x] 现有绑定隧道的限速规则继续正常工作
- [ ] 现有 UserTunnel 的限速继续正常工作
- [ ] 备份/恢复功能正常
### 5.4 Forward 独立限速闭环验证(新增)
- [x] POST /api/forward/create 接受 speedId 并写入 `forward.speed_id`
- [x] POST /api/forward/update 可更新/清空 speedId
- [x] Forward 表单可选择限速并提交 speedId
- [ ] `syncForwardServices` 实际使用 Forward.SpeedID 而非仅回退 UserTunnel.SpeedID
+220
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)
## 免责声明
+12 -8
View File
@@ -2,7 +2,7 @@
## OVERVIEW
Go-based Admin API for FLVX. Replaced legacy Spring Boot backend.
**Stack:** Go 1.23, net/http (std lib), GORM + SQLite/PostgreSQL (glebarez/sqlite - CGO-free).
**Stack:** Go 1.24, net/http (std lib), GORM + SQLite/PostgreSQL (glebarez/sqlite - CGO-free).
## STRUCTURE
```
@@ -17,14 +17,15 @@ go-backend/
│ ├── store/
│ │ ├── model/model.go # GORM model structs (single source of truth)
│ │ └── repo/ # Data Access Layer (Repository pattern, GORM)
│ │ ├── repository.go # Core queries, Open/OpenPostgres, AutoMigrate
│ │ ├── repository_mutations.go # Mutation helpers (user/node/tunnel/forward CRUD)
│ │ ├── repository_federation.go# Federation-specific queries
│ │ ├── repository.go # Core queries, Open/OpenPostgres, AutoMigrate (83k LOC)
│ │ ├── repository_mutations.go # Mutation helpers (user/node/tunnel/forward CRUD, 43k LOC)
│ │ ├── repository_federation.go # Federation-specific queries
│ │ ├── repository_flow.go # Flow/forward status queries
│ │ └── repository_control.go # Control plane queries
│ │ ├── repository_control.go # Control plane queries
│ │ └── repository_groups.go # Group management queries
│ └── auth/ # Auth logic
├── tests/ # Integration/Contract tests
├── Dockerfile # Multi-stage build (alpine)
├── tests/contract/ # Integration/contract tests (14 tests)
├── Dockerfile # Multi-stage build (golang:1.24-bookworm → debian:bookworm-slim)
└── Makefile # Build commands
```
@@ -36,6 +37,7 @@ go-backend/
| **Repository** | `go-backend/internal/store/repo/` | GORM-based queries, all DB ops encapsulated |
| **Auth Middleware** | `go-backend/internal/http/middleware/jwt.go` | Extracts `Authorization` header |
| **WebSocket** | `go-backend/internal/ws/` | Real-time updates (traffic, status) |
| **Contract Tests** | `go-backend/tests/contract/` | Integration tests for auth, federation, tunnels |
## CONVENTIONS
- **GORM ORM**: Uses GORM with `glebarez/sqlite` (CGO-free) and `gorm.io/driver/postgres`.
@@ -47,6 +49,7 @@ go-backend/
- **API Envelope**: All responses use `response.R{code, msg, data, ts}` structure.
- **Config**: Loaded from environment variables (see `cmd/paneld/main.go`).
- **SQLite Constraints**: `MaxOpenConns(1)`, WAL mode, busy_timeout=5000.
- **PostgreSQL**: Supported via `DB_TYPE=postgres` and `DATABASE_URL` env vars.
## ANTI-PATTERNS
- **DO NOT** let handlers call `repo.DB()` directly — add a Repository method instead.
@@ -58,6 +61,7 @@ go-backend/
```bash
cd go-backend
go run ./cmd/paneld # Default: SERVER_ADDR=:6365
go test ./...
go test ./... # Unit tests
go test ./tests/contract/... # Contract tests
make build
```
+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,
}
+5 -4
View File
@@ -1,10 +1,10 @@
# BACKEND HTTP HANDLER KNOWLEDGE BASE
**Generated:** Sun Feb 15 2026
**Generated:** Thu Feb 26 2026
## OVERVIEW
HTTP request handlers for FLVX Admin API. Core business logic layer.
**Stack:** Go 1.23, net/http, GORM via Repository pattern.
**Stack:** Go 1.24, net/http, GORM via Repository pattern.
## STRUCTURE
```
@@ -14,7 +14,7 @@ handler/
├── federation.go # Federation/cluster sync API
├── flow_policy.go # Traffic policy API
├── jobs.go # Background job management (sync, cleanup)
├── mutations.go # CRUD for users, tunnels, forwards (largest: 100k+ LOC)
├── mutations.go # CRUD for users, tunnels, forwards (~3700 LOC)
└── upgrade.go # System upgrade API
```
@@ -26,10 +26,11 @@ handler/
| **Federation Sync** | `federation.go` | Panel-to-panel sync |
| **Traffic Policies** | `flow_policy.go` | Flow limiting, quota management |
| **Background Jobs** | `jobs.go` | Scheduled sync/cleanup tasks |
| **Node Control** | `control_plane.go` | Node add/delete/list operations |
## CONVENTIONS
- Inherits from parent: GORM via Repository pattern, JWT in Authorization header.
- Large files expected (`mutations.go` 3716 LOC - central mutation hub).
- Large files expected (`mutations.go` ~3700 LOC - central mutation hub).
- Uses `repo.Repository` for DB access via `h.repo.XXX()` methods.
- Handlers never call `repo.DB()` directly — all queries go through Repository methods.
- Domain-driven file split: one file per functional area (federation, jobs, etc.).
+550 -133
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,91 @@ type diagnosisTarget struct {
Port int
}
type diagnosisWorkItem struct {
fromNodeID int64
targetIP string
targetPort int
description string
metadata map[string]interface{}
toNode chainNodeRecord
hasChainHop bool
ipPreference string
}
type diagnosisExecOptions struct {
commandTimeout time.Duration
pingTimeoutMS int
timeoutMessage string
}
type diagnosisProgress struct {
Total int `json:"total"`
Completed int `json:"completed"`
Success int `json:"success"`
Failed int `json:"failed"`
}
type diagnosisItemEmitter func(index int, item map[string]interface{}, progress diagnosisProgress)
func (h *Handler) buildDiagnosisStreamStartItems(workItems []diagnosisWorkItem) []map[string]interface{} {
if len(workItems) == 0 {
return []map[string]interface{}{}
}
nodeCache := map[int64]*nodeRecord{}
items := make([]map[string]interface{}, 0, len(workItems))
for _, workItem := range workItems {
targetIP := strings.TrimSpace(workItem.targetIP)
targetPort := workItem.targetPort
if workItem.hasChainHop {
fromNode, _ := h.cachedNode(nodeCache, workItem.fromNodeID)
targetNode, err := h.cachedNode(nodeCache, workItem.toNode.NodeID)
if err == nil {
resolvedIP, resolvedPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, workItem.toNode.Port, workItem.ipPreference)
if resolveErr == nil {
targetIP = resolvedIP
targetPort = resolvedPort
}
}
}
if targetPort <= 0 {
targetPort = 443
}
nodeName := fmt.Sprintf("node_%d", workItem.fromNodeID)
if node, err := h.cachedNode(nodeCache, workItem.fromNodeID); err == nil && strings.TrimSpace(node.Name) != "" {
nodeName = node.Name
}
item := map[string]interface{}{
"success": false,
"diagnosing": true,
"description": workItem.description,
"nodeName": nodeName,
"nodeId": strconv.FormatInt(workItem.fromNodeID, 10),
"targetIp": targetIP,
"targetPort": targetPort,
"message": "诊断中...",
}
for key, value := range workItem.metadata {
item[key] = value
}
items = append(items, item)
}
return items
}
const diagnosisMaxConcurrency = 8
const (
defaultNodeCommandTimeout = 6 * time.Second
diagnosisCommandTimeout = 30 * time.Second
diagnosisRequestTimeout = 2 * time.Minute
diagnosisCommandTimeoutMsg = "诊断超时(30秒)"
diagnosisRequestTimeoutMsg = "诊断超时(2分钟)"
)
func (h *Handler) resolveForwardAccess(r *http.Request, forwardID int64) (*forwardRecord, int64, int, error) {
userID, roleID, err := userRoleFromRequest(r)
if err != nil {
@@ -152,11 +239,32 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
return errors.New("转发入口端口不存在")
}
userTunnelID, limiterID, speed, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
if err != nil {
return err
// Determine limiter from forward's SpeedID first, fallback to UserTunnel's limiter
var limiterID *int64
var speed *int
if forward.SpeedID.Valid && forward.SpeedID.Int64 > 0 {
// Forward has its own speed limit
speedVal, err := h.repo.GetSpeedLimitSpeed(forward.SpeedID.Int64)
if err == nil && speedVal > 0 {
limiterID = &forward.SpeedID.Int64
speed = &speedVal
}
}
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, userTunnelID)
if limiterID == nil {
// Fall back to UserTunnel speed limit
var utLimiterID *int64
var utSpeed *int
_, utLimiterID, utSpeed, err = h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
if err != nil {
return err
}
limiterID = utLimiterID
speed = utSpeed
}
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, 0)
tunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)
if err != nil {
return err
@@ -164,7 +272,9 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
for _, fp := range ports {
if limiterID != nil && speed != nil {
h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed)
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {
return err
}
}
node, err := h.getNodeRecord(fp.NodeID)
@@ -272,23 +382,30 @@ func (h *Handler) applyNodeProtocolChange(nodeID int64, httpVal, tlsVal, socksVa
}
func (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interface{}, tolerateExists bool, tolerateNotFound bool) (ws.CommandResult, error) {
return h.sendNodeCommandWithTimeout(nodeID, commandType, data, defaultNodeCommandTimeout, tolerateExists, tolerateNotFound)
}
func (h *Handler) sendNodeCommandWithTimeout(nodeID int64, commandType string, data interface{}, timeout time.Duration, tolerateExists bool, tolerateNotFound bool) (ws.CommandResult, error) {
var (
result ws.CommandResult
err error
)
if timeout <= 0 {
timeout = defaultNodeCommandTimeout
}
node, nodeErr := h.getNodeRecord(nodeID)
if nodeErr == nil && node != nil && node.IsRemote == 1 {
result, err = h.sendRemoteNodeCommand(node, commandType, data)
result, err = h.sendRemoteNodeCommandWithTimeout(node, commandType, data, timeout)
} else {
result, err = h.wsServer.SendCommand(nodeID, commandType, data, 12*time.Second)
result, err = h.wsServer.SendCommand(nodeID, commandType, data, timeout)
}
if err == nil {
return result, nil
}
msg := strings.ToLower(strings.TrimSpace(err.Error()))
if tolerateExists {
if strings.Contains(msg, "exists") || strings.Contains(msg, "already") || strings.Contains(msg, "已存在") {
if isAlreadyExistsMessage(msg) {
return result, nil
}
}
@@ -301,6 +418,10 @@ func (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interfa
}
func (h *Handler) sendRemoteNodeCommand(node *nodeRecord, commandType string, data interface{}) (ws.CommandResult, error) {
return h.sendRemoteNodeCommandWithTimeout(node, commandType, data, 0)
}
func (h *Handler) sendRemoteNodeCommandWithTimeout(node *nodeRecord, commandType string, data interface{}, timeout time.Duration) (ws.CommandResult, error) {
if node == nil {
return ws.CommandResult{}, errors.New("节点不存在")
}
@@ -311,6 +432,9 @@ func (h *Handler) sendRemoteNodeCommand(node *nodeRecord, commandType string, da
}
fc := client.NewFederationClient()
if timeout > 0 {
fc = client.NewFederationClientWithTimeout(timeout)
}
res, err := fc.Command(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeNodeCommandRequest{
CommandType: commandType,
Data: data,
@@ -338,41 +462,65 @@ func (h *Handler) sendRemoteNodeCommand(node *nodeRecord, commandType string, da
return result, nil
}
func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]interface{}, error) {
func (h *Handler) diagnoseForwardRuntime(ctx context.Context, forward *forwardRecord) (map[string]interface{}, error) {
if ctx == nil {
ctx = context.Background()
}
forwardName, workItems, err := h.prepareForwardDiagnosis(forward)
if err != nil {
return nil, err
}
results := h.runDiagnosisWorkItems(ctx, workItems, nil)
payload := map[string]interface{}{
"forwardName": forwardName,
"timestamp": time.Now().UnixMilli(),
"results": results,
}
return payload, nil
}
func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []diagnosisWorkItem, error) {
if forward == nil {
return nil, errForwardNotFound
return "", nil, errForwardNotFound
}
targets, err := resolveDiagnosisTargets(forward.RemoteAddr)
if err != nil {
return nil, err
return "", nil, err
}
tunnel, err := h.getTunnelRecord(forward.TunnelID)
if err != nil {
return nil, err
return "", nil, err
}
chainRows, err := h.listChainNodesForTunnel(forward.TunnelID)
if err != nil {
return nil, err
return "", nil, err
}
if len(chainRows) == 0 {
return nil, errors.New("隧道配置不完整")
return "", nil, errors.New("隧道配置不完整")
}
ipPreference := h.repo.GetTunnelIPPreference(forward.TunnelID)
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
results := make([]map[string]interface{}, 0, len(chainRows)*2+len(targets))
nodeCache := map[int64]*nodeRecord{}
workItems := make([]diagnosisWorkItem, 0, len(chainRows)*2+len(targets))
switch tunnel.Type {
case 1:
for _, inNode := range inNodes {
for _, target := range targets {
description := fmt.Sprintf("入口(%s)->目标(%s)", inNode.NodeName, target.Address)
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, target.IP, target.Port, description, map[string]interface{}{
"fromChainType": 1,
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: inNode.NodeID,
targetIP: target.IP,
targetPort: target.Port,
description: description,
metadata: map[string]interface{}{
"fromChainType": 1,
},
})
}
}
@@ -381,19 +529,33 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
if len(chainHops) > 0 {
for _, firstNode := range chainHops[0] {
description := fmt.Sprintf("入口(%s)->第1跳(%s)", inNode.NodeName, firstNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, firstNode, description, map[string]interface{}{
"fromChainType": 1,
"toChainType": 2,
"toInx": firstNode.Inx,
}, ipPreference)
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: inNode.NodeID,
toNode: firstNode,
hasChainHop: true,
ipPreference: ipPreference,
description: description,
metadata: map[string]interface{}{
"fromChainType": 1,
"toChainType": 2,
"toInx": firstNode.Inx,
},
})
}
} else {
for _, outNode := range outNodes {
description := fmt.Sprintf("入口(%s)->出口(%s)", inNode.NodeName, outNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, outNode, description, map[string]interface{}{
"fromChainType": 1,
"toChainType": 3,
}, ipPreference)
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: inNode.NodeID,
toNode: outNode,
hasChainHop: true,
ipPreference: ipPreference,
description: description,
metadata: map[string]interface{}{
"fromChainType": 1,
"toChainType": 3,
},
})
}
}
}
@@ -403,21 +565,35 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
if i+1 < len(chainHops) {
for _, nextNode := range chainHops[i+1] {
description := fmt.Sprintf("第%d跳(%s)->第%d跳(%s)", i+1, currentNode.NodeName, i+2, nextNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, currentNode.NodeID, nextNode, description, map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
"toChainType": 2,
"toInx": nextNode.Inx,
}, ipPreference)
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: currentNode.NodeID,
toNode: nextNode,
hasChainHop: true,
ipPreference: ipPreference,
description: description,
metadata: map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
"toChainType": 2,
"toInx": nextNode.Inx,
},
})
}
} else {
for _, outNode := range outNodes {
description := fmt.Sprintf("第%d跳(%s)->出口(%s)", i+1, currentNode.NodeName, outNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, currentNode.NodeID, outNode, description, map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
"toChainType": 3,
}, ipPreference)
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: currentNode.NodeID,
toNode: outNode,
hasChainHop: true,
ipPreference: ipPreference,
description: description,
metadata: map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
"toChainType": 3,
},
})
}
}
}
@@ -426,8 +602,14 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
for _, outNode := range outNodes {
for _, target := range targets {
description := fmt.Sprintf("出口(%s)->目标(%s)", outNode.NodeName, target.Address)
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, target.IP, target.Port, description, map[string]interface{}{
"fromChainType": 3,
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: outNode.NodeID,
targetIP: target.IP,
targetPort: target.Port,
description: description,
metadata: map[string]interface{}{
"fromChainType": 3,
},
})
}
}
@@ -435,54 +617,80 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
for _, inNode := range inNodes {
for _, target := range targets {
description := fmt.Sprintf("入口(%s)->目标(%s)", inNode.NodeName, target.Address)
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, target.IP, target.Port, description, map[string]interface{}{
"fromChainType": 1,
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: inNode.NodeID,
targetIP: target.IP,
targetPort: target.Port,
description: description,
metadata: map[string]interface{}{
"fromChainType": 1,
},
})
}
}
}
return forward.Name, workItems, nil
}
func (h *Handler) diagnoseTunnelRuntime(ctx context.Context, tunnelID int64) (map[string]interface{}, error) {
if ctx == nil {
ctx = context.Background()
}
tunnelName, tunnelType, workItems, err := h.prepareTunnelDiagnosis(tunnelID)
if err != nil {
return nil, err
}
results := h.runDiagnosisWorkItems(ctx, workItems, nil)
payload := map[string]interface{}{
"forwardName": forward.Name,
"timestamp": time.Now().UnixMilli(),
"results": results,
"tunnelName": tunnelName,
"tunnelType": tunnelType,
"timestamp": time.Now().UnixMilli(),
"results": results,
}
return payload, nil
}
func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{}, error) {
func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diagnosisWorkItem, error) {
tunnel, err := h.getTunnelRecord(tunnelID)
if err != nil {
return nil, err
return "", "", nil, err
}
tunnelName, err := h.repo.GetTunnelName(tunnelID)
if err != nil {
return nil, err
return "", "", nil, err
}
if tunnelName == "" {
return nil, errors.New("隧道不存在")
return "", "", nil, errors.New("隧道不存在")
}
chainRows, err := h.listChainNodesForTunnel(tunnelID)
if err != nil {
return nil, err
return "", "", nil, err
}
if len(chainRows) == 0 {
return nil, errors.New("隧道配置不完整")
return "", "", nil, errors.New("隧道配置不完整")
}
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
results := make([]map[string]interface{}, 0, len(chainRows)*2)
nodeCache := map[int64]*nodeRecord{}
workItems := make([]diagnosisWorkItem, 0, len(chainRows)*2)
switch tunnel.Type {
case 1:
for _, inNode := range inNodes {
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
"fromChainType": 1,
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: inNode.NodeID,
targetIP: "www.bing.com",
targetPort: 443,
description: description,
metadata: map[string]interface{}{
"fromChainType": 1,
},
})
}
case 2:
@@ -490,19 +698,33 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
if len(chainHops) > 0 {
for _, firstNode := range chainHops[0] {
description := fmt.Sprintf("入口(%s)->第1跳(%s)", inNode.NodeName, firstNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, firstNode, description, map[string]interface{}{
"fromChainType": 1,
"toChainType": 2,
"toInx": firstNode.Inx,
}, ipPreference)
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: inNode.NodeID,
toNode: firstNode,
hasChainHop: true,
ipPreference: ipPreference,
description: description,
metadata: map[string]interface{}{
"fromChainType": 1,
"toChainType": 2,
"toInx": firstNode.Inx,
},
})
}
} else {
for _, outNode := range outNodes {
description := fmt.Sprintf("入口(%s)->出口(%s)", inNode.NodeName, outNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, outNode, description, map[string]interface{}{
"fromChainType": 1,
"toChainType": 3,
}, ipPreference)
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: inNode.NodeID,
toNode: outNode,
hasChainHop: true,
ipPreference: ipPreference,
description: description,
metadata: map[string]interface{}{
"fromChainType": 1,
"toChainType": 3,
},
})
}
}
}
@@ -512,21 +734,35 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
if i+1 < len(chainHops) {
for _, nextNode := range chainHops[i+1] {
description := fmt.Sprintf("第%d跳(%s)->第%d跳(%s)", i+1, currentNode.NodeName, i+2, nextNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, currentNode.NodeID, nextNode, description, map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
"toChainType": 2,
"toInx": nextNode.Inx,
}, ipPreference)
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: currentNode.NodeID,
toNode: nextNode,
hasChainHop: true,
ipPreference: ipPreference,
description: description,
metadata: map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
"toChainType": 2,
"toInx": nextNode.Inx,
},
})
}
} else {
for _, outNode := range outNodes {
description := fmt.Sprintf("第%d跳(%s)->出口(%s)", i+1, currentNode.NodeName, outNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, currentNode.NodeID, outNode, description, map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
"toChainType": 3,
}, ipPreference)
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: currentNode.NodeID,
toNode: outNode,
hasChainHop: true,
ipPreference: ipPreference,
description: description,
metadata: map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
"toChainType": 3,
},
})
}
}
}
@@ -534,26 +770,33 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
for _, outNode := range outNodes {
description := fmt.Sprintf("出口(%s)->外网", outNode.NodeName)
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
"fromChainType": 3,
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: outNode.NodeID,
targetIP: "www.bing.com",
targetPort: 443,
description: description,
metadata: map[string]interface{}{
"fromChainType": 3,
},
})
}
default:
for _, inNode := range inNodes {
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
"fromChainType": 1,
workItems = append(workItems, diagnosisWorkItem{
fromNodeID: inNode.NodeID,
targetIP: "www.bing.com",
targetPort: 443,
description: description,
metadata: map[string]interface{}{
"fromChainType": 1,
},
})
}
}
payload := map[string]interface{}{
"tunnelName": tunnelName,
"tunnelType": map[bool]string{true: "端口转发", false: "隧道转发"}[tunnel.Type == 1],
"timestamp": time.Now().UnixMilli(),
"results": results,
}
return payload, nil
tunnelType := map[bool]string{true: "端口转发", false: "隧道转发"}[tunnel.Type == 1]
return tunnelName, tunnelType, workItems, nil
}
func splitChainNodeGroups(rows []chainNodeRecord) ([]chainNodeRecord, [][]chainNodeRecord, []chainNodeRecord) {
@@ -605,6 +848,159 @@ func resolveDiagnosisTargets(remoteAddr string) ([]diagnosisTarget, error) {
return targets, nil
}
func diagnosisContextMessage(ctx context.Context) string {
if ctx == nil {
return diagnosisRequestTimeoutMsg
}
switch ctx.Err() {
case context.DeadlineExceeded:
return diagnosisRequestTimeoutMsg
case context.Canceled:
return "诊断已取消"
default:
return diagnosisRequestTimeoutMsg
}
}
func diagnosisExecOptionsFromContext(ctx context.Context) diagnosisExecOptions {
timeout := diagnosisCommandTimeout
if ctx != nil {
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
if remaining <= 0 {
remaining = 100 * time.Millisecond
}
if remaining < timeout {
timeout = remaining
}
}
}
if timeout <= 0 {
timeout = 100 * time.Millisecond
}
pingTimeoutMS := int(timeout / time.Millisecond)
if pingTimeoutMS <= 0 {
pingTimeoutMS = 100
}
return diagnosisExecOptions{
commandTimeout: timeout,
pingTimeoutMS: pingTimeoutMS,
timeoutMessage: diagnosisContextMessage(ctx),
}
}
func newDiagnosisTimeoutItem(workItem diagnosisWorkItem, message string) map[string]interface{} {
targetPort := workItem.targetPort
if targetPort <= 0 {
targetPort = workItem.toNode.Port
}
item := newDiagnosisResultItem(workItem.fromNodeID, workItem.targetIP, targetPort, workItem.description, workItem.metadata)
item["success"] = false
if strings.TrimSpace(message) == "" {
message = diagnosisCommandTimeoutMsg
}
item["message"] = message
return item
}
func (h *Handler) executeDiagnosisWorkItem(workItem diagnosisWorkItem, options diagnosisExecOptions) map[string]interface{} {
single := make([]map[string]interface{}, 0, 1)
nodeCache := map[int64]*nodeRecord{}
if workItem.hasChainHop {
h.appendChainHopDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.toNode, workItem.description, workItem.metadata, workItem.ipPreference, options)
} else {
h.appendPathDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.targetIP, workItem.targetPort, workItem.description, workItem.metadata, options)
}
if len(single) == 0 {
return newDiagnosisTimeoutItem(workItem, "诊断任务未返回结果")
}
return single[0]
}
func (h *Handler) runDiagnosisWorkItems(ctx context.Context, workItems []diagnosisWorkItem, emitter diagnosisItemEmitter) []map[string]interface{} {
if ctx == nil {
ctx = context.Background()
}
results := make([]map[string]interface{}, len(workItems))
if len(workItems) == 0 {
return results
}
workerLimit := diagnosisMaxConcurrency
if workerLimit < 1 {
workerLimit = 1
}
if workerLimit > len(workItems) {
workerLimit = len(workItems)
}
type diagnosisWorkResult struct {
index int
item map[string]interface{}
}
jobs := make(chan int)
resultCh := make(chan diagnosisWorkResult, len(workItems))
var wg sync.WaitGroup
for i := 0; i < workerLimit; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for index := range jobs {
select {
case <-ctx.Done():
resultCh <- diagnosisWorkResult{index: index, item: newDiagnosisTimeoutItem(workItems[index], diagnosisContextMessage(ctx))}
continue
default:
}
options := diagnosisExecOptionsFromContext(ctx)
resultCh <- diagnosisWorkResult{index: index, item: h.executeDiagnosisWorkItem(workItems[index], options)}
}
}()
}
enqueueLoop:
for i := 0; i < len(workItems); i++ {
select {
case <-ctx.Done():
message := diagnosisContextMessage(ctx)
for j := i; j < len(workItems); j++ {
resultCh <- diagnosisWorkResult{index: j, item: newDiagnosisTimeoutItem(workItems[j], message)}
}
break enqueueLoop
case jobs <- i:
}
}
close(jobs)
go func() {
wg.Wait()
close(resultCh)
}()
progress := diagnosisProgress{Total: len(workItems)}
for result := range resultCh {
results[result.index] = result.item
progress.Completed++
if asBool(result.item["success"], false) {
progress.Success++
} else {
progress.Failed++
}
if emitter != nil {
emitter(result.index, result.item, progress)
}
}
for i := range results {
if results[i] == nil {
results[i] = newDiagnosisTimeoutItem(workItems[i], diagnosisCommandTimeoutMsg)
}
}
return results
}
func (h *Handler) cachedNode(nodeCache map[int64]*nodeRecord, nodeID int64) (*nodeRecord, error) {
if node, ok := nodeCache[nodeID]; ok {
return node, nil
@@ -646,7 +1042,7 @@ func (h *Handler) appendFailedDiagnosis(results *[]map[string]interface{}, nodeC
*results = append(*results, item)
}
func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}) {
func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}, options diagnosisExecOptions) {
item := newDiagnosisResultItem(fromNodeID, targetIP, targetPort, description, metadata)
fromNode, err := h.cachedNode(nodeCache, fromNodeID)
@@ -663,9 +1059,9 @@ func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCac
pingErr error
)
if fromNode.IsRemote == 1 {
pingData, pingErr = h.tcpPingViaRemoteNode(fromNode, targetIP, targetPort)
pingData, pingErr = h.tcpPingViaRemoteNode(fromNode, targetIP, targetPort, options)
} else {
pingData, pingErr = h.tcpPingViaNode(fromNodeID, targetIP, targetPort)
pingData, pingErr = h.tcpPingViaNode(fromNodeID, targetIP, targetPort, options)
}
if pingErr != nil {
item["success"] = false
@@ -696,7 +1092,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 {
@@ -708,7 +1104,7 @@ func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nod
h.appendFailedDiagnosis(results, nodeCache, fromNodeID, strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]"), toNode.Port, description, metadata, err.Error())
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) {
@@ -761,13 +1157,19 @@ func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, er
return h.repo.ListChainNodesForTunnel(tunnelID)
}
func (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int) (map[string]interface{}, error) {
res, err := h.sendNodeCommand(nodeID, "TcpPing", map[string]interface{}{
func (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {
if options.commandTimeout <= 0 {
options.commandTimeout = diagnosisCommandTimeout
}
if options.pingTimeoutMS <= 0 {
options.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)
}
res, err := h.sendNodeCommandWithTimeout(nodeID, "TcpPing", map[string]interface{}{
"ip": ip,
"port": port,
"count": 4,
"timeout": 5000,
}, false, false)
"timeout": options.pingTimeoutMS,
}, options.commandTimeout, false, false)
if err != nil {
return nil, err
}
@@ -777,7 +1179,7 @@ func (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int) (map[string]
return res.Data, nil
}
func (h *Handler) tcpPingViaRemoteNode(node *nodeRecord, ip string, port int) (map[string]interface{}, error) {
func (h *Handler) tcpPingViaRemoteNode(node *nodeRecord, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {
if node == nil {
return nil, errors.New("节点不存在")
}
@@ -786,13 +1188,19 @@ func (h *Handler) tcpPingViaRemoteNode(node *nodeRecord, ip string, port int) (m
if remoteURL == "" || remoteToken == "" {
return nil, errors.New("远程节点缺少共享配置")
}
if options.commandTimeout <= 0 {
options.commandTimeout = diagnosisCommandTimeout
}
if options.pingTimeoutMS <= 0 {
options.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)
}
fc := client.NewFederationClient()
fc := client.NewFederationClientWithTimeout(options.commandTimeout)
return fc.Diagnose(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeDiagnoseRequest{
IP: strings.TrimSpace(ip),
Port: port,
Count: 4,
Timeout: 5000,
Timeout: options.pingTimeoutMS,
})
}
@@ -884,6 +1292,17 @@ func isNotFoundError(err error) bool {
return strings.Contains(msg, "not found") || strings.Contains(msg, "不存在")
}
func isAlreadyExistsMessage(message string) bool {
msg := strings.ToLower(strings.TrimSpace(message))
if msg == "" {
return false
}
if strings.Contains(msg, "address already in use") {
return false
}
return strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在")
}
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {
protocols := []string{"tcp", "udp"}
services := make([]map[string]interface{}, 0, 2)
@@ -994,48 +1413,46 @@ func asBool(v interface{}, def bool) bool {
}
}
func (h *Handler) sendLimiterConfig(limiterID int64, speedMbps int, tunnelID int64) error {
rate := float64(speedMbps) / 8.0
limitStr := fmt.Sprintf("$ %.1fMB %.1fMB", rate, rate)
payload := map[string]interface{}{
"name": strconv.FormatInt(limiterID, 10),
"limits": []string{limitStr},
func (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int) error {
if err := h.upsertLimiterOnNode(nodeID, limiterID, speed); err != nil {
return fmt.Errorf("限速规则下发失败: %w", err)
}
nodes, err := h.tunnelEntryNodeIDs(tunnelID)
if err != nil {
return err
}
for _, nodeID := range nodes {
_, _ = h.sendNodeCommand(nodeID, "AddLimiters", payload, false, false)
}
return nil
}
func (h *Handler) sendDeleteLimiterConfig(limiterID int64, tunnelID int64) error {
payload := map[string]interface{}{
"limiter": strconv.FormatInt(limiterID, 10),
}
nodes, err := h.tunnelEntryNodeIDs(tunnelID)
if err != nil {
return err
}
for _, nodeID := range nodes {
_, _ = h.sendNodeCommand(nodeID, "DeleteLimiters", payload, false, true)
}
return nil
}
func (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int) {
func buildLimiterAddPayload(limiterID int64, speed int) (string, map[string]interface{}) {
rate := float64(speed) / 8.0
limitStr := fmt.Sprintf("$ %.1fMB %.1fMB", rate, rate)
payload := map[string]interface{}{
"name": strconv.FormatInt(limiterID, 10),
name := strconv.FormatInt(limiterID, 10)
return name, map[string]interface{}{
"name": name,
"limits": []string{limitStr},
}
_, _ = h.sendNodeCommand(nodeID, "AddLimiters", payload, false, false)
}
func buildLimiterUpdatePayload(name string, data map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"limiter": name,
"data": data,
}
}
func (h *Handler) upsertLimiterOnNode(nodeID int64, limiterID int64, speed int) error {
name, addPayload := buildLimiterAddPayload(limiterID, speed)
if _, err := h.sendNodeCommand(nodeID, "AddLimiters", addPayload, false, false); err != nil {
if !isAlreadyExistsMessage(err.Error()) {
return err
}
payload := map[string]interface{}{
"name": name,
"limits": addPayload["limits"],
}
if _, updateErr := h.sendNodeCommand(nodeID, "UpdateLimiters", buildLimiterUpdatePayload(name, payload), false, false); updateErr != nil {
return updateErr
}
}
return nil
}
@@ -53,3 +53,15 @@ func TestShouldTryLegacySingleService(t *testing.T) {
t.Fatalf("DeleteService should not require legacy fallback")
}
}
func TestIsAlreadyExistsMessage(t *testing.T) {
if !isAlreadyExistsMessage("service demo already exists") {
t.Fatalf("expected already exists message to be tolerated")
}
if !isAlreadyExistsMessage("服务已存在") {
t.Fatalf("expected Chinese already exists message to be tolerated")
}
if isAlreadyExistsMessage("listen tcp [::]:10001: bind: address already in use") {
t.Fatalf("address already in use must not be treated as already exists")
}
}
@@ -0,0 +1,209 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"go-backend/internal/http/response"
)
type diagnosisStreamEvent struct {
Type string `json:"type"`
Data interface{} `json:"data,omitempty"`
TS int64 `json:"ts"`
}
func prepareDiagnosisStreamResponse(w http.ResponseWriter) (http.Flusher, error) {
flusher, ok := w.(http.Flusher)
if !ok {
return nil, errors.New("当前服务不支持流式响应")
}
w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
return flusher, nil
}
func writeDiagnosisStreamEvent(encoder *json.Encoder, flusher http.Flusher, eventType string, data interface{}) error {
if encoder == nil || flusher == nil {
return errors.New("流式响应写入器未初始化")
}
event := diagnosisStreamEvent{Type: eventType, Data: data, TS: time.Now().UnixMilli()}
if err := encoder.Encode(event); err != nil {
return err
}
flusher.Flush()
return nil
}
func summarizeDiagnosisProgress(results []map[string]interface{}) diagnosisProgress {
progress := diagnosisProgress{Total: len(results)}
for _, item := range results {
progress.Completed++
if asBool(item["success"], false) {
progress.Success++
} else {
progress.Failed++
}
}
return progress
}
func shouldIgnoreDiagnosisStreamError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) {
return true
}
msg := strings.ToLower(strings.TrimSpace(err.Error()))
if strings.Contains(msg, "broken pipe") || strings.Contains(msg, "connection reset by peer") {
return true
}
if strings.Contains(msg, "stream already closed") {
return true
}
return false
}
func (h *Handler) streamDiagnosisRuntime(ctx context.Context, cancel context.CancelFunc, w http.ResponseWriter, startPayload map[string]interface{}, workItems []diagnosisWorkItem) error {
flusher, err := prepareDiagnosisStreamResponse(w)
if err != nil {
return err
}
encoder := json.NewEncoder(w)
payload := map[string]interface{}{
"total": len(workItems),
"timestamp": time.Now().UnixMilli(),
"items": h.buildDiagnosisStreamStartItems(workItems),
}
for key, value := range startPayload {
payload[key] = value
}
if err := writeDiagnosisStreamEvent(encoder, flusher, "start", payload); err != nil {
return err
}
streamBroken := false
emitter := func(index int, item map[string]interface{}, progress diagnosisProgress) {
if streamBroken {
return
}
itemPayload := map[string]interface{}{
"index": index,
"result": item,
"progress": progress,
}
if err := writeDiagnosisStreamEvent(encoder, flusher, "item", itemPayload); err != nil {
streamBroken = true
if cancel != nil {
cancel()
}
}
}
results := h.runDiagnosisWorkItems(ctx, workItems, emitter)
if streamBroken {
return context.Canceled
}
progress := summarizeDiagnosisProgress(results)
donePayload := map[string]interface{}{
"progress": progress,
"timedOut": errors.Is(ctx.Err(), context.DeadlineExceeded),
}
return writeDiagnosisStreamEvent(encoder, flusher, "done", donePayload)
}
func (h *Handler) tunnelDiagnoseStream(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
id := asInt64FromBodyKey(r, w, "tunnelId")
if id <= 0 {
return
}
tunnelName, tunnelType, workItems, err := h.prepareTunnelDiagnosis(id)
if err != nil {
if strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "不完整") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
ctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)
defer cancel()
startPayload := map[string]interface{}{
"tunnelName": tunnelName,
"tunnelType": tunnelType,
}
if err := h.streamDiagnosisRuntime(ctx, cancel, w, startPayload, workItems); err != nil {
if shouldIgnoreDiagnosisStreamError(err) {
return
}
if strings.Contains(err.Error(), "不支持流式响应") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
return
}
}
func (h *Handler) forwardDiagnoseStream(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
id := asInt64FromBodyKey(r, w, "forwardId")
if id <= 0 {
return
}
forward, _, _, err := h.resolveForwardAccess(r, id)
if err != nil {
if errors.Is(err, errForwardNotFound) {
response.WriteJSON(w, response.ErrDefault("转发不存在"))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
forwardName, workItems, err := h.prepareForwardDiagnosis(forward)
if err != nil {
if strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "不能为空") || strings.Contains(err.Error(), "错误") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
ctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)
defer cancel()
startPayload := map[string]interface{}{
"forwardName": forwardName,
}
if err := h.streamDiagnosisRuntime(ctx, cancel, w, startPayload, workItems); err != nil {
if shouldIgnoreDiagnosisStreamError(err) {
return
}
if strings.Contains(err.Error(), "不支持流式响应") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
return
}
}
@@ -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 {
+167 -47
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()))
@@ -1048,21 +1051,55 @@ func (h *Handler) userTunnelUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("权限ID不能为空"))
return
}
speedID := asAnyToInt64Ptr(req["speedId"])
if err := h.validateSpeedLimitReference(speedID); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
userID, tunnelID, utErr := h.repo.GetUserTunnelUserAndTunnel(id)
if utErr != nil {
response.WriteJSON(w, response.Err(-2, utErr.Error()))
return
}
_, oldFlow, oldNum, oldExpTime, oldFlowReset, oldSpeedID, oldStatus, oldErr :=
h.repo.GetExistingUserTunnel(userID, tunnelID)
if oldErr != nil {
response.WriteJSON(w, response.Err(-2, oldErr.Error()))
return
}
if err := h.repo.UpdateUserTunnel(id,
asInt64(req["flow"], 0),
asInt(req["num"], 0),
asInt64(req["expTime"], time.Now().Add(365*24*time.Hour).UnixMilli()),
asInt64(req["flowResetTime"], 1),
nullableInt(asAnyToInt64Ptr(req["speedId"])),
nullableInt(speedID),
asInt(req["status"], 1),
); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
userID, tunnelID, utErr := h.repo.GetUserTunnelUserAndTunnel(id)
if utErr == nil {
h.syncUserTunnelForwards(userID, tunnelID)
if syncErr := h.syncUserTunnelForwards(userID, tunnelID); syncErr != nil {
rollbackErr := h.repo.UpdateUserTunnel(
id,
oldFlow,
int(oldNum),
oldExpTime,
oldFlowReset,
oldSpeedID,
oldStatus,
)
if rollbackErr != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("下发失败且回滚失败: %v; 回滚错误: %v", syncErr, rollbackErr)))
return
}
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("下发失败,已回滚: %v", syncErr)))
return
}
response.WriteJSON(w, response.OKEmpty())
@@ -1103,6 +1140,18 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("转发名称和目标地址不能为空"))
return
}
speedID := asAnyToInt64Ptr(req["speedId"])
if speedID != nil {
exists, speedErr := h.repo.SpeedLimitExists(*speedID)
if speedErr != nil {
response.WriteJSON(w, response.Err(-2, speedErr.Error()))
return
}
if !exists {
response.WriteJSON(w, response.ErrDefault("限速规则不存在"))
return
}
}
port := asInt(req["inPort"], 0)
if port <= 0 {
port = h.pickTunnelPort(tunnelID)
@@ -1127,7 +1176,7 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
if userName == "" {
userName = "user"
}
forwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, inx, entryNodes, port)
forwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, inx, entryNodes, port, nullableInt(speedID))
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -1137,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
@@ -1202,6 +1251,24 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
if strategy == "" {
strategy = forward.Strategy
}
speedID := asAnyToInt64Ptr(req["speedId"])
if speedID != nil {
exists, speedErr := h.repo.SpeedLimitExists(*speedID)
if speedErr != nil {
response.WriteJSON(w, response.Err(-2, speedErr.Error()))
return
}
if !exists {
response.WriteJSON(w, response.ErrDefault("限速规则不存在"))
return
}
}
newSpeedID := forward.SpeedID
if speedID != nil {
newSpeedID = sql.NullInt64{Int64: *speedID, Valid: true}
} else if _, ok := req["speedId"]; ok {
newSpeedID = sql.NullInt64{Valid: false}
}
port := asInt(req["inPort"], 0)
if port <= 0 {
@@ -1225,7 +1292,7 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
}
}
now := time.Now().UnixMilli()
if err := h.repo.UpdateForward(id, name, tunnelID, remoteAddr, strategy, now); err != nil {
if err := h.repo.UpdateForward(id, name, tunnelID, remoteAddr, strategy, now, newSpeedID); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
@@ -1335,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()))
@@ -1586,29 +1655,22 @@ func (h *Handler) speedLimitCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
tunnelID := asInt64(req["tunnelId"], 0)
if tunnelID <= 0 {
response.WriteJSON(w, response.ErrDefault("隧道ID不能为空"))
return
}
name := asString(req["name"])
if name == "" {
response.WriteJSON(w, response.ErrDefault("名称不能为空"))
return
}
tunnelName := h.repo.GetTunnelNameByID(tunnelID)
if tunnelName == "" {
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
return
}
now := time.Now().UnixMilli()
speed := asInt(req["speed"], 100)
id, err := h.repo.CreateSpeedLimit(name, speed, tunnelID, tunnelName, now, asInt(req["status"], 1))
now := time.Now().UnixMilli()
_, err := h.repo.CreateSpeedLimit(name, speed, now, asInt(req["status"], 1))
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
_ = h.sendLimiterConfig(id, speed, tunnelID)
response.WriteJSON(w, response.OKEmpty())
}
@@ -1618,23 +1680,26 @@ func (h *Handler) speedLimitUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
id := asInt64(req["id"], 0)
tunnelID := asInt64(req["tunnelId"], 0)
if id <= 0 || tunnelID <= 0 {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
if id <= 0 {
response.WriteJSON(w, response.ErrDefault("限速规则ID不能为空"))
return
}
tunnelName := h.repo.GetTunnelNameByID(tunnelID)
if tunnelName == "" {
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
name := asString(req["name"])
if name == "" {
response.WriteJSON(w, response.ErrDefault("名称不能为空"))
return
}
speed := asInt(req["speed"], 100)
if err := h.repo.UpdateSpeedLimit(id, asString(req["name"]), speed, tunnelID, tunnelName, asInt(req["status"], 1), time.Now().UnixMilli()); err != nil {
if err := h.repo.UpdateSpeedLimit(id, name, speed, asInt(req["status"], 1), time.Now().UnixMilli()); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
_ = h.sendLimiterConfig(id, speed, tunnelID)
response.WriteJSON(w, response.OKEmpty())
}
@@ -1643,15 +1708,12 @@ func (h *Handler) speedLimitDelete(w http.ResponseWriter, r *http.Request) {
if id <= 0 {
return
}
tunnelID := h.repo.GetSpeedLimitTunnelID(id)
if err := h.repo.DeleteSpeedLimit(id); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if tunnelID > 0 {
_ = h.sendDeleteLimiterConfig(id, tunnelID)
}
response.WriteJSON(w, response.OKEmpty())
}
@@ -2713,7 +2775,7 @@ func pickNodeAddressV6(node *nodeRecord) string {
func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[string]interface{}) error {
allocated := map[int64]int{}
inNodes := asMapSlice(req["inNodeId"])
for _, n := range inNodes {
for i, n := range inNodes {
nodeID := asInt64(n["nodeId"], 0)
if nodeID <= 0 {
continue
@@ -2725,13 +2787,13 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
nodeID,
sql.NullInt64{},
defaultString(asString(n["strategy"]), "round"),
0,
i+1,
defaultString(asString(n["protocol"]), "tls"),
); err != nil {
return err
}
}
for _, n := range asMapSlice(req["outNodeId"]) {
for i, n := range asMapSlice(req["outNodeId"]) {
nodeID := asInt64(n["nodeId"], 0)
if nodeID <= 0 {
continue
@@ -2751,7 +2813,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
nodeID,
sql.NullInt64{Int64: int64(port), Valid: true},
defaultString(asString(n["strategy"]), "round"),
0,
i+1,
defaultString(asString(n["protocol"]), "tls"),
); err != nil {
return err
@@ -2977,6 +3039,7 @@ func (h *Handler) rollbackForwardMutation(oldForward *forwardRecord, oldPorts []
h.repo.RollbackForwardFields(
oldForward.ID, oldForward.UserID, oldForward.UserName, oldForward.Name,
oldForward.TunnelID, oldForward.RemoteAddr, oldForward.Strategy, oldForward.Status,
oldForward.SpeedID,
time.Now().UnixMilli(),
)
@@ -2998,6 +3061,10 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
h.repo.GetExistingUserTunnel(userID, tunnelID)
speedID := asAnyToInt64Ptr(req["speedId"])
if err := h.validateSpeedLimitReference(speedID); err != nil {
return err
}
reqFlow := asInt64(req["flow"], -1)
reqNum := asInt(req["num"], -1)
reqExpTime := asInt64(req["expTime"], -1)
@@ -3038,7 +3105,24 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
reqStatus = 1
}
return h.repo.InsertUserTunnel(userID, tunnelID, nullableInt(speedID), reqNum, reqFlow, reqFlowReset, reqExpTime, reqStatus)
if err := h.repo.InsertUserTunnel(userID, tunnelID, nullableInt(speedID), reqNum, reqFlow, reqFlowReset, reqExpTime, reqStatus); err != nil {
return err
}
if syncErr := h.syncUserTunnelForwards(userID, tunnelID); syncErr != nil {
insertedID, _, _, _, _, _, _, lookupErr := h.repo.GetExistingUserTunnel(userID, tunnelID)
if lookupErr != nil {
return fmt.Errorf("下发失败且回滚失败: %v; 回滚查询错误: %w", syncErr, lookupErr)
}
if rollbackErr := h.repo.DeleteUserTunnel(insertedID); rollbackErr != nil {
return fmt.Errorf("下发失败且回滚失败: %v; 回滚删除错误: %w", syncErr, rollbackErr)
}
return fmt.Errorf("下发失败,已回滚: %w", syncErr)
}
return nil
}
if err != nil {
return err
@@ -3076,25 +3160,61 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
newSpeedID = sql.NullInt64{Valid: false}
}
err = h.repo.UpdateUserTunnelFields(existingID, newSpeedID, newFlow, newNum, newExpTime, newFlowReset, newStatus)
if err == nil {
h.syncUserTunnelForwards(userID, tunnelID)
if err := h.repo.UpdateUserTunnelFields(existingID, newSpeedID, newFlow, newNum, newExpTime, newFlowReset, newStatus); err != nil {
return err
}
return err
if syncErr := h.syncUserTunnelForwards(userID, tunnelID); syncErr != nil {
rollbackErr := h.repo.UpdateUserTunnelFields(
existingID,
currentSpeedID,
currentFlow,
int(currentNum),
currentExpTime,
currentFlowReset,
currentStatus,
)
if rollbackErr != nil {
return fmt.Errorf("下发失败且回滚失败: %v; 回滚错误: %w", syncErr, rollbackErr)
}
return fmt.Errorf("下发失败,已回滚: %w", syncErr)
}
return nil
}
func (h *Handler) syncUserTunnelForwards(userID, tunnelID int64) {
func (h *Handler) syncUserTunnelForwards(userID, tunnelID int64) error {
forwards, err := h.listForwardsByTunnel(tunnelID)
if err != nil {
return
return err
}
for i := range forwards {
f := &forwards[i]
if f.UserID == userID {
_ = h.syncForwardServices(f, "UpdateService", true)
if err := h.syncForwardServices(f, "UpdateService", true); err != nil {
return err
}
}
}
return nil
}
func (h *Handler) validateSpeedLimitReference(speedID *int64) error {
if speedID == nil {
return nil
}
exists, err := h.repo.SpeedLimitExists(*speedID)
if err != nil {
return err
}
if !exists {
return errors.New("限速规则不存在")
}
return nil
}
func asAnySlice(v interface{}) []interface{} {
+27 -24
View File
@@ -29,19 +29,20 @@ func (User) TableName() string { return "user" }
// Forward maps to the "forward" table.
type Forward struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
UserID int64 `gorm:"column:user_id;not null"`
UserName string `gorm:"column:user_name;type:varchar(100);not null"`
Name string `gorm:"type:varchar(100);not null"`
TunnelID int64 `gorm:"column:tunnel_id;not null"`
RemoteAddr string `gorm:"column:remote_addr;type:text;not null"`
Strategy string `gorm:"type:varchar(100);not null;default:'fifo'"`
InFlow int64 `gorm:"column:in_flow;not null;default:0"`
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime int64 `gorm:"column:updated_time;not null"`
Status int `gorm:"not null"`
Inx int `gorm:"not null;default:0"`
ID int64 `gorm:"primaryKey;autoIncrement"`
UserID int64 `gorm:"column:user_id;not null"`
UserName string `gorm:"column:user_name;type:varchar(100);not null"`
Name string `gorm:"type:varchar(100);not null"`
TunnelID int64 `gorm:"column:tunnel_id;not null"`
RemoteAddr string `gorm:"column:remote_addr;type:text;not null"`
Strategy string `gorm:"type:varchar(100);not null;default:'fifo'"`
InFlow int64 `gorm:"not null;default:0"`
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime int64 `gorm:"column:updated_time;not null"`
Status int `gorm:"not null"`
Inx int `gorm:"not null;default:0"`
SpeedID sql.NullInt64 `gorm:"column:speed_id"`
}
func (Forward) TableName() string { return "forward" }
@@ -83,14 +84,14 @@ type Node struct {
func (Node) TableName() string { return "node" }
type SpeedLimit struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"type:varchar(100);not null"`
Speed int `gorm:"not null"`
TunnelID int64 `gorm:"column:tunnel_id;not null"`
TunnelName string `gorm:"column:tunnel_name;type:varchar(100);not null"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
Status int `gorm:"not null"`
ID int64 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"type:varchar(100);not null"`
Speed int `gorm:"not null"`
TunnelID sql.NullInt64 `gorm:"column:tunnel_id"`
TunnelName sql.NullString `gorm:"column:tunnel_name;type:varchar(100)"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
Status int `gorm:"not null"`
}
func (SpeedLimit) TableName() string { return "speed_limit" }
@@ -213,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"`
}
@@ -395,6 +396,7 @@ type ForwardBackup struct {
UpdatedTime int64 `json:"updatedTime"`
Status int `json:"status"`
Inx int `json:"inx"`
SpeedID *int64 `json:"speedId,omitempty"`
ForwardPorts *[]ForwardPortBackup `json:"forwardPorts,omitempty"`
}
@@ -421,8 +423,8 @@ type SpeedLimitBackup struct {
ID int64 `json:"id"`
Name string `json:"name"`
Speed int64 `json:"speed"`
TunnelID int64 `json:"tunnelId"`
TunnelName string `json:"tunnelName"`
TunnelID *int64 `json:"tunnelId,omitempty"`
TunnelName string `json:"tunnelName,omitempty"`
CreatedTime int64 `json:"createdTime"`
UpdatedTime int64 `json:"updatedTime,omitempty"`
Status int `json:"status"`
@@ -492,6 +494,7 @@ type ForwardRecord struct {
RemoteAddr string
Strategy string
Status int
SpeedID sql.NullInt64
}
// TunnelRecord is a minimal tunnel view used by control plane.
+90 -13
View File
@@ -656,7 +656,7 @@ func (r *Repository) ListUsers() ([]map[string]interface{}, error) {
return nil, errors.New("repository not initialized")
}
var users []model.User
if err := r.db.Where("role_id != ?", 0).Order("id ASC").Find(&users).Error; err != nil {
if err := r.db.Where("role_id != ?", 0).Order("id DESC").Find(&users).Error; err != nil {
return nil, err
}
items := make([]map[string]interface{}, 0, len(users))
@@ -678,17 +678,17 @@ func (r *Repository) ListSpeedLimits() ([]map[string]interface{}, error) {
return nil, errors.New("repository not initialized")
}
var limits []model.SpeedLimit
if err := r.db.Order("id ASC").Find(&limits).Error; err != nil {
if err := r.db.Order("id DESC").Find(&limits).Error; err != nil {
return nil, err
}
items := make([]map[string]interface{}, 0, len(limits))
for _, sl := range limits {
items = append(items, map[string]interface{}{
item := map[string]interface{}{
"id": sl.ID, "name": sl.Name, "speed": sl.Speed,
"tunnelId": sl.TunnelID, "tunnelName": sl.TunnelName,
"status": sl.Status, "createdTime": sl.CreatedTime,
"updatedTime": nullableInt64(sl.UpdatedTime),
})
}
items = append(items, item)
}
return items, nil
}
@@ -712,11 +712,12 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
CreatedTime int64
Status int
Inx int
SpeedID sql.NullInt64
}
var rows []fwdRow
err := r.db.Model(&model.Forward{}).
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx").
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id").
Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
Order("forward.inx ASC, forward.id ASC").
Find(&rows).Error
@@ -730,14 +731,18 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
if err != nil {
return nil, err
}
items = append(items, map[string]interface{}{
item := map[string]interface{}{
"id": row.ID, "userId": row.UserID, "userName": row.UserName,
"name": row.Name, "tunnelId": row.TunnelID, "tunnelName": row.TunnelName,
"inIp": nullableForwardIngress(inIP), "inPort": nullableInt64(inPort),
"remoteAddr": row.RemoteAddr, "strategy": row.Strategy,
"inFlow": row.InFlow, "outFlow": row.OutFlow,
"createdTime": row.CreatedTime, "status": row.Status, "inx": int64(row.Inx),
})
}
if row.SpeedID.Valid {
item["speedId"] = row.SpeedID.Int64
}
items = append(items, item)
}
return items, nil
}
@@ -1308,7 +1313,6 @@ func (r *Repository) ListActiveForwardPeerShareRuntimesByNodeAndServiceName(node
return items, nil
}
func (r *Repository) ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID int64) ([]string, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
@@ -1813,7 +1817,6 @@ func (r *Repository) exportSpeedLimits() ([]model.SpeedLimitBackup, error) {
for _, sl := range sls {
b := model.SpeedLimitBackup{
ID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),
TunnelID: sl.TunnelID, TunnelName: sl.TunnelName,
CreatedTime: sl.CreatedTime, Status: sl.Status,
}
if sl.UpdatedTime.Valid {
@@ -2186,8 +2189,8 @@ func importSpeedLimits(tx *gorm.DB, speedLimits []model.SpeedLimitBackup, now in
ID: sl.ID,
Name: sl.Name,
Speed: int(sl.Speed),
TunnelID: sl.TunnelID,
TunnelName: sl.TunnelName,
TunnelID: sql.NullInt64{Int64: 0, Valid: false},
TunnelName: sql.NullString{String: "", Valid: false},
CreatedTime: sl.CreatedTime,
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
Status: sl.Status,
@@ -2460,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
@@ -2514,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
@@ -46,6 +46,7 @@ func (r *Repository) ListForwardsByTunnel(tunnelID int64) ([]model.ForwardRecord
RemoteAddr: f.RemoteAddr,
Strategy: f.Strategy,
Status: f.Status,
SpeedID: f.SpeedID,
})
}
for i := range rows {
@@ -228,7 +228,6 @@ func (r *Repository) ListTunnelIDsByNamePrefix(prefix string) ([]int64, error) {
return ids, nil
}
// NextIndex returns COALESCE(MAX(inx), -1) + 1 for the given table.
func (r *Repository) NextIndex(table string) int {
if r == nil || r.db == nil {
return 0
@@ -251,7 +250,7 @@ func (r *Repository) NextIndex(table string) int {
var row inxRow
err := r.db.Model(modelRef).
Select("inx").
Order("inx DESC").
Order("inx ASC, id ASC").
Limit(1).
Take(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -260,10 +259,7 @@ func (r *Repository) NextIndex(table string) int {
if err != nil {
return 0
}
if row.Inx < 0 {
return 0
}
return row.Inx + 1
return row.Inx - 1
}
// CreateRemoteNode inserts a new remote node.
@@ -38,6 +38,7 @@ func (r *Repository) ListActiveForwardsByUser(userID int64) ([]model.ForwardReco
RemoteAddr: f.RemoteAddr,
Strategy: f.Strategy,
Status: f.Status,
SpeedID: f.SpeedID,
})
}
for i := range rows {
@@ -68,6 +69,7 @@ func (r *Repository) ListActiveForwardsByUserTunnel(userID, tunnelID int64) ([]m
RemoteAddr: f.RemoteAddr,
Strategy: f.Strategy,
Status: f.Status,
SpeedID: f.SpeedID,
})
}
for i := range rows {
@@ -99,6 +101,7 @@ func (r *Repository) GetForwardRecord(forwardID int64) (*model.ForwardRecord, er
RemoteAddr: f.RemoteAddr,
Strategy: f.Strategy,
Status: f.Status,
SpeedID: f.SpeedID,
}
if strings.TrimSpace(fr.Strategy) == "" {
fr.Strategy = "fifo"
@@ -169,3 +172,15 @@ func (r *Repository) SpeedLimitExists(id int64) (bool, error) {
}
return count > 0, nil
}
func (r *Repository) GetSpeedLimitSpeed(id int64) (int, error) {
if r == nil || r.db == nil {
return 0, errors.New("repository not initialized")
}
var sl model.SpeedLimit
err := r.db.Select("speed").Where("id = ?", id).First(&sl).Error
if err != nil {
return 0, err
}
return sl.Speed, nil
}
@@ -1,6 +1,7 @@
package repo
import (
"database/sql"
"errors"
"testing"
@@ -83,3 +84,169 @@ func TestMigrateSchemaReturnsPostgresIDRepairError(t *testing.T) {
t.Fatalf("expected error %v, got %v", wantErr, err)
}
}
func TestMigrateSchemaRunsViteConfigValueMigrationForLegacySchema(t *testing.T) {
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
sqlDB, _ := db.DB()
if sqlDB != nil {
_ = sqlDB.Close()
}
})
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
t.Fatalf("create schema_version: %v", err)
}
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 2).Error; err != nil {
t.Fatalf("seed schema_version: %v", err)
}
originalIDRepair := ensurePostgresIDDefaultsFn
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
return nil
}
t.Cleanup(func() {
ensurePostgresIDDefaultsFn = originalIDRepair
})
called := 0
originalMigrate := migrateViteConfigValueColumnTypeFn
migrateViteConfigValueColumnTypeFn = func(db *gorm.DB) error {
called++
return nil
}
t.Cleanup(func() {
migrateViteConfigValueColumnTypeFn = originalMigrate
})
if err := migrateSchema(db); err != nil {
t.Fatalf("migrateSchema: %v", err)
}
if called != 1 {
t.Fatalf("expected vite_config migration to run once, got %d", called)
}
}
func TestMigrateSchemaReturnsViteConfigMigrationError(t *testing.T) {
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
sqlDB, _ := db.DB()
if sqlDB != nil {
_ = sqlDB.Close()
}
})
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
t.Fatalf("create schema_version: %v", err)
}
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 2).Error; err != nil {
t.Fatalf("seed schema_version: %v", err)
}
originalIDRepair := ensurePostgresIDDefaultsFn
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
return nil
}
t.Cleanup(func() {
ensurePostgresIDDefaultsFn = originalIDRepair
})
wantErr := errors.New("vite config migration failed")
originalMigrate := migrateViteConfigValueColumnTypeFn
migrateViteConfigValueColumnTypeFn = func(db *gorm.DB) error {
return wantErr
}
t.Cleanup(func() {
migrateViteConfigValueColumnTypeFn = originalMigrate
})
err = migrateSchema(db)
if !errors.Is(err, wantErr) {
t.Fatalf("expected error %v, got %v", wantErr, err)
}
}
func TestMigrateSchemaClearsSpeedLimitTunnelBinding(t *testing.T) {
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
sqlDB, _ := db.DB()
if sqlDB != nil {
_ = sqlDB.Close()
}
})
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
t.Fatalf("create schema_version: %v", err)
}
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 3).Error; err != nil {
t.Fatalf("seed schema_version: %v", err)
}
if err := db.Exec(`
CREATE TABLE speed_limit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
speed INTEGER NOT NULL,
tunnel_id INTEGER,
tunnel_name VARCHAR(100),
created_time INTEGER NOT NULL,
updated_time INTEGER,
status INTEGER NOT NULL
)
`).Error; err != nil {
t.Fatalf("create speed_limit: %v", err)
}
if err := db.Exec(`
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
VALUES(?, ?, ?, ?, ?, ?, ?)
`, "legacy-speed-limit", 100, 101, "legacy-tunnel", 1, 1, 1).Error; err != nil {
t.Fatalf("seed speed_limit: %v", err)
}
originalIDRepair := ensurePostgresIDDefaultsFn
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
return nil
}
t.Cleanup(func() {
ensurePostgresIDDefaultsFn = originalIDRepair
})
if err := migrateSchema(db); err != nil {
t.Fatalf("migrateSchema: %v", err)
}
var tunnelID sql.NullInt64
var tunnelName sql.NullString
if err := db.Raw(`SELECT tunnel_id, tunnel_name FROM speed_limit WHERE name = ?`, "legacy-speed-limit").Row().Scan(&tunnelID, &tunnelName); err != nil {
t.Fatalf("query speed_limit: %v", err)
}
if tunnelID.Valid {
t.Fatalf("expected tunnel_id cleared to NULL, got %d", tunnelID.Int64)
}
if tunnelName.Valid {
t.Fatalf("expected tunnel_name cleared to NULL, got %q", tunnelName.String)
}
var schemaVersion int
if err := db.Raw(`SELECT version FROM schema_version LIMIT 1`).Row().Scan(&schemaVersion); err != nil {
t.Fatalf("query schema_version: %v", err)
}
if schemaVersion != currentSchemaVersion {
t.Fatalf("expected schema version %d, got %d", currentSchemaVersion, schemaVersion)
}
}
@@ -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")
@@ -657,7 +643,7 @@ func (r *Repository) GetMinForwardPort(forwardID int64) sql.NullInt64 {
return p
}
func (r *Repository) UpdateForward(id int64, name string, tunnelID int64, remoteAddr, strategy string, now int64) error {
func (r *Repository) UpdateForward(id int64, name string, tunnelID int64, remoteAddr, strategy string, now int64, speedID interface{}) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
@@ -668,6 +654,7 @@ func (r *Repository) UpdateForward(id int64, name string, tunnelID int64, remote
"tunnel_id": tunnelID,
"remote_addr": remoteAddr,
"strategy": strategy,
"speed_id": nullInt64FromInterface(speedID),
"updated_time": now,
}).Error
}
@@ -724,7 +711,7 @@ func (r *Repository) ReplaceForwardPorts(forwardID int64, entries []struct {
})
}
func (r *Repository) RollbackForwardFields(id, userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, status int, now int64) {
func (r *Repository) RollbackForwardFields(id, userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, status int, speedID interface{}, now int64) {
if r == nil || r.db == nil {
return
}
@@ -738,6 +725,7 @@ func (r *Repository) RollbackForwardFields(id, userID int64, userName, name stri
"remote_addr": remoteAddr,
"strategy": strategy,
"status": status,
"speed_id": nullInt64FromInterface(speedID),
"updated_time": now,
}).Error
}
@@ -764,15 +752,15 @@ func (r *Repository) GetUsedPortsOnNodeAsMap(nodeID int64) (map[int]bool, error)
return used, nil
}
func (r *Repository) CreateSpeedLimit(name string, speed int, tunnelID int64, tunnelName string, now int64, status int) (int64, error) {
func (r *Repository) CreateSpeedLimit(name string, speed int, now int64, status int) (int64, error) {
if r == nil || r.db == nil {
return 0, errors.New("repository not initialized")
}
sl := model.SpeedLimit{
Name: name,
Speed: speed,
TunnelID: tunnelID,
TunnelName: tunnelName,
TunnelID: sql.NullInt64{Int64: 0, Valid: false},
TunnelName: sql.NullString{String: "", Valid: false},
CreatedTime: now,
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
Status: status,
@@ -783,34 +771,24 @@ func (r *Repository) CreateSpeedLimit(name string, speed int, tunnelID int64, tu
return sl.ID, nil
}
func (r *Repository) UpdateSpeedLimit(id int64, name string, speed int, tunnelID int64, tunnelName string, status int, now int64) error {
func (r *Repository) UpdateSpeedLimit(id int64, name string, speed int, status int, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
updates := map[string]interface{}{
"name": name,
"speed": speed,
"status": status,
"tunnel_id": nil,
"tunnel_name": nil,
"updated_time": sql.NullInt64{
Int64: now,
Valid: true,
},
}
return r.db.Model(&model.SpeedLimit{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"name": name,
"speed": speed,
"tunnel_id": tunnelID,
"tunnel_name": tunnelName,
"status": status,
"updated_time": sql.NullInt64{
Int64: now,
Valid: true,
},
}).Error
}
func (r *Repository) GetSpeedLimitTunnelID(speedLimitID int64) int64 {
if r == nil || r.db == nil {
return 0
}
var sl model.SpeedLimit
if err := r.db.Select("tunnel_id").Where("id = ?", speedLimitID).First(&sl).Error; err != nil {
return 0
}
return sl.TunnelID
Updates(updates).Error
}
func (r *Repository) DeleteSpeedLimit(id int64) error {
@@ -1190,7 +1168,7 @@ func (r *Repository) EnsureUserTunnelGrant(userID, tunnelID int64) (int64, bool,
return ut.ID, true, nil
}
func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, now int64, inx int, entryNodeIDs []int64, port int) (int64, error) {
func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, now int64, inx int, entryNodeIDs []int64, port int, speedID interface{}) (int64, error) {
if r == nil || r.db == nil {
return 0, errors.New("repository not initialized")
}
@@ -1209,6 +1187,7 @@ func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnel
UpdatedTime: now,
Status: 1,
Inx: inx,
SpeedID: nullInt64FromInterface(speedID),
}
if err := tx.Create(&fwd).Error; err != nil {
return err
@@ -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 {
@@ -2,6 +2,7 @@ package contract_test
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -108,7 +109,11 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) {
if !ok {
t.Fatalf("expected object item, got %T", arr[0])
}
if got := int64(item["id"].(float64)); got != userForwardID {
idFloat, ok := item["id"].(float64)
if !ok {
t.Fatalf("expected id to be float64, got %T", item["id"])
}
if got := int64(idFloat); got != userForwardID {
t.Fatalf("expected forward id %d, got %d", userForwardID, got)
}
})
@@ -143,7 +148,11 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) {
if _, ok := first["message"]; !ok {
t.Fatalf("expected message field in diagnosis result")
}
if got := int(first["fromChainType"].(float64)); got != 1 {
fromChainTypeFloat, ok := first["fromChainType"].(float64)
if !ok {
t.Fatalf("expected fromChainType to be float64, got %T", first["fromChainType"])
}
if got := int(fromChainTypeFloat); got != 1 {
t.Fatalf("expected fromChainType=1, got %d", got)
}
})
@@ -471,6 +480,234 @@ func TestUserTunnelReassignmentKeepsStableID(t *testing.T) {
}
}
func TestForwardSpeedIDWriteAndClearContracts(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'speed_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "forward-speed-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "forward-speed-tunnel")
if err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "forward-speed-node", "forward-speed-secret", "10.30.0.1", "10.30.0.1", "", "31000-31010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, repo, "forward-speed-node")
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 31001, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
`, "forward-speed-limit-a", 2048, now, 1).Error; err != nil {
t.Fatalf("insert speed limit a: %v", err)
}
speedIDA := mustLastInsertID(t, repo, "forward-speed-limit-a")
if err := repo.DB().Exec(`
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
`, "forward-speed-limit-b", 4096, now, 1).Error; err != nil {
t.Fatalf("insert speed limit b: %v", err)
}
speedIDB := mustLastInsertID(t, repo, "forward-speed-limit-b")
server := httptest.NewServer(router)
defer server.Close()
stopNode := startMockNodeSession(t, server.URL, "forward-speed-secret")
defer stopNode()
createPayload := map[string]interface{}{
"name": "forward-speed-target",
"tunnelId": tunnelID,
"remoteAddr": "1.1.1.1:443",
"strategy": "fifo",
"speedId": speedIDA,
}
createBody, err := json.Marshal(createPayload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
createReq.Header.Set("Authorization", adminToken)
createReq.Header.Set("Content-Type", "application/json")
createRes := httptest.NewRecorder()
router.ServeHTTP(createRes, createReq)
assertCode(t, createRes, 0)
forwardID := mustLastInsertID(t, repo, "forward-speed-target")
storedSpeed := repo.DB().Raw(`SELECT speed_id FROM forward WHERE id = ?`, forwardID).Row()
var createdSpeed sql.NullInt64
if err := storedSpeed.Scan(&createdSpeed); err != nil {
t.Fatalf("query created forward speed_id: %v", err)
}
if !createdSpeed.Valid || createdSpeed.Int64 != speedIDA {
t.Fatalf("expected created speed_id=%d, got valid=%v value=%d", speedIDA, createdSpeed.Valid, createdSpeed.Int64)
}
updateToBPayload := map[string]interface{}{
"id": forwardID,
"speedId": speedIDB,
}
updateToBBody, err := json.Marshal(updateToBPayload)
if err != nil {
t.Fatalf("marshal update-to-b payload: %v", err)
}
updateToBReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateToBBody))
updateToBReq.Header.Set("Authorization", adminToken)
updateToBReq.Header.Set("Content-Type", "application/json")
updateToBRes := httptest.NewRecorder()
router.ServeHTTP(updateToBRes, updateToBReq)
assertCode(t, updateToBRes, 0)
storedSpeed = repo.DB().Raw(`SELECT speed_id FROM forward WHERE id = ?`, forwardID).Row()
var updatedSpeed sql.NullInt64
if err := storedSpeed.Scan(&updatedSpeed); err != nil {
t.Fatalf("query updated forward speed_id: %v", err)
}
if !updatedSpeed.Valid || updatedSpeed.Int64 != speedIDB {
t.Fatalf("expected updated speed_id=%d, got valid=%v value=%d", speedIDB, updatedSpeed.Valid, updatedSpeed.Int64)
}
clearPayload := map[string]interface{}{
"id": forwardID,
"speedId": nil,
}
clearBody, err := json.Marshal(clearPayload)
if err != nil {
t.Fatalf("marshal clear payload: %v", err)
}
clearReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(clearBody))
clearReq.Header.Set("Authorization", adminToken)
clearReq.Header.Set("Content-Type", "application/json")
clearRes := httptest.NewRecorder()
router.ServeHTTP(clearRes, clearReq)
assertCode(t, clearRes, 0)
storedSpeed = repo.DB().Raw(`SELECT speed_id FROM forward WHERE id = ?`, forwardID).Row()
var clearedSpeed sql.NullInt64
if err := storedSpeed.Scan(&clearedSpeed); err != nil {
t.Fatalf("query cleared forward speed_id: %v", err)
}
if clearedSpeed.Valid {
t.Fatalf("expected cleared speed_id to be NULL, got %d", clearedSpeed.Int64)
}
}
func TestForwardCreateThenPauseResumeContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "forward-toggle-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "forward-toggle-tunnel")
if err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "forward-toggle-node", "forward-toggle-secret", "10.31.0.1", "10.31.0.1", "", "41000-41010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, repo, "forward-toggle-node")
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 41001, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
server := httptest.NewServer(router)
defer server.Close()
stopNode := startMockNodeSession(t, server.URL, "forward-toggle-secret")
defer stopNode()
createPayload := map[string]interface{}{
"name": "forward-toggle-target",
"tunnelId": tunnelID,
"remoteAddr": "1.1.1.1:443",
"strategy": "fifo",
}
createBody, err := json.Marshal(createPayload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
createReq.Header.Set("Authorization", adminToken)
createReq.Header.Set("Content-Type", "application/json")
createRes := httptest.NewRecorder()
router.ServeHTTP(createRes, createReq)
assertCode(t, createRes, 0)
forwardID := mustLastInsertID(t, repo, "forward-toggle-target")
pauseBody, err := json.Marshal(map[string]interface{}{"id": forwardID})
if err != nil {
t.Fatalf("marshal pause payload: %v", err)
}
pauseReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/pause", bytes.NewReader(pauseBody))
pauseReq.Header.Set("Authorization", adminToken)
pauseReq.Header.Set("Content-Type", "application/json")
pauseRes := httptest.NewRecorder()
router.ServeHTTP(pauseRes, pauseReq)
assertCode(t, pauseRes, 0)
pausedStatus := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = ?`, forwardID)
if pausedStatus != 0 {
t.Fatalf("expected status=0 after pause, got %d", pausedStatus)
}
resumeBody, err := json.Marshal(map[string]interface{}{"id": forwardID})
if err != nil {
t.Fatalf("marshal resume payload: %v", err)
}
resumeReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/resume", bytes.NewReader(resumeBody))
resumeReq.Header.Set("Authorization", adminToken)
resumeReq.Header.Set("Content-Type", "application/json")
resumeRes := httptest.NewRecorder()
router.ServeHTTP(resumeRes, resumeReq)
assertCode(t, resumeRes, 0)
resumedStatus := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = ?`, forwardID)
if resumedStatus != 1 {
t.Fatalf("expected status=1 after resume, got %d", resumedStatus)
}
}
func jsonNumber(v int64) string {
return strconv.FormatInt(v, 10)
}
@@ -0,0 +1,635 @@
package contract_test
import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
"go-backend/internal/auth"
"go-backend/internal/http/response"
"go-backend/internal/security"
)
func TestForwardCreateRollbackWhenLimiterDispatchFailsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "limiter-fail-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "limiter-fail-tunnel")
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "limiter-fail-node", "limiter-fail-secret", "10.20.0.1", "10.20.0.1", "", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, r, "limiter-fail-node")
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 32001, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
`, "limiter-fail-rule", 1024, now, 1).Error; err != nil {
t.Fatalf("insert speed limit: %v", err)
}
speedID := mustLastInsertID(t, r, "limiter-fail-rule")
stopNode := startMockNodeSessionWithCommandFailures(t, server.URL, "limiter-fail-secret", map[string]string{
"addlimiters": "mock add limiters failed",
})
defer stopNode()
payload := map[string]interface{}{
"name": "limiter-fail-forward",
"tunnelId": tunnelID,
"remoteAddr": "1.1.1.1:443",
"strategy": "fifo",
"speedId": speedID,
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code == 0 {
t.Fatalf("expected create failure on limiter dispatch, got code=0")
}
forwardCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM forward WHERE name = ?`, "limiter-fail-forward")
if forwardCount != 0 {
t.Fatalf("expected forward rollback delete on limiter failure, got count=%d", forwardCount)
}
}
func TestForwardCreateSucceedsWhenLimiterAlreadyExistsAndUpdateSucceedsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "limiter-exists-update-ok-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "limiter-exists-update-ok-tunnel")
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "limiter-exists-update-ok-node", "limiter-exists-update-ok-secret", "10.20.1.1", "10.20.1.1", "", "32200-32210", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, r, "limiter-exists-update-ok-node")
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 32201, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
`, "limiter-exists-update-ok-rule", 1024, now, 1).Error; err != nil {
t.Fatalf("insert speed limit: %v", err)
}
speedID := mustLastInsertID(t, r, "limiter-exists-update-ok-rule")
stopNode := startMockNodeSessionWithCommandFailures(t, server.URL, "limiter-exists-update-ok-secret", map[string]string{
"addlimiters": "limiter 8 already exists",
})
defer stopNode()
payload := map[string]interface{}{
"name": "limiter-exists-update-ok-forward",
"tunnelId": tunnelID,
"remoteAddr": "1.1.1.1:443",
"strategy": "fifo",
"speedId": speedID,
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected create success when updater succeeds, got code=%d msg=%s", out.Code, out.Msg)
}
forwardCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM forward WHERE name = ?`, "limiter-exists-update-ok-forward")
if forwardCount != 1 {
t.Fatalf("expected forward kept when update limiter succeeds, got count=%d", forwardCount)
}
}
func TestForwardCreateRollbackWhenLimiterAlreadyExistsAndUpdateFailsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "limiter-exists-update-fail-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "limiter-exists-update-fail-tunnel")
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "limiter-exists-update-fail-node", "limiter-exists-update-fail-secret", "10.20.2.1", "10.20.2.1", "", "32300-32310", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, r, "limiter-exists-update-fail-node")
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 32301, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
`, "limiter-exists-update-fail-rule", 1024, now, 1).Error; err != nil {
t.Fatalf("insert speed limit: %v", err)
}
speedID := mustLastInsertID(t, r, "limiter-exists-update-fail-rule")
stopNode := startMockNodeSessionWithCommandFailures(t, server.URL, "limiter-exists-update-fail-secret", map[string]string{
"addlimiters": "limiter 9 already exists",
"updatelimiters": "mock update limiters failed",
})
defer stopNode()
payload := map[string]interface{}{
"name": "limiter-exists-update-fail-forward",
"tunnelId": tunnelID,
"remoteAddr": "1.1.1.1:443",
"strategy": "fifo",
"speedId": speedID,
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code == 0 {
t.Fatalf("expected create failure when update limiter fails, got code=0")
}
if !strings.Contains(out.Msg, "mock update limiters failed") {
t.Fatalf("expected update failure message, got %q", out.Msg)
}
forwardCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM forward WHERE name = ?`, "limiter-exists-update-fail-forward")
if forwardCount != 0 {
t.Fatalf("expected forward rollback delete when update limiter fails, got count=%d", forwardCount)
}
}
func TestForwardCreateRollbackWhenServiceDispatchReturnsAddressInUseContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "addr-in-use-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "addr-in-use-tunnel")
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "addr-in-use-node", "addr-in-use-secret", "10.20.0.11", "10.20.0.11", "", "32100-32110", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, r, "addr-in-use-node")
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 32101, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
stopNode := startMockNodeSessionWithCommandFailures(t, server.URL, "addr-in-use-secret", map[string]string{
"updateservice": "listen tcp [::]:32101: bind: address already in use",
"addservice": "listen tcp [::]:32101: bind: address already in use",
})
defer stopNode()
payload := map[string]interface{}{
"name": "addr-in-use-forward",
"tunnelId": tunnelID,
"remoteAddr": "1.1.1.1:443",
"strategy": "fifo",
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code == 0 {
t.Fatalf("expected create failure on address-in-use service dispatch, got code=0")
}
forwardCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM forward WHERE name = ?`, "addr-in-use-forward")
if forwardCount != 0 {
t.Fatalf("expected forward rollback delete on address-in-use failure, got count=%d", forwardCount)
}
}
func TestBatchAssignRollbackWhenLimiterDispatchFailsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'assign_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "assign-limiter-fail-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "assign-limiter-fail-tunnel")
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "assign-limiter-fail-node", "assign-limiter-fail-secret", "10.21.0.1", "10.21.0.1", "", "33000-33010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, r, "assign-limiter-fail-node")
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 33001, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
`, "assign-limiter-fail-rule", 2048, now, 1).Error; err != nil {
t.Fatalf("insert speed limit: %v", err)
}
speedID := mustLastInsertID(t, r, "assign-limiter-fail-rule")
if err := r.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(21, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(2, 'assign_user', 'assign-limiter-fail-forward', ?, '9.9.9.9:53', 'fifo', 0, 0, ?, ?, 1, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, "assign-limiter-fail-forward")
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, 33001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
stopNode := startMockNodeSessionWithCommandFailures(t, server.URL, "assign-limiter-fail-secret", map[string]string{
"addlimiters": "mock add limiters failed",
})
defer stopNode()
assignPayload := map[string]interface{}{
"userId": 2,
"tunnels": []map[string]interface{}{{
"tunnelId": tunnelID,
"speedId": speedID,
}},
}
body, err := json.Marshal(assignPayload)
if err != nil {
t.Fatalf("marshal assign payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/batch-assign", bytes.NewReader(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code == 0 {
t.Fatalf("expected assign failure on limiter dispatch, got code=0")
}
var persistedSpeedID sql.NullInt64
if err := r.DB().Raw(`SELECT speed_id FROM user_tunnel WHERE user_id = 2 AND tunnel_id = ?`, tunnelID).Row().Scan(&persistedSpeedID); err != nil {
t.Fatalf("query user_tunnel speed_id: %v", err)
}
if persistedSpeedID.Valid {
t.Fatalf("expected speed_id rollback to NULL, got %d", persistedSpeedID.Int64)
}
}
func TestBatchAssignInsertRollbackWhenLimiterDispatchFailsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(3, 'assign_insert_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "assign-insert-limiter-fail-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "assign-insert-limiter-fail-tunnel")
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "assign-insert-limiter-fail-node", "assign-insert-limiter-fail-secret", "10.22.0.1", "10.22.0.1", "", "34000-34010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, r, "assign-insert-limiter-fail-node")
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 34001, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
`, "assign-insert-limiter-fail-rule", 3072, now, 1).Error; err != nil {
t.Fatalf("insert speed limit: %v", err)
}
speedID := mustLastInsertID(t, r, "assign-insert-limiter-fail-rule")
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(3, 'assign_insert_user', 'assign-insert-limiter-fail-forward', ?, '8.8.4.4:53', 'fifo', 0, 0, ?, ?, 1, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, "assign-insert-limiter-fail-forward")
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, 34001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
stopNode := startMockNodeSessionWithCommandFailures(t, server.URL, "assign-insert-limiter-fail-secret", map[string]string{
"addlimiters": "mock add limiters failed",
})
defer stopNode()
assignPayload := map[string]interface{}{
"userId": 3,
"tunnels": []map[string]interface{}{{
"tunnelId": tunnelID,
"speedId": speedID,
}},
}
body, err := json.Marshal(assignPayload)
if err != nil {
t.Fatalf("marshal assign payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/batch-assign", bytes.NewReader(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code == 0 {
t.Fatalf("expected assign(insert) failure on limiter dispatch, got code=0")
}
insertedCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM user_tunnel WHERE user_id = 3 AND tunnel_id = ?`, tunnelID)
if insertedCount != 0 {
t.Fatalf("expected inserted user_tunnel rollback delete, got count=%d", insertedCount)
}
}
func startMockNodeSessionWithCommandFailures(t *testing.T, baseURL string, nodeSecret string, failCommands map[string]string) func() {
t.Helper()
u, err := url.Parse(baseURL)
if err != nil {
t.Fatalf("parse provider url: %v", err)
}
if strings.EqualFold(u.Scheme, "https") {
u.Scheme = "wss"
} else {
u.Scheme = "ws"
}
u.Path = "/system-info"
q := u.Query()
q.Set("type", "1")
q.Set("secret", nodeSecret)
q.Set("version", "v1")
q.Set("http", "1")
q.Set("tls", "1")
q.Set("socks", "1")
u.RawQuery = q.Encode()
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
t.Fatalf("dial mock node websocket: %v", err)
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for {
_, raw, readErr := conn.ReadMessage()
if readErr != nil {
return
}
plain := raw
var wrap struct {
Encrypted bool `json:"encrypted"`
Data string `json:"data"`
}
if err := json.Unmarshal(raw, &wrap); err == nil && wrap.Encrypted && strings.TrimSpace(wrap.Data) != "" {
crypto, cryptoErr := security.NewAESCrypto(nodeSecret)
if cryptoErr == nil {
if dec, decErr := crypto.Decrypt(wrap.Data); decErr == nil {
plain = []byte(dec)
}
}
}
var cmd struct {
Type string `json:"type"`
RequestID string `json:"requestId"`
}
if err := json.Unmarshal(plain, &cmd); err != nil {
continue
}
if strings.TrimSpace(cmd.RequestID) == "" {
continue
}
cmdType := strings.TrimSpace(cmd.Type)
failMsg, shouldFail := failCommands[strings.ToLower(cmdType)]
respType := fmt.Sprintf("%sResponse", cmdType)
respPayload := map[string]interface{}{
"type": respType,
"success": !shouldFail,
"message": "OK",
"requestId": cmd.RequestID,
}
if shouldFail {
if strings.TrimSpace(failMsg) == "" {
failMsg = "mock command failed"
}
respPayload["message"] = failMsg
}
respBytes, err := json.Marshal(respPayload)
if err != nil {
continue
}
_ = conn.WriteMessage(websocket.TextMessage, respBytes)
}
}()
var stopOnce sync.Once
return func() {
stopOnce.Do(func() {
_ = conn.Close()
wg.Wait()
})
}
}
@@ -25,6 +25,7 @@ import (
func TestCaptchaVerifyLoginContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
verifiedToken := ""
if err := r.DB().Exec(`
INSERT INTO vite_config(name, value, time)
@@ -34,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) {
@@ -0,0 +1,313 @@
package contract_test
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
"go-backend/internal/store/repo"
)
func TestSpeedLimitWithoutTunnelContract(t *testing.T) {
secret := "contract-jwt-secret"
router, _ := setupContractRouter(t, secret)
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
t.Run("create speed limit", func(t *testing.T) {
body := `{"name":"test-limit-no-tunnel","speed":100,"status":1}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/create", bytes.NewBufferString(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
})
t.Run("list does not expose tunnel binding fields", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/list", nil)
req.Header.Set("Authorization", adminToken)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d", out.Code)
}
data, ok := out.Data.([]interface{})
if !ok {
t.Fatalf("expected data to be array, got %T", out.Data)
}
for _, item := range data {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
if m["name"] != "test-limit-no-tunnel" {
continue
}
if tunnelID, exists := m["tunnelId"]; exists && tunnelID != nil {
t.Fatalf("expected tunnelId to be absent or nil, got %v", tunnelID)
}
if tunnelName, exists := m["tunnelName"]; exists && tunnelName != nil && tunnelName != "" {
t.Fatalf("expected tunnelName to be absent or empty, got %v", tunnelName)
}
return
}
t.Fatal("speed limit 'test-limit-no-tunnel' not found in list")
})
}
func TestSpeedLimitCreateIgnoresTunnelBindingContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
tunnelID := mustCreateSpeedLimitTunnel(t, r, "test-speed-limit-create-ignore-tunnel")
body := `{"name":"test-limit-ignore-tunnel","speed":200,"tunnelId":` + jsonInt(tunnelID) + `,"status":1}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/create", bytes.NewBufferString(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
req = httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/list", nil)
req.Header.Set("Authorization", adminToken)
res = httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d", out.Code)
}
data, ok := out.Data.([]interface{})
if !ok {
t.Fatalf("expected data to be array, got %T", out.Data)
}
for _, item := range data {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
if m["name"] != "test-limit-ignore-tunnel" {
continue
}
if tunnelIDVal, exists := m["tunnelId"]; exists && tunnelIDVal != nil {
t.Fatalf("expected tunnelId ignored and nil, got %v", tunnelIDVal)
}
if tunnelNameVal, exists := m["tunnelName"]; exists && tunnelNameVal != nil && tunnelNameVal != "" {
t.Fatalf("expected tunnelName ignored and empty, got %v", tunnelNameVal)
}
return
}
t.Fatal("speed limit 'test-limit-ignore-tunnel' not found in list")
}
func TestSpeedLimitUpdateIgnoresTunnelBindingContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
tunnelID := mustCreateSpeedLimitTunnel(t, r, "test-speed-limit-update-ignore-tunnel")
speedLimitID := mustCreateSpeedLimitRepo(t, r, "test-limit-update-ignore-tunnel")
body := `{"id":` + jsonInt(speedLimitID) + `,"name":"test-limit-update-ignore-tunnel","speed":256,"tunnelId":` + jsonInt(tunnelID) + `,"status":1}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/update", bytes.NewBufferString(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
req = httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/list", nil)
req.Header.Set("Authorization", adminToken)
res = httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d", out.Code)
}
data, ok := out.Data.([]interface{})
if !ok {
t.Fatalf("expected data to be array, got %T", out.Data)
}
for _, item := range data {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
if m["name"] != "test-limit-update-ignore-tunnel" {
continue
}
if tunnelIDVal, exists := m["tunnelId"]; exists && tunnelIDVal != nil {
t.Fatalf("expected tunnelId ignored and nil after update, got %v", tunnelIDVal)
}
if speedVal, ok := m["speed"].(float64); !ok || int(speedVal) != 256 {
t.Fatalf("expected speed 256 after update, got %v", m["speed"])
}
return
}
t.Fatal("speed limit 'test-limit-update-ignore-tunnel' not found in list")
}
func TestSpeedLimitDatabaseNullableFields(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "speed-limit-null.db")
r, err := repo.Open(dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
id, err := r.CreateSpeedLimit("db-test-limit", 100, 1, 1)
if err != nil {
t.Fatalf("CreateSpeedLimit failed: %v", err)
}
if id <= 0 {
t.Fatalf("expected valid id, got %d", id)
}
var tunnelID sql.NullInt64
var tunnelName sql.NullString
err = r.DB().Raw("SELECT tunnel_id, tunnel_name FROM speed_limit WHERE id = ?", id).Row().Scan(&tunnelID, &tunnelName)
if err != nil {
t.Fatalf("query failed: %v", err)
}
if tunnelID.Valid {
t.Fatalf("expected TunnelID to be NULL, got %d", tunnelID.Int64)
}
if tunnelName.Valid && tunnelName.String != "" {
t.Fatalf("expected TunnelName to be NULL or empty, got %s", tunnelName.String)
}
}
func TestSpeedLimitUpdateClearsHistoricalBinding(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "speed-limit-update-clear.db")
r, err := repo.Open(dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
tunnelID := mustCreateSpeedLimitTunnel(t, r, "speed-limit-update-clear-tunnel")
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
VALUES(?, ?, ?, ?, ?, ?, ?)
`, "speed-limit-update-clear", 300, tunnelID, "speed-limit-update-clear-tunnel", now, now, 1).Error; err != nil {
t.Fatalf("insert speed limit with tunnel binding: %v", err)
}
speedLimitID := mustLastInsertID(t, r, "speed-limit-update-clear")
err = r.UpdateSpeedLimit(speedLimitID, "speed-limit-update-clear", 512, 1, time.Now().UnixMilli())
if err != nil {
t.Fatalf("UpdateSpeedLimit failed: %v", err)
}
var dbTunnelID sql.NullInt64
var dbTunnelName sql.NullString
err = r.DB().Raw("SELECT tunnel_id, tunnel_name FROM speed_limit WHERE id = ?", speedLimitID).Row().Scan(&dbTunnelID, &dbTunnelName)
if err != nil {
t.Fatalf("query updated speed limit failed: %v", err)
}
if dbTunnelID.Valid {
t.Fatalf("expected tunnel_id cleared after update, got %d", dbTunnelID.Int64)
}
if dbTunnelName.Valid && dbTunnelName.String != "" {
t.Fatalf("expected tunnel_name cleared after update, got %q", dbTunnelName.String)
}
}
func TestSpeedLimitGetSpeed(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "speed-limit-getspeed.db")
r, err := repo.Open(dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
speedLimitID, err := r.CreateSpeedLimit("get-speed-test", 500, 1, 1)
if err != nil {
t.Fatalf("create speed limit: %v", err)
}
t.Run("GetSpeedLimitSpeed returns correct speed", func(t *testing.T) {
speed, err := r.GetSpeedLimitSpeed(speedLimitID)
if err != nil {
t.Fatalf("GetSpeedLimitSpeed failed: %v", err)
}
if speed != 500 {
t.Fatalf("expected speed 500, got %d", speed)
}
})
t.Run("GetSpeedLimitSpeed returns error for non-existent id", func(t *testing.T) {
_, err := r.GetSpeedLimitSpeed(99999)
if err == nil {
t.Fatal("expected error for non-existent speed limit ID")
}
})
}
func mustCreateSpeedLimitTunnel(t *testing.T, r *repo.Repository, name string) int64 {
t.Helper()
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
`, name, now, now).Error; err != nil {
t.Fatalf("create tunnel failed: %v", err)
}
return mustLastInsertID(t, r, name)
}
func mustCreateSpeedLimitRepo(t *testing.T, r *repo.Repository, name string) int64 {
t.Helper()
now := time.Now().UnixMilli()
id, err := r.CreateSpeedLimit(name, 100, now, 1)
if err != nil {
t.Fatalf("create speed limit failed: %v", err)
}
return id
}
@@ -1,4 +1,4 @@
package contract
package contract_test
import (
"encoding/json"
@@ -13,7 +13,7 @@ import (
func TestUserTunnelVisibleListContracts(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupDiagnosisContractRouter(t, secret)
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
if err := repo.DB().Exec(`
@@ -126,8 +126,11 @@ func collectTunnelIDs(t *testing.T, data interface{}) map[int64]bool {
if !ok {
t.Fatalf("expected object item, got %T", item)
}
id := int64(obj["id"].(float64))
ids[id] = true
idFloat, ok := obj["id"].(float64)
if !ok {
t.Fatalf("expected id to be float64, got %T", obj["id"])
}
ids[int64(idFloat)] = true
}
return ids
}
+7 -5
View File
@@ -1,6 +1,6 @@
# GO-GOST SERVICE KNOWLEDGE BASE
**Generated:** Sun Feb 15 2026
**Generated:** Thu Feb 26 2026
## OVERVIEW
Forwarding agent built on GOST v3 with a local fork of `github.com/go-gost/x` under `x/`.
@@ -19,16 +19,18 @@ go-gost/
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Panel integration config | `go-gost/config.go` | Expects `config.json` in cwd by default |
| Service lifecycle/reload | `go-gost/program.go` | Parses config; handles SIGHUP reload |
| WebSocket reporting | `go-gost/main.go` | Starts reporter + sets HTTP report URL |
| Protocol behaviors | `go-gost/x/` | Handlers/listeners/dialers live here |
| **Panel integration config** | `go-gost/config.go` | Expects `config.json` in cwd by default |
| **Service lifecycle/reload** | `go-gost/program.go` | Parses config; handles SIGHUP reload |
| **WebSocket reporting** | `go-gost/main.go` | Starts reporter + sets HTTP report URL |
| **Protocol behaviors** | `go-gost/x/` | Handlers/listeners/dialers live here |
| **Build** | `go-gost/Makefile` | Cross-compile targets for amd64/arm64 |
## CONVENTIONS
- Two configs exist: panel integration uses `config.json`; forwarding services use GOST config (defaults to `gost.{json,yaml}` via viper search paths).
- `go-gost/x/` is the primary extension surface; avoid editing vendored deps.
- Agent communicates with panel via WebSocket (real-time commands) + HTTP (batch traffic reports).
- All panel communication uses AES encryption with node `secret` as PSK.
- CI builds with `CGO_ENABLED=0` for static binaries, then compresses with UPX.
## ANTI-PATTERNS
- **DO NOT EDIT** generated protobuf in `x/internal/util/grpc/proto/`.
+11 -10
View File
@@ -6,27 +6,28 @@ Local fork of `github.com/go-gost/x` used by `go-gost/` via `replace github.com/
## STRUCTURE
```
go-gost/x/
├── api/ # Gin management API + embedded swagger docs
├── api/ # Gin management API + embedded swagger docs (22 files)
├── config/ # Config model + parsing/load/reload
├── connector/ # Outbound connect implementations
├── dialer/ # Outbound dialers (tcp/tls/ws/quic/...)
├── dialer/ # Outbound dialers (tcp/tls/ws/quic/...)
├── handler/ # Protocol handlers (socks/http/tunnel/relay/...)
├── listener/ # Inbound listeners (tcp/udp/tun/tap/redirect/...)
├── limiter/ # Traffic/rate/conn limiters
├── registry/ # Registries for services/handlers/listeners/etc
├── registry/ # Registries for services/handlers/listeners/etc (20 files)
├── service/ # Service wrappers + reporting hooks
├── socket/ # WebSocket reporter / panel integration
├── socket/ # WebSocket reporter / panel integration (6 files)
└── internal/ # Shared internals (grpc proto, net utils, sniffing, tls, ...)
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Management API routes/auth | `go-gost/x/api/api.go` | `/docs`, `/config/*`; BasicAuth + interceptor |
| Service config parsing | `go-gost/x/config/parsing/` | Converts config to running services |
| Add a handler | `go-gost/x/handler/` | Per-protocol subdirs |
| Add a listener/dialer | `go-gost/x/listener/`, `go-gost/x/dialer/` | Transport variants |
| Panel reporting | `go-gost/x/socket/` | WebSocket + HTTP report URL hooks |
| **Management API routes/auth** | `go-gost/x/api/api.go` | `/docs`, `/config/*`; BasicAuth + interceptor |
| **Service config parsing** | `go-gost/x/config/parsing/` | Converts config to running services |
| **Add a handler** | `go-gost/x/handler/` | Per-protocol subdirs |
| **Add a listener/dialer** | `go-gost/x/listener/`, `go-gost/x/dialer/` | Transport variants |
| **Panel reporting** | `go-gost/x/socket/` | WebSocket + HTTP report URL hooks |
| **Register new component** | `go-gost/x/registry/` | `Register{Type}(name, creator)` |
## CONVENTIONS
- `go-gost/x/` is a standalone Go module (`go-gost/x/go.mod`); run go tooling from this dir when debugging module resolution.
@@ -41,4 +42,4 @@ go-gost/x/
```bash
cd go-gost/x
go test ./...
```
```
+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()
+11 -12
View File
@@ -1,9 +1,9 @@
# VITE FRONTEND KNOWLEDGE BASE
**Generated:** Thu Feb 19 2026
**Commit:** 137c34e
**Generated:** Thu Feb 26 2026
**Commit:** 21008cc
**Branch:** main
**Tag:** 2.1.4-rc2
**Tag:** 2.1.5-rc15
## OVERVIEW
Web management console for FLVX.
@@ -15,8 +15,8 @@ vite-frontend/
├── src/
│ ├── api/ # Axios wrapper + typed endpoint helpers
│ ├── components/ui/ # shadcn/radix primitive components
│ ├── shadcn-bridge/heroui/ # HeroUI-compatible facade used by pages/layouts
│ ├── pages/ # Route views + page modules (forward/node/tunnel split helpers)
│ ├── shadcn-bridge/heroui/ # HeroUI-compatible facade (23 components)
│ ├── pages/ # Route views + page modules (forward/node/tunnel)
│ ├── hooks/ # H5/WebView/mobile hooks
│ ├── styles/
│ │ ├── globals.css # Base styles + imports tailwind-theme.pcss
@@ -25,8 +25,8 @@ vite-frontend/
│ ├── main.tsx # ReactDOM + BrowserRouter + Provider
│ └── provider.tsx # Toast/theme/provider composition
├── components.json # shadcn/ui config
├── tailwind.config.js # Compatibility config still used by migration scaffolding
├── vite.config.ts # base '/', host 0.0.0.0:3000; build minify/treeshake disabled
├── tailwind.config.js # Compatibility config for migration scaffolding
├── vite.config.ts # base '/', host 0.0.0.0:3000; minify/treeshake disabled
└── package.json
```
@@ -47,8 +47,8 @@ vite-frontend/
- **API Envelope**: Responses follow `{code, msg, data, ts}`.
- **UI Imports**: Use `src/shadcn-bridge/heroui/*` in app pages/layouts for compatibility.
- **Semantic Colors**: Keep `globals.css -> tailwind-theme.pcss` import intact or semantic classes break.
- **Build profile**: `minify: false`, `treeshake: false` for easier debugging.
- **Layout mode**: H5/mobile mode still controlled by existing route/query and hook logic.
- **Build profile**: `minify: false`, `treeshake: false` for debugging.
- **Layout mode**: H5/mobile mode controlled by existing route/query and hook logic.
## ANTI-PATTERNS
- **DO NOT ADD** `Bearer` to auth header in frontend requests.
@@ -57,10 +57,9 @@ vite-frontend/
- **DO NOT ADD** frontend tests; no Vitest/Jest setup exists.
## NOTES
- PR `#144` (shadcn migration) and PR `#142` (user-group binding) are merged in `main`.
- Release tag `2.1.4-rc2` points to commit `137c34e`.
- Button border/color parity depends on both bridge mapping and semantic Tailwind token export.
- Uses `rolldown-vite` (experimental Rust bundler) instead of standard Vite.
- Build outputs are non-minified (debugging mode).
- No test infrastructure exists (Vitest/Jest not configured).
## COMMANDS
```bash
+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: {
+2 -4
View File
@@ -51,6 +51,7 @@ export interface ForwardApiItem {
outFlow?: number;
userId?: number;
tunnelId?: number;
speedId?: number | null;
inx?: number;
[key: string]: unknown;
}
@@ -96,10 +97,8 @@ export interface StatisticsFlowApiItem {
export interface SpeedLimitApiItem {
id: number;
name: string;
tunnelId: number;
speed: number;
status: number;
tunnelName: string;
createdTime: string;
updatedTime: string;
uploadSpeed?: number;
@@ -284,6 +283,7 @@ export interface ForwardMutationPayload {
inPort?: number | null;
remoteAddr?: string;
strategy?: string;
speedId?: number | null;
}
export interface SpeedLimitMutationPayload {
@@ -291,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 () => {
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,5 +1,6 @@
export interface ForwardDiagnosisEntry {
success: boolean;
success?: boolean;
diagnosing?: boolean;
description: string;
nodeName: string;
nodeId: string;
+16 -95
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;
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,10 +115,6 @@ export default function LimitPage() {
newErrors.speed = "请输入有效的速度限制(≥1 Mbps)";
}
if (!form.tunnelId) {
newErrors.tunnelId = "请选择要绑定的隧道";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
@@ -154,8 +126,6 @@ export default function LimitPage() {
setForm({
name: "",
speed: 100,
tunnelId: null,
tunnelName: "",
status: 1,
});
setErrors({});
@@ -169,8 +139,6 @@ export default function LimitPage() {
id: rule.id,
name: rule.name,
speed: rule.speed,
tunnelId: rule.tunnelId,
tunnelName: rule.tunnelName,
status: rule.status,
});
setErrors({});
@@ -212,14 +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;
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">
@@ -393,9 +354,7 @@ export default function LimitPage() {
{isEdit ? "编辑限速规则" : "新增限速规则"}
</h2>
<p className="text-small text-default-500">
{isEdit
? "修改现有限速规则的配置信息"
: "创建新的限速规则并绑定到隧道"}
{isEdit ? "修改现有限速规则的配置信息" : "创建新的限速规则"}
</p>
</ModalHeader>
<ModalBody>
@@ -434,44 +393,6 @@ export default function LimitPage() {
}))
}
/>
<Select
description={isEdit ? "编辑时无法修改绑定隧道" : undefined}
errorMessage={errors.tunnelId}
isDisabled={isEdit}
isInvalid={!!errors.tunnelId}
label="绑定隧道"
placeholder="请选择要绑定的隧道"
selectedKeys={
form.tunnelId ? [form.tunnelId.toString()] : []
}
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
if (selectedKey) {
const selectedTunnel = tunnels.find(
(tunnel) => tunnel.id === parseInt(selectedKey),
);
setForm((prev) => ({
...prev,
tunnelId: parseInt(selectedKey),
tunnelName: selectedTunnel?.name || "",
}));
} else {
setForm((prev) => ({
...prev,
tunnelId: null,
tunnelName: "",
}));
}
}}
>
{tunnels.map((tunnel) => (
<SelectItem key={tunnel.id}>{tunnel.name}</SelectItem>
))}
</Select>
</div>
</ModalBody>
<ModalFooter>
+389 -173
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));
};
// 更新某一跳的所有节点的协议
@@ -366,6 +355,51 @@ export default function TunnelPage() {
return form.chainNodes || [];
};
const mergeOrderedNodes = (
currentNodes: ChainTunnel[],
selectedNodeIds: number[],
buildDefault: (nodeId: number) => ChainTunnel,
): ChainTunnel[] => {
const selectedSet = new Set(selectedNodeIds);
const kept = currentNodes.filter((node) => selectedSet.has(node.nodeId));
const keptIds = new Set(kept.map((node) => node.nodeId));
const added = selectedNodeIds
.filter((nodeId) => !keptIds.has(nodeId))
.map((nodeId) => buildDefault(nodeId));
return [...kept, ...added];
};
const syncChainGroupNodes = (
groupIndex: number,
selectedNodeIds: number[],
) => {
setForm((prev) => {
const chainNodes = [...(prev.chainNodes || [])];
const currentGroup = chainNodes[groupIndex] || [];
const protocol = currentGroup[0]?.protocol || "tls";
const strategy = currentGroup[0]?.strategy || "round";
const realNodes = currentGroup.filter((node) => node.nodeId !== -1);
const mergedNodes = mergeOrderedNodes(
realNodes,
selectedNodeIds,
(nodeId) => ({
nodeId,
chainType: 2,
protocol,
strategy,
}),
);
chainNodes[groupIndex] =
mergedNodes.length > 0
? mergedNodes
: [{ nodeId: -1, chainType: 2, protocol, strategy }];
return { ...prev, chainNodes };
});
};
// 提交表单
const handleSubmit = async () => {
if (!validateForm()) return;
@@ -416,28 +450,167 @@ 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);
const startItems = Array.isArray(payload.items)
? (payload.items as DiagnosisResult["results"])
: [];
setDiagnosisResult((prev) => ({
tunnelName: startTunnelName,
tunnelType: startTunnelType,
timestamp: Date.now(),
results: startItems.length > 0 ? startItems : prev?.results || [],
}));
if (Number.isFinite(startTotal) && startTotal >= 0) {
setDiagnosisProgress((prev) => ({
...prev,
total: startTotal,
}));
}
},
onItem: ({ result, progress }) => {
setDiagnosisResult((prev) => {
const base: DiagnosisResult = prev || {
tunnelName: tunnel.name,
tunnelType: tunnel.type === 1 ? "端口转发" : "隧道转发",
timestamp: Date.now(),
results: [],
};
const nextResults = [...base.results];
const existingIndex = nextResults.findIndex(
(item) =>
item.description === result.description &&
item.nodeId === result.nodeId &&
item.targetIp === result.targetIp &&
item.targetPort === result.targetPort,
);
if (response.code === 0) {
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,
diagnosing: false,
};
} else {
nextResults.push({
...result,
diagnosing: false,
});
}
return {
...base,
timestamp: Date.now(),
results: nextResults,
};
});
setDiagnosisProgress({
total: progress.total,
completed: progress.completed,
success: progress.success,
failed: progress.failed,
timedOut: Boolean(progress.timedOut),
});
},
onDone: (progress) => {
setDiagnosisProgress({
total: progress.total,
completed: progress.completed,
success: progress.success,
failed: progress.failed,
timedOut: Boolean(progress.timedOut),
});
},
onError: (message) => {
streamErrorMessage = message;
},
},
abortController.signal,
);
if (streamResult.fallback) {
const response = await diagnoseTunnel(tunnel.id);
if (response.code === 0) {
const resultData = response.data as DiagnosisResult;
const successCount = resultData.results.filter((r) => r.success).length;
const failedCount = resultData.results.length - successCount;
setDiagnosisResult(resultData);
setDiagnosisProgress({
total: resultData.results.length,
completed: resultData.results.length,
success: successCount,
failed: failedCount,
timedOut: false,
});
} else {
toast.error(response.msg || "诊断失败");
setDiagnosisResult(
buildDiagnosisFallbackResult({
tunnelName: tunnel.name,
tunnelType: tunnel.type,
description: "诊断失败",
message: response.msg || "诊断过程中发生错误",
}),
);
setDiagnosisProgress({
total: 1,
completed: 1,
success: 0,
failed: 1,
timedOut: false,
});
}
return;
}
if (streamErrorMessage) {
toast.error(streamErrorMessage);
}
if (streamResult.timedOut) {
toast.error("诊断超时(单条30秒 / 整体2分钟),已返回当前结果");
}
} catch {
if (abortController.signal.aborted) {
return;
}
toast.error("网络错误,请重试");
setDiagnosisResult(
buildDiagnosisFallbackResult({
@@ -447,7 +620,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);
}
};
@@ -796,6 +979,10 @@ export default function TunnelPage() {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{sortedTunnels.map((tunnel) => {
const typeDisplay = getTunnelTypeDisplay(tunnel.type);
const tunnelTypeChipClassName =
tunnel.type === 1
? "text-xs bg-primary-100 text-primary-800 border-primary-300 dark:bg-primary-900/45 dark:text-primary-200 dark:border-primary-700"
: "text-xs bg-success-100 text-success-800 border-success-300 dark:bg-success-900/35 dark:text-success-200 dark:border-success-700";
return (
<SortableItem key={tunnel.id} id={tunnel.id}>
@@ -819,7 +1006,7 @@ export default function TunnelPage() {
</h3>
<div className="flex items-center gap-1.5 mt-1">
<Chip
className="text-xs"
className={tunnelTypeChipClassName}
color={typeDisplay.color as any}
size="sm"
variant="flat"
@@ -1197,6 +1384,7 @@ export default function TunnelPage() {
<Select
description="当节点同时拥有IPv4和IPv6地址时,选择隧道连接使用的地址类型"
label="隧道连接地址偏好"
placeholder="自动选择"
selectedKeys={[form.ipPreference || ""]}
variant="bordered"
onSelectionChange={(keys) => {
@@ -1208,7 +1396,6 @@ export default function TunnelPage() {
}));
}}
>
<SelectItem key="">自动选择</SelectItem>
<SelectItem key="v4">优先IPv4</SelectItem>
<SelectItem key="v6">优先IPv6</SelectItem>
</Select>
@@ -1238,21 +1425,16 @@ export default function TunnelPage() {
selectionMode="multiple"
variant="bordered"
onSelectionChange={(keys) => {
const selectedIds = Array.from(keys).map((key) =>
parseInt(key as string),
);
const newInNodeId: ChainTunnel[] = selectedIds.map(
(nodeId) => {
// 保留已有的端口配置
const existing = form.inNodeId.find(
(ct) => ct.nodeId === nodeId,
);
const selectedIds = toSelectedNodeIds(keys);
return existing || { nodeId, chainType: 1 };
},
);
setForm((prev) => ({ ...prev, inNodeId: newInNodeId }));
setForm((prev) => ({
...prev,
inNodeId: mergeOrderedNodes(
prev.inNodeId,
selectedIds,
(nodeId) => ({ nodeId, chainType: 1 }),
),
}));
}}
>
{nodes.map((node) => (
@@ -1421,37 +1603,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) => (
@@ -1642,43 +1797,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: ChainTunnel[] =
selectedIds.map((nodeId) => {
const existing = realNodes.find(
(ct) => ct.nodeId === nodeId,
);
return (
existing || {
return {
...prev,
outNodeId: mergeOrderedNodes(
realNodes,
selectedIds,
(nodeId) => ({
nodeId,
chainType: 3,
protocol,
strategy,
}
);
});
setForm((prev) => ({
...prev,
outNodeId: newOutNodeId,
}));
}),
),
};
});
}}
>
{nodes.map((node) => (
@@ -1939,7 +2083,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) => (
@@ -1968,20 +2119,41 @@ export default function TunnelPage() {
)}
</ModalHeader>
<ModalBody className="bg-content1">
{diagnosisLoading ? (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3">
<Spinner size="sm" />
<span className="text-default-600">正在诊断...</span>
</div>
</div>
) : diagnosisResult ? (
{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="诊断超时(单条30秒 / 整体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">
总测试数
@@ -1989,10 +2161,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">
成功
@@ -2000,10 +2173,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">
失败
@@ -2076,6 +2250,10 @@ export default function TunnelPage() {
</thead>
<tbody className="divide-y divide-divider bg-white dark:bg-gray-800">
{results.map((result, index) => {
const isDiagnosing = Boolean(
result.diagnosing,
);
const isSuccess = result.success === true;
const quality = getDiagnosisQualityDisplay(
result.averageTime,
result.packetLoss,
@@ -2085,22 +2263,28 @@ export default function TunnelPage() {
<tr
key={index}
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
result.success
isDiagnosing
? "bg-warning-50 dark:bg-warning-900/20"
: isSuccess
? "bg-white dark:bg-gray-800"
: "bg-danger-50 dark:bg-danger-900/30"
}`}
>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<span
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{result.success ? "✓" : "✗"}
</span>
{isDiagnosing ? (
<Spinner size="sm" />
) : (
<span
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
isSuccess
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{isSuccess ? "✓" : "✗"}
</span>
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-foreground truncate">
{result.description}
@@ -2115,18 +2299,24 @@ export default function TunnelPage() {
<td className="px-3 py-2 text-center">
<Chip
color={
result.success
isDiagnosing
? "warning"
: isSuccess
? "success"
: "danger"
}
size="sm"
variant="flat"
>
{result.success ? "成功" : "失败"}
{isDiagnosing
? "诊断中"
: isSuccess
? "成功"
: "失败"}
</Chip>
</td>
<td className="px-3 py-2 text-center">
{result.success ? (
{isSuccess ? (
<span className="font-semibold text-primary">
{result.averageTime?.toFixed(0)}
</span>
@@ -2137,7 +2327,7 @@ export default function TunnelPage() {
)}
</td>
<td className="px-3 py-2 text-center">
{result.success ? (
{isSuccess ? (
<span
className={`font-semibold ${
(result.packetLoss || 0) > 0
@@ -2154,7 +2344,7 @@ export default function TunnelPage() {
)}
</td>
<td className="px-3 py-2 text-center">
{result.success && quality ? (
{isSuccess && quality ? (
<Chip
className="text-xs whitespace-nowrap"
color={quality.color as any}
@@ -2248,6 +2438,8 @@ export default function TunnelPage() {
</h3>
</div>
{results.map((result, index) => {
const isDiagnosing = Boolean(result.diagnosing);
const isSuccess = result.success === true;
const quality = getDiagnosisQualityDisplay(
result.averageTime,
result.packetLoss,
@@ -2257,21 +2449,27 @@ export default function TunnelPage() {
<div
key={index}
className={`border rounded-lg p-3 ${
result.success
isDiagnosing
? "border-warning-200 dark:border-warning-300/30 bg-warning-50 dark:bg-warning-900/20"
: isSuccess
? "border-divider bg-white dark:bg-gray-800"
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
}`}
>
<div className="flex items-start gap-2 mb-2">
<span
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{result.success ? "✓" : "✗"}
</span>
{isDiagnosing ? (
<Spinner size="sm" />
) : (
<span
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
isSuccess
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{isSuccess ? "✓" : "✗"}
</span>
)}
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm text-foreground break-words">
{result.description}
@@ -2283,16 +2481,24 @@ export default function TunnelPage() {
<Chip
className="flex-shrink-0"
color={
result.success ? "success" : "danger"
isDiagnosing
? "warning"
: isSuccess
? "success"
: "danger"
}
size="sm"
variant="flat"
>
{result.success ? "成功" : "失败"}
{isDiagnosing
? "诊断中"
: isSuccess
? "成功"
: "失败"}
</Chip>
</div>
{result.success ? (
{isSuccess ? (
<div className="grid grid-cols-3 gap-2 mt-2 pt-2 border-t border-divider">
<div className="text-center">
<div className="text-lg font-bold text-primary">
@@ -2336,8 +2542,16 @@ export default function TunnelPage() {
</div>
) : (
<div className="mt-2 pt-2 border-t border-divider">
<div className="text-xs text-danger">
{result.message || "连接失败"}
<div
className={`text-xs ${
isDiagnosing
? "text-warning"
: "text-danger"
}`}
>
{isDiagnosing
? result.message || "诊断中..."
: result.message || "连接失败"}
</div>
</div>
)}
@@ -2378,14 +2592,16 @@ export default function TunnelPage() {
</div>
{/* 失败详情(仅桌面端显示,移动端已在卡片中显示) */}
{diagnosisResult.results.some((r) => !r.success) && (
{diagnosisResult.results.some(
(r) => r.success === false && !r.diagnosing,
) && (
<div className="space-y-2 hidden md:block">
<h4 className="text-sm font-semibold text-danger">
失败详情
</h4>
<div className="space-y-2">
{diagnosisResult.results
.filter((r) => !r.success)
.filter((r) => r.success === false && !r.diagnosing)
.map((result, index) => (
<Alert
key={index}
+2 -1
View File
@@ -1,5 +1,6 @@
export interface DiagnosisEntry {
success: boolean;
success?: boolean;
diagnosing?: boolean;
description: string;
nodeName: string;
nodeId: string;
+60 -41
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import toast from "react-hot-toast";
import { parseDate } from "@internationalized/date";
@@ -219,6 +219,22 @@ export default function UserPage() {
const [speedLimits, setSpeedLimits] = useState<SpeedLimit[]>([]);
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
const noLimitSpeedLimitIds = useMemo(() => {
return new Set(
speedLimits
.filter((speedLimit) => speedLimit.name.trim() === "不限速")
.map((speedLimit) => speedLimit.id),
);
}, [speedLimits]);
const normalizeSpeedId = (speedId?: number | null): number | null => {
if (speedId === null || speedId === undefined) {
return null;
}
return noLimitSpeedLimitIds.has(speedId) ? null : speedId;
};
// 生命周期
useEffect(() => {
loadUsers();
@@ -432,7 +448,10 @@ export default function UserPage() {
try {
const tunnelsToAssign: TunnelAssignItem[] = Array.from(
batchTunnelSelections.entries(),
).map(([tunnelId, speedId]) => ({ tunnelId, speedId }));
).map(([tunnelId, speedId]) => ({
tunnelId,
speedId: normalizeSpeedId(speedId),
}));
const response = await batchAssignUserTunnel({
userId: currentUser.id,
@@ -456,6 +475,7 @@ export default function UserPage() {
const handleEditTunnel = (userTunnel: UserTunnel) => {
setEditTunnelForm({
...userTunnel,
speedId: normalizeSpeedId(userTunnel.speedId),
expTime: userTunnel.expTime,
});
onEditTunnelModalOpen();
@@ -472,7 +492,7 @@ export default function UserPage() {
num: editTunnelForm.num,
expTime: editTunnelForm.expTime,
flowResetTime: editTunnelForm.flowResetTime,
speedId: editTunnelForm.speedId,
speedId: normalizeSpeedId(editTunnelForm.speedId),
status: editTunnelForm.status,
});
@@ -583,13 +603,17 @@ export default function UserPage() {
};
const editAvailableSpeedLimits = speedLimits.filter(
(speedLimit) => speedLimit.tunnelId === editTunnelForm?.tunnelId,
(speedLimit) => !noLimitSpeedLimitIds.has(speedLimit.id),
);
const getSpeedLimitsForTunnel = (tunnelId: number) => {
return speedLimits.filter((sl) => sl.tunnelId === tunnelId);
const getSpeedLimitsForTunnel = (_tunnelId: number) => {
return speedLimits.filter(
(speedLimit) => !noLimitSpeedLimitIds.has(speedLimit.id),
);
};
const editTunnelSelectedSpeedId = normalizeSpeedId(editTunnelForm?.speedId);
const toggleTunnelSelection = (tunnelId: number) => {
setBatchTunnelSelections((prev) => {
const newMap = new Map(prev);
@@ -1146,7 +1170,7 @@ export default function UserPage() {
classNames={{
trigger: "min-h-10 h-10",
}}
placeholder="限速规则"
placeholder="不限速"
selectedKeys={
batchTunnelSelections.get(tunnel.id) !==
null &&
@@ -1157,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>
)}
@@ -1405,37 +1426,35 @@ export default function UserPage() {
<Select
label="限速规则"
placeholder="不限速"
selectedKeys={
editTunnelForm.speedId
? [editTunnelForm.speedId.toString()]
: ["null"]
editTunnelSelectedSpeedId !== null
? [editTunnelSelectedSpeedId.toString()]
: []
}
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 -1
View File
@@ -88,7 +88,7 @@ export interface Tunnel {
export interface SpeedLimit {
id: number;
name: string;
tunnelId: number;
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;
};