Compare commits

...

197 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
sagit 21008ccb43 fix: resolve federation forward card showing zero flow (#212)
## Summary

Fixed federation port-forward tunnels not displaying traffic on forward
cards.

**Root Cause:**
- Frontend's mergeFederationShareFlow only parsed shareId from tunnel
name (Share-{id}-Port-{port} format)
- Federation tunnels with custom names couldn't be resolved, causing
traffic display to show 0

**Fix:**
- Added fallback to resolve shareId via remote-usage bindings[].tunnelId
- Multiple resolution candidates are merged with max(currentFlow)
selection
- Maintains directFlow precedence over federation share flow

**Tests:**
- Added contract test for custom tunnel name with binding-based
resolution
- All existing tests pass
- Frontend builds successfully

Fixes: federation port-forward showing total flow 0
2026-02-25 21:24:50 +08:00
sagitchu 362d327bf9 fix: resolve federation forward card showing zero flow
- Fixed mergeFederationShareFlow to resolve shareId via tunnel binding
  when tunnel name is not in Share-{id}-Port-{port} format
- Added fallback to parse shareId from remote-usage bindings[].tunnelId
- Added contract test for custom tunnel name with binding-based resolution
- All tests pass, frontend builds successfully

Fixes federation port-forward tunnels with custom names not displaying
traffic on forward cards.
2026-02-25 21:21:46 +08:00
sagit 9a650fcc8f release: 2.1.5-rc14 - Federation forward flow linkage enhancement (#210)
## Summary
- Enhanced federation forward flow stats with local peer share support
- Merged local and remote flow usage tracking
- Added contract test for federation forward card flow linkage

## Changes
- `vite-frontend/src/pages/forward.tsx`: Integrated `getPeerShareList`
API
- `go-backend/tests/contract/`: Added federation forward flow linkage
contract test

## Testing
- Contract test added and verified
2026-02-25 20:36:54 +08:00
sagitchu 804a5a29ea feat: enhance federation forward flow linkage with peer share support
- Add getPeerShareList API integration in forward.tsx
- Merge local peer share flow with remote usage stats
- Add contract test for federation forward card flow linkage
- Ensure max current flow is tracked across both local and remote sources
2026-02-25 20:35:33 +08:00
sagit 6189fe23f1 feat: include forward ports in federation remote usage and display share flow (#209)
* feat(backend): include forward ports in federation remote usage list

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

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

* feat(frontend): display federation share flow in forward list

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-25 16:59:39 +08:00
sagit 7ba90e8696 fix(backend): resolve federation forward traffic stats and listener disappearance (#208)
* fix(backend): add repository methods for federation forward runtime management

- GetActiveForwardPeerShareRuntimeByServiceName: lookup runtime by share_id and service_name
- MarkForwardPeerShareRuntimeReleasedByServiceName: release runtime by service_name
- ListActiveForwardPeerShareRuntimesByNodeAndServiceName: node-scoped query for flow processing

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

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

* fix(backend): bind and release federation forward runtimes on service commands

- bindPeerShareForwardRuntimeServices: create runtime if missing, update ServiceName/Port/Applied/Status
- releasePeerShareForwardRuntimeServices: handle deleteservice command to mark runtime released
- parseFederationForwardServiceNamesForRelease: extract service names from delete payload
- Tests: bind creates runtime when missing, release marks runtime as released

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

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

* fix(backend): scope federation flow lookup by node to avoid cross-share collisions

- flowUpload: use GetNodeBySecret to extract nodeID for flow processing
- processFlowItem: accept nodeID parameter and pass to flow handlers
- processPeerShareFlowByServiceName: try node-scoped query first, fallback to global
- Add warning log when multiple runtimes match (ambiguous)
- Tests: update all processFlowItem calls with nodeID parameter

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

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

* test(contract): adjust federation dual panel contract expectations

Update assertion for entry share runtime binding behavior after fix

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-25 14:10:24 +08:00
sagit 0eed74fe10 fix: stabilize federation forward runtime cleanup and frontend version UX (#207)
## Summary
- fix federation forward runtime cleanup so valid forward listeners are
not treated as orphan during unbound binding windows
- add backend regression coverage for federation forward/runtime cleanup
and expiry handling paths
- wire frontend version update footer/flows and related UI updates
across admin pages

## Verification
- go test ./...
- make build
2026-02-25 11:22:12 +08:00
sagitchu 466cc65069 fix: stabilize federation forward runtime cleanup and wire frontend version update UX 2026-02-25 11:20:51 +08:00
sagit 9c41410f17 fix(backend): handle federation service arrays in runtime command (#206)
## Summary
- accept both top-level service arrays and wrapped `services` payloads
in federation runtime command parsing
- fix forward runtime service binding and port-range validation for
remote shared-node AddService/UpdateService calls
- add regression tests for top-level array payload handling to prevent
listener cleanup/flow mapping regressions

## Verification
- go test ./internal/http/handler -run
"TestBindPeerShareForwardRuntimeServicesAcceptsTopLevelServiceArray|TestValidateFederationCommandPortsAcceptsTopLevelServiceArray|TestBindPeerShareForwardRuntimeServicesOnlyBindsForwardRole|TestCleanOrphanedServicesSkipsActiveSharedForwardRuntimeServices|TestProcessFlowItemTracksPeerShareFlowByForwardServiceName"
- go test ./...
- make build
2026-02-25 00:08:32 +08:00
sagitchu bc71c524e0 fix(backend): accept top-level federation service payloads 2026-02-25 00:06:31 +08:00
sagit f46b2b4d86 fix(backend): keep shared federation port-forward listeners alive (#205)
## Summary
- prevent shared federation forward services from being misclassified as
orphaned during node config cleanup
- harden peer-share runtime mapping for service-name based flow
accounting and node/port conflict checks
- add regression tests for listener persistence, cleanup guards, runtime
binding, and federation port-forward lifecycle

## Verification
- go test ./internal/http/handler -run
\"TestCleanOrphanedServicesSkipsActiveSharedForwardRuntimeServices|TestCleanOrphanedServicesSkipsFederationServicePrefix|TestProcessFlowItemTracksPeerShareFlowByForwardServiceName|TestProcessFlowItemSkipsPeerShareFlowWhenServiceNameIsAmbiguous|TestFederationTunnelCreateRejectsOccupiedPort\"
- go test ./...
- make build
2026-02-24 23:08:02 +08:00
sagitchu 9f17d63cdc fix(backend): keep shared federation port-forward services stable 2026-02-24 23:06:09 +08:00
sagit 92f8ec47db fix(backend): track traffic stats for federation port-forward tunnels (#204)
## Summary

- Fixed traffic statistics not being tracked for federation port-forward
tunnels (tunnelType=1) in Panel Peering mode
- Added `parsePeerShareIDFromFederationTunnelName()` to extract shareID
from tunnel names matching `Share-{shareId}-Port-{port}` pattern
- Added `processPeerShareFlowFromForward()` to update
`peer_share.current_flow` when forward traffic belongs to a federation
port-forward tunnel
- Added regression test
`TestProcessFlowItemTracksPeerShareFlowForFederationPortForward`

## Root Cause

Federation + tunnel type=2 (隧道转发) traffic uses service names
`fed_svc_{runtimeID}` → correctly routed to `processPeerShareFlow()` →
`peer_share.current_flow` updated.

Federation + tunnel type=1 (端口转发) traffic uses regular forward service
names `{forwardId}_{userId}_{userTunnelId}` → only `AddFlow()` called →
`peer_share.current_flow` NOT updated → shared flow stats remain 0.

## Test Plan

- [x] `go test ./internal/http/handler -run
'TestProcessFlowItemTracksPeerShareFlow'` passes
- [x] `go test ./internal/http/handler` passes
- [x] `go test ./...` passes
- [x] `make build` succeeds
2026-02-24 20:41:18 +08:00
sagitchu a97484cd9b fix(backend): track traffic stats for federation port-forward tunnels
Federation mode panel peering with port-forward tunnel type (tunnelType=1)
was not updating peer_share.current_flow because regular forward traffic
uses different service name pattern than federation tunnel traffic.

Added parsePeerShareIDFromFederationTunnelName() to extract shareID from
tunnel names matching 'Share-{shareId}-Port-{port}' pattern, and
processPeerShareFlowFromForward() to update peer_share flow stats when
the forward belongs to a federation port-forward tunnel.
2026-02-24 20:25:04 +08:00
sagit ee6bc8c50e fix(frontend): keep mobile batch toolbar right-aligned (#203)
## Summary
- keep mobile batch action bars visually right-aligned while preserving
Android horizontal swipe behavior
- move overflow handling to an outer scroller and keep action layout in
an inner `min-w-full` flex row
- apply the same structure across forward, node, and tunnel pages for
consistent behavior

## Verification
- `npm run build` *(fails due to pre-existing issue: `src/main.tsx`
cannot find module `virtual:pwa-register`)*
- `npm run lint -- src/pages/forward.tsx src/pages/node.tsx
src/pages/tunnel.tsx` *(fails due to pre-existing a11y labels in
`forward.tsx` lines 3202/3223)*
2026-02-23 22:55:49 +08:00
sagitchu c94ab84ab9 fix(frontend): keep mobile batch toolbar right-aligned
Use an overflow wrapper with an inner min-w-full flex row so controls stay visually right-aligned when space is sufficient, while Android can still horizontally swipe when the toolbar overflows.
2026-02-23 22:53:48 +08:00
sagit 84a03215f4 fix(frontend): restore Android swipe for mobile batch toolbar (#202)
## Summary
- fix mobile batch action toolbar in forward/node/tunnel pages to keep
horizontal overflow reachable on Android
- switch toolbar alignment to start on mobile and keep right alignment
on `sm+` to avoid `justify-end` overflow reachability issues
- add `touch-pan-x` to improve horizontal swipe handling on mobile
browsers while preserving existing desktop layout

## Verification
- `npm run build` *(fails due to pre-existing issue: `src/main.tsx`
cannot find module `virtual:pwa-register`)*
- `npm run lint` *(fails due to pre-existing a11y errors in unrelated
files and in existing forward labels)*
2026-02-23 22:26:11 +08:00
sagitchu def93749eb fix(frontend): restore Android swipe for mobile batch toolbar
Switch mobile batch toolbar alignment to start and enable horizontal pan gestures so overflow actions remain reachable on Android while desktop right alignment stays unchanged.
2026-02-23 22:24:28 +08:00
sagit 945a1c0dfc fix(frontend): remove border from batch toggle button (#199) 2026-02-23 22:03:29 +08:00
sagit d752e096a3 fix(frontend): move batch operations into top toolbar (#198)
## Summary
- switch batch mode interactions to the top-right action bar on forward,
tunnel, and node pages
- remove bottom floating batch toolbars and restore normal action
toolbar when exiting batch mode
- stabilize toolbar layout and improve batch button visibility to avoid
visual jitter on toggle
2026-02-23 21:30:57 +08:00
sagitchu 880a3b81b0 fix(frontend): move batch actions into top toolbar
Switching batch controls to the top-right action bar with stable single-row layout improves clarity and prevents visual jumps while entering batch mode.
2026-02-23 21:28:50 +08:00
sagit 191aface2e fix(frontend): extend h5 tabbar safe-area coverage (#197)
Ensure the PWA bottom tab bar and tab hit areas fully cover the safe-area inset while keeping related frontend formatting updates consistent.
2026-02-23 07:45:14 +00:00
sagit e121dadb90 fix(backend): allow WHMCS API login when captcha is enabled (#196)
## Summary
- bypass captcha verification for machine API clients tagged as WHMCS
while keeping captcha enforcement for normal login flows
- add a backend contract test that verifies WHMCS-tagged login succeeds
when captcha is enabled
- keep WHMCS module repository changes out of this PR so only backend
behavior is merged to main
2026-02-23 14:42:24 +08:00
sagitchu bafcfbde3a fix(backend): allow WHMCS API login when captcha is enabled 2026-02-23 14:40:29 +08:00
sagit 98c463c62b feat(frontend): add installable PWA support and refresh app icons (#195) 2026-02-23 06:26:29 +00:00
sagit daf34d0f6c fix(backend): prevent login block when captcha enabled without cloudflare key (#194)
When captcha_enabled=true but cloudflare_secret_key is not configured,
the login flow would block users with "未配置Cloudflare Site Key" error.
Now captcha is treated as disabled if the secret key is missing, allowing
users to log in normally on fresh PostgreSQL installations.

Fixes login issue on new panel setups with PostgreSQL.
2026-02-22 22:54:51 +08:00
sagit bb505d461d fix(frontend): enable modal scroll on panel sharing page (#193)
Add scrollBehavior="inside" to Create Share and Edit Share modals
to allow content scrolling on mobile devices when form fields
exceed viewport height.
2026-02-22 21:53:29 +08:00
sagit 00be0ac31e fix(frontend): enable content scroll on mobile (#192)
Main content area had overflow-hidden on mobile which prevented
scrolling when content exceeded viewport height. Changed to
overflow-y-auto for consistent scroll behavior across all devices.
2026-02-22 21:12:45 +08:00
sagit fc5624a190 fix(frontend): modal footer buttons visible on mobile (#190)
ModalFooter used flex-col-reverse which caused buttons to display in
reverse order on mobile and potentially pushed cancel button out of
view. Changed to flex-wrap justify-end for consistent cross-platform
button display.
2026-02-22 20:05:02 +08:00
sagit 42ae3457b5 feat(frontend): persist filter state across page reloads (#189)
* feat(frontend): persist filter state across page reloads

Add useLocalStorageState hook to persist filter/search state in
forward, node, tunnel, user, and limit pages. Reset filter clears
the persisted value from localStorage.

* feat(ui): add collapsible sidebar and smooth animations
2026-02-22 19:01:03 +08:00
sagit 088027da7b fix(frontend): resolve remaining font blur in node and forward cards (#188)
## Summary
- apply the same anti-blur drag transform strategy used in tunnel cards
to node cards
- update forward card and table-row sortable transforms to round x/y
pixels and reduce subpixel text blur
- limit `willChange` usage to active dragging so text does not remain on
a promoted layer while idle

## Verification
- npm run build (vite-frontend)
2026-02-22 16:55:08 +08:00
sagitchu d37adee5df fix(frontend): resolve remaining font blur in node and forward cards 2026-02-22 16:53:35 +08:00
sagit c147e52d72 fix(frontend): ship pending card-view animation and interaction refinements (#186)
## Summary
- include the full pending frontend refinements across card views,
search/filter interactions, and bridge components
- keep sortable/card animation behavior aligned with the latest
anti-blur adjustments in tunnel card rendering
- bundle related UI consistency updates across tunnel, forward, node,
user, and dashboard pages

## Verification
- npm run build (vite-frontend)
2026-02-22 15:54:30 +08:00
sagitchu d483258eef fix(frontend): ship pending card-view animation and interaction refinements 2026-02-22 15:52:48 +08:00
sagit 79c28103d5 fix(frontend): prevent font blur in sortable card components (#185)
Add backface-visibility: hidden and font-smoothing properties to SortableItem components in tunnel, forward, and node pages to prevent GPU subpixel rendering blur during drag operations.

- tunnel.tsx: SortableItem wrapper anti-blur fix
- forward.tsx: SortableCard wrapper anti-blur fix
- node.tsx: SortableItem component anti-blur fix

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-22 15:11:47 +08:00
sagit f36bf1437c fix(frontend): prevent font blurriness caused by scale animations (#183)
Remove scale transforms from Framer Motion animations that cause subpixel rendering issues on text elements. Replace with opacity + translateY for smooth animations without blur.

- Remove scale from FadeIn component (animated-page.tsx)
- Remove scale from login page entrance animation (index.tsx)
- Remove scale from search bar button animation (search-bar.tsx)
- Remove whileTap scale from sidebar menu buttons (admin.tsx)
- Remove scale from captcha modal animation (globals.css)
- Add .gpu-accelerated utility class for future use

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-22 13:28:47 +08:00
sagit 4ad3aa2c06 feat: redesign forward card view completely syncing filters and sorti… (#179)
…ng logic
2026-02-21 21:19:53 +08:00
sagitchu 357a4b165e Merge remote-tracking branch 'origin/main' into card-view 2026-02-21 21:18:20 +08:00
sagitchu a15be253f5 feat: add framer-motion animations (page transitions, modals, dropdowns, buttons, search bar) 2026-02-21 21:16:42 +08:00
sagitchu 572d1c16a6 feat: Implement toggleable search input and data filtering across tunnel, user, and forward pages. 2026-02-21 20:43:32 +08:00
sagit 39e22c07de feat(backend): auto redeploy tunnel and forward config after node upgrade (#180) 2026-02-21 12:29:34 +00:00
sagitchu ca24573803 feat: redesign forward card view completely syncing filters and sorting logic 2026-02-21 20:08:29 +08:00
sagit 0cb3263a2e feat(frontend): support markdown in dashboard announcements (#176)
## Summary
- render dashboard announcements with Markdown using `react-markdown` +
`remark-gfm` while sanitizing output with `rehype-sanitize`
- add styled Markdown element mappings in the announcement banner for
links, lists, code blocks, and blockquotes
- update announcement management hint text to communicate Markdown
support in the config page

## Verification
- npm run build (vite-frontend)
2026-02-21 16:50:19 +08:00
sagit fb2189c924 Merge branch 'main' into feat/announcement-markdown-support 2026-02-21 16:36:51 +08:00
sagitchu 1383174b31 refactor: Standardize empty state UI for tunnel and user pages, update announcement banner styling, and add new entries to .gitignore. 2026-02-21 16:36:36 +08:00
sagitchu c95bde7055 style: space out provider and consumer share panel title cards from tabs 2026-02-21 16:36:05 +08:00
Sagit 57f5e3a1a3 feat(frontend): support markdown in dashboard announcements 2026-02-21 05:48:24 +00:00
sagit 66ad52c199 feat(node): add stable/test release channels for install and upgrade (#175)
## Summary
- classify release tags by channel: pure numeric tags are treated as
stable, while tags containing alpha/beta/rc (or other non-numeric
formats) are treated as test releases
- add channel-aware backend APIs for install/upgrade/release listing and
pin install commands to the selected tag via `VERSION=<tag>`
- update node page UI and API clients to let users pick stable vs test
channel for install and upgrade workflows

## Verification
- `go test ./internal/http/handler/...`
- `npm run build`
2026-02-21 13:23:20 +08:00
Sagit 2081dc9658 feat(node): support stable and test release channels 2026-02-21 04:17:20 +00:00
sagit b93255df3d feat: improve forward page grouped view (#165)
Added drag-and-drop sorting and filtering to grouped view.
2026-02-20 19:52:26 +08:00
sagitchu e1aef8700e style: make filter button icon-only 2026-02-20 19:49:57 +08:00
sagitchu d5b3a39774 feat: replace inline filters with modal dialog 2026-02-20 19:45:48 +08:00
sagitchu 022e9e3807 style: tightly pack user and tunnel filters 2026-02-20 19:39:12 +08:00
sagitchu d333d463f6 style: fix dropdown filter spacing 2026-02-20 19:33:56 +08:00
sagitchu abc9f21ab9 feat: improve forward page grouped view 2026-02-20 19:27:40 +08:00
sagit d216567c02 fix(frontend): switch grouped forward view to full list (#161)
* fix(frontend): left-align announcement logo in homepage banner

* fix(frontend): keep multiselect panels floating and preserve summaries

* fix(frontend): rebalance config card header spacing

* fix(frontend): normalize formatting and fix multiselect modal behavior

* fix(frontend): polish batch actions and sharing page guidance

* fix(frontend): switch grouped forward view to full list

* fix(backend): switch diagnosis internet target to bing

* style(frontend): beautify forward group view list display

* style: remove 'x' suffix from traffic ratio input and center config alert

* fix(ui): expand exit node select upwards in modal

* style: fix vertical alignment of logo and title in announcement banner
2026-02-20 10:31:39 +00:00
sagit 45bfd35a20 fix(backend): respect tunnel IP preference in forward diagnosis (#160)
Forward diagnosis chain-hop probes now inherit the tunnel ipPreference so v6-priority tunnels test IPv6 targets instead of defaulting to IPv4. Add a contract test to lock IPv6 target selection for entry->chain and chain->exit diagnostics.
2026-02-20 05:56:12 +00:00
sagit 5a1b72387d fix(frontend): polish batch actions and sharing page guidance (#157)
* fix(frontend): polish batch actions and sharing page guidance

* fix(frontend): add top spacing above sharing section banners

* fix(frontend): allow manual and calendar expiry date input

* fix(frontend): switch batch action buttons to warning tone

* fix(frontend): switch batch action buttons to default tone
2026-02-20 12:50:17 +08:00
sagit 66de566a00 fix(frontend): stabilize multiselect behavior and UI spacing (#154)
* fix(frontend): left-align announcement logo in homepage banner

* fix(frontend): keep multiselect panels floating and preserve summaries

* fix(frontend): rebalance config card header spacing

* fix(frontend): normalize formatting and fix multiselect modal behavior

* fix(frontend): restore config divider spacing on desktop
2026-02-19 20:50:12 +08:00
sagit 18c2da7c7e fix(frontend): prevent multiselect overflow and normalize card spacing (#152)
## Summary
- fix multi-select trigger overflow in shared select bridge by making
trigger/value flex children shrink correctly
- harden grouped assignment summaries against long selected-value text
wrapping overflow
- normalize card header/body spacing and node card IP row height so card
layouts stay visually consistent across modules

## Verification
- ran `npm run build` in `vite-frontend` successfully
- checked diagnostics on changed frontend files (no diagnostics)
2026-02-19 18:23:28 +08:00
Sagit c4d807f1c4 fix(frontend): align card body spacing with node cards 2026-02-19 10:16:59 +00:00
Sagit d1460ab9c7 fix(frontend): tighten remaining card header spacing 2026-02-19 09:52:14 +00:00
Sagit 9189c68800 fix(frontend): normalize card spacing and multiselect overflow 2026-02-19 09:17:04 +00:00
sagit efbdabceca fix(frontend): align diagnosis status and permission layout (#148)
* fix(frontend): align diagnosis status and permission layout

* fix(frontend): polish batch toolbar controls on cards

* fix(user): stabilize tunnel permission checkbox interactions
2026-02-19 16:46:37 +08:00
sagit 6e5a71f489 fix(frontend): resolve scroll, diagnosis layout, and multi-select regressions (#147)
* fix(frontend): restore modal scroll and bridged multi-select UI

* fix(frontend): collapse multi-select panel and clean diagnosis labels
2026-02-19 16:10:02 +08:00
sagit e5c57f81ad docs(readme): refresh Modifications section for rewrite scope (#146)
## Summary
- Clarify that FLVX is a deeply reworked fork rather than a light patch.
- Update the Modifications section to reflect backend rewrite and
frontend rework scope.
- Align infrastructure notes with current deployment and installer
workflow wording.
2026-02-19 15:17:32 +08:00
Sagit 0b1609c6cb docs(repo): refresh README Modifications for rewrite scope 2026-02-19 07:15:43 +00:00
sagit a10c68ef20 docs(repo): refresh AGENTS knowledge for 2.1.4-rc2 (#145)
## Summary
- update root `AGENTS.md` metadata and notes to reflect `main@137c34e`
and tag `2.1.4-rc2`
- refresh `vite-frontend/AGENTS.md` to document the shadcn bridge
architecture and Tailwind v4 semantic token wiring
- add current frontend conventions/anti-patterns to prevent regressions
(raw JWT header and token import requirements)
2026-02-19 15:08:37 +08:00
Sagit 9aedeab406 docs(repo): refresh AGENTS docs for 2.1.4-rc2 2026-02-19 07:06:41 +00:00
sagit 137c34e3f5 feat(user): support user-group assignment in user management (#142)
## Summary
- add backend support to bind users to one or more user groups on
create/update
- add a new `/user/groups` API endpoint and repository methods for
user-group mapping queries/mutations
- update user modal UI to fetch/select user groups and submit `groupIds`
with user create/edit requests

## Notes
- includes minor frontend formatting changes in existing pages
(`config.tsx`, `dashboard.tsx`, `tunnel.tsx`) that were part of the
working tree
2026-02-19 14:51:44 +08:00
sagit 25d29c305f feat(frontend): complete shadcn/ui migration with compatibility bridge (#144)
## Summary
- extract reusable frontend domain modules (hooks, api typing/error
helpers, and page helper submodules for dashboard/forward/node/tunnel)
to reduce page-level coupling
- add a local shadcn/ui foundation plus HeroUI-compatible bridge layer
and migrate app imports to the bridge, enabling full UI stack
replacement without rewriting business logic
- replace HeroUI theme/plugin dependencies with local Tailwind token
configuration, remove HeroUI packages from dependencies, and document
the end-to-end migration/refactor execution plan

## Verification
- npm run build
- npm ls @heroui/button @heroui/system @nextui-org/system --depth=0
2026-02-19 14:50:29 +08:00
Sagit 9dcf9a1a43 fix(frontend): restore button border and color semantics 2026-02-19 06:36:41 +00:00
Sagit 2308b25bcf fix(frontend): forward refs through shadcn bridge buttons 2026-02-19 05:55:54 +00:00
Sagit b6c2159614 docs(frontend): document refactor batches and shadcn migration execution 2026-02-19 05:15:58 +00:00
Sagit 30c96a280d feat(frontend): wire pages to shadcn bridge and modular helpers 2026-02-19 05:15:26 +00:00
Sagit 12c50df6a7 feat(frontend): add shadcn ui primitives and compatibility bridge 2026-02-19 05:14:42 +00:00
Sagit c5124a01e6 refactor(frontend): extract reusable hooks and page helper modules 2026-02-19 05:14:11 +00:00
Sagit 6d57b49595 style(user): simplify search input wrapper classes 2026-02-18 18:55:50 +00:00
Sagit d12c5bf2e1 feat(user): support user-group assignment in user management 2026-02-18 14:47:12 +00:00
sagit 42701e6c01 chore(repo): remove analysis submodule reference (#141) 2026-02-18 21:24:17 +08:00
sagit d6c17aee79 feat(config): use tunnel-style selectors for backup import/export (#140)
* feat(config): use tunnel-style backup selectors with select-all

* feat(config): move backup selectors into modals

* chore(repo): ignore .opencode and add analysis reference
2026-02-18 21:14:05 +08:00
sagit 17f8a06704 fix(tunnel): sync forwards to agents after tunnel update (#139)
Co-authored-by: Antigravity <antigravity@google.com>
2026-02-18 19:48:09 +08:00
sagit e7b777890e fix(backend): rename legacy postgres unique constraints before AutoMigrate (#138)
Old schema.sql created tables with inline UNIQUE column constraints,
which PostgreSQL auto-names as <table>_<column>_key. GORM expects
uni_<table>_<column> (its NamingStrategy convention). On upgrade,
AutoMigrate issued DROP CONSTRAINT uni_... against a name that did not
exist, crashing startup with SQLSTATE 42704.

Add preparePostgresLegacySchema() that runs before autoMigrateAll and
renames all five mismatched constraints:
- vite_config_name_key -> uni_vite_config_name
- peer_share_token_key -> uni_peer_share_token
- peer_share_runtime_reservation_id_key -> uni_peer_share_runtime_reservation_id
- peer_share_runtime_resource_key_key -> uni_peer_share_runtime_resource_key
- federation_tunnel_binding_resource_key_key -> uni_federation_tunnel_binding_resource_key

The function is idempotent: it checks information_schema before each
rename so re-runs on already-migrated databases are no-ops.

Co-authored-by: Antigravity <antigravity@google.com>
2026-02-18 18:44:37 +08:00
Misaka Master 5b03ce87ff feat(tunnel): 支持 0~1 浮点倍率输入 (#137)
Co-authored-by: ZJU-Inno-WMX <wumingxuan@zju.edu.cn>
2026-02-18 18:01:18 +08:00
sagit 2aebb9ed5e Merge pull request #134 from Sagit-chu/fix-tunnel-ipv6-preference
fix(tunnel): respect IPv6 preference in tunnel creation and diagnostics
2026-02-18 10:04:50 +08:00
Antigravity e209fc689a fix(tunnel): respect IPv6 preference in tunnel creation and diagnostics 2026-02-18 02:03:19 +00:00
sagit f7bcb13f75 Merge pull request #132 from Sagit-chu/opencode/silent-wizard
refactor(backend): migrate to modular repository pattern
2026-02-17 16:48:34 +08:00
Antigravity 3d1a8c8963 refactor(tests): centralize DB query assertions with helpers
Reduce repetitive raw SQL in test bodies by routing scalar and multi-column checks through shared helpers, keeping test intent clearer without changing behavior.
2026-02-17 06:02:46 +00:00
sagit d82c099c7f Merge branch 'main' into opencode/silent-wizard 2026-02-17 13:18:31 +08:00
sagit 2dfcad6154 Merge pull request #133 from Sagit-chu/docs/document-existing-specs
docs: Document existing functionality with OpenSpec
2026-02-17 13:13:40 +08:00
Antigravity ba7e3c9893 docs: Add project specs and existing feature documentation 2026-02-17 05:12:21 +00:00
Antigravity d4622903b2 Merge remote-tracking branch 'origin/main' into opencode/silent-wizard
# Conflicts:
#	go-backend/internal/store/sqlite/repository.go
2026-02-17 04:54:11 +00:00
Antigravity 66be07750f refactor(backend): migrate to modular repository pattern with separated concerns
- Extract database layer into model and repo packages
- Split repository into focused modules (control, federation, flow, groups, mutations)
- Remove monolithic db.go and sqlite/repository.go
- Update handlers to use new repository structure
- Migrate contract tests to new patterns
- Add migration plan documentation
2026-02-17 04:47:11 +00:00
sagit a982c663d2 fix(backend): force column checks in migration even if schema version is current (#131)
fix(backend): force column checks in migration
2026-02-15 16:52:04 +00:00
sagit 98b4d78b4d fix: add missing ip_preference column to PostgreSQL tunnel table (#130) 2026-02-15 16:30:30 +00:00
sagit 45d7970177 feat: 添加公告系统(支持SQLite和PostgreSQL) (#129)
* feat(announcement): add database schema for SQLite and PostgreSQL

Add announcement table with id, title, content, enabled, created_at, updated_at columns to both SQLite and PostgreSQL schemas to support announcement system.

* feat(announcement): implement SQLite repository for announcement management

Add announcement CRUD operations in SQLite repository including create, read, update, delete, and list methods with proper error handling.

* feat(announcement): add HTTP handlers and auth middleware for announcement API

Implement admin-only update endpoint and public read endpoint for announcements. Add auth middleware to enforce admin-only access for update operations.

* feat(announcement): add frontend API client for announcement endpoints

Implement API client methods for fetching announcements and updating announcement settings with proper error handling.

* feat(announcement): add announcement display component to dashboard

Implement announcement display section in dashboard with real-time updates and proper styling using HeroUI components.

* feat(announcement): add announcement management UI to config page

Implement announcement settings panel with enable/disable toggle and content editor for admin users to manage announcements.
2026-02-15 15:43:41 +00:00
sagit 1b4500202a feat: update agents.md and add a feat (#128)
* docs(agents): update knowledge base with encryption, API envelope, and build conventions

Add comprehensive documentation of project conventions including:
- Encryption patterns (AES with node secret PSK)
- API envelope structure (code, msg, data, ts)
- Build peculiarities (minify: false, rolldown-vite, UPX compression)
- Unique styles (flat monorepo, asymmetric Go layout, hybrid frontend mode)
- Module boundaries and anti-patterns
- Large file hotspots and code map references

Updated 7 AGENTS.md files across root and submodules.

* test(backend): add comprehensive dual-stack IP preference test suite

Added 43 tests covering:
- Core IP selection logic (selectTunnelDialHost)
- Node capability detection (nodeSupportsV4/V6)
- Address picker functions
- API contract tests for create/update/list
- Database compatibility (SQLite + PostgreSQL)

Fixed pre-existing broken test in federation_runtime_test.go
2026-02-15 15:17:15 +00:00
sagit 9a9e83dda0 docs(agents): update knowledge base with encryption, API envelope, and build conventions (#127)
Add comprehensive documentation of project conventions including:
- Encryption patterns (AES with node secret PSK)
- API envelope structure (code, msg, data, ts)
- Build peculiarities (minify: false, rolldown-vite, UPX compression)
- Unique styles (flat monorepo, asymmetric Go layout, hybrid frontend mode)
- Module boundaries and anti-patterns
- Large file hotspots and code map references

Updated 7 AGENTS.md files across root and submodules.
2026-02-15 14:33:00 +00:00
sagit e5e22baf43 fix(federation): cleanup tunnels when unsharing federation node (#126)
When unsharing a federation node, tunnels created via federationTunnelCreate
were not cleaned up, allowing clients to continue using them. Added
cleanupFederationTunnels() to delete these tunnels and reload the node agent.
2026-02-15 11:04:53 +00:00
sagit 961c06655a fix(backend): enforce port range restrictions in federation mode (#125)
Added dual-layer port range enforcement for federation sharing:

Server-side (Provider):
- federationRuntimeApplyRole: validate runtime.Port against share range
- validateFederationCommandPorts: hardened against malformed JSON bypass
- New helpers: validateRemoteNodePort, remoteNodePortRange

Client-side (Consumer):
- prepareTunnelCreateState: pre-check ports for remote nodes
- tunnelCreate type=1: validate targetPort for remote entry
- forwardCreate/Update/BatchChangeTunnel: port range validation

Prevents consumers from using arbitrary ports outside provider's allowed range.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-15 10:42:39 +00:00
sagit 8dc31383e0 fix(backend): correct federation port forwarding tunnel type handling (#123)
- Fix Type 1 (port forwarding) tunnels to call applyFederationRuntime
  Previously only Type 2 tunnels applied federation runtime, causing
  port forwarding tunnels to not be properly configured in federation mode

- Remove incorrect UDP tunnel type override in federationTunnelCreate
  UDP tunnels were being incorrectly set to Type 2, which conflicted with
  the federation runtime logic that expects Type 1 for port forwarding

These fixes ensure all tunnel types are properly handled in federation mode
with correct runtime configuration applied.
2026-02-15 12:12:54 +08:00
sagit 184ac3c3e5 Merge pull request #122 from Sagit-chu/claude/stupefied-jemison
fix(backend): enforce port range in federation runtime commands
2026-02-15 11:26:44 +08:00
sagitchu 77dbd719ed fix(backend): enforce port range in federation runtime commands
The federationRuntimeCommand handler forwarded AddService/UpdateService
commands from consumers to provider nodes without validating that the
port in the service payload falls within the share's allowed port range.
This allowed consumers to use any port on shared nodes, bypassing the
provider's port_range_start/port_range_end restrictions.

Add port extraction and validation in federationRuntimeCommand for
service commands, rejecting requests with ports outside the allowed
range with a 403 error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 11:22:50 +08:00
sagit 04ce125416 Merge pull request #121 from Sagit-chu/fix/backup-forward-ports
fix(backend): include forward_port in backup export/import
2026-02-15 10:02:18 +08:00
sagit fd5cfc2a40 fix(backend): include forward_port in backup export/import 2026-02-15 01:44:57 +00:00
sagit 4e4193e0b0 Merge pull request #120 from Sagit-chu/opencode/pg-id-repair-ci-2-1-3-rc2
fix(backend): harden postgres id default repair and CI coverage
2026-02-14 19:04:30 +08:00
sagit 3f80278dd4 fix(backend): harden postgres id default repair and CI coverage
Run Postgres id-sequence repair on every startup migration and add contract coverage plus a GitHub Actions Postgres job to catch schema-drift regressions before release.
2026-02-14 11:02:46 +00:00
sagit 6abe3e7713 Merge pull request #117 from Sagit-chu/opencode/happy-island
fix(backend): restore user tunnel visibility on PostgreSQL
2026-02-14 12:33:59 +08:00
sagit 47c05c3d02 Merge remote-tracking branch 'origin/main' into opencode/happy-island 2026-02-14 04:32:50 +00:00
sagit d05c8a2ea4 fix(backend): restore user tunnel visibility on PostgreSQL 2026-02-14 04:31:52 +00:00
sagit e00e41bb64 fix(frontend): remove focus styles from Select trigger and Textarea (#116) 2026-02-14 03:40:14 +00:00
sagit 5271efec1e fix(frontend): aggressive removal of input focus styles (#115) 2026-02-14 03:22:15 +00:00
sagit 2d39cb3005 fix(frontend): force remove input focus border and ring (#114)
* fix(frontend): remove blue outline on input focus

* fix(frontend): force remove input focus border and ring

* fix: resolve conflict markers in globals.css
2026-02-14 03:15:34 +00:00
sagit 3e52c8eace fix(frontend): remove blue outline on input focus (#113) 2026-02-14 02:37:59 +00:00
sagit 7808d57a79 Merge pull request #112 from Sagit-chu/opencode/proud-planet
fix(frontend): remove deep blue focus border
2026-02-14 10:16:33 +08:00
sagit 46bc4ca6e4 fix(frontend): remove deep blue focus border 2026-02-14 02:14:50 +00:00
sagit 28e66ab172 Merge pull request #111 from Sagit-chu/opencode/proud-planet
fix: apply remote entry runtime updates and input focus styling
2026-02-14 09:26:40 +08:00
sagit f19bccec4c Merge remote-tracking branch 'origin/main' into opencode/proud-planet 2026-02-14 01:25:21 +00:00
sagit e37d6cf666 fix(frontend): remove input inner shadow and improve focus visibility 2026-02-14 01:16:08 +00:00
sagit 177c2bc35f Merge pull request #110 from Sagit-chu/opencode/proud-planet
fix(backend): stabilize tls forwarding and udp ttl defaults
2026-02-13 22:42:27 +08:00
sagit 76c0978763 Merge branch 'main' into opencode/proud-planet 2026-02-13 22:40:19 +08:00
sagit fd1168d855 fix(backend): set udp ttl default for tls tunnel protocol 2026-02-13 14:22:13 +00:00
sagit 92c9590c1a fix(backend): apply entry chains for remote federation nodes
Ensure remote entry nodes receive AddChains during tunnel runtime apply while tolerating offline/timeout cases. Add focused contract coverage for online and offline remote entry behavior.
2026-02-13 13:59:17 +00:00
sagit 2afb1d275a Merge pull request #109 from Sagit-chu/opencode/playful-circuit
fix(backup): handle nullable tunnel export fields safely
2026-02-13 18:38:02 +08:00
sagit 880cd4cac5 fix(backup): handle nullable tunnel export fields safely 2026-02-13 10:34:58 +00:00
sagit a69a0f040b Merge branch 'main' into opencode/kind-planet 2026-02-13 17:48:27 +08:00
sagit cf6294a77d fix(backend): normalize strategy data and proxy ip parsing 2026-02-13 09:42:38 +00:00
sagit 524ee4cd95 Merge pull request #107 from Sagit-chu/opencode/curious-harbor
fix(backup): restore backup export/import APIs and route compatibility
2026-02-13 16:36:34 +08:00
sagit c049ceaacf fix(backend): resolve backup handler build conflict after main merge 2026-02-13 08:32:00 +00:00
sagit 3424221176 Merge branch 'main' into opencode/curious-harbor 2026-02-13 16:21:56 +08:00
sagit 5a9715eb26 fix(backup): restore backup export/import APIs and route compatibility 2026-02-13 08:17:34 +00:00
sagit 1b79213aed Merge pull request #106 from Sagit-chu/opencode/init-deep-agents
docs: update AGENTS.md hierarchy with new subdirectory docs
2026-02-13 16:11:51 +08:00
sagit c0d71125f4 Merge branch 'main' into opencode/init-deep-agents 2026-02-13 15:53:38 +08:00
sagit f01c0481cd docs: update AGENTS.md hierarchy with new subdirectory docs
- Update root AGENTS.md with expanded anti-patterns and notes
- Add handler/AGENTS.md for high-complexity backend handlers
- Add connector/AGENTS.md for GOST connector protocols
- Add socket/AGENTS.md for GOST socket utilities
2026-02-13 07:52:17 +00:00
sagit f227ffddc3 Merge pull request #105 from Sagit-chu/opencode/neon-rocket
fix: 修复备份
2026-02-13 15:33:25 +08:00
sagit c4f14f985e Merge remote-tracking branch 'origin/main' into opencode/neon-rocket 2026-02-13 07:30:53 +00:00
sagit 3b294c6b9e chore(frontend): fix HeroUI deps and apply lint cleanup 2026-02-13 07:25:27 +00:00
sagit 641aa66afc feat(backup): restore backup export/import flow 2026-02-13 07:25:13 +00:00
sagit fea1bf52f3 Merge pull request #102 from Sagit-chu/opencode/quick-comet
feat: 导入导出备份
2026-02-13 14:39:25 +08:00
sagit a72d84fa76 Merge branch 'main' into opencode/quick-comet 2026-02-13 14:21:00 +08:00
sagit 146821ebba Merge pull request #104 from Sagit-chu/opencode/tidy-cactus
fix: 修复共享节点作为入口的问题和调整docker 网络
2026-02-13 14:16:56 +08:00
sagit 0191f29cf1 chore(docker): update network subnet in compose files
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-13 06:02:09 +00:00
sagit 149a841a49 test(federation): add tests for remote node command and offline status
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-13 06:02:01 +00:00
sagit 229ae9e454 feat(backend): route node commands to remote panels
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-13 06:01:52 +00:00
sagit ae8a3db3df feat(federation): add remote node command support
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-13 06:01:42 +00:00
sagit 51660c413e Merge branch 'main' into opencode/quick-comet 2026-02-13 13:46:31 +08:00
sagit 2d2ca389e3 fix(backup): add transaction support and auto-backup before import
- Add transaction support for import operations with rollback on failure
- Add auto-backup before import to allow recovery on failure
- Convert user import to use INSERT ON CONFLICT pattern
- Add Execer interface to support both DB and Tx in import functions
2026-02-13 05:44:50 +00:00
sagit 3799729706 Merge pull request #103 from Sagit-chu/opencode/tidy-panda
fix: tls udp 转发
2026-02-13 13:15:06 +08:00
sagit 8628c35802 Merge branch 'main' into opencode/tidy-panda 2026-02-13 13:13:33 +08:00
sagit acea5ea76c fix(gost): filter expected net.ErrClosed noise in service handler
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-13 05:09:54 +00:00
sagit 8652380da1 feat(backend): TLS tunnel relay nodelay injection
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-13 05:09:46 +00:00
sagit dd206ced14 fix(permission): revoke inherited tunnel access after group unbind/removal 2026-02-13 03:28:21 +00:00
sagit f720b92f53 Merge branch 'main' into opencode/quick-comet 2026-02-13 11:10:09 +08:00
sagit f879a58bb4 feat(frontend): add backup export and import UI
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-13 03:06:32 +00:00
sagit b11283d488 feat(backend): add backup and restore functionality
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-13 03:06:23 +00:00
sagit 70f8dfeac1 Merge pull request #101 from Sagit-chu/opencode/eager-garden
fix(backend): implement keyword search in user list
2026-02-13 10:49:57 +08:00
sagit 37005a1954 Merge branch 'main' into opencode/eager-garden 2026-02-13 10:45:37 +08:00
sagit b55e056316 fix(backend): implement keyword search in user list
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-13 02:41:38 +00:00
sagit b3b7f5e56d Merge pull request #100 from Sagit-chu/opencode/gentle-comet
fix(tunnel): auto-update entry node IP on every tunnel update
2026-02-13 10:19:32 +08:00
sagit d6ff6ea500 Merge branch 'origin/main' into opencode/gentle-comet 2026-02-13 02:03:24 +00:00
sagit 9ed875b7ef fix(tunnel): auto-update entry node IP on every tunnel update 2026-02-13 01:24:48 +00:00
234 changed files with 35165 additions and 9087 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
}
+37
View File
@@ -48,6 +48,43 @@ jobs:
- name: Build
run: go build -v ./...
backend-postgres-contract:
name: Go Backend PostgreSQL Contract
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: flux_test
POSTGRES_PASSWORD: flux_test_pass
POSTGRES_DB: flux_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U flux_test -d flux_test"
--health-interval 10s
--health-timeout 5s
--health-retries 10
defaults:
run:
working-directory: go-backend
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
cache-dependency-path: go-backend/go.sum
- name: Download dependencies
run: go mod download
- name: Run PostgreSQL contract test
env:
FLVX_POSTGRES_TEST_DSN: 'postgres://flux_test:flux_test_pass@127.0.0.1:5432/flux_test?sslmode=disable'
run: go test ./tests/contract -run TestPostgresNodeCreateRepairsMissingIDDefaultContract -count=1
agent:
name: Build Agent
runs-on: ubuntu-latest
+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
+5
View File
@@ -259,6 +259,8 @@ gitee/
doraemon.jks
device.id
commit.sh
.opencode/
analysis/
sql/
!go-backend/internal/store/sqlite/sql/
!go-backend/internal/store/sqlite/sql/schema.sql
@@ -266,3 +268,6 @@ sql/
!go-backend/internal/store/postgres/sql/
!go-backend/internal/store/postgres/sql/schema.sql
!go-backend/internal/store/postgres/sql/data.sql
go-backend/gost.db-shm
.gitignore
go-backend/gost.db-wal
+59 -14
View File
@@ -1,24 +1,27 @@
# PROJECT KNOWLEDGE BASE
**Generated:** Mon Feb 02 2026
**Commit:** 7ca01ab
**Branch:** beta
**Generated:** Thu Feb 26 2026
**Commit:** 21008cc
**Branch:** main
**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
```
./
├── 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 (SQLite, net/http)
├── vite-frontend/ # React/Vite dashboard (HeroUI + Tailwind)
├── 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
@@ -28,10 +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) |
| **Web UI** | `vite-frontend/` | React/Vite dashboard (HeroUI + Tailwind) |
| **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 |
@@ -40,14 +48,28 @@ 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
- `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `go-backend/`.
- `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
- **Auth**: `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `go-backend/`.
- **Module Fork**: `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
- **Encryption**: Agent-to-panel communication uses AES encryption with node `secret` as PSK.
- **API Envelope**: All REST responses follow `{code, msg, data, ts}` structure (code 0 = success).
- **Frontend UI Layer**: Import UI primitives from `src/shadcn-bridge/heroui/*` (legacy-compatible facade), not direct `@heroui/*` packages.
- **Tailwind v4 Semantic Colors**: `src/styles/globals.css` must import `src/styles/tailwind-theme.pcss`; removing it breaks semantic classes like `bg-primary`, `text-foreground`, and `border-input`.
- **Go Versions**: `go-backend` uses Go 1.24, `go-gost` uses Go 1.23, `go-gost/x` uses Go 1.22.
## ANTI-PATTERNS (THIS PROJECT)
- Do not edit generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
- **DO NOT EDIT** generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
- **DO NOT ADD** `Bearer` prefix to Authorization header - expects raw JWT token.
- **DO NOT MODIFY** `install.sh` or `panel_install.sh` locally - CI overwrites these on release.
- **DO NOT** let backend handlers call `repo.DB()` directly — add a Repository method instead.
- **DO NOT ADD** frontend tests - project has no test infrastructure (Vitest/Jest not configured).
- **DO NOT REINTRODUCE** `@heroui/*` or `@nextui-org/*` dependencies; migration is now shadcn bridge-based.
- **DO NOT** use `type:jsonb` or `type:serial` in GORM tags (SQLite incompatible).
- **DO NOT** omit `TableName()` on new models — GORM pluralizes by default.
## COMMANDS
```bash
@@ -63,8 +85,31 @@ 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.
- CI uses UPX compression (`--best --lzma`) on Go binaries before release.
- CI dynamically injects `PINNED_VERSION` into install scripts and docker-compose files during releases.
- `panel_install.sh` auto-detects IPv6 and modifies `/etc/docker/daemon.json` to enable IPv6 bridge.
- Download proxy `https://gcode.hostcentral.cc/` used for GitHub downloads in China/restricted environments.
- Backend has contract tests in `go-backend/tests/contract/` - frontend has no test infrastructure.
- `analysis/3x-ui/` contains a separate git repo for reference/comparison - not part of FLVX core.
- CI workflows: `ci-build.yml` (build check), `docker-build.yml` (multi-arch images + release), `deploy-docs.yml` (MkDocs).
- PostgreSQL migration supported via `panel_install.sh` menu option using pgloader.
- Repository layer is large: `repository.go` (83k LOC), `repository_mutations.go` (43k LOC).
- Button visual parity relies on `vite-frontend/src/shadcn-bridge/heroui/button.tsx` color mapping + `vite-frontend/src/styles/tailwind-theme.pcss` token export.
+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
+17 -16
View File
@@ -129,27 +129,28 @@ docker compose up -d
- **License**: Apache License 2.0
## Modifications
The following major changes and additions have been made in this fork (FLVX):
This fork (FLVX) is no longer a light patch on top of the upstream project. It has been deeply reworked, with both backend and frontend rebuilt around a Go-based architecture.
### 1. Backend Architecture (Replaced)
- **Removed**: The original `springboot-backend/` (Java/Spring Boot) has been entirely removed.
- **Added**: A new `go-backend/` (Go/SQLite) implementation replaces the original backend.
### 1. Backend (Rewritten)
- **Removed**: The original `springboot-backend/` (Java/Spring Boot) implementation.
- **Added**: A fully rewritten `go-backend/` service (Go), including updated data and API handling for panel management.
### 2. Forwarding Agent (Modified)
- **Modified**: `go-gost/` - Modified forwarding agent wrapper.
- **Modified**: `go-gost/x/` - Modified local fork of the `gost` extensions library.
### 2. Frontend (Reworked)
- **Reworked**: `vite-frontend/` has been substantially rebuilt to match the new backend contract and current UI layer architecture.
- **Updated**: Dashboard pages/components and interaction flows for the current React/Vite stack.
### 3. Frontend (Modified)
- **Modified**: `vite-frontend/` - Significant updates to the React/Vite dashboard to compatible with the new Go backend, including UI/UX improvements (HeroUI + Tailwind).
### 3. Forwarding Stack (Modified)
- **Modified**: `go-gost/` forwarding agent wrapper.
- **Modified**: `go-gost/x/` local fork of `github.com/go-gost/x`.
### 4. Mobile Applications (Removed)
- **Removed**: `android-app/` - Source code for the Android client.
- **Removed**: `ios-app/` - Source code for the iOS client.
### 4. Mobile Clients (Removed)
- **Removed**: `android-app/` source code.
- **Removed**: `ios-app/` source code.
### 5. Infrastructure & Scripts
- **Modified**: `docker-compose.yml` (installer output name, auto-selects IPv4/IPv6 template, updated for Go backend).
- **Modified**: `install.sh`, `panel_install.sh` (Updated installation logic).
- **Added**: `AGENTS.md` (Project documentation).
### 5. Deployment & Project Infrastructure
- **Updated**: Docker deployment templates and installer output flow (IPv4/IPv6 compose variants).
- **Updated**: Release installation scripts (`install.sh`, `panel_install.sh`) and supporting automation.
- **Added/Updated**: Project-level engineering documentation (for example `AGENTS.md`).
---
+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)
## 免责声明
+3 -6
View File
@@ -7,18 +7,17 @@ services:
driver: json-file
options:
max-size: "20m"
max-file: "3"
environment:
DB_TYPE: ${DB_TYPE:-sqlite}
DB_PATH: /app/data/gost.db
DATABASE_URL: ${DATABASE_URL:-}
JWT_SECRET: ${JWT_SECRET}
LOG_DIR: /app/logs
SERVER_ADDR: :6365
TZ: Asia/Shanghai
ports:
- "${BACKEND_PORT}:6365"
volumes:
- backend_logs:/app/logs
- sqlite_data:/app/data
networks:
- gost-network
@@ -63,6 +62,7 @@ services:
driver: json-file
options:
max-size: "20m"
max-file: "3"
ports:
- "${FRONTEND_PORT}:80"
depends_on:
@@ -79,9 +79,6 @@ volumes:
postgres_data:
name: postgres_data
driver: local
backend_logs:
name: backend_logs
driver: local
networks:
@@ -90,4 +87,4 @@ networks:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
- subnet: 172.80.0.0/16
+3 -6
View File
@@ -7,18 +7,17 @@ services:
driver: json-file
options:
max-size: "20m"
max-file: "3"
environment:
DB_TYPE: ${DB_TYPE:-sqlite}
DB_PATH: /app/data/gost.db
DATABASE_URL: ${DATABASE_URL:-}
JWT_SECRET: ${JWT_SECRET}
LOG_DIR: /app/logs
SERVER_ADDR: :6365
TZ: Asia/Shanghai
ports:
- "${BACKEND_PORT}:6365"
volumes:
- backend_logs:/app/logs
- sqlite_data:/app/data
networks:
- gost-network
@@ -63,6 +62,7 @@ services:
driver: json-file
options:
max-size: "20m"
max-file: "3"
ports:
- "${FRONTEND_PORT}:80"
depends_on:
@@ -79,9 +79,6 @@ volumes:
postgres_data:
name: postgres_data
driver: local
backend_logs:
name: backend_logs
driver: local
networks:
@@ -91,5 +88,5 @@ networks:
enable_ipv6: true
ipam:
config:
- subnet: 172.20.0.0/16
- subnet: 172.80.0.0/16
- subnet: fd00:dead:beef::/48
+32 -12
View File
@@ -1,8 +1,8 @@
# GO BACKEND KNOWLEDGE BASE
## OVERVIEW
Go-based Admin API for FLVX (formerly Flux Panel). Replaces the legacy Spring Boot backend.
**Stack:** Go 1.23, net/http (std lib), SQLite (modernc.org/sqlite).
Go-based Admin API for FLVX. Replaced legacy Spring Boot backend.
**Stack:** Go 1.24, net/http (std lib), GORM + SQLite/PostgreSQL (glebarez/sqlite - CGO-free).
## STRUCTURE
```
@@ -14,12 +14,18 @@ go-backend/
│ │ ├── handler/ # API Handlers (User, Tunnel, Node, etc.)
│ │ ├── middleware/ # JWT, CORS, Logging, Recover
│ │ └── response/ # JSON response helpers
│ ├── store/sqlite/ # Data Access Layer (Repository pattern)
│ │ ├── repository.go # SQL queries & Struct definitions
│ │ └── sql/ # Embedded schema.sql & data.sql
│ ├── store/
│ │ ├── model/model.go # GORM model structs (single source of truth)
│ │ └── repo/ # Data Access Layer (Repository pattern, GORM)
│ │ ├── repository.go # Core queries, Open/OpenPostgres, AutoMigrate (83k LOC)
│ │ ├── repository_mutations.go # Mutation helpers (user/node/tunnel/forward CRUD, 43k LOC)
│ │ ├── repository_federation.go # Federation-specific queries
│ │ ├── repository_flow.go # Flow/forward status queries
│ │ ├── repository_control.go # Control plane queries
│ │ └── repository_groups.go # Group management queries
│ └── auth/ # Auth logic
├── tests/ # 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
```
@@ -27,21 +33,35 @@ go-backend/
| Task | Location | Notes |
|------|----------|-------|
| **API Routes** | `go-backend/internal/http/router.go` | Registers handlers to `http.ServeMux` |
| **DB Schema** | `go-backend/internal/store/sqlite/sql/schema.sql` | Embedded in binary |
| **SQL Queries** | `go-backend/internal/store/sqlite/repository.go` | Raw SQL, no ORM |
| **DB Models** | `go-backend/internal/store/model/model.go` | GORM structs with `TableName()` methods |
| **Repository** | `go-backend/internal/store/repo/` | GORM-based queries, all DB ops encapsulated |
| **Auth Middleware** | `go-backend/internal/http/middleware/jwt.go` | Extracts `Authorization` header |
| **WebSocket** | `go-backend/internal/ws/` | Real-time updates (traffic, status) |
| **Contract Tests** | `go-backend/tests/contract/` | Integration tests for auth, federation, tunnels |
## CONVENTIONS
- **No ORM**: Uses raw SQL with `database/sql` and `modernc.org/sqlite`.
- **GORM ORM**: Uses GORM with `glebarez/sqlite` (CGO-free) and `gorm.io/driver/postgres`.
- **AutoMigrate**: Schema created at startup via `autoMigrateAll()` — no hand-written DDL.
- **TableName()**: All models define explicit `TableName()` returning singular snake_case names.
- **Repository Pattern**: Handlers never access `*gorm.DB` directly — all queries go through `repo.Repository` methods.
- **Standard Lib**: Uses `net/http` for routing (Go 1.22+ patterns).
- **Auth**: Expects raw JWT in `Authorization` header (no `Bearer` prefix).
- **API Envelope**: All responses use `response.R{code, msg, data, ts}` structure.
- **Config**: Loaded from environment variables (see `cmd/paneld/main.go`).
- **SQLite Constraints**: `MaxOpenConns(1)`, WAL mode, busy_timeout=5000.
- **PostgreSQL**: Supported via `DB_TYPE=postgres` and `DATABASE_URL` env vars.
## ANTI-PATTERNS
- **DO NOT** let handlers call `repo.DB()` directly — add a Repository method instead.
- **DO NOT CHANGE** handler signatures without updating `router.go`.
- **DO NOT** use `type:jsonb` or `type:serial` in GORM tags (SQLite incompatible).
- **DO NOT** omit `TableName()` on new models — GORM pluralizes by default.
## COMMANDS
```bash
cd go-backend
go run ./cmd/paneld
go test ./...
go run ./cmd/paneld # Default: SERVER_ADDR=:6365
go test ./... # Unit tests
go test ./tests/contract/... # Contract tests
make build
```
+536
View File
@@ -0,0 +1,536 @@
# 数据库 GORM ORM 迁移计划
**创建时间:** 2026-02-15
**更新时间:** 2026-02-17 (实施:完成 P1 + P2 + P3 + P5(Repo 查询层 + schema 收尾) + 测试/构建收尾)
**分支:** main (commit e5e22ba)
**状态:** 基本完成(保留 4 处 PG 序列修复 DDL `Exec`)
---
## 一、现状分析
### 1.1 迁移前架构 (已归档)
项目原使用 `database/sql` + 手写 raw SQL,通过 `internal/store/db.go` 中的运行时 SQL 重写层实现 SQLite/PostgreSQL 双数据库兼容。
| 组件 | 行数 | 角色 | 当前状态 |
|------|------|------|----------|
| `store/db.go` | ~520 | SQL 方言重写层 | **已删除** |
| `store/sqlite/repository.go` | ~3118 | Repository 查询方法 | **已重写为 store/repo/** |
| `handler/mutations.go` | ~3748 | Handler 内直接写 raw SQL | **已迁移到 repo(生产 SQL=0)** |
| `handler/handler.go` | ~1283 | 部分方法用 `repo.DB()` | **大部分已迁移** |
| `handler/federation.go` | ~若干 | Federation 相关 SQL | **已迁移到 repo** |
| `handler/control_plane.go` | ~若干 | 控制面相关 SQL | **已迁移到 repo** |
| `handler/flow_policy.go` | ~若干 | 流量策略相关 SQL | **已迁移到 repo** |
| `handler/jobs.go` | ~若干 | 后台任务相关 SQL | **已迁移到 repo** |
| `store/postgres/` | 目录 | PostgreSQL 专用 schema/data | **已删除** |
### 1.2 痛点 (迁移目标)
1. ~~**双 Schema 维护**~~:已通过 AutoMigrate 解决
2. ~~**SQL 重写层复杂**~~:db.go 已删除
3. ~~**handler 直接写 SQL**~~:`mutations.go` 生产路径 `tx.Exec`/`tx.Raw` 已清零(测试代码除外)
4. ~~**无类型安全**~~:repo 业务查询已 GORM 化;剩余 4 处为 PG 序列修复 DDL `Exec`(设计保留)
5. ~~**模型定义分散**~~:已集中到 model/model.go
---
## 二、方案:引入 GORM ORM(全面重写)
### 2.1 方案变更说明
原计划为 **方案 D(扩展现有 DDL 重写层)**,现变更为 **方案 A(GORM 全面重写)**。
### 2.2 选择 GORM 的理由
1. Go 生态最成熟的 ORM,社区庞大,文档完善
2. 原生支持 SQLite + PostgreSQL 双数据库,自动处理方言差异
3. AutoMigrate 消除双 schema 维护,自动处理 AUTOINCREMENT ↔ SERIAL 等
4. 类型安全的模型定义,编译期检查字段映射
5. 内置事务管理(closure pattern 自动 rollback/commit)
6. 自动处理 `"user"` 保留字引号
### 2.3 GORM 驱动选择
| 数据库 | 驱动 | 包 | 备注 |
|--------|------|-----|------|
| SQLite | modernc.org/sqlite (CGO-free) | `github.com/glebarez/sqlite` | 纯 Go,无需 CGO |
| PostgreSQL | pgx/v5 | `gorm.io/driver/postgres` | 默认使用 pgx |
> **注意**:标准 `gorm.io/driver/sqlite` 依赖 CGO,必须使用 `glebarez/sqlite` 包装器。
### 2.4 核心设计原则
1. **Model 集中定义**:所有 GORM Model 在 `internal/store/model/` 包中
2. **Repository 模式保留**:Repository struct 持有 `*gorm.DB`,对外方法签名尽量不变
3. **Handler 不直接操作 DB**:所有数据库操作必须封装在 Repository 方法中
4. **AutoMigrate 替代 schema.sql**:启动时自动迁移,不再维护手写 DDL
5. **保留 PG 序列修复**:pgloader 迁移场景仍需 `ensurePostgresIDDefaults()`
6. **Package 重命名**:`store/sqlite` → `store/repo`
---
## 三、Model 设计
### 3.1 GORM 类型映射
| Go 类型 | GORM 行为 | PostgreSQL | SQLite |
|---------|-----------|------------|--------|
| `int64` + `primaryKey` | 自增主键 | `bigserial` | `INTEGER PRIMARY KEY AUTOINCREMENT` |
| `int64` | 64位整数 | `bigint` | `integer` (SQLite 自动 64位) |
| `int` | 整数 | `integer` | `integer` |
| `float64` | 浮点 | `double precision` | `real` |
| `string` + `size:100` | 变长字符 | `varchar(100)` | `varchar(100)` |
| `string` (无 size) | 文本 | `text` | `text` |
| `sql.NullInt64` | 可空整数 | `bigint NULL` | `integer NULL` |
| `sql.NullString` | 可空文本 | `text NULL` | `text NULL` |
### 3.2 表清单(21 张表)
| 表名 | Model | 特殊处理 |
|------|-------|----------|
| `user` | `User` | `TableName()` 返回 `"user"` (PG 保留字) |
| `forward` | `Forward` | |
| `forward_port` | `ForwardPort` | |
| `node` | `Node` | |
| `speed_limit` | `SpeedLimit` | |
| `statistics_flow` | `StatisticsFlow` | |
| `tunnel` | `Tunnel` | |
| `chain_tunnel` | `ChainTunnel` | |
| `user_tunnel` | `UserTunnel` | 复合唯一索引 (user_id, tunnel_id) |
| `tunnel_group` | `TunnelGroup` | |
| `user_group` | `UserGroup` | |
| `tunnel_group_tunnel` | `TunnelGroupTunnel` | 复合唯一索引 |
| `user_group_user` | `UserGroupUser` | 复合唯一索引 |
| `group_permission` | `GroupPermission` | 复合唯一索引 |
| `group_permission_grant` | `GroupPermissionGrant` | 复合唯一索引 |
| `vite_config` | `ViteConfig` | name 唯一 |
| `peer_share` | `PeerShare` | token 唯一 |
| `peer_share_runtime` | `PeerShareRuntime` | reservation_id, resource_key 唯一 |
| `federation_tunnel_binding` | `FederationTunnelBinding` | 复合唯一索引 + resource_key 唯一 |
| `announcement` | `Announcement` | |
| `schema_version` | `SchemaVersion` | |
---
## 四、详细实施步骤
### 阶段 1:基础设施 — 添加依赖 + 定义 Model ✅ 已完成
| 步骤 | 任务 | 文件 | 状态 |
|------|------|------|------|
| 1.1 | `go get gorm.io/gorm gorm.io/driver/postgres github.com/glebarez/sqlite` | `go.mod` | ✅ |
| 1.2 | 创建 `internal/store/model/model.go`,定义全部 21 个表 Model | 新文件 | ✅ |
| 1.3 | 为 `user` 表添加 `TableName()` 处理 PG 保留字 | model.go | ✅ |
| 1.4 | 为复合唯一索引的表添加 GORM 索引 tag | model.go | ✅ |
| 1.5 | 将 Backup 相关 struct 也迁移到 model/ | model.go | ✅ |
| 1.6 | 验证 `go build ./...` 编译通过 | - | ✅ |
### 阶段 2:GORM DB 初始化 ✅ 已完成
| 步骤 | 任务 | 文件 | 状态 |
|------|------|------|------|
| 2.1 | 修改 Repository struct,`*store.DB` → `*gorm.DB` | repository.go | ✅ |
| 2.2 | 重写 `Open()` — 用 `glebarez/sqlite` 打开 SQLite | repository.go | ✅ |
| 2.3 | 重写 `OpenPostgres()` — 用 `gorm.io/driver/postgres` 打开 PG | repository.go | ✅ |
| 2.4 | 用 `db.AutoMigrate()` 替代 `bootstrapSchema()` | repository.go | ✅ |
| 2.5 | 实现种子数据逻辑(FirstOrCreate 替代 data.sql) | repository.go | ✅ |
| 2.6 | 保留并适配 `ensurePostgresIDDefaults()`(用 `db.Exec()`) | repository.go | ✅ |
| 2.7 | 保留并适配 `migrateSchema()` 增量迁移 | repository.go | ✅ |
| 2.8 | `DB()` 方法返回 `*gorm.DB` | repository.go | ✅ |
| 2.9 | SQLite 连接池设置 `MaxOpenConns(1)` 防锁 | repository.go | ✅ |
### 阶段 3:重写 repository 查询方法 ⚠️ ~97% 完成
将所有 raw SQL 查询替换为 GORM 链式调用。
> **2026-02-16 审计**:基础 CRUD 查询已 GORM 化,但 mutation、JOIN 查询、import/export 仍大量使用 raw SQL。
> **2026-02-17 更新**:已完成 `repository_mutations.go`、Import、以及 `repository_federation/control/flow` 查询层 GORM 化;`repository.go` 中 Raw 已清零,当前仅保留 4 处 PG 序列修复 DDL `Exec`。
| 步骤 | 任务 | 方法数 | 状态 |
|------|------|--------|------|
| 3.1 | 用户查询:GetUserByUsername, GetUserByID, UsernameExists* 等 | ~5 | ✅ |
| 3.2 | 配置查询:GetConfigByName, ListConfigs, UpsertConfig | ~3 | ✅ |
| 3.3 | 公告查询:GetAnnouncement, UpsertAnnouncement | ~2 | ✅ |
| 3.4 | 节点查询:GetNodeBy*, ListNodes, UpdateNode* | ~6 | ✅ |
| 3.5 | 隧道查询:ListTunnels, ListTunnelGroups 等 (含 chain_tunnel 关联) | ~5 | ✅ |
| 3.6 | 转发查询:ListForwards, resolveForwardIngress | ~3 | ✅ |
| 3.7 | 用户隧道:GetUserPackageTunnels, GetUserPackageForwards | ~3 | ✅ |
| 3.8 | 统计/限速:GetStatisticsFlows, ListSpeedLimits, AddFlow | ~4 | ✅ |
| 3.9 | 分组查询:ListUserGroups, ListGroupPermissions 等 | ~4 | ✅ |
| 3.10 | PeerShare 全部方法 (CRUD + Runtime) | ~15 | ✅ |
| 3.11 | FederationTunnelBinding 全部方法 | ~4 | ✅ (Upsert 用 clause.OnConflict) |
| 3.12 | Export 全部方法 | ~10 | ✅ |
| 3.13 | Import 全部方法 | ~10 | ✅ 已全部改为 GORM `Clauses(clause.OnConflict)`(见 §9.6) |
| **3.14** | **repository_mutations.go 全部方法 (~40 个)** | **~40** | **✅ 已全量改为 GORM 链式调用(见 §9.3)** |
| **3.15** | **repository_federation.go 查询方法** | **~8** | **✅ 已全部改为 GORM 链式调用** |
| **3.16** | **repository_control.go 复杂查询** | **~5** | **✅ 已全部改为 GORM 链式调用** |
| **3.17** | **repository_flow.go 查询方法** | **~5** | **✅ 已全部改为 GORM 链式调用** |
| **3.18** | **Jobs 查询方法 (repository.go 尾部)** | **~8** | **✅ 已 GORM 化** |
### 阶段 4:消除 handler 中直接 SQL — 提取为 Repository 方法 ✅ 已完成
> **2026-02-16 审计**:handler 中的 SQL 已大部分提取到 repo 层,但这些 repo 方法本身仍使用 raw SQL(见阶段 3)。
> **2026-02-17 更新**:`mutations.go` 直接 `tx.Exec`/`tx.Raw` 已从 27 处降至 0 处(生产代码),详见 §9.4。
mutations.go 和其他 handler 文件中大量直接操作 `h.repo.DB()` 执行 raw SQL,需要:
1. 将 SQL 逻辑提取为 Repository 方法
2. Handler 只调用 Repository 方法
| 步骤 | 任务 | 文件 | 状态 |
|------|------|------|------|
| 4.1 | 用户 CRUD:userCreate, userUpdate, userDelete, userResetFlow | mutations.go | ✅ 已提取到 repo 方法 |
| 4.2 | 节点 CRUD:nodeCreate, nodeUpdate, nodeDelete, nodeBatch* | mutations.go | ✅ 已提取到 repo 方法 |
| 4.3 | 隧道 CRUD:tunnelCreate, tunnelUpdate, tunnelDelete, tunnelBatch* | mutations.go | ✅ tunnelCreate/Update 的 SQL 已下沉 repo |
| 4.4 | 转发 CRUD:forwardCreate, forwardUpdate, forwardDelete, forwardBatch* | mutations.go | ✅ 已提取到 repo (CreateForwardTx 等) |
| 4.5 | 限速 CRUD:speedLimitCreate, speedLimitUpdate, speedLimitDelete | mutations.go | ✅ 已提取到 repo 方法 |
| 4.6 | 分组 CRUD:所有 group* 方法 | mutations.go | ✅ 成员同步/权限管理 SQL 已下沉 repo |
| 4.7 | 用户隧道:userTunnelAssign, userTunnelRemove, userTunnelUpdate | mutations.go | ✅ 已提取到 repo 方法 |
| 4.8 | handler.go 中的直接 SQL (openAPISubStore 等) | handler.go | ✅ 已迁移(含 nil 检查清理) |
| 4.9 | federation.go 中的 raw SQL | federation.go | ✅ 已提取到 repo_federation.go |
| 4.10 | control_plane.go 中的 raw SQL | control_plane.go | ✅ 已提取到 repo_control.go |
| 4.11 | flow_policy.go 中的 raw SQL | flow_policy.go | ✅ 已提取到 repo_flow.go |
| 4.12 | jobs.go 中的 raw SQL | jobs.go | ✅ 已提取到 repo 方法(含 nil 检查清理) |
### 阶段 5:清理旧代码 ✅ 已完成
| 步骤 | 任务 | 文件 | 状态 |
|------|------|------|------|
| 5.1 | 删除 `internal/store/postgres/` 整个目录 | 目录删除 | ✅ |
| 5.2 | 删除 `internal/store/sqlite/sql/` 目录 | 目录删除 | ✅ |
| 5.3 | 删除 `internal/store/db.go` SQL 重写层 | 文件删除 | ✅ |
| 5.4 | 删除 `internal/store/db_test.go` | 文件删除 | ✅ |
| 5.5 | 清理 repository.go 中不再需要的 embed 指令 | 清理 | ✅ |
### 阶段 6:Package 重命名 ✅ 已完成
| 步骤 | 任务 | 文件 | 状态 |
|------|------|------|------|
| 6.1 | `internal/store/sqlite/` → `internal/store/repo/` | 目录重命名 | ✅ |
| 6.2 | 更新所有 import 路径:`store/sqlite` → `store/repo` (13处) | 全局替换 | ✅ |
### 阶段 7:测试 + 验证 ⚠️ 部分完成
| 步骤 | 任务 | 状态 |
|------|------|------|
| 7.1 | 更新所有现有测试适配 GORM | ✅ 测试已适配 (使用 repo.DB() 做数据准备) |
| 7.2 | `go test ./...` 全部通过 | ✅ 已通过(含 `internal/http/handler`、`tests/contract`) |
| 7.3 | `make build` 构建成功 | ✅ 已通过 |
### 阶段 8:文档更新 ✅ 已完成
| 步骤 | 任务 | 文件 | 状态 |
|------|------|------|------|
| 8.1 | 更新 `go-backend/AGENTS.md` — 移除 "DO NOT USE ORM",记录 GORM 规范 | AGENTS.md | ✅ |
| 8.2 | 更新根 `AGENTS.md` | AGENTS.md | ✅ |
| 8.3 | 更新 `handler/AGENTS.md` | AGENTS.md | ✅ |
---
## 五、GORM 使用规范
### 5.1 查询模式
```go
// 单条查询 - 未找到返回 nil, nil (保持现有语义)
var user model.User
err := r.db.Where("id = ?", id).First(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
// 列表查询
var users []model.User
err := r.db.Where("role_id != ?", 0).Order("id ASC").Find(&users).Error
// 创建
err := r.db.Create(&user).Error
// 更新 (部分字段)
err := r.db.Model(&model.User{}).Where("id = ?", id).Updates(map[string]interface{}{
"user": username, "flow": flow, "updated_time": now,
}).Error
// 事务 (closure pattern - 自动 rollback/commit)
err := r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", id).Delete(&model.Forward{}).Error; err != nil {
return err
}
return tx.Where("id = ?", id).Delete(&model.User{}).Error
})
// 原生 SQL (仅用于复杂查询和 PG 特有操作)
r.db.Exec("SELECT setval(?::regclass, ?, ?)", seqRef, maxID, true)
```
### 5.2 关键注意事项
1. **user 保留字**:通过 `TableName()` 返回 `"user"`,GORM 自动处理引号
2. **SQLite MaxOpenConns**:必须设为 1 防止 "database locked"
3. **SQLite WAL 模式**:DSN 中配置 `_pragma=journal_mode(WAL)`
4. **不要用 `type:jsonb`**:SQLite 不支持,用 `serializer:json`
5. **不要用 `type:serial`**:让 GORM 从 `primaryKey` 自动推断
6. **AutoMigrate 在 SQLite 中使用 copy-swap-drop**:大表慎用
---
## 六、影响范围
### 需要修改的文件
| 文件 | 修改类型 | 描述 | 当前状态 |
|------|----------|------|----------|
| `go.mod` / `go.sum` | 修改 | 添加 GORM + 驱动依赖 | ✅ |
| `internal/store/model/model.go` | **新增** | 全部 21 个 GORM Model | ✅ |
| `internal/store/repo/repository.go` | **重写** | 全部查询 GORM 化 | ⚠️ 业务查询已 GORM;仅剩 PG 序列修复 DDL `Exec` 4 处 |
| `internal/store/repo/repository_mutations.go` | **重写** | Mutation helpers | ✅ 全量 GORM(Raw=0) |
| `internal/store/repo/repository_federation.go` | **重写** | Federation 查询 | ✅ 已 GORM 化(Raw=0) |
| `internal/store/repo/repository_control.go` | **重写** | 控制面查询 | ✅ 已 GORM 化(Raw=0) |
| `internal/store/repo/repository_flow.go` | **重写** | 流量/转发查询 | ✅ 已 GORM 化(Raw=0) |
| `internal/http/handler/mutations.go` | **重写** | 全部 CRUD 提取到 repo | ✅ 生产代码 `tx.Exec/tx.Raw` = 0 |
| `internal/http/handler/handler.go` | 修改 | 更新 import、移除直接 SQL | ✅ (仅剩 nil check) |
| `internal/http/handler/federation.go` | 修改 | GORM 替代 raw SQL | ✅ |
| `internal/http/handler/control_plane.go` | 修改 | GORM 替代 raw SQL | ✅ |
| `internal/http/handler/flow_policy.go` | 修改 | GORM 替代 raw SQL | ✅ |
| `internal/http/handler/jobs.go` | 修改 | GORM 替代 raw SQL | ✅ (仅剩 nil check) |
| `internal/ws/server.go` | 修改 | 更新 import | ✅ |
| `internal/app/app.go` | 修改 | 更新 import | ✅ |
| `internal/store/postgres/` | **删除** | 不再需要 | ✅ |
| `internal/store/db.go` | **删除** | GORM 自动处理方言 | ✅ |
| `internal/store/db_test.go` | **删除** | 旧重写层测试 | ✅ |
| `internal/store/sqlite/sql/` | **删除** | AutoMigrate 替代 | ✅ |
| `tests/contract/*.go` | 修改 | 适配 GORM | ✅ |
| `AGENTS.md` (3处) | 更新 | 反映新架构 | ✅ |
### 不需要修改的文件
- `internal/http/router.go` — 路由不变
- `internal/config/config.go` — 配置不变
- `internal/auth/` — 认证不变
- `internal/security/` — 加密不变
- `internal/http/middleware/` — 中间件不变
- `internal/http/response/` — 响应格式不变
- `Dockerfile`, `Makefile` — 构建不变
---
## 七、风险与缓解
| 风险 | 可能性 | 影响 | 缓解措施 |
|------|--------|------|----------|
| GORM AutoMigrate SQLite/PG 行为差异 | 中 | 高 | 先写 Model 验证双数据库 AutoMigrate |
| handler 中散落 raw SQL 遗漏 | 中 | 高 | 全局搜索 `.Exec(`, `.Query(`, `.QueryRow(` |
| 事务语义变化 | 低 | 中 | 逐方法对比旧代码事务边界 |
| 大量代码变更导致回归 | 高 | 高 | 分阶段提交,每阶段 `go test` |
| GORM 性能开销 | 低 | 低 | 此场景下可忽略 |
| SQLite "database locked" | 中 | 高 | `MaxOpenConns(1)` + WAL 模式 |
---
## 八、迁移顺序原则
1. **先 Model 后查询**:确保 AutoMigrate 双数据库通过
2. **先 Repository 后 Handler**:Handler 依赖 Repository
3. **先核心后边缘**:User → Node → Tunnel → Forward → 分组 → Federation
4. **每步编译**:每完成一组方法确保 `go build ./...` 通过
5. **最后清理**:全部重写完成后再删除旧代码和重命名 package
---
---
## 九、2026-02-16 审计发现 + 2026-02-17 进展记录
### 9.1 总体完成度
| 指标 | 数值 |
|------|------|
| 阶段完成数 | 7/8 完成 (1, 2, 4, 5, 6, 7, 8),1/8 部分完成 (3) |
| GORM 链式调用 | ~226 处 |
| Raw SQL 调用 (`.Exec`/`.Raw`+`.Scan`) | 4 处(生产代码) |
| GORM 占比 | ~98% |
| Handler 内 `tx.Exec`/`tx.Raw` | 0 处(生产代码) |
| `last_insert_rowid()` 生产代码 | 0 处(已消灭) |
### 9.2 ✅ P0:`last_insert_rowid()`(生产代码)已清零
`last_insert_rowid()` 已从生产路径移除,创建主键统一改为 `Create(&model)` 自动回填 ID,
确保 SQLite / PostgreSQL 双数据库行为一致。
> 备注:测试代码中的历史 SQL 兼容性用例可在后续测试清理阶段单独处理。
### 9.3 ✅ P1:`repository_mutations.go` 已全量 GORM 化
本次已完成 `repository_mutations.go` 的集中清理:
1. User / Node / Tunnel / Forward / UserTunnel / SpeedLimit / Group / Permission 全部 mutation 方法改为 GORM 链式调用。
2. 事务内级联删除统一为 `tx.Where(...).Delete(&Model{})` 模式。
3. `ON CONFLICT DO NOTHING` 统一替换为 `Clauses(clause.OnConflict{DoNothing: true})`。
4. 保留原有调用语义(含 `sql.ErrNoRows` 行为兼容)并完成 `go build ./...` 验证。
> 当前 `repository_mutations.go` 中生产代码 `.Raw(`/`.Exec(` 调用已降为 0。
### 9.4 ✅ P2:Handler `mutations.go` 直接 SQL 已清零
2026-02-17 本轮静态扫描结果:`mutations.go` **0 处** `tx.Exec`/`tx.Raw`(生产代码)。
本轮完成下沉到 repo 的逻辑:
- `tunnelUpdate` 中 `UPDATE tunnel` + `DELETE chain_tunnel`
- `isRemoteNodeTx` 查询
- `pickNodePortTx` 的 node/chain_tunnel/forward_port 端口占用查询
- `replaceTunnelChainsTx` 的 chain_tunnel 写入
- 分组成员同步(`tunnel_group_tunnel` / `user_group_user`)
- 权限删除与 grant 回收(`group_permission` / `group_permission_grant` / `user_tunnel`)
- federation 绑定替换(`federation_tunnel_binding`)
### 9.5 ✅ P3(部分):已移除 `QueryInt64List` / `QueryPairs` SQL 透传
- `repository_mutations.go` 中两个 SQL 透传入口已删除。
- Handler 已切换为语义化 repo 方法:
- `ListUserIDsByUserGroup`
- `ListTunnelIDsByTunnelGroup`
- `ListGroupPermissionPairsByUserGroup`
- `ListGroupPermissionPairsByTunnelGroup`
### 9.6 ✅ P3:Import 函数已全部 GORM 化
`repository.go` 中 Import 相关函数已完成迁移:
- `importUsers`
- `importNodes`
- `importTunnels`(含 `chain_tunnel` 子项 upsert)
- `importForwards`(含 `forward_port` 覆盖写入)
- `importUserTunnels`
- `importSpeedLimits`
- `importTunnelGroups`
- `importUserGroups`
- `importPermissions`
- `importConfigs`(原本已是 GORM)
迁移后统一采用 `Clauses(clause.OnConflict{Columns: id/name, DoUpdates: ...}).Create(&model)` 模式,
保留原 `ON CONFLICT ... DO UPDATE` 语义;Import 区段 `tx.Exec`/`tx.Raw` 已清零。
### 9.7 ✅ P4:`h.repo.DB() == nil` 检查已清理
`internal/http/handler/` 下已无 `h.repo.DB()` 直接访问;handler 仅通过语义化 repo 方法进行数据访问。
### 9.8 ✅ P5:Repository 层 Raw 已收敛(仅保留 PG 序列修复 DDL)
当前生产代码中 `.Raw()` 已清零;仅剩 `repository.go` 的 4 处 `Exec()`,全部位于 PG 序列修复 DDL:
- `CREATE SEQUENCE IF NOT EXISTS ...`
- `ALTER TABLE ... ALTER COLUMN id SET DEFAULT nextval(...)`
- `ALTER SEQUENCE ... OWNED BY ...`
- `SELECT setval(...::regclass, ?, ?)`
以上 4 处属于数据库管理 DDL/序列同步语义,当前保留,不再继续向 GORM 链式调用替换。
`repository_federation.go` / `repository_control.go` / `repository_flow.go` 已完成 GORM 化(Raw=0)。
---
## 十、后续工作优先级
| 优先级 | 任务 | 影响范围 | 工作量 |
|--------|------|----------|--------|
| **P0** | ✅ 已完成:生产代码中 `last_insert_rowid()` 清零(测试用例待单独清理) | 6 处生产(已完成) | 完成 |
| **P1** | ✅ 已完成:`repository_mutations.go` ~40 方法改为 GORM 链式调用 | 659 行(已完成) | 完成 |
| **P2** | ✅ 已完成:`mutations.go` handler 直接 SQL 全部提取为 repo 方法 | mutations.go | 完成 |
| **P3** | ✅ 已完成:移除 `QueryInt64List`/`QueryPairs` 透传,切换语义化 repo 方法 | 2 个方法 + 调用方(已完成) | 完成 |
| **P3** | ✅ 已完成:Import 函数 Raw SQL 改为 GORM `Clauses(clause.OnConflict{}).Create()` | 9 个函数(已完成) | 完成 |
| **P4** | ✅ 已完成:`h.repo.DB() == nil` 检查清理完毕 | 4 处(已完成) | 完成 |
| **P5** | ✅ 已完成:repo 查询层 Raw 清零,`repository.go` 保留 4 处 PG 序列修复 DDL `Exec`(设计保留) | repository.go | 完成 |
| **P5** | ✅ 已完成:更新 MIGRATION_PLAN.md 状态标记与收尾记录 | 本文件 | 完成 |
### 10.5 本轮执行记录(2026-02-17,P5 schema 收尾)
1. 完成 `repository.go` schema 迁移段去 Raw:
- `normalizeStrategy` 改为 `Model(...).Where(...).Update(...)`
- `ensurePostgresIDDefaults`/`ensurePostgresTableIDDefault` 的 information_schema 查询改为 GORM `Table+Joins+Where+Scan`
- `syncPostgresTableIDSequence` 的 `MAX(id)` 查询改为 GORM `Table+Select+Scan`
2. 复扫结果:
- `repository.go` `.Raw()` = 0
- repo 生产路径剩余 `.Exec()` = 4(全部为 PG 序列修复 DDL)
3. 验证结果:
- `go build ./...` ✅
- `go test ./internal/store/repo/...` ✅
### 10.6 本轮执行记录(2026-02-17,测试/构建收尾)
1. 修复事务内 SQLite 连接阻塞(`MaxOpenConns(1)` 场景):
- 新增 `GetNodeRecordTx` 并在 `prepareTunnelCreateState` 使用事务句柄读取节点。
- 新增 `GetNodeRemoteFieldsTx` 并在 `tunnelCreate` 事务内改用事务句柄读取远端字段。
- `applyFederationRuntime` 改为显式接收 `localDomain`,避免事务内再次走 `repo.GetConfigByName`。
2. 修复 legacy SQLite schema 迁移契约:
- 新增 `prepareSQLiteLegacyColumns` 预补齐 `node/tunnel` 关键列。
- SQLite 模式下对已存在 `node/tunnel` 表跳过对应 `AutoMigrate` 重建流程,避免 `node__temp.name` 约束失败。
3. 验证结果:
- `go test ./internal/http/handler/...` ✅
- `go test ./tests/contract/...` ✅
- `go test ./...` ✅
- `go build ./...` ✅
- `make build` ✅
### 10.1 本轮执行记录(2026-02-17,P5 查询层)
1. 完成 `repository_federation.go` 全量 GORM 化:
- `ListRemoteNodes` / `UpdateNodeRemoteConfig`
- `ListActiveBindingsForNode` / `GetNodeBasicInfo`
- `ListUsedPortsOnNode` / `ListTunnelIDsByNamePrefix` / `NextIndex`
2. 完成 `repository_control.go` 全量 GORM 化:
- `ListForwardsByTunnel` / `ListForwardPorts` / `GetTunnelOutProtocol`
- `ResolveUserTunnelAndLimiter` / `ListChainNodesForTunnel`
3. 完成 `repository_flow.go` 全量 GORM 化:
- `ListActiveForwardsByUser` / `ListActiveForwardsByUserTunnel`
- `GetForwardRecord` / `GetTunnelRecord`
4. 复扫结果:
- `repository_federation.go` Raw/Exec = 0
- `repository_control.go` Raw/Exec = 0
- `repository_flow.go` Raw/Exec = 0
- repo 生产路径剩余 Raw/Exec = 9(全部在 `repository.go`)
5. 验证结果:
- `go build ./...` ✅
- `go test ./internal/store/repo/...` ✅
### 10.2 本轮执行记录(2026-02-17)
1. 完成 P3 Import 9 个函数的 GORM 化(`repository.go`),并保持 `ON CONFLICT` 语义一致。
2. 复扫确认:`repository.go` Import 区段 `tx.Exec`/`tx.Raw` 已清零。
3. 验证结果:
- `go build ./...` ✅(使用显式 `GOMODCACHE/GOPATH/GOCACHE/HOME` 环境)
- `go test ./internal/store/repo/...` ✅
### 10.3 本轮执行记录(2026-02-17,P2 部分)
1. 将 tunnel 更新/chain 重建路径 SQL 下沉到 `repository_mutations.go`:
- 新增 `UpdateTunnelTx`
- 新增 `DeleteChainTunnelsByTunnelTx`
- 新增 `CreateChainTunnelTx`
2. 将 handler 内部 SQL helper 迁移到 repo:
- 新增 `IsRemoteNodeTx`
- 新增 `PickNodePortTx`
- `replaceTunnelChainsTx` 改为 handler 方法并改用 repo 调用,不再直接 SQL
3. 复扫结果:`mutations.go` 直接 SQL 从 27 处降至 17 处。
4. 验证结果:
- `go build ./...` ✅
- `go test ./internal/store/repo/...` ✅
### 10.4 本轮执行记录(2026-02-17,P2 收尾)
1. 新增并落地事务语义化 repo 方法:
- `ReplaceTunnelGroupMembersTx` / `ReplaceUserGroupMembersTx`
- `ListUserIDsByUserGroupTx`
- `GetGroupPermissionPairByIDTx` / `DeleteGroupPermissionByIDTx`
- `RevokeGroupGrantsForRemovedUsersTx` / `RevokeGroupPermissionPairTx`
- `ReplaceFederationTunnelBindingsTx`
2. 删除 handler 内 SQL helper(`queryInt64ListTx` / `revokeGroupGrantsForRemovedUsersTx` / `revokeGroupPermissionPairTx` / `replaceFederationTunnelBindingsTx`)。
3. 复扫确认:`mutations.go` 生产路径 `tx.Exec`/`tx.Raw` = 0。
4. 验证结果:
- `go build ./...` ✅
- `go test ./internal/store/repo/...` ✅
---
*本文档将随迁移进展实时更新状态标记。*
*最后审计时间:2026-02-17,审计工具:代码静态分析 (grep/AST) + go build/go test 验证*
+6
View File
@@ -12,10 +12,14 @@ require (
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/glebarez/sqlite v1.11.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -24,6 +28,8 @@ require (
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.29.0 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/gorm v1.31.1 // indirect
modernc.org/libc v1.65.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
+12
View File
@@ -3,6 +3,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -17,6 +21,10 @@ github.com/jackc/pgx/v5 v5.7.3 h1:PO1wNKj/bTAwxSJnO1Z4Ai8j4magtqg2SLNjEDzcXQo=
github.com/jackc/pgx/v5 v5.7.3/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
@@ -49,6 +57,10 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
+9 -9
View File
@@ -10,30 +10,30 @@ import (
"go-backend/internal/config"
httpserver "go-backend/internal/http"
"go-backend/internal/http/handler"
"go-backend/internal/store/sqlite"
"go-backend/internal/store/repo"
)
type App struct {
cfg config.Config
server *http.Server
repo *sqlite.Repository
repo *repo.Repository
h *handler.Handler
}
func New(cfg config.Config) (*App, error) {
var (
repo *sqlite.Repository
err error
r *repo.Repository
err error
)
switch strings.ToLower(strings.TrimSpace(cfg.DBType)) {
case "", "sqlite":
repo, err = sqlite.Open(cfg.DBPath)
r, err = repo.Open(cfg.DBPath)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
case "postgres", "postgresql":
repo, err = sqlite.OpenPostgres(cfg.DatabaseURL)
r, err = repo.OpenPostgres(cfg.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("open postgres: %w", err)
}
@@ -41,7 +41,7 @@ func New(cfg config.Config) (*App, error) {
return nil, fmt.Errorf("unsupported DB_TYPE %q", cfg.DBType)
}
h := handler.New(repo, cfg.JWTSecret)
h := handler.New(r, cfg.JWTSecret)
router := httpserver.NewRouter(h, cfg.JWTSecret)
s := &http.Server{
@@ -49,11 +49,11 @@ 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,
}
return &App{cfg: cfg, server: s, repo: repo, h: h}, nil
return &App{cfg: cfg, server: s, repo: r, h: h}, nil
}
func (a *App) Run() error {
@@ -77,6 +77,18 @@ type RuntimeDiagnoseRequest struct {
Timeout int `json:"timeout"`
}
type RuntimeNodeCommandRequest struct {
CommandType string `json:"commandType"`
Data interface{} `json:"data"`
}
type RuntimeNodeCommandResponse struct {
Type string `json:"type"`
Success bool `json:"success"`
Message string `json:"message"`
Data map[string]interface{} `json:"data,omitempty"`
}
func NewFederationClient() *FederationClient {
return &FederationClient{
client: &http.Client{
@@ -333,3 +345,42 @@ func (c *FederationClient) Diagnose(url, token, localDomain string, reqData Runt
return res.Data, nil
}
func (c *FederationClient) Command(url, token, localDomain string, reqData RuntimeNodeCommandRequest) (*RuntimeNodeCommandResponse, error) {
url = strings.TrimSuffix(url, "/")
bodyBytes, _ := json.Marshal(reqData)
req, err := http.NewRequest("POST", url+"/api/v1/federation/runtime/command", strings.NewReader(string(bodyBytes)))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if localDomain != "" {
req.Header.Set("X-Panel-Domain", localDomain)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
}
var res struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data RuntimeNodeCommandResponse `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return nil, err
}
if res.Code != 0 {
return nil, fmt.Errorf("remote api error: %s", res.Msg)
}
return &res.Data, nil
}
@@ -0,0 +1,46 @@
# BACKEND HTTP HANDLER KNOWLEDGE BASE
**Generated:** Thu Feb 26 2026
## OVERVIEW
HTTP request handlers for FLVX Admin API. Core business logic layer.
**Stack:** Go 1.24, net/http, GORM via Repository pattern.
## STRUCTURE
```
handler/
├── handler.go # Main Handler struct, login/captcha, job scheduling
├── control_plane.go # Node control plane API (add/delete/list)
├── 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 (~3700 LOC)
└── upgrade.go # System upgrade API
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| **User/Tunnel CRUD** | `mutations.go` | Largest file; all create/update/delete ops |
| **Login/Captcha** | `handler.go` | Login flow, captcha verification |
| **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` ~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.).
## ANTI-PATTERNS
- Do NOT let handlers call `repo.DB()` directly — add a Repository method instead.
- Do NOT change handler signatures without updating router.go.
## COMMANDS
```bash
cd go-backend
go test ./internal/http/handler/...
```
File diff suppressed because it is too large Load Diff
@@ -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,50 @@
package handler
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
}
func mustQueryInt(t *testing.T, r *repo.Repository, query string, args ...interface{}) int {
t.Helper()
var v int
if err := r.DB().Raw(query, args...).Row().Scan(&v); err != nil {
t.Fatalf("query int failed: %v (query=%q)", err, query)
}
return v
}
func mustQueryInt64Int64String(t *testing.T, r *repo.Repository, query string, args ...interface{}) (int64, int64, string) {
t.Helper()
var a int64
var b int64
var c string
if err := r.DB().Raw(query, args...).Row().Scan(&a, &b, &c); err != nil {
t.Fatalf("query int64+int64+string failed: %v (query=%q)", err, query)
}
return a, b, c
}
func mustQueryInt64Int64Int(t *testing.T, r *repo.Repository, query string, args ...interface{}) (int64, int64, int) {
t.Helper()
var a int64
var b int64
var c int
if err := r.DB().Raw(query, args...).Row().Scan(&a, &b, &c); err != nil {
t.Fatalf("query int64+int64+int failed: %v (query=%q)", err, query)
}
return a, b, c
}
@@ -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
}
}
@@ -0,0 +1,377 @@
package handler
import (
"testing"
)
// ---------------------------------------------------------------------------
// nodeSupportsV4 / nodeSupportsV6
// ---------------------------------------------------------------------------
func TestNodeSupportsV4_Nil(t *testing.T) {
if nodeSupportsV4(nil) {
t.Fatal("nil node must not support v4")
}
}
func TestNodeSupportsV6_Nil(t *testing.T) {
if nodeSupportsV6(nil) {
t.Fatal("nil node must not support v6")
}
}
func TestNodeSupportsV4_ExplicitV4(t *testing.T) {
n := &nodeRecord{ServerIPv4: "10.0.0.1"}
if !nodeSupportsV4(n) {
t.Fatal("explicit server_ip_v4 must support v4")
}
}
func TestNodeSupportsV6_ExplicitV6(t *testing.T) {
n := &nodeRecord{ServerIPv6: "2001:db8::1"}
if !nodeSupportsV6(n) {
t.Fatal("explicit server_ip_v6 must support v6")
}
}
func TestNodeSupportsV4_OnlyV6Set(t *testing.T) {
n := &nodeRecord{ServerIPv6: "2001:db8::1"}
if nodeSupportsV4(n) {
t.Fatal("node with only v6 should not support v4")
}
}
func TestNodeSupportsV6_OnlyV4Set(t *testing.T) {
n := &nodeRecord{ServerIPv4: "10.0.0.1"}
if nodeSupportsV6(n) {
t.Fatal("node with only v4 should not support v6")
}
}
func TestNodeSupportsV4_DualStack(t *testing.T) {
n := &nodeRecord{ServerIPv4: "10.0.0.1", ServerIPv6: "2001:db8::1"}
if !nodeSupportsV4(n) {
t.Fatal("dual-stack node must support v4")
}
}
func TestNodeSupportsV6_DualStack(t *testing.T) {
n := &nodeRecord{ServerIPv4: "10.0.0.1", ServerIPv6: "2001:db8::1"}
if !nodeSupportsV6(n) {
t.Fatal("dual-stack node must support v6")
}
}
func TestNodeSupportsV4_LegacyV4Only(t *testing.T) {
n := &nodeRecord{ServerIP: "192.168.1.1"}
if !nodeSupportsV4(n) {
t.Fatal("legacy v4 ip in server_ip must support v4")
}
if nodeSupportsV6(n) {
t.Fatal("legacy v4 ip in server_ip must not support v6")
}
}
func TestNodeSupportsV6_LegacyV6Only(t *testing.T) {
n := &nodeRecord{ServerIP: "2001:db8::1"}
if !nodeSupportsV6(n) {
t.Fatal("legacy v6 ip in server_ip must support v6")
}
if nodeSupportsV4(n) {
t.Fatal("legacy v6 ip in server_ip must not support v4")
}
}
func TestNodeSupportsV4_EmptyNode(t *testing.T) {
n := &nodeRecord{}
if nodeSupportsV4(n) {
t.Fatal("empty node must not support v4")
}
if nodeSupportsV6(n) {
t.Fatal("empty node must not support v6")
}
}
func TestNodeSupportsV4_LegacyBracketed(t *testing.T) {
n := &nodeRecord{ServerIP: "[::1]"}
if nodeSupportsV4(n) {
t.Fatal("bracketed ipv6 must not support v4")
}
if !nodeSupportsV6(n) {
t.Fatal("bracketed ipv6 must support v6")
}
}
// ---------------------------------------------------------------------------
// pickNodeAddressV4 / pickNodeAddressV6
// ---------------------------------------------------------------------------
func TestPickNodeAddressV4_Nil(t *testing.T) {
if pickNodeAddressV4(nil) != "" {
t.Fatal("nil node must return empty")
}
}
func TestPickNodeAddressV6_Nil(t *testing.T) {
if pickNodeAddressV6(nil) != "" {
t.Fatal("nil node must return empty")
}
}
func TestPickNodeAddressV4_PreferExplicit(t *testing.T) {
n := &nodeRecord{ServerIPv4: "10.0.0.1", ServerIP: "192.168.0.1"}
got := pickNodeAddressV4(n)
if got != "10.0.0.1" {
t.Fatalf("expected explicit v4 10.0.0.1, got %q", got)
}
}
func TestPickNodeAddressV4_FallbackLegacy(t *testing.T) {
n := &nodeRecord{ServerIP: "192.168.0.1"}
got := pickNodeAddressV4(n)
if got != "192.168.0.1" {
t.Fatalf("expected legacy 192.168.0.1, got %q", got)
}
}
func TestPickNodeAddressV6_PreferExplicit(t *testing.T) {
n := &nodeRecord{ServerIPv6: "2001:db8::1", ServerIP: "::1"}
got := pickNodeAddressV6(n)
if got != "2001:db8::1" {
t.Fatalf("expected explicit v6 2001:db8::1, got %q", got)
}
}
func TestPickNodeAddressV6_FallbackLegacy(t *testing.T) {
n := &nodeRecord{ServerIP: "::1"}
got := pickNodeAddressV6(n)
if got != "::1" {
t.Fatalf("expected legacy ::1, got %q", got)
}
}
// ---------------------------------------------------------------------------
// selectTunnelDialHost — core IP preference selection logic
// ---------------------------------------------------------------------------
func dualStackNode(name, v4, v6 string) *nodeRecord {
return &nodeRecord{
Name: name,
ServerIPv4: v4,
ServerIPv6: v6,
}
}
func v4OnlyNode(name, v4 string) *nodeRecord {
return &nodeRecord{
Name: name,
ServerIPv4: v4,
}
}
func v6OnlyNode(name, v6 string) *nodeRecord {
return &nodeRecord{
Name: name,
ServerIPv6: v6,
}
}
func TestSelectTunnelDialHost_NilNodes(t *testing.T) {
_, err := selectTunnelDialHost(nil, nil, "")
if err == nil {
t.Fatal("expected error for nil nodes")
}
_, err = selectTunnelDialHost(dualStackNode("a", "1.1.1.1", "::1"), nil, "")
if err == nil {
t.Fatal("expected error for nil toNode")
}
_, err = selectTunnelDialHost(nil, dualStackNode("b", "1.1.1.1", "::1"), "")
if err == nil {
t.Fatal("expected error for nil fromNode")
}
}
func TestSelectTunnelDialHost_DualStack_DefaultPreference(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
host, err := selectTunnelDialHost(from, to, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Default prefers v4 when both available
if host != "10.0.0.2" {
t.Fatalf("default preference should pick v4, got %q", host)
}
}
func TestSelectTunnelDialHost_DualStack_PreferV4(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
host, err := selectTunnelDialHost(from, to, "v4")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "10.0.0.2" {
t.Fatalf("v4 preference should pick v4 address, got %q", host)
}
}
func TestSelectTunnelDialHost_DualStack_PreferV6(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
host, err := selectTunnelDialHost(from, to, "v6")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "2001:db8::2" {
t.Fatalf("v6 preference should pick v6 address, got %q", host)
}
}
func TestSelectTunnelDialHost_V4Only_PreferV6Fallback(t *testing.T) {
from := v4OnlyNode("from", "10.0.0.1")
to := v4OnlyNode("to", "10.0.0.2")
// User prefers v6, but both nodes are v4-only — should fallback to v4
host, err := selectTunnelDialHost(from, to, "v6")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "10.0.0.2" {
t.Fatalf("v6 preference on v4-only nodes should fallback to v4, got %q", host)
}
}
func TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {
from := v6OnlyNode("from", "2001:db8::1")
to := v6OnlyNode("to", "2001:db8::2")
// User prefers v4, but both nodes are v6-only — should fallback to v6
host, err := selectTunnelDialHost(from, to, "v4")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "2001:db8::2" {
t.Fatalf("v4 preference on v6-only nodes should fallback to v6, got %q", host)
}
}
func TestSelectTunnelDialHost_Incompatible(t *testing.T) {
from := v4OnlyNode("from", "10.0.0.1")
to := v6OnlyNode("to", "2001:db8::2")
_, err := selectTunnelDialHost(from, to, "")
if err == nil {
t.Fatal("expected error for incompatible nodes (v4-only -> v6-only)")
}
}
func TestSelectTunnelDialHost_Incompatible_Reverse(t *testing.T) {
from := v6OnlyNode("from", "2001:db8::1")
to := v4OnlyNode("to", "10.0.0.2")
_, err := selectTunnelDialHost(from, to, "")
if err == nil {
t.Fatal("expected error for incompatible nodes (v6-only -> v4-only)")
}
}
func TestSelectTunnelDialHost_WhitespacePreference(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
// Whitespace should be trimmed, treated as "v6"
host, err := selectTunnelDialHost(from, to, " v6 ")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "2001:db8::2" {
t.Fatalf("trimmed v6 preference should pick v6 address, got %q", host)
}
}
func TestSelectTunnelDialHost_MixedStack_FromDualToV4(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := v4OnlyNode("to", "10.0.0.2")
// v6 preferred, but target only has v4 — should succeed with v4
host, err := selectTunnelDialHost(from, to, "v6")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "10.0.0.2" {
t.Fatalf("should fallback to v4 when target is v4-only, got %q", host)
}
}
func TestSelectTunnelDialHost_MixedStack_FromDualToV6(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := v6OnlyNode("to", "2001:db8::2")
// v4 preferred, but target only has v6 — should succeed with v6
host, err := selectTunnelDialHost(from, to, "v4")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "2001:db8::2" {
t.Fatalf("should fallback to v6 when target is v6-only, got %q", host)
}
}
func TestSelectTunnelDialHost_MixedStack_FromV4ToDual(t *testing.T) {
from := v4OnlyNode("from", "10.0.0.1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
// v6 preferred, but from only has v4 — should use v4 (from can only reach v4 of target)
host, err := selectTunnelDialHost(from, to, "v6")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "10.0.0.2" {
t.Fatalf("should use v4 when from is v4-only, got %q", host)
}
}
func TestSelectTunnelDialHost_MixedStack_FromV6ToDual(t *testing.T) {
from := v6OnlyNode("from", "2001:db8::1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
// v4 preferred, but from only has v6 — should use v6
host, err := selectTunnelDialHost(from, to, "v4")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "2001:db8::2" {
t.Fatalf("should use v6 when from is v6-only, got %q", host)
}
}
// ---------------------------------------------------------------------------
// nodeDisplayName
// ---------------------------------------------------------------------------
func TestNodeDisplayName_Nil(t *testing.T) {
got := nodeDisplayName(nil)
if got != "node" {
t.Fatalf("nil node display name should be 'node', got %q", got)
}
}
func TestNodeDisplayName_Named(t *testing.T) {
n := &nodeRecord{ID: 42, Name: "hk-node"}
got := nodeDisplayName(n)
if got != "hk-node" {
t.Fatalf("expected 'hk-node', got %q", got)
}
}
func TestNodeDisplayName_Unnamed(t *testing.T) {
n := &nodeRecord{ID: 42}
got := nodeDisplayName(n)
if got != "node_42" {
t.Fatalf("expected 'node_42', got %q", got)
}
}
+474 -134
View File
@@ -1,19 +1,19 @@
package handler
import (
"database/sql"
"encoding/json"
"fmt"
"net"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"go-backend/internal/http/client"
"go-backend/internal/http/response"
"go-backend/internal/store/sqlite"
"go-backend/internal/store/repo"
)
type federationTunnelRequest struct {
@@ -91,6 +91,11 @@ type federationRuntimeDiagnoseRequest struct {
Timeout int `json:"timeout"`
}
type federationRuntimeCommandRequest struct {
CommandType string `json:"commandType"`
Data interface{} `json:"data"`
}
type peerShareUsedPort struct {
RuntimeID int64 `json:"runtimeId"`
Port int `json:"port"`
@@ -102,7 +107,7 @@ type peerShareUsedPort struct {
}
type peerShareListItem struct {
sqlite.PeerShare
repo.PeerShare
UsedPorts []int `json:"usedPorts"`
UsedPortDetails []peerShareUsedPort `json:"usedPortDetails"`
ActiveRuntimeNum int `json:"activeRuntimeNum"`
@@ -258,7 +263,7 @@ func (h *Handler) federationShareCreate(w http.ResponseWriter, r *http.Request)
now := time.Now().UnixMilli()
token := randomToken(32)
share := &sqlite.PeerShare{
share := &repo.PeerShare{
Name: req.Name,
NodeID: req.NodeID,
Token: token,
@@ -293,13 +298,20 @@ func (h *Handler) federationShareDelete(w http.ResponseWriter, r *http.Request)
return
}
share, _ := h.repo.GetPeerShare(req.ID)
h.cleanupPeerShareRuntimes(req.ID)
h.cleanupFederationTunnels(req.ID)
if err := h.repo.DeletePeerShare(req.ID); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if share != nil && h.wsServer != nil {
h.wsServer.SendCommand(share.NodeID, "reload", nil, time.Second*5)
}
response.WriteJSON(w, response.OKEmpty())
}
@@ -417,40 +429,25 @@ func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Reque
return
}
rows, err := h.repo.DB().Query(`
SELECT id, name, remote_url, remote_token, remote_config
FROM node
WHERE is_remote = 1
ORDER BY id DESC
`)
remoteNodes, err := h.repo.ListRemoteNodes()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
defer rows.Close()
fc := client.NewFederationClient()
localDomain := h.federationLocalDomain()
items := make([]remoteUsageNodeItem, 0)
for rows.Next() {
var (
nodeID int64
nodeName string
remoteURL sql.NullString
remoteToken sql.NullString
remoteConfig sql.NullString
)
if err := rows.Scan(&nodeID, &nodeName, &remoteURL, &remoteToken, &remoteConfig); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
for _, node := range remoteNodes {
nodeID := node.ID
nodeName := node.Name
shareID, maxBandwidth, currentFlow, expiryTime, portRangeStart, portRangeEnd := parseRemoteShareUsageConfig(remoteConfig.String)
shareID, maxBandwidth, currentFlow, expiryTime, portRangeStart, portRangeEnd := parseRemoteShareUsageConfig(node.RemoteConfig.String)
var syncError string
url := strings.TrimSpace(remoteURL.String)
token := strings.TrimSpace(remoteToken.String)
url := strings.TrimSpace(node.RemoteURL.String)
token := strings.TrimSpace(node.RemoteToken.String)
if url != "" && token != "" {
info, connectErr := fc.Connect(url, token, localDomain)
if connectErr != nil {
@@ -471,42 +468,62 @@ func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Reque
"portRangeStart": info.PortRangeStart,
"portRangeEnd": info.PortRangeEnd,
})
_, _ = h.repo.DB().Exec(`UPDATE node SET remote_config = ? WHERE id = ?`, string(configData), nodeID)
_ = h.repo.UpdateNodeRemoteConfig(nodeID, string(configData))
}
}
bindingRows, err := h.repo.DB().Query(`
SELECT fb.id, fb.tunnel_id, COALESCE(t.name, ''), fb.chain_type, fb.hop_inx, fb.allocated_port, fb.resource_key, fb.remote_binding_id, fb.updated_time
FROM federation_tunnel_binding fb
LEFT JOIN tunnel t ON t.id = fb.tunnel_id
WHERE fb.node_id = ? AND fb.status = 1
ORDER BY fb.allocated_port ASC, fb.id ASC
`, nodeID)
bindingRows, err := h.repo.ListActiveBindingsForNode(nodeID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
forwardPortRows, err := h.repo.ListActiveForwardPortsForNode(nodeID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
usedSet := make(map[int]struct{})
bindings := make([]remoteUsageBindingItem, 0)
for bindingRows.Next() {
var item remoteUsageBindingItem
if err := bindingRows.Scan(&item.BindingID, &item.TunnelID, &item.TunnelName, &item.ChainType, &item.HopInx, &item.AllocatedPort, &item.ResourceKey, &item.RemoteBindingID, &item.UpdatedTime); err != nil {
_ = bindingRows.Close()
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
bindings = append(bindings, item)
if item.AllocatedPort > 0 {
usedSet[item.AllocatedPort] = struct{}{}
bindings := make([]remoteUsageBindingItem, 0, len(bindingRows)+len(forwardPortRows))
for _, b := range bindingRows {
bindings = append(bindings, remoteUsageBindingItem{
BindingID: b.ID,
TunnelID: b.TunnelID,
TunnelName: b.TunnelName,
ChainType: b.ChainType,
HopInx: b.HopInx,
AllocatedPort: b.AllocatedPort,
ResourceKey: b.ResourceKey,
RemoteBindingID: b.RemoteBindingID,
UpdatedTime: b.UpdatedTime,
})
if b.AllocatedPort > 0 {
usedSet[b.AllocatedPort] = struct{}{}
}
}
if err := bindingRows.Err(); err != nil {
_ = bindingRows.Close()
response.WriteJSON(w, response.Err(-2, err.Error()))
return
for _, fp := range forwardPortRows {
bindings = append(bindings, remoteUsageBindingItem{
BindingID: -fp.ForwardID,
TunnelID: fp.TunnelID,
TunnelName: fp.TunnelName,
ChainType: 1,
HopInx: 0,
AllocatedPort: fp.Port,
ResourceKey: fmt.Sprintf("forward:%d", fp.ForwardID),
RemoteBindingID: "",
UpdatedTime: fp.UpdatedTime,
})
if fp.Port > 0 {
usedSet[fp.Port] = struct{}{}
}
}
_ = bindingRows.Close()
sort.Slice(bindings, func(i, j int) bool {
if bindings[i].AllocatedPort == bindings[j].AllocatedPort {
return bindings[i].BindingID < bindings[j].BindingID
}
return bindings[i].AllocatedPort < bindings[j].AllocatedPort
})
usedPorts := make([]int, 0, len(usedSet))
for port := range usedSet {
@@ -530,14 +547,32 @@ func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Reque
SyncError: syncError,
})
}
if err := rows.Err(); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(items))
}
func remoteNodePortRange(node *nodeRecord) (int, int) {
if node == nil || node.IsRemote != 1 || node.RemoteConfig == "" {
return 0, 0
}
_, _, _, _, portRangeStart, portRangeEnd := parseRemoteShareUsageConfig(node.RemoteConfig)
return portRangeStart, portRangeEnd
}
func validateRemoteNodePort(node *nodeRecord, port int) error {
if node == nil || node.IsRemote != 1 || port <= 0 {
return nil
}
start, end := remoteNodePortRange(node)
if start <= 0 || end <= 0 {
return nil
}
if port < start || port > end {
return fmt.Errorf("远程节点端口 %d 超出允许范围 %d-%d", port, start, end)
}
return nil
}
func parseRemoteShareUsageConfig(raw string) (int64, int64, int64, int64, int, int) {
raw = strings.TrimSpace(raw)
if raw == "" {
@@ -604,31 +639,21 @@ func (h *Handler) nodeImport(w http.ResponseWriter, r *http.Request) {
portRange = fmt.Sprintf("%d-%d", info.PortRangeStart, info.PortRangeEnd)
}
db := h.repo.DB()
inx := nextIndex(db, "node")
inx := h.repo.NextIndex("node")
now := time.Now().UnixMilli()
_, err = db.Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
`,
if err = h.repo.CreateRemoteNode(
fmt.Sprintf("%s (Remote)", info.NodeName),
randomToken(16), // Dummy secret
randomToken(16),
info.ServerIP,
"", "", // v4/v6 unknown, use server_ip
portRange,
"",
"",
now, now,
now,
info.Status,
"[::]", "[::]",
inx,
req.RemoteURL,
req.Token,
string(configBytes),
)
if err != nil {
); err != nil {
response.WriteJSON(w, response.Err(-2, "Database error: "+err.Error()))
return
}
@@ -720,11 +745,7 @@ func (h *Handler) federationConnect(w http.ResponseWriter, r *http.Request) {
return
}
var nodeName string
var serverIP string
var status int
err = h.repo.DB().QueryRow("SELECT name, server_ip, status FROM node WHERE id = ?", share.NodeID).Scan(&nodeName, &serverIP, &status)
nodeInfo, err := h.repo.GetNodeBasicInfo(share.NodeID)
if err != nil {
response.WriteJSON(w, response.Err(-2, "Node not found"))
return
@@ -734,9 +755,9 @@ func (h *Handler) federationConnect(w http.ResponseWriter, r *http.Request) {
"shareId": share.ID,
"shareName": share.Name,
"nodeId": share.NodeID,
"nodeName": nodeName,
"serverIp": serverIP,
"status": status,
"nodeName": nodeInfo.Name,
"serverIp": nodeInfo.ServerIP,
"status": nodeInfo.Status,
"maxBandwidth": share.MaxBandwidth,
"currentFlow": share.CurrentFlow,
"expiryTime": share.ExpiryTime,
@@ -773,44 +794,71 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
return
}
tunnelType := 1
if strings.ToLower(req.Protocol) == "udp" {
tunnelType = 2
}
tx, err := h.repo.DB().Begin()
usedPorts, err := h.repo.ListUsedPortsOnNode(share.NodeID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
defer tx.Rollback()
for _, port := range usedPorts {
if port == req.RemotePort {
response.WriteJSON(w, response.Err(403, "Port already in use"))
return
}
}
runtimeOnPort, err := h.repo.GetActiveForwardPeerShareRuntimeByPort(share.ID, req.RemotePort)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if runtimeOnPort != nil {
response.WriteJSON(w, response.Err(403, "Port already in use"))
return
}
existsOnNodePort, err := h.repo.ExistsActivePeerShareRuntimeOnNodePort(share.NodeID, req.RemotePort)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if existsOnNodePort {
response.WriteJSON(w, response.Err(403, "Port already in use"))
return
}
now := time.Now().UnixMilli()
tunnelID, err := tx.ExecReturningID(`INSERT INTO tunnel (name, type, protocol, flow, created_time, updated_time, status, in_ip) VALUES (?, ?, ?, 0, ?, ?, 1, ?)`,
tunnelID, err := h.repo.CreateFederationTunnel(
fmt.Sprintf("Share-%d-Port-%d", share.ID, req.RemotePort),
tunnelType,
1,
req.Protocol,
now,
now,
"",
)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
_, err = tx.Exec(`INSERT INTO chain_tunnel (tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES (?, '1', ?, ?, 'fifo', 0, ?)`,
tunnelID,
share.NodeID,
req.RemotePort,
req.Protocol,
)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := tx.Commit(); err != nil {
runtime := &repo.PeerShareRuntime{
ShareID: share.ID,
NodeID: share.NodeID,
ReservationID: randomToken(24),
ResourceKey: fmt.Sprintf("federation-forward-%d-%d-%d", share.ID, tunnelID, req.RemotePort),
BindingID: "",
Role: "forward",
ChainName: "",
ServiceName: "",
Protocol: defaultString(req.Protocol, "tcp"),
Strategy: "fifo",
Port: req.RemotePort,
Target: strings.TrimSpace(req.Target),
Applied: 0,
Status: 1,
CreatedTime: now,
UpdatedTime: now,
}
if err := h.repo.CreatePeerShareRuntime(runtime); err != nil {
_ = h.deleteTunnelByID(tunnelID)
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
@@ -895,7 +943,7 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
return
}
runtime := &sqlite.PeerShareRuntime{
runtime := &repo.PeerShareRuntime{
ShareID: share.ID,
NodeID: share.NodeID,
ReservationID: randomToken(24),
@@ -949,7 +997,7 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
return
}
var runtime *sqlite.PeerShareRuntime
var runtime *repo.PeerShareRuntime
if strings.TrimSpace(req.ReservationID) != "" {
runtime, err = h.repo.GetPeerShareRuntimeByReservationID(share.ID, strings.TrimSpace(req.ReservationID))
} else {
@@ -977,6 +1025,13 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
return
}
if share.PortRangeStart > 0 && share.PortRangeEnd > 0 && runtime.Port > 0 {
if runtime.Port < share.PortRangeStart || runtime.Port > share.PortRangeEnd {
response.WriteJSON(w, response.Err(403, fmt.Sprintf("port %d out of allowed range %d-%d", runtime.Port, share.PortRangeStart, share.PortRangeEnd)))
return
}
}
node, err := h.getNodeRecord(share.NodeID)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
@@ -1000,14 +1055,19 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
response.WriteJSON(w, response.ErrDefault("Invalid target"))
return
}
targetProtocol := defaultString(target.Protocol, protocol)
connector := map[string]interface{}{
"type": "relay",
}
if isTLSTunnelProtocol(targetProtocol) {
connector["metadata"] = map[string]interface{}{"nodelay": true}
}
nodeItems = append(nodeItems, map[string]interface{}{
"name": fmt.Sprintf("node_%d", i+1),
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, target.Port)),
"connector": map[string]interface{}{
"type": "relay",
},
"name": fmt.Sprintf("node_%d", i+1),
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, target.Port)),
"connector": connector,
"dialer": map[string]interface{}{
"type": defaultString(target.Protocol, protocol),
"type": targetProtocol,
},
})
}
@@ -1046,6 +1106,9 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
"type": protocol,
},
}
if isTLSTunnelProtocol(protocol) {
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{"nodelay": true}
}
if req.Role == "middle" {
service["handler"].(map[string]interface{})["chain"] = chainName
}
@@ -1105,7 +1168,7 @@ func (h *Handler) federationRuntimeReleaseRole(w http.ResponseWriter, r *http.Re
return
}
var runtime *sqlite.PeerShareRuntime
var runtime *repo.PeerShareRuntime
if strings.TrimSpace(req.BindingID) != "" {
runtime, err = h.repo.GetPeerShareRuntimeByBindingID(share.ID, strings.TrimSpace(req.BindingID))
} else if strings.TrimSpace(req.ReservationID) != "" {
@@ -1169,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
@@ -1191,7 +1258,271 @@ func (h *Handler) federationRuntimeDiagnose(w http.ResponseWriter, r *http.Reque
response.WriteJSON(w, response.OK(res.Data))
}
func (h *Handler) pickPeerSharePort(share *sqlite.PeerShare, requestedPort int) (int, error) {
func (h *Handler) federationRuntimeCommand(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("Invalid method"))
return
}
token := extractBearerToken(r)
share, err := h.repo.GetPeerShareByToken(token)
if err != nil || share == nil {
response.WriteJSON(w, response.Err(401, "Unauthorized"))
return
}
var req federationRuntimeCommandRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
return
}
cmd := strings.TrimSpace(req.CommandType)
if cmd == "" {
response.WriteJSON(w, response.ErrDefault("commandType is required"))
return
}
if !isFederationRuntimeCommandAllowed(cmd) {
response.WriteJSON(w, response.ErrDefault("command not allowed"))
return
}
if isFederationServiceCommand(cmd) {
if err := validateFederationCommandPorts(share, req.Data); err != nil {
response.WriteJSON(w, response.Err(403, err.Error()))
return
}
}
res, err := h.sendNodeCommand(share.NodeID, cmd, req.Data, false, false)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if strings.EqualFold(cmd, "addservice") || strings.EqualFold(cmd, "updateservice") {
h.bindPeerShareForwardRuntimeServices(share, req.Data)
} else if strings.EqualFold(cmd, "deleteservice") {
h.releasePeerShareForwardRuntimeServices(share, req.Data)
}
response.WriteJSON(w, response.OK(res))
}
type federationForwardServiceBinding struct {
Name string
Port int
}
func extractFederationServiceEntries(data interface{}) []map[string]interface{} {
if data == nil {
return nil
}
if entries := asMapSlice(data); len(entries) > 0 {
return entries
}
dataMap, ok := data.(map[string]interface{})
if !ok {
return nil
}
if entries := asMapSlice(dataMap["services"]); len(entries) > 0 {
return entries
}
return nil
}
func parseFederationForwardServiceBindings(data interface{}) []federationForwardServiceBinding {
serviceList := extractFederationServiceEntries(data)
bindings := make([]federationForwardServiceBinding, 0, len(serviceList))
for _, svcMap := range serviceList {
name := normalizeForwardRuntimeServiceName(asString(svcMap["name"]))
if name == "" {
continue
}
if _, _, _, ok := parseFlowServiceIDs(name); !ok {
continue
}
addr := strings.TrimSpace(asString(svcMap["addr"]))
if addr == "" {
continue
}
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
continue
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 {
continue
}
bindings = append(bindings, federationForwardServiceBinding{Name: name, Port: port})
}
return bindings
}
func parseFederationForwardServiceNamesForRelease(data interface{}) []string {
names := make(map[string]struct{})
appendName := func(raw string) {
name := normalizeForwardRuntimeServiceName(raw)
if name == "" {
return
}
if _, _, _, ok := parseFlowServiceIDs(name); !ok {
return
}
names[name] = struct{}{}
}
for _, svcMap := range extractFederationServiceEntries(data) {
appendName(asString(svcMap["name"]))
}
if dataMap, ok := data.(map[string]interface{}); ok {
for _, item := range asAnySlice(dataMap["services"]) {
appendName(asString(item))
}
}
for _, item := range asAnySlice(data) {
appendName(asString(item))
}
if len(names) == 0 {
return nil
}
out := make([]string, 0, len(names))
for name := range names {
out = append(out, name)
}
sort.Strings(out)
return out
}
func (h *Handler) bindPeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) {
if h == nil || h.repo == nil || share == nil {
return
}
bindings := parseFederationForwardServiceBindings(data)
if len(bindings) == 0 {
return
}
now := time.Now().UnixMilli()
for _, binding := range bindings {
runtime, err := h.repo.GetActiveForwardPeerShareRuntimeByPort(share.ID, binding.Port)
if err != nil {
continue
}
if runtime == nil {
runtime, err = h.repo.GetActiveForwardPeerShareRuntimeByServiceName(share.ID, binding.Name)
if err != nil {
continue
}
}
if runtime == nil {
_ = h.repo.CreatePeerShareRuntime(&repo.PeerShareRuntime{
ShareID: share.ID,
NodeID: share.NodeID,
ReservationID: randomToken(24),
ResourceKey: fmt.Sprintf("forward-runtime:%d:%s:%d:%s", share.ID, binding.Name, binding.Port, randomToken(8)),
BindingID: "",
Role: "forward",
ChainName: "",
ServiceName: binding.Name,
Protocol: "tcp",
Strategy: "fifo",
Port: binding.Port,
Target: "",
Applied: 1,
Status: 1,
CreatedTime: now,
UpdatedTime: now,
})
continue
}
if runtime.ServiceName == binding.Name && runtime.Applied == 1 && runtime.Port == binding.Port && runtime.Status == 1 {
continue
}
runtime.ServiceName = binding.Name
runtime.Port = binding.Port
runtime.Applied = 1
runtime.Status = 1
runtime.UpdatedTime = now
if strings.TrimSpace(runtime.Protocol) == "" {
runtime.Protocol = "tcp"
}
if strings.TrimSpace(runtime.Strategy) == "" {
runtime.Strategy = "fifo"
}
_ = h.repo.UpdatePeerShareRuntime(runtime)
}
}
func (h *Handler) releasePeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) {
if h == nil || h.repo == nil || share == nil {
return
}
names := parseFederationForwardServiceNamesForRelease(data)
if len(names) == 0 {
return
}
now := time.Now().UnixMilli()
for _, name := range names {
_ = h.repo.MarkForwardPeerShareRuntimeReleasedByServiceName(share.ID, name, now)
}
}
func isFederationRuntimeCommandAllowed(commandType string) bool {
switch strings.ToLower(strings.TrimSpace(commandType)) {
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "deletechains", "addlimiters", "updatelimiters", "deletelimiters", "tcpping", "reload":
return true
default:
return false
}
}
func isFederationServiceCommand(commandType string) bool {
switch strings.ToLower(strings.TrimSpace(commandType)) {
case "addservice", "updateservice":
return true
default:
return false
}
}
func validateFederationCommandPorts(share *repo.PeerShare, data interface{}) error {
if share == nil || (share.PortRangeStart <= 0 && share.PortRangeEnd <= 0) {
return nil
}
serviceList := extractFederationServiceEntries(data)
if len(serviceList) == 0 {
return nil
}
for _, svcMap := range serviceList {
addr := asString(svcMap["addr"])
if addr == "" {
continue
}
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("invalid service address: %s", addr)
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 {
return fmt.Errorf("invalid port in service address: %s", addr)
}
if port < share.PortRangeStart || port > share.PortRangeEnd {
return fmt.Errorf("port %d out of allowed range %d-%d", port, share.PortRangeStart, share.PortRangeEnd)
}
}
return nil
}
func (h *Handler) pickPeerSharePort(share *repo.PeerShare, requestedPort int) (int, error) {
if share == nil {
return 0, fmt.Errorf("share not found")
}
@@ -1201,29 +1532,13 @@ func (h *Handler) pickPeerSharePort(share *sqlite.PeerShare, requestedPort int)
used := make(map[int]struct{})
rows, err := h.repo.DB().Query(`SELECT port FROM chain_tunnel WHERE node_id = ? AND port IS NOT NULL AND port > 0`, share.NodeID)
nodePorts, err := h.repo.ListUsedPortsOnNode(share.NodeID)
if err != nil {
return 0, err
}
for rows.Next() {
var p sql.NullInt64
if scanErr := rows.Scan(&p); scanErr == nil && p.Valid && p.Int64 > 0 {
used[int(p.Int64)] = struct{}{}
}
for _, p := range nodePorts {
used[p] = struct{}{}
}
_ = rows.Close()
rows, err = h.repo.DB().Query(`SELECT port FROM forward_port WHERE node_id = ? AND port > 0`, share.NodeID)
if err != nil {
return 0, err
}
for rows.Next() {
var p sql.NullInt64
if scanErr := rows.Scan(&p); scanErr == nil && p.Valid && p.Int64 > 0 {
used[int(p.Int64)] = struct{}{}
}
}
_ = rows.Close()
ports, err := h.repo.ListActivePeerShareRuntimePorts(share.ID, share.NodeID)
if err != nil {
@@ -1264,7 +1579,7 @@ func extractBearerToken(r *http.Request) string {
return ""
}
func isPeerShareFlowExceeded(share *sqlite.PeerShare) bool {
func isPeerShareFlowExceeded(share *repo.PeerShare) bool {
if share == nil {
return false
}
@@ -1348,7 +1663,7 @@ func parseIPLiteral(raw string) net.IP {
}
if ip := net.ParseIP(value); ip != nil {
return ip
return normalizeIPAddress(ip)
}
host, _, err := net.SplitHostPort(value)
@@ -1360,7 +1675,17 @@ func parseIPLiteral(raw string) net.IP {
if host == "" {
return nil
}
return net.ParseIP(host)
return normalizeIPAddress(net.ParseIP(host))
}
func normalizeIPAddress(ip net.IP) net.IP {
if ip == nil {
return nil
}
if v4 := ip.To4(); v4 != nil {
return v4
}
return ip.To16()
}
func isTrustedProxyIP(ip net.IP) bool {
@@ -1491,3 +1816,18 @@ func (h *Handler) cleanupPeerShareRuntimes(shareID int64) {
_ = h.repo.MarkPeerShareRuntimeReleased(runtime.ID, now)
}
}
func (h *Handler) cleanupFederationTunnels(shareID int64) {
if h == nil || h.repo == nil || shareID <= 0 {
return
}
namePrefix := fmt.Sprintf("Share-%d-Port-", shareID)
tunnelIDs, err := h.repo.ListTunnelIDsByNamePrefix(namePrefix)
if err != nil || len(tunnelIDs) == 0 {
return
}
for _, tid := range tunnelIDs {
_ = h.deleteTunnelByID(tid)
}
}
@@ -10,33 +10,33 @@ import (
"time"
"go-backend/internal/http/response"
"go-backend/internal/store/sqlite"
"go-backend/internal/store/repo"
)
func TestPickPeerSharePortUsesRuntimeReservations(t *testing.T) {
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer repo.Close()
defer r.Close()
h := &Handler{repo: repo}
h := &Handler{repo: r}
now := time.Now().UnixMilli()
if _, err := repo.DB().Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, ?, ?, ?, ?, ?, ?)`, 1, 2, 1, 3000, "round", 1, "tls"); err != nil {
if err := r.DB().Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, ?, ?, ?, ?, ?, ?)`, 1, 2, 1, 3000, "round", 1, "tls").Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if _, err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, 1, 1, 3001); err != nil {
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, 1, 1, 3001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
if _, err := repo.DB().Exec(`
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, 77, 1, "res-1", "rk-1", "b-1", "exit", "", "fed_svc_1", "tls", "round", 3002, "", 1, 1, now, now); err != nil {
`, 77, 1, "res-1", "rk-1", "b-1", "exit", "", "fed_svc_1", "tls", "round", 3002, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert peer_share_runtime: %v", err)
}
share := &sqlite.PeerShare{
share := &repo.PeerShare{
ID: 77,
NodeID: 1,
PortRangeStart: 3000,
@@ -56,14 +56,35 @@ func TestPickPeerSharePortUsesRuntimeReservations(t *testing.T) {
}
}
func TestApplyTunnelRuntimeSkipsRemoteNodes(t *testing.T) {
h := &Handler{}
func TestApplyTunnelRuntimeSkipsRemoteChainAndOutNodes(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "rt-skip.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
h := &Handler{repo: r}
now := time.Now().UnixMilli()
for _, n := range []struct {
id int64
name string
ip string
}{
{12, "remote-chain", "10.99.0.2"},
{13, "remote-out", "10.99.0.3"},
} {
if err := r.DB().Exec(`
INSERT INTO node(id, name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, n.id, n.name, n.name+"-secret", n.ip, n.ip, "", "40000-40010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://remote-peer", "remote-token").Error; err != nil {
t.Fatalf("insert node %s: %v", n.name, err)
}
}
state := &tunnelCreateState{
TunnelID: 1,
Type: 2,
InNodes: []tunnelRuntimeNode{
{NodeID: 11, ChainType: 1, Protocol: "tls"},
},
InNodes: []tunnelRuntimeNode{},
ChainHops: [][]tunnelRuntimeNode{
{
{NodeID: 12, ChainType: 2, Inx: 1, Port: 41000, Protocol: "tls", Strategy: "round"},
@@ -73,9 +94,8 @@ func TestApplyTunnelRuntimeSkipsRemoteNodes(t *testing.T) {
{NodeID: 13, ChainType: 3, Port: 42000, Protocol: "tls", Strategy: "round"},
},
Nodes: map[int64]*nodeRecord{
11: {ID: 11, Name: "remote-in", IsRemote: 1},
12: {ID: 12, Name: "remote-chain", IsRemote: 1},
13: {ID: 13, Name: "remote-out", IsRemote: 1},
12: {ID: 12, Name: "remote-chain", IsRemote: 1, ServerIPv4: "10.99.0.2"},
13: {ID: 13, Name: "remote-out", IsRemote: 1, ServerIPv4: "10.99.0.3"},
},
}
@@ -84,47 +104,42 @@ func TestApplyTunnelRuntimeSkipsRemoteNodes(t *testing.T) {
t.Fatalf("apply runtime: %v", err)
}
if len(chains) != 0 {
t.Fatalf("expected no local chains created, got %d", len(chains))
t.Fatalf("expected no local chains for remote-only nodes, got %d", len(chains))
}
if len(services) != 0 {
t.Fatalf("expected no local services created, got %d", len(services))
t.Fatalf("expected no local services for remote-only nodes, got %d", len(services))
}
}
func TestPrepareTunnelCreateStateRemoteAutoPortDefersToFederation(t *testing.T) {
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer repo.Close()
defer r.Close()
h := &Handler{repo: repo}
h := &Handler{repo: r}
now := time.Now().UnixMilli()
insertNode := func(name string, status int, portRange string, isRemote int) int64 {
res, execErr := repo.DB().Exec(`
if execErr := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, name+"-secret", "10.0.0.1", "10.0.0.1", "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0, isRemote, "http://peer", "peer-token", `{"shareId":1}`)
if execErr != nil {
`, name, name+"-secret", "10.0.0.1", "10.0.0.1", "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0, isRemote, "http://peer", "peer-token", `{"shareId":1}`).Error; execErr != nil {
t.Fatalf("insert node %s: %v", name, execErr)
}
id, idErr := res.LastInsertId()
if idErr != nil {
t.Fatalf("node id %s: %v", name, idErr)
}
return id
return mustLastInsertID(t, r, name)
}
entryID := insertNode("entry", 1, "31000-31010", 0)
remoteOutID := insertNode("remote-out", 1, "30000", 1)
if _, err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, 1, remoteOutID, 30000); err != nil {
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, 1, remoteOutID, 30000).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
tx, err := repo.DB().Begin()
if err != nil {
tx := r.DB().Begin()
if tx.Error != nil {
t.Fatalf("begin tx: %v", err)
}
defer tx.Rollback()
@@ -152,17 +167,77 @@ func TestPrepareTunnelCreateStateRemoteAutoPortDefersToFederation(t *testing.T)
}
}
func TestFederationRuntimeReservePortRejectsWhenShareFlowExceeded(t *testing.T) {
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
func TestPrepareTunnelCreateStateAllowsOfflineRemoteMiddleNode(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer repo.Close()
defer r.Close()
h := &Handler{repo: repo}
h := &Handler{repo: r}
now := time.Now().UnixMilli()
if err := repo.CreatePeerShare(&sqlite.PeerShare{
insertNode := func(name string, status int, portRange string, isRemote int) int64 {
if execErr := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, name+"-secret", "10.0.0.1", "10.0.0.1", "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0, isRemote, "http://peer", "peer-token", `{"shareId":2}`).Error; execErr != nil {
t.Fatalf("insert node %s: %v", name, execErr)
}
return mustLastInsertID(t, r, name)
}
entryID := insertNode("entry-local", 1, "32000-32010", 0)
remoteMiddleID := insertNode("middle-remote", 0, "33000-33010", 1)
outID := insertNode("out-local", 1, "34000-34010", 0)
tx := r.DB().Begin()
if tx.Error != nil {
t.Fatalf("begin tx: %v", err)
}
defer tx.Rollback()
req := map[string]interface{}{
"name": "remote-middle-offline-status",
"inNodeId": []interface{}{
map[string]interface{}{"nodeId": float64(entryID), "protocol": "tls", "strategy": "round"},
},
"chainNodes": []interface{}{
[]interface{}{
map[string]interface{}{"nodeId": float64(remoteMiddleID), "protocol": "tls", "strategy": "round", "port": float64(0)},
},
},
"outNodeId": []interface{}{
map[string]interface{}{"nodeId": float64(outID), "protocol": "tls", "strategy": "round", "port": float64(0)},
},
}
state, err := h.prepareTunnelCreateState(tx, req, 2, 0)
if err != nil {
t.Fatalf("prepare state should allow offline remote middle node: %v", err)
}
if len(state.ChainHops) != 1 || len(state.ChainHops[0]) != 1 {
t.Fatalf("expected one middle hop node, got %+v", state.ChainHops)
}
if state.ChainHops[0][0].NodeID != remoteMiddleID {
t.Fatalf("expected remote middle node id %d, got %d", remoteMiddleID, state.ChainHops[0][0].NodeID)
}
if state.Nodes[remoteMiddleID] == nil || state.Nodes[remoteMiddleID].IsRemote != 1 {
t.Fatalf("expected remote middle node metadata in state")
}
}
func TestFederationRuntimeReservePortRejectsWhenShareFlowExceeded(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
h := &Handler{repo: r}
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "limited-share",
NodeID: 1,
Token: "limited-token",
@@ -12,30 +12,26 @@ import (
"time"
"go-backend/internal/http/response"
"go-backend/internal/store/sqlite"
"go-backend/internal/store/repo"
)
func TestFederationShareCreateRejectsRemoteNode(t *testing.T) {
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
t.Cleanup(func() { _ = r.Close() })
h := New(repo, "test-jwt-secret")
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
insertRes, err := repo.DB().Exec(`
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "remote-share-node", "remote-share-secret", "10.10.10.1", "10.10.10.1", "", "20000-20010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://peer.example", "peer-token", `{"shareId":1}`)
if err != nil {
`, "remote-share-node", "remote-share-secret", "10.10.10.1", "10.10.10.1", "", "20000-20010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://peer.example", "peer-token", `{"shareId":1}`).Error; err != nil {
t.Fatalf("insert remote node: %v", err)
}
remoteNodeID, err := insertRes.LastInsertId()
if err != nil {
t.Fatalf("get remote node id: %v", err)
}
remoteNodeID := mustLastInsertID(t, r, "remote-share-node")
body, err := json.Marshal(createPeerShareRequest{
Name: "remote-node-share",
@@ -70,36 +66,29 @@ func TestFederationShareCreateRejectsRemoteNode(t *testing.T) {
t.Fatalf("expected rejection message %q, got %q", "Only local nodes can be shared", payload.Msg)
}
var shareCount int
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share WHERE node_id = ?`, remoteNodeID).Scan(&shareCount); err != nil {
t.Fatalf("query peer_share count: %v", err)
}
shareCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share WHERE node_id = ?`, remoteNodeID)
if shareCount != 0 {
t.Fatalf("expected no share rows for remote node, got %d", shareCount)
}
}
func TestFederationShareCreateRejectsInvalidAllowedIPs(t *testing.T) {
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
t.Cleanup(func() { _ = r.Close() })
h := New(repo, "test-jwt-secret")
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
insertRes, err := repo.DB().Exec(`
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "local-share-node", "local-share-secret", "10.20.30.40", "10.20.30.40", "", "21000-21010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "")
if err != nil {
`, "local-share-node", "local-share-secret", "10.20.30.40", "10.20.30.40", "", "21000-21010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
t.Fatalf("insert local node: %v", err)
}
localNodeID, err := insertRes.LastInsertId()
if err != nil {
t.Fatalf("get local node id: %v", err)
}
localNodeID := mustLastInsertID(t, r, "local-share-node")
body, err := json.Marshal(createPeerShareRequest{
Name: "local-node-share",
@@ -135,26 +124,23 @@ func TestFederationShareCreateRejectsInvalidAllowedIPs(t *testing.T) {
t.Fatalf("expected invalid IP message, got %q", payload.Msg)
}
var shareCount int
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share WHERE node_id = ?`, localNodeID).Scan(&shareCount); err != nil {
t.Fatalf("query peer_share count: %v", err)
}
shareCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share WHERE node_id = ?`, localNodeID)
if shareCount != 0 {
t.Fatalf("expected no share rows for node, got %d", shareCount)
}
}
func TestFederationShareListIncludesRemoteUsedPorts(t *testing.T) {
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
t.Cleanup(func() { _ = r.Close() })
h := New(repo, "test-jwt-secret")
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := repo.CreatePeerShare(&sqlite.PeerShare{
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "provider-share",
NodeID: 9,
Token: "share-list-token",
@@ -169,12 +155,12 @@ func TestFederationShareListIncludesRemoteUsedPorts(t *testing.T) {
t.Fatalf("create peer share: %v", err)
}
share, err := repo.GetPeerShareByToken("share-list-token")
share, err := r.GetPeerShareByToken("share-list-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if _, err := repo.DB().Exec(`
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
@@ -183,7 +169,7 @@ func TestFederationShareListIncludesRemoteUsedPorts(t *testing.T) {
share.ID, share.NodeID, "r-1", "rk-1", "b-1", "middle", "fed_chain_1", "fed_svc_1", "tls", "round", 22001, "", 1, 1, now, now,
share.ID, share.NodeID, "r-2", "rk-2", "b-2", "exit", "", "fed_svc_2", "tls", "round", 22002, "", 1, 1, now, now,
share.ID, share.NodeID, "r-3", "rk-3", "", "", "", "", "tls", "round", 22003, "", 0, 0, now, now,
); err != nil {
).Error; err != nil {
t.Fatalf("insert peer_share_runtime rows: %v", err)
}
@@ -238,16 +224,16 @@ func TestFederationShareListIncludesRemoteUsedPorts(t *testing.T) {
}
func TestFederationShareDeleteCleansUpRuntimes(t *testing.T) {
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
t.Cleanup(func() { _ = r.Close() })
h := New(repo, "test-jwt-secret")
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := repo.CreatePeerShare(&sqlite.PeerShare{
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "delete-cleanup-share",
NodeID: 99,
Token: "delete-cleanup-token",
@@ -261,26 +247,23 @@ func TestFederationShareDeleteCleansUpRuntimes(t *testing.T) {
t.Fatalf("create peer share: %v", err)
}
share, err := repo.GetPeerShareByToken("delete-cleanup-token")
share, err := r.GetPeerShareByToken("delete-cleanup-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if _, err := repo.DB().Exec(`
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
share.ID, 99, "dc-r1", "dc-rk1", "dc-b1", "exit", "", "fed_svc_dc1", "tls", "round", 40001, "", 1, 1, now, now,
share.ID, 99, "dc-r2", "dc-rk2", "dc-b2", "middle", "fed_chain_dc2", "fed_svc_dc2", "tls", "round", 40002, "", 1, 1, now, now,
); err != nil {
).Error; err != nil {
t.Fatalf("insert peer_share_runtime rows: %v", err)
}
var runtimeCount int
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1`, share.ID).Scan(&runtimeCount); err != nil {
t.Fatalf("count active runtimes before: %v", err)
}
runtimeCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1`, share.ID)
if runtimeCount != 2 {
t.Fatalf("expected 2 active runtimes before delete, got %d", runtimeCount)
}
@@ -306,37 +289,31 @@ func TestFederationShareDeleteCleansUpRuntimes(t *testing.T) {
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
}
var shareCount int
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share WHERE id = ?`, share.ID).Scan(&shareCount); err != nil {
t.Fatalf("count peer_share after: %v", err)
}
shareCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share WHERE id = ?`, share.ID)
if shareCount != 0 {
t.Fatalf("expected peer_share deleted, got %d rows", shareCount)
}
var runtimeCountAfter int
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ?`, share.ID).Scan(&runtimeCountAfter); err != nil {
t.Fatalf("count peer_share_runtime after: %v", err)
}
runtimeCountAfter := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ?`, share.ID)
if runtimeCountAfter != 0 {
t.Fatalf("expected all peer_share_runtime rows deleted, got %d", runtimeCountAfter)
}
}
func TestFederationRemoteUsageListSyncErrorFallback(t *testing.T) {
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
t.Cleanup(func() { _ = r.Close() })
h := New(repo, "test-jwt-secret")
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if _, err := repo.DB().Exec(`
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "sync-error-node", "sync-error-secret", "10.50.60.70", "10.50.60.70", "", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://unreachable.invalid:9999", "bad-token", `{"shareId":42,"maxBandwidth":5368709120,"currentFlow":999999,"portRangeStart":32000,"portRangeEnd":32010}`); err != nil {
`, "sync-error-node", "sync-error-secret", "10.50.60.70", "10.50.60.70", "", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://unreachable.invalid:9999", "bad-token", `{"shareId":42,"maxBandwidth":5368709120,"currentFlow":999999,"portRangeStart":32000,"portRangeEnd":32010}`).Error; err != nil {
t.Fatalf("insert remote node: %v", err)
}
@@ -380,15 +357,15 @@ func TestFederationRemoteUsageListSyncErrorFallback(t *testing.T) {
}
func TestFederationShareResetFlow(t *testing.T) {
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
t.Cleanup(func() { _ = r.Close() })
h := New(repo, "test-jwt-secret")
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := repo.CreatePeerShare(&sqlite.PeerShare{
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "reset-flow-share",
NodeID: 11,
Token: "reset-flow-token",
@@ -402,7 +379,7 @@ func TestFederationShareResetFlow(t *testing.T) {
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := repo.GetPeerShareByToken("reset-flow-token")
share, err := r.GetPeerShareByToken("reset-flow-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
@@ -428,7 +405,7 @@ func TestFederationShareResetFlow(t *testing.T) {
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
}
updated, err := repo.GetPeerShare(share.ID)
updated, err := r.GetPeerShare(share.ID)
if err != nil || updated == nil {
t.Fatalf("reload peer share: %v", err)
}
@@ -437,48 +414,481 @@ func TestFederationShareResetFlow(t *testing.T) {
}
}
func TestFederationRemoteUsageList(t *testing.T) {
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
func TestFederationTunnelCreateCreatesPeerShareRuntime(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
t.Cleanup(func() { _ = r.Close() })
h := New(repo, "test-jwt-secret")
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
resNode, err := repo.DB().Exec(`
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "remote-consumer-node", "remote-consumer-secret", "10.30.40.50", "10.30.40.50", "", "31000-31010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://peer.example", "peer-token", `{"shareId":88,"maxBandwidth":2147483648,"currentFlow":1073741824,"portRangeStart":31000,"portRangeEnd":31010}`)
`, "federation-forward-node", "federation-forward-secret", "10.90.80.70", "10.90.80.70", "", "24000-24020", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, r, "federation-forward-node")
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "federation-forward-share",
NodeID: nodeID,
Token: "federation-forward-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 24000,
PortRangeEnd: 24020,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("federation-forward-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
body, err := json.Marshal(federationTunnelRequest{
Protocol: "tcp",
RemotePort: 24001,
Target: "1.1.1.1:443",
})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/tunnel/create", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+share.Token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
h.federationTunnelCreate(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
}
var payload response.R
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Code != 0 {
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
}
runtimeCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND port = ? AND status = 1`, share.ID, 24001)
if runtimeCount != 1 {
t.Fatalf("expected 1 runtime row for new federation forward tunnel, got %d", runtimeCount)
}
}
func TestFederationTunnelCreateRejectsOccupiedPort(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "federation-port-check-node", "federation-port-check-secret", "10.91.80.70", "10.91.80.70", "", "24100-24120", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, r, "federation-port-check-node")
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "federation-port-check-share",
NodeID: nodeID,
Token: "federation-port-check-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 24100,
PortRangeEnd: 24120,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
create := func() response.R {
body, err := json.Marshal(federationTunnelRequest{Protocol: "tcp", RemotePort: 24101, Target: "1.1.1.1:443"})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/tunnel/create", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer federation-port-check-token")
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
h.federationTunnelCreate(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
}
var payload response.R
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
return payload
}
first := create()
if first.Code != 0 {
t.Fatalf("expected first create success, got %d (%s)", first.Code, first.Msg)
}
second := create()
if second.Code != 403 {
t.Fatalf("expected second create to be rejected with 403, got %d (%s)", second.Code, second.Msg)
}
if second.Msg != "Port already in use" {
t.Fatalf("expected occupied port message, got %q", second.Msg)
}
}
func TestDeleteTunnelReleasesFederationForwardRuntimeByPort(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "delete-forward-share",
NodeID: 1,
Token: "delete-forward-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 25000,
PortRangeEnd: 25020,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("delete-forward-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
tunnelName := fmt.Sprintf("Share-%d-Port-%d", share.ID, 25001)
if err := r.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, 1, tunnelName, 1.0, 1, "tcp", 1, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "del-r1", "del-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 25001, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert runtime: %v", err)
}
if err := h.deleteTunnelByID(1); err != nil {
t.Fatalf("delete tunnel: %v", err)
}
activeCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND port = ? AND status = 1`, share.ID, 25001)
if activeCount != 0 {
t.Fatalf("expected runtime released after tunnel delete, active rows=%d", activeCount)
}
}
func TestBindPeerShareForwardRuntimeServicesOnlyBindsForwardRole(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "bind-forward-role-share",
NodeID: 1,
Token: "bind-forward-role-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 26000,
PortRangeEnd: 26020,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("bind-forward-role-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
1, share.ID, share.NodeID, "bind-r1", "bind-rk1", "", "forward", "", "", "tcp", "fifo", 26001, "", 0, 1, now, now,
2, share.ID, share.NodeID, "bind-r2", "bind-rk2", "", "middle", "", "", "tcp", "round", 26002, "", 0, 1, now, now,
).Error; err != nil {
t.Fatalf("insert runtimes: %v", err)
}
h.bindPeerShareForwardRuntimeServices(share, map[string]interface{}{
"services": []interface{}{
map[string]interface{}{"name": "77_2_10_tcp", "addr": "[::]:26001"},
map[string]interface{}{"name": "88_2_10_tcp", "addr": "[::]:26002"},
},
})
forwardServiceName := ""
middleServiceName := ""
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 1`).Scan(&forwardServiceName).Error; err != nil {
t.Fatalf("load forward runtime service name: %v", err)
}
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 2`).Scan(&middleServiceName).Error; err != nil {
t.Fatalf("load middle runtime service name: %v", err)
}
if forwardServiceName != "77_2_10" {
t.Fatalf("expected forward runtime service name bound, got %q", forwardServiceName)
}
if middleServiceName != "" {
t.Fatalf("expected non-forward runtime unchanged, got %q", middleServiceName)
}
}
func TestBindPeerShareForwardRuntimeServicesAcceptsTopLevelServiceArray(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "bind-array-share",
NodeID: 1,
Token: "bind-array-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 26100,
PortRangeEnd: 26120,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("bind-array-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
1, share.ID, share.NodeID, "bind-array-r1", "bind-array-rk1", "", "forward", "", "", "tcp", "fifo", 26101, "", 0, 1, now, now,
).Error; err != nil {
t.Fatalf("insert runtime: %v", err)
}
h.bindPeerShareForwardRuntimeServices(share, []interface{}{
map[string]interface{}{"name": "99_2_10_tcp", "addr": "[::]:26101"},
map[string]interface{}{"name": "99_2_10_udp", "addr": "[::]:26101"},
})
forwardServiceName := ""
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 1`).Scan(&forwardServiceName).Error; err != nil {
t.Fatalf("load forward runtime service name: %v", err)
}
if forwardServiceName != "99_2_10" {
t.Fatalf("expected forward runtime service name bound from top-level array, got %q", forwardServiceName)
}
}
func TestBindPeerShareForwardRuntimeServicesCreatesRuntimeWhenMissing(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-bind-create-runtime.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "bind-create-runtime-share",
NodeID: 1,
Token: "bind-create-runtime-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 26300,
PortRangeEnd: 26320,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("bind-create-runtime-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
h.bindPeerShareForwardRuntimeServices(share, map[string]interface{}{
"services": []interface{}{
map[string]interface{}{"name": "55_2_10_tcp", "addr": "[::]:26301"},
},
})
var count int64
if err := r.DB().Raw(`SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND role = ? AND status = 1`, share.ID, "forward").Scan(&count).Error; err != nil {
t.Fatalf("query runtime count: %v", err)
}
if count != 1 {
t.Fatalf("expected 1 active forward runtime row, got %d", count)
}
var serviceName string
var port int
var applied int
if err := r.DB().Raw(`SELECT service_name, port, applied FROM peer_share_runtime WHERE share_id = ? AND role = ? ORDER BY id DESC LIMIT 1`, share.ID, "forward").Row().Scan(&serviceName, &port, &applied); err != nil {
t.Fatalf("query created runtime: %v", err)
}
if serviceName != "55_2_10" {
t.Fatalf("expected service_name=55_2_10, got %q", serviceName)
}
if port != 26301 {
t.Fatalf("expected port=26301, got %d", port)
}
if applied != 1 {
t.Fatalf("expected applied=1, got %d", applied)
}
}
func TestReleasePeerShareForwardRuntimeServicesMarksRuntimeReleased(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-release-runtime.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "release-runtime-share",
NodeID: 1,
Token: "release-runtime-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 26400,
PortRangeEnd: 26420,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("release-runtime-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "release-r1", "release-rk1", "", "forward", "", "77_2_10", "tcp", "fifo", 26401, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert runtime: %v", err)
}
h.releasePeerShareForwardRuntimeServices(share, map[string]interface{}{
"services": []interface{}{"77_2_10_tcp"},
})
var status int
var applied int
var serviceName string
if err := r.DB().Raw(`SELECT status, applied, service_name FROM peer_share_runtime WHERE share_id = ? AND role = ? ORDER BY id DESC LIMIT 1`, share.ID, "forward").Row().Scan(&status, &applied, &serviceName); err != nil {
t.Fatalf("query released runtime: %v", err)
}
if status != 0 {
t.Fatalf("expected status=0 after release, got %d", status)
}
if applied != 0 {
t.Fatalf("expected applied=0 after release, got %d", applied)
}
if serviceName != "" {
t.Fatalf("expected service_name cleared after release, got %q", serviceName)
}
}
func TestValidateFederationCommandPortsAcceptsTopLevelServiceArray(t *testing.T) {
share := &repo.PeerShare{
PortRangeStart: 26200,
PortRangeEnd: 26210,
}
err := validateFederationCommandPorts(share, []interface{}{
map[string]interface{}{"name": "11_2_10_tcp", "addr": "[::]:26201"},
map[string]interface{}{"name": "11_2_10_udp", "addr": "[::]:26201"},
})
if err != nil {
t.Fatalf("expected top-level service array to pass port validation, got: %v", err)
}
}
func TestFederationRemoteUsageList(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "remote-consumer-node", "remote-consumer-secret", "10.30.40.50", "10.30.40.50", "", "31000-31010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://peer.example", "peer-token", `{"shareId":88,"maxBandwidth":2147483648,"currentFlow":1073741824,"portRangeStart":31000,"portRangeEnd":31010}`).Error; err != nil {
t.Fatalf("insert remote node: %v", err)
}
nodeID, err := resNode.LastInsertId()
if err != nil {
t.Fatalf("remote node id: %v", err)
}
nodeID := mustLastInsertID(t, r, "remote-consumer-node")
resTunnelA, err := repo.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, "consumer-tunnel-a", 2, "tls", 1, now, now, 1, "", 0)
if err != nil {
if err := r.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, "consumer-tunnel-a", 2, "tls", 1, now, now, 1, "", 0).Error; err != nil {
t.Fatalf("insert tunnel a: %v", err)
}
tunnelAID, _ := resTunnelA.LastInsertId()
tunnelAID := mustLastInsertID(t, r, "consumer-tunnel-a")
resTunnelB, err := repo.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, "consumer-tunnel-b", 2, "tls", 1, now, now, 1, "", 0)
if err != nil {
if err := r.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, "consumer-tunnel-b", 2, "tls", 1, now, now, 1, "", 0).Error; err != nil {
t.Fatalf("insert tunnel b: %v", err)
}
tunnelBID, _ := resTunnelB.LastInsertId()
tunnelBID := mustLastInsertID(t, r, "consumer-tunnel-b")
if _, err := repo.DB().Exec(`
if err := r.DB().Exec(`
INSERT INTO federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx, remote_url, resource_key, remote_binding_id, allocated_port, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
tunnelAID, nodeID, 2, 1, "http://peer.example", "rk-a", "rb-a", 31001, 1, now, now,
tunnelBID, nodeID, 3, 0, "http://peer.example", "rk-b", "rb-b", 31002, 1, now, now,
); err != nil {
).Error; err != nil {
t.Fatalf("insert federation bindings: %v", err)
}
@@ -531,14 +941,118 @@ func TestFederationRemoteUsageList(t *testing.T) {
}
}
func TestAuthPeerAllowedIPs(t *testing.T) {
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
func TestFederationRemoteUsageListIncludesForwardPorts(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-usage.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
t.Cleanup(func() { _ = r.Close() })
h := New(repo, "test-jwt-secret")
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "forward-usage-remote-node", "forward-usage-secret", "10.60.70.80", "10.60.70.80", "", "33000-33010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "", "", `{"shareId":99,"maxBandwidth":0,"currentFlow":0,"portRangeStart":33000,"portRangeEnd":33010}`).Error; err != nil {
t.Fatalf("insert remote node: %v", err)
}
var nodeID int64
if err := r.DB().Raw(`SELECT id FROM node WHERE name = ? ORDER BY id DESC LIMIT 1`, "forward-usage-remote-node").Row().Scan(&nodeID); err != nil {
t.Fatalf("query node id: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "forward-usage-tunnel", 1, "tls", 1, now, now, 1, "", 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
var tunnelID int64
if err := r.DB().Raw(`SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, "forward-usage-tunnel").Row().Scan(&tunnelID); err != nil {
t.Fatalf("query tunnel id: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, 1, "tester", "forward-usage-item", tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
var forwardID int64
if err := r.DB().Raw(`SELECT id FROM forward WHERE name = ? ORDER BY id DESC LIMIT 1`, "forward-usage-item").Row().Scan(&forwardID); err != nil {
t.Fatalf("query forward id: %v", err)
}
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, 33001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/remote-usage/list", nil)
res := httptest.NewRecorder()
h.federationRemoteUsageList(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
}
var payload response.R
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Code != 0 {
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
}
rows, ok := payload.Data.([]interface{})
if !ok || len(rows) == 0 {
t.Fatalf("expected non-empty usage list, got %T", payload.Data)
}
first, ok := rows[0].(map[string]interface{})
if !ok {
t.Fatalf("expected usage row map, got %T", rows[0])
}
usedPortsRaw, ok := first["usedPorts"].([]interface{})
if !ok {
t.Fatalf("expected usedPorts array, got %T", first["usedPorts"])
}
if len(usedPortsRaw) != 1 || int(usedPortsRaw[0].(float64)) != 33001 {
t.Fatalf("expected usedPorts [33001], got %v", usedPortsRaw)
}
bindingsRaw, ok := first["bindings"].([]interface{})
if !ok {
t.Fatalf("expected bindings array, got %T", first["bindings"])
}
if len(bindingsRaw) != 1 {
t.Fatalf("expected 1 binding row from forward usage, got %d", len(bindingsRaw))
}
binding, ok := bindingsRaw[0].(map[string]interface{})
if !ok {
t.Fatalf("expected binding row object, got %T", bindingsRaw[0])
}
if int(binding["allocatedPort"].(float64)) != 33001 {
t.Fatalf("expected allocatedPort=33001, got %v", binding["allocatedPort"])
}
if int(binding["chainType"].(float64)) != 1 {
t.Fatalf("expected chainType=1 for forward usage row, got %v", binding["chainType"])
}
}
func TestAuthPeerAllowedIPs(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
tests := []struct {
@@ -567,6 +1081,13 @@ func TestAuthPeerAllowedIPs(t *testing.T) {
xff: "198.51.100.20, 172.20.0.3",
wantAllowed: true,
},
{
name: "ipv4-mapped proxy xff allowed",
allowedIPs: "198.51.100.20",
remoteAddr: "[::ffff:172.20.0.3]:34567",
xff: "198.51.100.20, 172.20.0.3",
wantAllowed: true,
},
{
name: "non whitelisted ip denied",
allowedIPs: "203.0.113.10",
@@ -578,7 +1099,7 @@ func TestAuthPeerAllowedIPs(t *testing.T) {
for idx, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
token := fmt.Sprintf("share-token-%d", idx)
if err := repo.CreatePeerShare(&sqlite.PeerShare{
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "share-" + tt.name,
NodeID: 1,
Token: token,
+213 -70
View File
@@ -1,11 +1,13 @@
package handler
import (
"database/sql"
"encoding/json"
"log"
"strconv"
"strings"
"time"
"go-backend/internal/store/model"
)
const bytesPerGB int64 = 1024 * 1024 * 1024
@@ -31,7 +33,7 @@ type namedConfigItem struct {
Name string `json:"name"`
}
func (h *Handler) processFlowItem(item flowItem) {
func (h *Handler) processFlowItem(nodeID int64, item flowItem) {
serviceName := strings.TrimSpace(item.N)
if serviceName == "" || serviceName == "web_api" {
return
@@ -41,6 +43,7 @@ func (h *Handler) processFlowItem(item flowItem) {
if ok {
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
if userTunnelID > 0 {
h.enforceFlowPolicies(userID, userTunnelID)
@@ -88,6 +91,45 @@ func parsePeerShareRuntimeServiceID(serviceName string) (int64, bool) {
return runtimeID, true
}
func parsePeerShareInfoFromFederationTunnelName(tunnelName string) (int64, int, bool) {
tunnelName = strings.TrimSpace(tunnelName)
if !strings.HasPrefix(tunnelName, "Share-") {
return 0, 0, false
}
raw := strings.TrimPrefix(tunnelName, "Share-")
idx := strings.Index(raw, "-Port-")
if idx <= 0 {
return 0, 0, false
}
shareID, err := strconv.ParseInt(raw[:idx], 10, 64)
if err != nil || shareID <= 0 {
return 0, 0, false
}
portValue := strings.TrimSpace(raw[idx+len("-Port-"):])
port, err := strconv.Atoi(portValue)
if err != nil || port <= 0 {
return 0, 0, false
}
return shareID, port, true
}
func parsePeerShareIDFromFederationTunnelName(tunnelName string) (int64, bool) {
tunnelName = strings.TrimSpace(tunnelName)
if !strings.HasPrefix(tunnelName, "Share-") {
return 0, false
}
raw := strings.TrimPrefix(tunnelName, "Share-")
idx := strings.Index(raw, "-Port-")
if idx <= 0 {
return 0, false
}
shareID, err := strconv.ParseInt(raw[:idx], 10, 64)
if err != nil || shareID <= 0 {
return 0, false
}
return shareID, true
}
func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
if h == nil || h.repo == nil || runtimeID <= 0 {
return
@@ -114,6 +156,121 @@ func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
h.enforcePeerShareFlowLimit(share.ID)
}
func (h *Handler) processPeerShareFlowFromForward(forwardID int64, nodeID int64, serviceName string, item flowItem) {
if h == nil || h.repo == nil || forwardID <= 0 {
return
}
delta := item.D + item.U
if delta <= 0 {
return
}
forward, err := h.getForwardRecord(forwardID)
if err != nil || forward == nil {
// Forward not found in local database - might be a federation port-forward
// Try to find by service name in peer_share_runtime
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
tunnelName, err := h.repo.GetTunnelName(forward.TunnelID)
if err != nil {
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
shareID, ok := parsePeerShareIDFromFederationTunnelName(tunnelName)
if !ok {
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
if err := h.repo.AddPeerShareCurrentFlow(shareID, delta); err != nil {
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
share, err := h.repo.GetPeerShare(shareID)
if err != nil || share == nil {
return
}
if !isPeerShareFlowExceeded(share) {
return
}
h.enforcePeerShareFlowLimit(share.ID)
}
func normalizeForwardRuntimeServiceName(serviceName string) string {
name := strings.TrimSpace(serviceName)
if strings.HasSuffix(name, "_tcp") {
return strings.TrimSuffix(name, "_tcp")
}
if strings.HasSuffix(name, "_udp") {
return strings.TrimSuffix(name, "_udp")
}
return name
}
func (h *Handler) processPeerShareFlowByServiceName(nodeID int64, serviceName string, item flowItem) {
if h == nil || h.repo == nil || strings.TrimSpace(serviceName) == "" {
return
}
delta := item.D + item.U
if delta <= 0 {
return
}
normalized := normalizeForwardRuntimeServiceName(serviceName)
var runtimes []model.PeerShareRuntime
var err error
// Try node-scoped query first if nodeID is valid
if nodeID > 0 {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID, normalized)
if err != nil {
return
}
if len(runtimes) == 0 && normalized != serviceName {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID, serviceName)
if err != nil {
return
}
}
}
// Fallback to global query if node-scoped query returned nothing or nodeID is invalid
if len(runtimes) == 0 {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByServiceName(normalized)
if err != nil {
return
}
if len(runtimes) == 0 && normalized != serviceName {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByServiceName(serviceName)
if err != nil {
return
}
}
}
if len(runtimes) != 1 {
if len(runtimes) > 1 {
log.Printf("WARN: ambiguous peer share runtime match for service=%s nodeID=%d count=%d", serviceName, nodeID, len(runtimes))
}
return
}
runtime := runtimes[0]
_ = h.repo.AddPeerShareCurrentFlow(runtime.ShareID, delta)
matchedShare, err := h.repo.GetPeerShare(runtime.ShareID)
if err != nil || matchedShare == nil {
return
}
if isPeerShareFlowExceeded(matchedShare) {
h.enforcePeerShareFlowLimit(matchedShare.ID)
}
}
func (h *Handler) enforcePeerShareFlowLimit(shareID int64) {
if h == nil || h.repo == nil || shareID <= 0 {
return
@@ -207,22 +364,18 @@ func (h *Handler) getUserTunnelPolicy(userTunnelID int64) (*userTunnelPolicy, er
if userTunnelID <= 0 {
return nil, nil
}
row := h.repo.DB().QueryRow(`
SELECT id, user_id, tunnel_id, flow, in_flow, out_flow, exp_time, status
FROM user_tunnel
WHERE id = ?
LIMIT 1
`, userTunnelID)
var policy userTunnelPolicy
if err := row.Scan(&policy.ID, &policy.UserID, &policy.TunnelID, &policy.Flow, &policy.InFlow, &policy.OutFlow, &policy.ExpTime, &policy.Status); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
ut, err := h.repo.GetUserTunnelByID(userTunnelID)
if err != nil {
return nil, err
}
return &policy, nil
if ut == nil {
return nil, nil
}
return &userTunnelPolicy{
ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID,
Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow,
ExpTime: ut.ExpTime, Status: ut.Status,
}, nil
}
func (h *Handler) pauseUserForwards(userID int64, now int64) {
@@ -245,60 +398,20 @@ func (h *Handler) pauseForwardRecords(forwards []forwardRecord, now int64) {
for i := range forwards {
forward := forwards[i]
_ = h.controlForwardServices(&forward, "PauseService", false)
_, _ = h.repo.DB().Exec(`UPDATE forward SET status = 0, updated_time = ? WHERE id = ?`, now, forward.ID)
_ = h.repo.UpdateForwardStatus(forward.ID, 0, now)
}
}
func (h *Handler) listActiveForwardsByUser(userID int64) ([]forwardRecord, error) {
rows, err := h.repo.DB().Query(`
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, strategy, status
FROM forward
WHERE user_id = ? AND status = 1
ORDER BY id ASC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanForwardRecords(rows)
return h.repo.ListActiveForwardsByUser(userID)
}
func (h *Handler) listActiveForwardsByUserTunnel(userID int64, tunnelID int64) ([]forwardRecord, error) {
rows, err := h.repo.DB().Query(`
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, strategy, status
FROM forward
WHERE user_id = ? AND tunnel_id = ? AND status = 1
ORDER BY id ASC
`, userID, tunnelID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanForwardRecords(rows)
}
func scanForwardRecords(rows *sql.Rows) ([]forwardRecord, error) {
out := make([]forwardRecord, 0)
for rows.Next() {
var record forwardRecord
if err := rows.Scan(&record.ID, &record.UserID, &record.UserName, &record.Name, &record.TunnelID, &record.RemoteAddr, &record.Strategy, &record.Status); err != nil {
return nil, err
}
if strings.TrimSpace(record.Strategy) == "" {
record.Strategy = "fifo"
}
out = append(out, record)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
return h.repo.ListActiveForwardsByUserTunnel(userID, tunnelID)
}
func (h *Handler) cleanNodeConfigs(nodeID int64, rawConfig string) {
if h == nil || h.repo == nil || h.repo.DB() == nil || nodeID <= 0 {
if h == nil || h.repo == nil || nodeID <= 0 {
return
}
if strings.TrimSpace(rawConfig) == "" {
@@ -316,15 +429,46 @@ func (h *Handler) cleanNodeConfigs(nodeID int64, rawConfig string) {
}
func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem) {
runtimeServiceNames, err := h.repo.ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID)
if err != nil {
return
}
minUpdatedTime := time.Now().Add(-10 * time.Minute).UnixMilli()
hasUnboundForwardPeerRuntime, err := h.repo.HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID, minUpdatedTime)
if err != nil {
hasUnboundForwardPeerRuntime = false
}
runtimeServiceSet := make(map[string]struct{}, len(runtimeServiceNames))
for _, serviceName := range runtimeServiceNames {
serviceName = strings.TrimSpace(serviceName)
if serviceName == "" {
continue
}
runtimeServiceSet[serviceName] = struct{}{}
}
for _, item := range services {
name := strings.TrimSpace(item.Name)
if name == "" || name == "web_api" {
continue
}
if strings.HasPrefix(name, "fed_svc_") {
continue
}
normalizedName := normalizeForwardRuntimeServiceName(name)
if _, ok := runtimeServiceSet[normalizedName]; ok {
continue
}
if _, ok := runtimeServiceSet[name]; ok {
continue
}
parts := strings.Split(name, "_")
if len(parts) >= 3 {
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
continue
}
if err == nil && forwardID > 0 && !h.forwardExists(forwardID) {
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name, parts[0] + "_" + parts[1] + "_" + parts[2], parts[0] + "_" + parts[1] + "_" + parts[2] + "_tcp", parts[0] + "_" + parts[1] + "_" + parts[2] + "_udp"}}, false, true)
continue
@@ -344,6 +488,9 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
continue
}
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
continue
}
if err != nil || forwardID <= 0 || h.forwardExists(forwardID) {
continue
}
@@ -383,15 +530,13 @@ func (h *Handler) cleanOrphanedLimiters(nodeID int64, limiters []namedConfigItem
}
func (h *Handler) tunnelExists(tunnelID int64) bool {
var count int
err := h.repo.DB().QueryRow(`SELECT COUNT(1) FROM tunnel WHERE id = ?`, tunnelID).Scan(&count)
return err == nil && count > 0
ok, _ := h.repo.TunnelExists(tunnelID)
return ok
}
func (h *Handler) forwardExists(forwardID int64) bool {
var count int
err := h.repo.DB().QueryRow(`SELECT COUNT(1) FROM forward WHERE id = ?`, forwardID).Scan(&count)
return err == nil && count > 0
ok, _ := h.repo.ForwardExists(forwardID)
return ok
}
func (h *Handler) speedLimiterExists(name string) bool {
@@ -402,8 +547,6 @@ func (h *Handler) speedLimiterExists(name string) bool {
if err != nil || id <= 0 {
return false
}
var count int
err = h.repo.DB().QueryRow(`SELECT COUNT(1) FROM speed_limit WHERE id = ?`, id).Scan(&count)
return err == nil && count > 0
ok, _ := h.repo.SpeedLimitExists(id)
return ok
}
@@ -2,21 +2,22 @@ package handler
import (
"path/filepath"
"strconv"
"testing"
"time"
"go-backend/internal/store/sqlite"
"go-backend/internal/store/repo"
)
func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer repo.Close()
defer r.Close()
now := time.Now().UnixMilli()
if err := repo.CreatePeerShare(&sqlite.PeerShare{
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "flow-share",
NodeID: 1,
Token: "flow-share-token",
@@ -30,22 +31,22 @@ func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := repo.GetPeerShareByToken("flow-share-token")
share, err := r.GetPeerShareByToken("flow-share-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if _, err := repo.DB().Exec(`
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, 17, share.ID, share.NodeID, "res-17", "rk-17", "17", "exit", "", "fed_svc_17", "tls", "round", 32001, "", 1, 1, now, now); err != nil {
`, 17, share.ID, share.NodeID, "res-17", "rk-17", "17", "exit", "", "fed_svc_17", "tls", "round", 32001, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert peer_share_runtime: %v", err)
}
h := &Handler{repo: repo}
h.processFlowItem(flowItem{N: "fed_svc_17", U: 1200, D: 900})
h := &Handler{repo: r}
h.processFlowItem(1, flowItem{N: "fed_svc_17", U: 1200, D: 900})
updatedShare, err := repo.GetPeerShare(share.ID)
updatedShare, err := r.GetPeerShare(share.ID)
if err != nil || updatedShare == nil {
t.Fatalf("reload share: %v", err)
}
@@ -53,7 +54,7 @@ func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
t.Fatalf("expected current_flow=3100, got %d", updatedShare.CurrentFlow)
}
runtime, err := repo.GetPeerShareRuntimeByID(17)
runtime, err := r.GetPeerShareRuntimeByID(17)
if err != nil || runtime == nil {
t.Fatalf("reload runtime: %v", err)
}
@@ -61,3 +62,345 @@ func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
t.Fatalf("expected runtime status=0 after limit enforcement, got %d", runtime.Status)
}
}
func TestProcessFlowItemTracksPeerShareFlowForFederationPortForward(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "forward-share",
NodeID: 1,
Token: "forward-share-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 30000,
PortRangeEnd: 30010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := r.GetPeerShareByToken("forward-share-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'u2', 'x', 1, ?, 99999, 0, 0, 1, 1, ?, ?, 1)
`, now+24*60*60*1000, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
tunnelName := "Share-" + strconv.FormatInt(share.ID, 10) + "-Port-30001"
if err := r.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(1, ?, 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
`, tunnelName, now, now).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(10, 2, 1, NULL, 1, 99999, 0, 0, 1, ?, 1)
`, now+24*60*60*1000).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(20, 2, 'u2', 'f20', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
h := &Handler{repo: r}
h.processFlowItem(1, flowItem{N: "20_2_10", U: 120, D: 80})
updatedShare, err := r.GetPeerShare(share.ID)
if err != nil || updatedShare == nil {
t.Fatalf("reload share: %v", err)
}
if updatedShare.CurrentFlow != 200 {
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
}
}
func TestProcessFlowItemTracksPeerShareFlowByForwardServiceName(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-service.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "forward-service-share",
NodeID: 1,
Token: "forward-service-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31000,
PortRangeEnd: 31010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := r.GetPeerShareByToken("forward-service-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "svc-r1", "svc-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31001, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert peer_share_runtime: %v", err)
}
h := &Handler{repo: r}
h.processFlowItem(1, flowItem{N: "20_2_10_tcp", U: 120, D: 80})
updatedShare, err := r.GetPeerShare(share.ID)
if err != nil || updatedShare == nil {
t.Fatalf("reload share: %v", err)
}
if updatedShare.CurrentFlow != 200 {
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
}
}
func TestProcessFlowItemFallsBackToServiceNameWhenForwardIDCollidesAcrossPanels(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-collision.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "collision-share",
NodeID: 1,
Token: "collision-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31400,
PortRangeEnd: 31410,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := r.GetPeerShareByToken("collision-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "collision-r1", "collision-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31401, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert peer_share_runtime: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(2, 'local-tunnel-with-colliding-forward-id', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
`, now, now).Error; err != nil {
t.Fatalf("insert local tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(20, 1, 'local-user', 'local-f20', 2, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
`, now, now).Error; err != nil {
t.Fatalf("insert local forward: %v", err)
}
h := &Handler{repo: r}
h.processFlowItem(1, flowItem{N: "20_2_10_tcp", U: 120, D: 80})
updatedShare, err := r.GetPeerShare(share.ID)
if err != nil || updatedShare == nil {
t.Fatalf("reload share: %v", err)
}
if updatedShare.CurrentFlow != 200 {
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
}
}
func TestProcessFlowItemSkipsPeerShareFlowWhenServiceNameIsAmbiguous(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-ambiguous.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "ambiguous-share-a",
NodeID: 1,
Token: "ambiguous-token-a",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31100,
PortRangeEnd: 31110,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share A: %v", err)
}
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "ambiguous-share-b",
NodeID: 1,
Token: "ambiguous-token-b",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31200,
PortRangeEnd: 31210,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share B: %v", err)
}
shareA, _ := r.GetPeerShareByToken("ambiguous-token-a")
shareB, _ := r.GetPeerShareByToken("ambiguous-token-b")
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
shareA.ID, 1, "amb-r1", "amb-rk1", "", "forward", "", "99_2_10", "tcp", "fifo", 31101, "", 1, 1, now, now,
shareB.ID, 1, "amb-r2", "amb-rk2", "", "forward", "", "99_2_10", "tcp", "fifo", 31201, "", 1, 1, now, now,
).Error; err != nil {
t.Fatalf("insert ambiguous runtimes: %v", err)
}
h := &Handler{repo: r}
h.processFlowItem(1, flowItem{N: "99_2_10_tcp", U: 120, D: 80})
updatedA, _ := r.GetPeerShare(shareA.ID)
updatedB, _ := r.GetPeerShare(shareB.ID)
if updatedA.CurrentFlow != 0 || updatedB.CurrentFlow != 0 {
t.Fatalf("expected ambiguous service flow to be skipped, got shareA=%d shareB=%d", updatedA.CurrentFlow, updatedB.CurrentFlow)
}
}
func TestCleanOrphanedServicesSkipsActiveSharedForwardRuntimeServices(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-runtime.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "cleanup-runtime-share",
NodeID: 1,
Token: "cleanup-runtime-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31300,
PortRangeEnd: 31310,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := r.GetPeerShareByToken("cleanup-runtime-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "cleanup-r1", "cleanup-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31301, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert peer_share_runtime: %v", err)
}
h := &Handler{repo: r}
defer func() {
if rec := recover(); rec != nil {
t.Fatalf("cleanOrphanedServices should skip active shared runtime service; got panic: %v", rec)
}
}()
h.cleanOrphanedServices(share.NodeID, []namedConfigItem{{Name: "20_2_10_tcp"}})
}
func TestCleanOrphanedServicesSkipsFederationServicePrefix(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-fed-svc.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
h := &Handler{repo: r}
defer func() {
if rec := recover(); rec != nil {
t.Fatalf("cleanOrphanedServices should skip fed_svc_ service names; got panic: %v", rec)
}
}()
h.cleanOrphanedServices(1, []namedConfigItem{{Name: "fed_svc_999_tcp"}})
}
func TestCleanOrphanedServicesSkipsForwardPatternWhenNodeHasActivePeerShareForwardRuntime(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-forward-runtime-empty-service.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "cleanup-forward-runtime-empty-service",
NodeID: 1,
Token: "cleanup-forward-runtime-empty-service-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31420,
PortRangeEnd: 31430,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := r.GetPeerShareByToken("cleanup-forward-runtime-empty-service-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "cleanup-forward-empty-r1", "cleanup-forward-empty-rk1", "", "forward", "", "", "tcp", "fifo", 31421, "", 0, 1, now, now).Error; err != nil {
t.Fatalf("insert peer_share_runtime with empty service name: %v", err)
}
h := &Handler{repo: r}
defer func() {
if rec := recover(); rec != nil {
t.Fatalf("cleanOrphanedServices should skip forward-pattern services when active peer-share forward runtime exists; got panic: %v", rec)
}
}()
h.cleanOrphanedServices(share.NodeID, []namedConfigItem{{Name: "20_2_10_tcp"}})
}
+282 -32
View File
@@ -3,6 +3,7 @@ package handler
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -18,12 +19,12 @@ import (
"go-backend/internal/http/middleware"
"go-backend/internal/http/response"
"go-backend/internal/security"
"go-backend/internal/store/sqlite"
"go-backend/internal/store/repo"
"go-backend/internal/ws"
)
type Handler struct {
repo *sqlite.Repository
repo *repo.Repository
jwtSecret string
wsServer *ws.Server
@@ -34,6 +35,9 @@ type Handler struct {
jobsCancel context.CancelFunc
jobsStarted bool
jobsWG sync.WaitGroup
upgradeMu sync.Mutex
pendingUpgradeRedeploy map[int64]struct{}
}
type loginRequest struct {
@@ -69,13 +73,21 @@ type flowItem struct {
D int64 `json:"d"`
}
func New(repo *sqlite.Repository, jwtSecret string) *Handler {
return &Handler{
repo: repo,
jwtSecret: jwtSecret,
wsServer: ws.NewServer(repo, jwtSecret),
captchaTokens: make(map[string]int64),
const (
pngDataURLPrefix = "data:image/png;base64,"
maxBrandAssetDataURLBytes = 1024 * 1024
)
func New(repo *repo.Repository, jwtSecret string) *Handler {
h := &Handler{
repo: repo,
jwtSecret: jwtSecret,
wsServer: ws.NewServer(repo, jwtSecret),
captchaTokens: make(map[string]int64),
pendingUpgradeRedeploy: make(map[int64]struct{}),
}
h.wsServer.SetNodeOnlineHook(h.onNodeOnline)
return h
}
func (h *Handler) WebSocketHandler() http.Handler {
@@ -89,10 +101,17 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/user/update", h.userUpdate)
mux.HandleFunc("/api/v1/user/delete", h.userDelete)
mux.HandleFunc("/api/v1/user/reset", h.userResetFlow)
mux.HandleFunc("/api/v1/user/groups", h.userGroups)
mux.HandleFunc("/api/v1/config/get", h.getConfigByName)
mux.HandleFunc("/api/v1/config/list", h.getConfigs)
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig)
mux.HandleFunc("/api/v1/backup/export", h.backupExport)
mux.HandleFunc("/api/v1/backup/import", h.backupImport)
mux.HandleFunc("/api/v1/backup/restore", h.backupImport)
mux.HandleFunc("/api/v1/api/v1/backup/export", h.backupExport)
mux.HandleFunc("/api/v1/api/v1/backup/import", h.backupImport)
mux.HandleFunc("/api/v1/api/v1/backup/restore", h.backupImport)
mux.HandleFunc("/api/v1/captcha/check", h.checkCaptcha)
mux.HandleFunc("/api/v1/captcha/verify", h.captchaVerify)
mux.HandleFunc("/api/v1/user/package", h.userPackage)
@@ -115,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)
@@ -130,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)
@@ -140,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)
@@ -169,7 +189,10 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/federation/runtime/apply-role", h.authPeer(h.federationRuntimeApplyRole))
mux.HandleFunc("/api/v1/federation/runtime/release-role", h.authPeer(h.federationRuntimeReleaseRole))
mux.HandleFunc("/api/v1/federation/runtime/diagnose", h.authPeer(h.federationRuntimeDiagnose))
mux.HandleFunc("/api/v1/federation/runtime/command", h.authPeer(h.federationRuntimeCommand))
mux.HandleFunc("/api/v1/federation/node/import", h.nodeImport)
mux.HandleFunc("/api/v1/announcement/get", h.getAnnouncement)
mux.HandleFunc("/api/v1/announcement/update", h.updateAnnouncement)
mux.HandleFunc("/flow/test", h.flowTest)
mux.HandleFunc("/flow/config", h.flowConfig)
@@ -203,7 +226,7 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if captchaEnabled {
if captchaEnabled && !h.apiClientCaptchaBypassEnabled(r) {
captchaID := strings.TrimSpace(req.CaptchaID)
if captchaID == "" {
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
@@ -306,11 +329,35 @@ func (h *Handler) userList(w http.ResponseWriter, r *http.Request) {
return
}
var req struct {
Current int `json:"current"`
Size int `json:"size"`
Keyword string `json:"keyword"`
}
if err := decodeJSON(r.Body, &req); err != nil && err != io.EOF {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
users, err := h.repo.ListUsers()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
keyword := strings.ToLower(strings.TrimSpace(req.Keyword))
if keyword != "" {
filtered := make([]map[string]interface{}, 0, len(users))
for _, item := range users {
username := strings.ToLower(strings.TrimSpace(fmt.Sprint(item["user"])))
displayName := strings.ToLower(strings.TrimSpace(fmt.Sprint(item["name"])))
if strings.Contains(username, keyword) || strings.Contains(displayName, keyword) {
filtered = append(filtered, item)
}
}
users = filtered
}
response.WriteJSON(w, response.OK(users))
}
@@ -393,7 +440,7 @@ func (h *Handler) openAPISubStore(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if h == nil || h.repo == nil || h.repo.DB() == nil {
if h == nil || h.repo == nil {
response.WriteJSON(w, response.Err(-2, "database unavailable"))
return
}
@@ -436,27 +483,21 @@ func (h *Handler) openAPISubStore(w http.ResponseWriter, r *http.Request) {
return
}
var userID int64
var inFlow int64
var outFlow int64
var flow int64
var expTime int64
err = h.repo.DB().QueryRow(`SELECT user_id, in_flow, out_flow, flow, exp_time FROM user_tunnel WHERE id = ? LIMIT 1`, tunnelID).
Scan(&userID, &inFlow, &outFlow, &flow, &expTime)
ut, err := h.repo.GetUserTunnelByID(tunnelID)
if err != nil {
if err == sql.ErrNoRows {
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if userID != user.ID {
if ut == nil {
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
return
}
if ut.UserID != user.ID {
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
return
}
headerValue = buildSubscriptionHeader(outFlow, inFlow, flow*giga, expTime/1000)
headerValue = buildSubscriptionHeader(ut.OutFlow, ut.InFlow, ut.Flow*giga, ut.ExpTime/1000)
}
w.Header().Set("subscription-userinfo", headerValue)
@@ -670,7 +711,8 @@ func (h *Handler) flowConfig(w http.ResponseWriter, r *http.Request) {
func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
secret := r.URL.Query().Get("secret")
if ok, _ := h.repo.NodeExistsBySecret(secret); !ok {
node, _ := h.repo.GetNodeBySecret(secret)
if node == nil {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok"))
return
@@ -681,7 +723,7 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
var items []flowItem
if json.Unmarshal([]byte(raw), &items) == nil {
for _, item := range items {
h.processFlowItem(item)
h.processFlowItem(node.ID, item)
}
}
}
@@ -712,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
}
@@ -732,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
}
@@ -749,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("请求失败"))
@@ -954,10 +1042,41 @@ 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 {
if r == nil {
return false
}
client := strings.ToLower(strings.TrimSpace(r.Header.Get("X-FLVX-API-Client")))
switch client {
case "whmcs", "whmcs-module":
return true
default:
return false
}
}
func (h *Handler) markCaptchaToken(token string) {
@@ -1116,3 +1235,134 @@ func (h *Handler) verifyCloudflareTurnstile(token, secretKey string) bool {
}
return body.Success
}
type backupExportRequest struct {
Types []string `json:"types"`
}
func (h *Handler) backupExport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req backupExportRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.Err(500, "请求参数错误"))
return
}
var backup interface{}
var err error
if len(req.Types) == 0 {
backup, err = h.repo.ExportAll()
} else {
backup, err = h.repo.ExportPartial(req.Types)
}
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
w.Header().Set("Content-Disposition", "attachment; filename=backup.json")
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(backup); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
}
type backupImportRequest struct {
Types []string `json:"types"`
repo.BackupData
}
func (h *Handler) backupImport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req backupImportRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.Err(500, "请求参数错误"))
return
}
if len(req.Types) == 0 {
response.WriteJSON(w, response.Err(500, "请选择要导入的数据类型"))
return
}
autoBackup, err := h.repo.ExportAll()
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("导入前自动备份失败: %v", err)))
return
}
if req.BackupData.Version == "" {
response.WriteJSON(w, response.Err(500, "备份数据格式错误"))
return
}
result, err := h.repo.Import(&req.BackupData, req.Types)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("导入失败: %v", err)))
return
}
result.AutoBackup = autoBackup
response.WriteJSON(w, response.OK(result))
}
func (h *Handler) getAnnouncement(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
ann, err := h.repo.GetAnnouncement()
if err != nil {
response.WriteJSON(w, response.Err(-1, fmt.Sprintf("获取公告失败: %v", err)))
return
}
if ann == nil {
response.WriteJSON(w, response.OK(map[string]interface{}{
"content": "",
"enabled": 0,
}))
return
}
response.WriteJSON(w, response.OK(map[string]interface{}{
"content": ann.Content,
"enabled": ann.Enabled,
}))
}
func (h *Handler) updateAnnouncement(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req struct {
Content string `json:"content"`
Enabled int `json:"enabled"`
}
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.Err(500, "请求参数错误"))
return
}
now := time.Now().UnixMilli()
if err := h.repo.UpsertAnnouncement(req.Content, req.Enabled, now); err != nil {
response.WriteJSON(w, response.Err(-1, fmt.Sprintf("更新公告失败: %v", err)))
return
}
response.WriteJSON(w, response.OKEmpty())
}
+15 -107
View File
@@ -2,12 +2,11 @@ package handler
import (
"context"
"database/sql"
"time"
)
func (h *Handler) StartBackgroundJobs() {
if h == nil || h.repo == nil || h.repo.DB() == nil {
if h == nil || h.repo == nil {
return
}
@@ -97,47 +96,28 @@ func durationUntilNextDailyMaintenance(now time.Time) time.Duration {
}
func (h *Handler) runStatisticsFlowJob(now time.Time) {
if h == nil || h.repo == nil || h.repo.DB() == nil {
if h == nil || h.repo == nil {
return
}
db := h.repo.DB()
nowMs := now.UnixMilli()
cutoffMs := nowMs - int64((48*time.Hour)/time.Millisecond)
_, _ = db.Exec(`DELETE FROM statistics_flow WHERE created_time < ?`, cutoffMs)
_ = h.repo.PurgeOldStatisticsFlows(cutoffMs)
hourMark := now.Truncate(time.Hour)
hourText := hourMark.Format("15:04")
createdTime := hourMark.UnixMilli()
rows, err := db.Query(`SELECT id, in_flow, out_flow FROM user ORDER BY id ASC`)
users, err := h.repo.ListAllUserFlowSnapshots()
if err != nil {
return
}
type userFlowSnapshot struct {
userID int64
inFlow int64
outFlow int64
}
users := make([]userFlowSnapshot, 0)
for rows.Next() {
var userID int64
var inFlow int64
var outFlow int64
if err := rows.Scan(&userID, &inFlow, &outFlow); err != nil {
continue
}
users = append(users, userFlowSnapshot{userID: userID, inFlow: inFlow, outFlow: outFlow})
}
_ = rows.Close()
for _, user := range users {
currentTotal := user.inFlow + user.outFlow
currentTotal := user.InFlow + user.OutFlow
increment := currentTotal
var lastTotal sql.NullInt64
err := db.QueryRow(`SELECT total_flow FROM statistics_flow WHERE user_id = ? ORDER BY id DESC LIMIT 1`, user.userID).Scan(&lastTotal)
lastTotal, err := h.repo.GetLastStatisticsFlowTotal(user.UserID)
if err == nil && lastTotal.Valid {
increment = currentTotal - lastTotal.Int64
if increment < 0 {
@@ -145,15 +125,12 @@ func (h *Handler) runStatisticsFlowJob(now time.Time) {
}
}
_, _ = db.Exec(`
INSERT INTO statistics_flow(user_id, flow, total_flow, time, created_time)
VALUES(?, ?, ?, ?, ?)
`, user.userID, increment, currentTotal, hourText, createdTime)
_ = h.repo.CreateStatisticsFlow(user.UserID, increment, currentTotal, hourText, createdTime)
}
}
func (h *Handler) runResetAndExpiryJob(now time.Time) {
if h == nil || h.repo == nil || h.repo.DB() == nil {
if h == nil || h.repo == nil {
return
}
@@ -163,108 +140,39 @@ func (h *Handler) runResetAndExpiryJob(now time.Time) {
}
func (h *Handler) resetMonthlyFlow(now time.Time) {
db := h.repo.DB()
currentDay := now.Day()
lastDay := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()).Day()
if currentDay == lastDay {
_, _ = db.Exec(`
UPDATE user
SET in_flow = 0, out_flow = 0
WHERE flow_reset_time != 0
AND (flow_reset_time = ? OR flow_reset_time > ?)
`, currentDay, lastDay)
_, _ = db.Exec(`
UPDATE user_tunnel
SET in_flow = 0, out_flow = 0
WHERE flow_reset_time != 0
AND (flow_reset_time = ? OR flow_reset_time > ?)
`, currentDay, lastDay)
return
}
_, _ = db.Exec(`
UPDATE user
SET in_flow = 0, out_flow = 0
WHERE flow_reset_time != 0
AND flow_reset_time = ?
`, currentDay)
_, _ = db.Exec(`
UPDATE user_tunnel
SET in_flow = 0, out_flow = 0
WHERE flow_reset_time != 0
AND flow_reset_time = ?
`, currentDay)
_ = h.repo.ResetUserMonthlyFlow(currentDay, lastDay)
_ = h.repo.ResetUserTunnelMonthlyFlow(currentDay, lastDay)
}
func (h *Handler) disableExpiredUsers(nowMs int64) {
db := h.repo.DB()
rows, err := db.Query(`
SELECT id
FROM user
WHERE role_id != 0
AND status = 1
AND exp_time IS NOT NULL
AND exp_time < ?
`, nowMs)
userIDs, err := h.repo.ListExpiredActiveUserIDs(nowMs)
if err != nil {
return
}
userIDs := make([]int64, 0)
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
continue
}
userIDs = append(userIDs, userID)
}
_ = rows.Close()
for _, userID := range userIDs {
forwards, err := h.listActiveForwardsByUser(userID)
if err == nil {
h.pauseForwardRecords(forwards, nowMs)
}
_, _ = db.Exec(`UPDATE user SET status = 0 WHERE id = ?`, userID)
_ = h.repo.DisableUser(userID)
}
}
func (h *Handler) disableExpiredUserTunnels(nowMs int64) {
db := h.repo.DB()
rows, err := db.Query(`
SELECT id, user_id, tunnel_id
FROM user_tunnel
WHERE status = 1
AND exp_time IS NOT NULL
AND exp_time < ?
`, nowMs)
items, err := h.repo.ListExpiredActiveUserTunnels(nowMs)
if err != nil {
return
}
type expiredUserTunnel struct {
userTunnelID int64
userID int64
tunnelID int64
}
items := make([]expiredUserTunnel, 0)
for rows.Next() {
var userTunnelID int64
var userID int64
var tunnelID int64
if err := rows.Scan(&userTunnelID, &userID, &tunnelID); err != nil {
continue
}
items = append(items, expiredUserTunnel{userTunnelID: userTunnelID, userID: userID, tunnelID: tunnelID})
}
_ = rows.Close()
for _, item := range items {
forwards, err := h.listActiveForwardsByUserTunnel(item.userID, item.tunnelID)
forwards, err := h.listActiveForwardsByUserTunnel(item.UserID, item.TunnelID)
if err == nil {
h.pauseForwardRecords(forwards, nowMs)
}
_, _ = db.Exec(`UPDATE user_tunnel SET status = 0 WHERE id = ?`, item.userTunnelID)
_ = h.repo.DisableUserTunnel(item.ID)
}
}
+59 -42
View File
@@ -5,48 +5,40 @@ import (
"testing"
"time"
"go-backend/internal/store/sqlite"
"go-backend/internal/store/repo"
)
func TestRunStatisticsFlowJobTracksIncrementAndPrunes(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "jobs-stats.db")
repo, err := sqlite.Open(dbPath)
r, err := repo.Open(dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
t.Cleanup(func() { _ = r.Close() })
h := New(repo, "secret")
h := New(r, "secret")
now := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)
nowMs := now.UnixMilli()
if _, err := repo.DB().Exec(`UPDATE user SET in_flow = 100, out_flow = 200 WHERE id = 1`); err != nil {
if err := r.DB().Exec(`UPDATE user SET in_flow = 100, out_flow = 200 WHERE id = 1`).Error; err != nil {
t.Fatalf("seed user flow: %v", err)
}
if _, err := repo.DB().Exec(`INSERT INTO statistics_flow(user_id, flow, total_flow, time, created_time) VALUES(1, 250, 250, '11:00', ?)`, now.Add(-time.Hour).UnixMilli()); err != nil {
if err := r.DB().Exec(`INSERT INTO statistics_flow(user_id, flow, total_flow, time, created_time) VALUES(1, 250, 250, '11:00', ?)`, now.Add(-time.Hour).UnixMilli()).Error; err != nil {
t.Fatalf("seed recent statistics row: %v", err)
}
if _, err := repo.DB().Exec(`INSERT INTO statistics_flow(user_id, flow, total_flow, time, created_time) VALUES(1, 10, 10, '00:00', ?)`, now.Add(-49*time.Hour).UnixMilli()); err != nil {
if err := r.DB().Exec(`INSERT INTO statistics_flow(user_id, flow, total_flow, time, created_time) VALUES(1, 10, 10, '00:00', ?)`, now.Add(-49*time.Hour).UnixMilli()).Error; err != nil {
t.Fatalf("seed stale statistics row: %v", err)
}
h.runStatisticsFlowJob(now)
var staleCount int
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM statistics_flow WHERE created_time < ?`, nowMs-int64((48*time.Hour)/time.Millisecond)).Scan(&staleCount); err != nil {
t.Fatalf("query stale statistics rows: %v", err)
}
staleCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM statistics_flow WHERE created_time < ?`, nowMs-int64((48*time.Hour)/time.Millisecond))
if staleCount != 0 {
t.Fatalf("expected stale statistics rows to be pruned, got %d", staleCount)
}
var flow int64
var total int64
var hour string
if err := repo.DB().QueryRow(`SELECT flow, total_flow, time FROM statistics_flow WHERE user_id = 1 ORDER BY id DESC LIMIT 1`).Scan(&flow, &total, &hour); err != nil {
t.Fatalf("query latest statistics row: %v", err)
}
flow, total, hour := mustQueryInt64Int64String(t, r, `SELECT flow, total_flow, time FROM statistics_flow WHERE user_id = 1 ORDER BY id DESC LIMIT 1`)
if flow != 50 {
t.Fatalf("expected increment flow 50, got %d", flow)
}
@@ -60,69 +52,94 @@ func TestRunStatisticsFlowJobTracksIncrementAndPrunes(t *testing.T) {
func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "jobs-reset.db")
repo, err := sqlite.Open(dbPath)
r, err := repo.Open(dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
t.Cleanup(func() { _ = r.Close() })
h := New(repo, "secret")
h := New(r, "secret")
now := time.Date(2026, 3, 15, 0, 0, 5, 0, time.UTC)
nowMs := now.UnixMilli()
if _, err := repo.DB().Exec(`
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, 'expired_user', 'x', 1, ?, 100, 1000, 2000, 15, 1, ?, ?, 1)
`, nowMs-1000, nowMs, nowMs); err != nil {
`, nowMs-1000, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert expired user: %v", err)
}
if _, err := repo.DB().Exec(`
if err := r.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(3, 'non_expiring_user', 'x', 1, 0, 100, 1000, 2000, 15, 1, ?, ?, 1)
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert non-expiring user: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(1, 't1', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
`, nowMs, nowMs); err != nil {
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
if _, err := repo.DB().Exec(`
if err := r.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(10, 2, 1, NULL, 1, 1, 300, 400, 15, ?, 1)
`, nowMs-1000); err != nil {
`, nowMs-1000).Error; err != nil {
t.Fatalf("insert expired user_tunnel: %v", err)
}
if _, err := repo.DB().Exec(`
if err := r.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(11, 3, 1, NULL, 1, 1, 300, 400, 15, 0, 1)
`).Error; err != nil {
t.Fatalf("insert non-expiring user_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(20, 2, 'expired_user', 'f1', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, nowMs, nowMs); err != nil {
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(21, 3, 'non_expiring_user', 'f2', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 1)
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert non-expiring forward: %v", err)
}
h.runResetAndExpiryJob(now)
var userIn, userOut int64
var userStatus int
if err := repo.DB().QueryRow(`SELECT in_flow, out_flow, status FROM user WHERE id = 2`).Scan(&userIn, &userOut, &userStatus); err != nil {
t.Fatalf("query user after maintenance: %v", err)
}
userIn, userOut, userStatus := mustQueryInt64Int64Int(t, r, `SELECT in_flow, out_flow, status FROM user WHERE id = 2`)
if userIn != 0 || userOut != 0 || userStatus != 0 {
t.Fatalf("expected user reset+disabled, got in=%d out=%d status=%d", userIn, userOut, userStatus)
}
var utIn, utOut int64
var utStatus int
if err := repo.DB().QueryRow(`SELECT in_flow, out_flow, status FROM user_tunnel WHERE id = 10`).Scan(&utIn, &utOut, &utStatus); err != nil {
t.Fatalf("query user_tunnel after maintenance: %v", err)
}
utIn, utOut, utStatus := mustQueryInt64Int64Int(t, r, `SELECT in_flow, out_flow, status FROM user_tunnel WHERE id = 10`)
if utIn != 0 || utOut != 0 || utStatus != 0 {
t.Fatalf("expected user_tunnel reset+disabled, got in=%d out=%d status=%d", utIn, utOut, utStatus)
}
var forwardStatus int
if err := repo.DB().QueryRow(`SELECT status FROM forward WHERE id = 20`).Scan(&forwardStatus); err != nil {
t.Fatalf("query forward after maintenance: %v", err)
}
forwardStatus := mustQueryInt(t, r, `SELECT status FROM forward WHERE id = 20`)
if forwardStatus != 0 {
t.Fatalf("expected forward status=0 after expiry handling, got %d", forwardStatus)
}
nonExpUserStatus := mustQueryInt(t, r, `SELECT status FROM user WHERE id = 3`)
if nonExpUserStatus != 1 {
t.Fatalf("expected non-expiring user to remain enabled, got status=%d", nonExpUserStatus)
}
nonExpTunnelStatus := mustQueryInt(t, r, `SELECT status FROM user_tunnel WHERE id = 11`)
if nonExpTunnelStatus != 1 {
t.Fatalf("expected non-expiring user_tunnel to remain enabled, got status=%d", nonExpTunnelStatus)
}
nonExpForwardStatus := mustQueryInt(t, r, `SELECT status FROM forward WHERE id = 21`)
if nonExpForwardStatus != 1 {
t.Fatalf("expected non-expiring forward to remain enabled, got status=%d", nonExpForwardStatus)
}
}
File diff suppressed because it is too large Load Diff
+195 -79
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
@@ -19,8 +20,104 @@ const (
githubHTMLBase = "https://github.com"
upgradeTimeout = 5 * time.Minute
batchWorkers = 5
releaseChannelStable = "stable"
releaseChannelDev = "dev"
)
var (
stableVersionPattern = regexp.MustCompile(`^\d+(?:\.\d+)+$`)
testKeywordPattern = regexp.MustCompile(`(?i)(alpha|beta|rc)`)
)
type githubRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
PublishedAt string `json:"published_at"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
}
func normalizeReleaseChannel(channel string) string {
switch strings.ToLower(strings.TrimSpace(channel)) {
case releaseChannelDev:
return releaseChannelDev
default:
return releaseChannelStable
}
}
func releaseChannelFromTag(tag string) string {
normalized := strings.ToLower(strings.TrimSpace(tag))
if normalized == "" {
return releaseChannelDev
}
if testKeywordPattern.MatchString(normalized) {
return releaseChannelDev
}
if stableVersionPattern.MatchString(normalized) {
return releaseChannelStable
}
return releaseChannelDev
}
func releaseChannelLabel(channel string) string {
if normalizeReleaseChannel(channel) == releaseChannelDev {
return "测试版"
}
return "正式版"
}
func fetchGitHubReleases(perPage int) ([]githubRelease, error) {
if perPage <= 0 {
perPage = 20
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Get(fmt.Sprintf("%s/repos/%s/releases?per_page=%d", githubAPIBase, githubRepo, perPage))
if err != nil {
return nil, fmt.Errorf("请求GitHub API失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
}
var releases []githubRelease
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, fmt.Errorf("解析GitHub API响应失败: %v", err)
}
return releases, nil
}
func resolveLatestReleaseByChannel(channel string) (string, error) {
normalizedChannel := normalizeReleaseChannel(channel)
releases, err := fetchGitHubReleases(50)
if err != nil {
return "", err
}
for _, r := range releases {
if r.Draft {
continue
}
tag := strings.TrimSpace(r.TagName)
if tag == "" {
continue
}
if releaseChannelFromTag(tag) == normalizedChannel {
return tag, nil
}
}
return "", fmt.Errorf("未找到%s版本号", releaseChannelLabel(normalizedChannel))
}
func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -30,6 +127,7 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
var req struct {
ID int64 `json:"id"`
Version string `json:"version"`
Channel string `json:"channel"`
}
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
@@ -40,12 +138,13 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
return
}
channel := normalizeReleaseChannel(req.Channel)
version := strings.TrimSpace(req.Version)
if version == "" {
var err error
version, err = resolveLatestRelease()
version, err = resolveLatestReleaseByChannel(channel)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err)))
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
return
}
}
@@ -67,6 +166,7 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("升级失败: %v", err)))
return
}
h.markNodePendingUpgradeRedeploy(req.ID)
response.WriteJSON(w, response.OK(map[string]interface{}{
"version": version,
@@ -75,61 +175,11 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
}
func resolveLatestRelease() (string, error) {
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Timeout: 10 * time.Second,
}
resp, err := client.Get(githubProxy + "/" + githubHTMLBase + "/" + githubRepo + "/releases/latest")
if err != nil {
return "", fmt.Errorf("请求GitHub失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound && resp.StatusCode != http.StatusMovedPermanently {
return resolveLatestReleaseAPI()
}
location := resp.Header.Get("Location")
if location == "" {
return resolveLatestReleaseAPI()
}
parts := strings.Split(location, "/")
tag := parts[len(parts)-1]
if tag == "" || tag == "latest" {
return resolveLatestReleaseAPI()
}
return tag, nil
return resolveLatestReleaseByChannel(releaseChannelStable)
}
func resolveLatestReleaseAPI() (string, error) {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases/latest")
if err != nil {
return "", fmt.Errorf("请求GitHub API失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
}
var release struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return "", fmt.Errorf("解析GitHub API响应失败: %v", err)
}
if strings.TrimSpace(release.TagName) == "" {
return "", fmt.Errorf("无法从GitHub获取最新版本号")
}
return release.TagName, nil
return resolveLatestReleaseByChannel(releaseChannelStable)
}
func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
@@ -141,6 +191,7 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
var req struct {
IDs []int64 `json:"ids"`
Version string `json:"version"`
Channel string `json:"channel"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
@@ -151,12 +202,13 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
return
}
channel := normalizeReleaseChannel(req.Channel)
version := strings.TrimSpace(req.Version)
if version == "" {
var err error
version, err = resolveLatestRelease()
version, err = resolveLatestReleaseByChannel(channel)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err)))
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
return
}
}
@@ -195,6 +247,7 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
results[index] = upgradeResult{ID: nodeID, Success: false, Message: err.Error()}
return
}
h.markNodePendingUpgradeRedeploy(nodeID)
results[index] = upgradeResult{ID: nodeID, Success: true, Message: result.Message}
}(i, id)
}
@@ -212,37 +265,28 @@ func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) {
return
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases?per_page=20")
var req struct {
Channel string `json:"channel"`
}
if err := decodeJSON(r.Body, &req); err != nil && err != io.EOF {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
channel := normalizeReleaseChannel(req.Channel)
releases, err := fetchGitHubReleases(50)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err)))
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: GitHub API返回 %d: %s", resp.StatusCode, string(body))))
return
}
var releases []struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
PublishedAt string `json:"published_at"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
}
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("解析版本列表失败: %v", err)))
return
}
type releaseItem struct {
Version string `json:"version"`
Name string `json:"name"`
PublishedAt string `json:"publishedAt"`
Prerelease bool `json:"prerelease"`
Channel string `json:"channel"`
}
items := make([]releaseItem, 0, len(releases))
@@ -250,11 +294,20 @@ func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) {
if r.Draft {
continue
}
tag := strings.TrimSpace(r.TagName)
if tag == "" {
continue
}
itemChannel := releaseChannelFromTag(tag)
if itemChannel != channel {
continue
}
items = append(items, releaseItem{
Version: r.TagName,
Version: tag,
Name: r.Name,
PublishedAt: r.PublishedAt,
Prerelease: r.Prerelease,
Prerelease: itemChannel == releaseChannelDev,
Channel: itemChannel,
})
}
@@ -289,3 +342,66 @@ func (h *Handler) nodeRollback(w http.ResponseWriter, r *http.Request) {
"message": result.Message,
}))
}
func (h *Handler) markNodePendingUpgradeRedeploy(nodeID int64) {
if h == nil || nodeID <= 0 {
return
}
h.upgradeMu.Lock()
h.pendingUpgradeRedeploy[nodeID] = struct{}{}
h.upgradeMu.Unlock()
}
func (h *Handler) consumeNodePendingUpgradeRedeploy(nodeID int64) bool {
if h == nil || nodeID <= 0 {
return false
}
h.upgradeMu.Lock()
_, ok := h.pendingUpgradeRedeploy[nodeID]
if ok {
delete(h.pendingUpgradeRedeploy, nodeID)
}
h.upgradeMu.Unlock()
return ok
}
func (h *Handler) onNodeOnline(nodeID int64) {
if !h.consumeNodePendingUpgradeRedeploy(nodeID) {
return
}
h.redeployNodeRuntimeAfterUpgrade(nodeID)
}
func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) {
tunnelIDs, err := h.repo.ListActiveTunnelIDsByNode(nodeID)
if err != nil {
fmt.Printf("post-upgrade redeploy: list tunnels for node %d failed: %v\n", nodeID, err)
return
}
forwardIDs, err := h.repo.ListActiveForwardIDsByNode(nodeID)
if err != nil {
fmt.Printf("post-upgrade redeploy: list forwards for node %d failed: %v\n", nodeID, err)
return
}
tunnelFailed := make(map[int64]struct{})
for _, tunnelID := range tunnelIDs {
if err := h.redeployTunnelAndForwards(tunnelID); err != nil {
tunnelFailed[tunnelID] = struct{}{}
fmt.Printf("post-upgrade redeploy: tunnel %d failed on node %d: %v\n", tunnelID, nodeID, err)
}
}
for _, forwardID := range forwardIDs {
forward, getErr := h.getForwardRecord(forwardID)
if getErr != nil || forward == nil {
continue
}
if _, skipped := tunnelFailed[forward.TunnelID]; skipped {
continue
}
if err := h.syncForwardServices(forward, "UpdateService", true); err != nil {
fmt.Printf("post-upgrade redeploy: forward %d failed on node %d: %v\n", forwardID, nodeID, err)
}
}
}
@@ -0,0 +1,46 @@
package handler
import "testing"
func TestReleaseChannelFromTag(t *testing.T) {
tests := []struct {
name string
tag string
expects string
}{
{name: "stable semantic version", tag: "2.1.4", expects: releaseChannelStable},
{name: "v prefix should be dev", tag: "v2.1.4", expects: releaseChannelDev},
{name: "rc release", tag: "2.1.4-rc2", expects: releaseChannelDev},
{name: "beta release", tag: "2.1.4-beta.1", expects: releaseChannelDev},
{name: "alpha release", tag: "2.1.4-alpha", expects: releaseChannelDev},
{name: "non numeric tag", tag: "nightly", expects: releaseChannelDev},
{name: "empty tag", tag: "", expects: releaseChannelDev},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := releaseChannelFromTag(tc.tag); got != tc.expects {
t.Fatalf("releaseChannelFromTag(%q) = %q, want %q", tc.tag, got, tc.expects)
}
})
}
}
func TestNormalizeReleaseChannel(t *testing.T) {
tests := []struct {
input string
expects string
}{
{input: "", expects: releaseChannelStable},
{input: "stable", expects: releaseChannelStable},
{input: "dev", expects: releaseChannelDev},
{input: "DEV", expects: releaseChannelDev},
{input: "preview", expects: releaseChannelStable},
}
for _, tc := range tests {
if got := normalizeReleaseChannel(tc.input); got != tc.expects {
t.Fatalf("normalizeReleaseChannel(%q) = %q, want %q", tc.input, got, tc.expects)
}
}
}
@@ -93,6 +93,8 @@ func shouldSkip(path string) bool {
return true
case path == "/api/v1/federation/runtime/diagnose":
return true
case path == "/api/v1/federation/runtime/command":
return true
default:
return false
}
@@ -115,6 +117,14 @@ func requiresAdmin(path string) bool {
return true
}
if strings.HasPrefix(path, "/api/v1/backup/") {
return true
}
if strings.HasPrefix(path, "/api/v1/api/v1/backup/") {
return true
}
if strings.HasPrefix(path, "/api/v1/tunnel/") {
if strings.HasPrefix(path, "/api/v1/tunnel/user/tunnel") {
return false
@@ -127,6 +137,8 @@ func requiresAdmin(path string) bool {
return true
case "/api/v1/config/update", "/api/v1/config/update-single":
return true
case "/api/v1/announcement/update":
return true
default:
return false
}
-466
View File
@@ -1,466 +0,0 @@
// Package store provides a thin dialect-aware wrapper around database/sql,
// enabling transparent use of both SQLite and PostgreSQL.
package store
import (
"database/sql"
"strconv"
"strings"
)
// Dialect identifies the underlying database engine.
type Dialect int
const (
DialectSQLite Dialect = iota
DialectPostgres
)
// String returns a human-readable dialect name.
func (d Dialect) String() string {
switch d {
case DialectSQLite:
return "sqlite"
case DialectPostgres:
return "postgres"
default:
return "unknown"
}
}
// DB wraps *sql.DB with dialect awareness.
type DB struct {
raw *sql.DB
dialect Dialect
}
// Wrap creates a new dialect-aware DB from an existing *sql.DB.
func Wrap(raw *sql.DB, dialect Dialect) *DB {
return &DB{raw: raw, dialect: dialect}
}
// Dialect returns the database dialect.
func (db *DB) Dialect() Dialect {
if db == nil {
return DialectSQLite
}
return db.dialect
}
// RawDB returns the underlying *sql.DB.
func (db *DB) RawDB() *sql.DB {
if db == nil {
return nil
}
return db.raw
}
// Close closes the underlying connection.
func (db *DB) Close() error {
if db == nil || db.raw == nil {
return nil
}
return db.raw.Close()
}
// Ping verifies the connection is alive.
func (db *DB) Ping() error {
return db.raw.Ping()
}
// Exec executes a query with transparent placeholder and syntax rewriting.
func (db *DB) Exec(query string, args ...any) (sql.Result, error) {
return db.raw.Exec(db.rewrite(query), args...)
}
// Query executes a query that returns rows, with transparent rewriting.
func (db *DB) Query(query string, args ...any) (*sql.Rows, error) {
return db.raw.Query(db.rewrite(query), args...)
}
// QueryRow executes a query that returns at most one row, with transparent rewriting.
func (db *DB) QueryRow(query string, args ...any) *sql.Row {
return db.raw.QueryRow(db.rewrite(query), args...)
}
// Begin starts a transaction, returning a dialect-aware Tx.
func (db *DB) Begin() (*Tx, error) {
tx, err := db.raw.Begin()
if err != nil {
return nil, err
}
return &Tx{raw: tx, dialect: db.dialect}, nil
}
// ExecReturningID executes an INSERT and returns the auto-generated id.
// - SQLite: uses LastInsertId()
// - PostgreSQL: appends RETURNING id and uses QueryRow().Scan()
func (db *DB) ExecReturningID(query string, args ...any) (int64, error) {
q := db.rewrite(query)
if db.dialect == DialectPostgres {
q = ensureReturningID(q)
var id int64
if err := db.raw.QueryRow(q, args...).Scan(&id); err != nil {
return 0, err
}
return id, nil
}
res, err := db.raw.Exec(q, args...)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// Tx wraps *sql.Tx with dialect awareness.
type Tx struct {
raw *sql.Tx
dialect Dialect
}
// Exec executes a query inside the transaction with transparent rewriting.
func (tx *Tx) Exec(query string, args ...any) (sql.Result, error) {
return tx.raw.Exec(rewriteQuery(tx.dialect, query), args...)
}
// Query executes a query that returns rows inside the transaction.
func (tx *Tx) Query(query string, args ...any) (*sql.Rows, error) {
return tx.raw.Query(rewriteQuery(tx.dialect, query), args...)
}
// QueryRow executes a query that returns at most one row inside the transaction.
func (tx *Tx) QueryRow(query string, args ...any) *sql.Row {
return tx.raw.QueryRow(rewriteQuery(tx.dialect, query), args...)
}
// Commit commits the transaction.
func (tx *Tx) Commit() error { return tx.raw.Commit() }
// Rollback aborts the transaction.
func (tx *Tx) Rollback() error { return tx.raw.Rollback() }
// ExecReturningID executes an INSERT inside the transaction and returns the id.
func (tx *Tx) ExecReturningID(query string, args ...any) (int64, error) {
q := rewriteQuery(tx.dialect, query)
if tx.dialect == DialectPostgres {
q = ensureReturningID(q)
var id int64
if err := tx.raw.QueryRow(q, args...).Scan(&id); err != nil {
return 0, err
}
return id, nil
}
res, err := tx.raw.Exec(q, args...)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (db *DB) rewrite(query string) string {
return rewriteQuery(db.dialect, query)
}
func rewriteQuery(dialect Dialect, query string) string {
if dialect != DialectPostgres {
return query
}
query = rewriteUserIdentifier(query)
query = rewriteInsertOrIgnore(query)
query = rewritePlaceholders(query)
return query
}
func rewriteUserIdentifier(query string) string {
var buf strings.Builder
buf.Grow(len(query) + 16)
i := 0
for i < len(query) {
if end, ok := skipSQLProtectedSegment(query, i); ok {
buf.WriteString(query[i:end])
i = end
continue
}
ch := query[i]
if isIdentifierChar(ch) {
j := i + 1
for j < len(query) && isIdentifierChar(query[j]) {
j++
}
tok := query[i:j]
if strings.EqualFold(tok, "user") {
buf.WriteString(`"user"`)
} else {
buf.WriteString(tok)
}
i = j
continue
}
buf.WriteByte(ch)
i++
}
return buf.String()
}
func isIdentifierChar(ch byte) bool {
if ch >= 'a' && ch <= 'z' {
return true
}
if ch >= 'A' && ch <= 'Z' {
return true
}
if ch >= '0' && ch <= '9' {
return true
}
return ch == '_'
}
func rewriteInsertOrIgnore(query string) string {
start, end, ok := findKeywordSequenceOutside(query, []string{"INSERT", "OR", "IGNORE", "INTO"}, 0)
if !ok {
return query
}
rewritten := query[:start] + "INSERT INTO" + query[end:]
rewritten = strings.TrimRight(rewritten, "; \t\n")
insertIntoEnd := start + len("INSERT INTO")
if _, _, hasOnConflict := findKeywordSequenceOutside(rewritten, []string{"ON", "CONFLICT"}, insertIntoEnd); hasOnConflict {
return rewritten
}
if retStart, _, hasReturning := findKeywordSequenceOutside(rewritten, []string{"RETURNING"}, insertIntoEnd); hasReturning {
prefix := strings.TrimRight(rewritten[:retStart], " \t\n")
suffix := strings.TrimLeft(rewritten[retStart:], " \t\n")
return prefix + " ON CONFLICT DO NOTHING " + suffix
}
return rewritten + " ON CONFLICT DO NOTHING"
}
func rewritePlaceholders(query string) string {
var buf strings.Builder
buf.Grow(len(query) + 16)
n := 1
for i := 0; i < len(query); i++ {
if end, ok := skipSQLProtectedSegment(query, i); ok {
buf.WriteString(query[i:end])
i = end - 1
continue
}
ch := query[i]
if ch == '?' {
buf.WriteByte('$')
buf.WriteString(strconv.Itoa(n))
n++
continue
}
buf.WriteByte(ch)
}
return buf.String()
}
func ensureReturningID(query string) string {
trimmed := strings.TrimRight(query, "; \t\n")
if _, _, ok := findKeywordSequenceOutside(trimmed, []string{"RETURNING"}, 0); ok {
return trimmed
}
return trimmed + " RETURNING id"
}
func findKeywordSequenceOutside(query string, keywords []string, from int) (int, int, bool) {
if len(keywords) == 0 {
return 0, 0, false
}
if from < 0 {
from = 0
}
if from >= len(query) {
return 0, 0, false
}
matched := 0
seqStart := -1
for i := from; i < len(query); {
if end, ok := skipSQLProtectedSegment(query, i); ok {
i = end
continue
}
ch := query[i]
if isIdentifierChar(ch) {
j := i + 1
for j < len(query) && isIdentifierChar(query[j]) {
j++
}
tok := query[i:j]
if strings.EqualFold(tok, keywords[matched]) {
if matched == 0 {
seqStart = i
}
matched++
if matched == len(keywords) {
return seqStart, j, true
}
} else if strings.EqualFold(tok, keywords[0]) {
seqStart = i
matched = 1
} else {
matched = 0
seqStart = -1
}
i = j
continue
}
if !isSQLSpace(ch) {
matched = 0
seqStart = -1
}
i++
}
return 0, 0, false
}
func skipSQLProtectedSegment(query string, i int) (int, bool) {
if i < 0 || i >= len(query) {
return 0, false
}
switch query[i] {
case '\'':
return skipSingleQuotedLiteral(query, i), true
case '"':
return skipDoubleQuotedIdentifier(query, i), true
case '-':
if i+1 < len(query) && query[i+1] == '-' {
return skipLineComment(query, i), true
}
case '/':
if i+1 < len(query) && query[i+1] == '*' {
return skipBlockComment(query, i), true
}
case '$':
if end, ok := skipDollarQuotedLiteral(query, i); ok {
return end, true
}
}
return 0, false
}
func skipSingleQuotedLiteral(query string, i int) int {
for j := i + 1; j < len(query); j++ {
if query[j] != '\'' {
continue
}
if j+1 < len(query) && query[j+1] == '\'' {
j++
continue
}
return j + 1
}
return len(query)
}
func skipDoubleQuotedIdentifier(query string, i int) int {
for j := i + 1; j < len(query); j++ {
if query[j] != '"' {
continue
}
if j+1 < len(query) && query[j+1] == '"' {
j++
continue
}
return j + 1
}
return len(query)
}
func skipLineComment(query string, i int) int {
for j := i + 2; j < len(query); j++ {
if query[j] == '\n' {
return j
}
}
return len(query)
}
func skipBlockComment(query string, i int) int {
depth := 1
for j := i + 2; j < len(query)-1; j++ {
if query[j] == '/' && query[j+1] == '*' {
depth++
j++
continue
}
if query[j] == '*' && query[j+1] == '/' {
depth--
j++
if depth == 0 {
return j + 1
}
}
}
return len(query)
}
func skipDollarQuotedLiteral(query string, i int) (int, bool) {
if i < 0 || i >= len(query) || query[i] != '$' {
return 0, false
}
if i+1 >= len(query) {
return 0, false
}
var endTag int
if query[i+1] == '$' {
endTag = i + 1
} else {
if !isDollarTagStart(query[i+1]) {
return 0, false
}
j := i + 2
for j < len(query) && isDollarTagChar(query[j]) {
j++
}
if j >= len(query) || query[j] != '$' {
return 0, false
}
endTag = j
}
tag := query[i : endTag+1]
if closeIdx := strings.Index(query[endTag+1:], tag); closeIdx >= 0 {
return endTag + 1 + closeIdx + len(tag), true
}
return len(query), true
}
func isDollarTagStart(ch byte) bool {
return ch == '_' || (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')
}
func isDollarTagChar(ch byte) bool {
if isDollarTagStart(ch) {
return true
}
return ch >= '0' && ch <= '9'
}
func isSQLSpace(ch byte) bool {
switch ch {
case ' ', '\t', '\n', '\r', '\f':
return true
default:
return false
}
}
-116
View File
@@ -1,116 +0,0 @@
package store
import "testing"
func TestRewritePlaceholdersSkipsProtectedSegments(t *testing.T) {
q := `SELECT ?, '?', "id?", $$body ? $$, $tag$X?$tag$, col -- comment ?
FROM t /* block ? */ WHERE id = ?`
got := rewritePlaceholders(q)
want := `SELECT $1, '?', "id?", $$body ? $$, $tag$X?$tag$, col -- comment ?
FROM t /* block ? */ WHERE id = $2`
if got != want {
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
}
}
func TestRewriteInsertOrIgnoreBasic(t *testing.T) {
q := `INSERT OR IGNORE INTO user_group_user(user_group_id, user_id, created_time) VALUES(?, ?, ?)`
got := rewriteInsertOrIgnore(q)
want := `INSERT INTO user_group_user(user_group_id, user_id, created_time) VALUES(?, ?, ?) ON CONFLICT DO NOTHING`
if got != want {
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
}
}
func TestRewriteInsertOrIgnoreBeforeReturning(t *testing.T) {
q := `INSERT OR IGNORE INTO x(a) VALUES(?) RETURNING id`
got := rewriteInsertOrIgnore(q)
want := `INSERT INTO x(a) VALUES(?) ON CONFLICT DO NOTHING RETURNING id`
if got != want {
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
}
}
func TestRewriteInsertOrIgnoreNotDuplicatingOnConflict(t *testing.T) {
q := `INSERT OR IGNORE INTO x(a) VALUES(?) ON CONFLICT(a) DO UPDATE SET a=excluded.a`
got := rewriteInsertOrIgnore(q)
want := `INSERT INTO x(a) VALUES(?) ON CONFLICT(a) DO UPDATE SET a=excluded.a`
if got != want {
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
}
}
func TestEnsureReturningID(t *testing.T) {
if got := ensureReturningID(`INSERT INTO x(a) VALUES($1)`); got != `INSERT INTO x(a) VALUES($1) RETURNING id` {
t.Fatalf("missing RETURNING append: %s", got)
}
if got := ensureReturningID(`INSERT INTO x(a) VALUES($1) RETURNING other_id`); got != `INSERT INTO x(a) VALUES($1) RETURNING other_id` {
t.Fatalf("RETURNING should not be duplicated: %s", got)
}
}
func TestRewriteUserIdentifierSafety(t *testing.T) {
q := `SELECT user, user_id, 'user', "user", note FROM user -- user
WHERE owner='user'`
got := rewriteUserIdentifier(q)
want := `SELECT "user", user_id, 'user', "user", note FROM "user" -- user
WHERE owner='user'`
if got != want {
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
}
}
func TestRewriteQueryPostgresPipeline(t *testing.T) {
q := `INSERT OR IGNORE INTO user(name, note) VALUES(?, '?')`
got := rewriteQuery(DialectPostgres, q)
want := `INSERT INTO "user"(name, note) VALUES($1, '?') ON CONFLICT DO NOTHING`
if got != want {
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
}
}
func TestRewriteInsertOrIgnoreSkipsStringLiteral(t *testing.T) {
q := `SELECT 'INSERT OR IGNORE INTO t(a) VALUES(?)' AS q`
got := rewriteInsertOrIgnore(q)
if got != q {
t.Fatalf("string literal should stay unchanged\nwant: %s\ngot: %s", q, got)
}
}
func TestRewriteInsertOrIgnoreSkipsCommentedKeyword(t *testing.T) {
q := `-- INSERT OR IGNORE INTO ignored(a) VALUES(?)
INSERT OR IGNORE INTO real_t(a) VALUES(?)`
got := rewriteInsertOrIgnore(q)
want := `-- INSERT OR IGNORE INTO ignored(a) VALUES(?)
INSERT INTO real_t(a) VALUES(?) ON CONFLICT DO NOTHING`
if got != want {
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
}
}
func TestRewritePlaceholdersSkipsNestedBlockComment(t *testing.T) {
q := `SELECT ? /* outer ? /* inner ? */ still_outer ? */ FROM t WHERE id = ?`
got := rewritePlaceholders(q)
want := `SELECT $1 /* outer ? /* inner ? */ still_outer ? */ FROM t WHERE id = $2`
if got != want {
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
}
}
func TestRewritePlaceholdersSkipsUnterminatedBlockComment(t *testing.T) {
q := `SELECT ? /* unterminated ? comment`
got := rewritePlaceholders(q)
want := `SELECT $1 /* unterminated ? comment`
if got != want {
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
}
}
func TestRewriteUserIdentifierSkipsDollarQuotedAndComment(t *testing.T) {
q := `SELECT user, $$user ?$$ AS body, col FROM user /* user */ -- user`
got := rewriteUserIdentifier(q)
want := `SELECT "user", $$user ?$$ AS body, col FROM "user" /* user */ -- user`
if got != want {
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
}
}
+594
View File
@@ -0,0 +1,594 @@
// Package model defines GORM model structs for all database tables,
// providing a single source of truth for the schema that works
// transparently with both SQLite and PostgreSQL.
package model
import "database/sql"
// ─── Core Business Tables ────────────────────────────────────────────
// User maps to the "user" table. PostgreSQL treats "user" as a reserved
// word, so TableName() is required for correct quoting.
type User struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
User string `gorm:"column:user;type:varchar(100);not null"`
Pwd string `gorm:"type:varchar(100);not null"`
RoleID int `gorm:"column:role_id;not null"`
ExpTime int64 `gorm:"column:exp_time;not null"`
Flow int64 `gorm:"not null"`
InFlow int64 `gorm:"column:in_flow;not null;default:0"`
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
FlowResetTime int64 `gorm:"column:flow_reset_time;not null"`
Num int `gorm:"not null"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
Status int `gorm:"not null"`
}
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:"not null;default:0"`
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime int64 `gorm:"column:updated_time;not null"`
Status int `gorm:"not null"`
Inx int `gorm:"not null;default:0"`
SpeedID sql.NullInt64 `gorm:"column:speed_id"`
}
func (Forward) TableName() string { return "forward" }
type ForwardPort struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
ForwardID int64 `gorm:"column:forward_id;not null"`
NodeID int64 `gorm:"column:node_id;not null"`
Port int `gorm:"not null"`
}
func (ForwardPort) TableName() string { return "forward_port" }
type Node struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"type:varchar(100);not null"`
Secret string `gorm:"type:varchar(100);not null"`
ServerIP string `gorm:"column:server_ip;type:varchar(100);not null"`
ServerIPV4 sql.NullString `gorm:"column:server_ip_v4;type:varchar(100)"`
ServerIPV6 sql.NullString `gorm:"column:server_ip_v6;type:varchar(100)"`
Port string `gorm:"type:text;not null"`
InterfaceName sql.NullString `gorm:"column:interface_name;type:varchar(200)"`
Version sql.NullString `gorm:"type:varchar(100)"`
HTTP int `gorm:"column:http;not null;default:0"`
TLS int `gorm:"column:tls;not null;default:0"`
Socks int `gorm:"not null;default:0"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
Status int `gorm:"not null"`
TCPListenAddr string `gorm:"column:tcp_listen_addr;type:varchar(100);not null;default:'[::]'"`
UDPListenAddr string `gorm:"column:udp_listen_addr;type:varchar(100);not null;default:'[::]'"`
Inx int `gorm:"not null;default:0"`
IsRemote int `gorm:"column:is_remote;default:0"`
RemoteURL sql.NullString `gorm:"column:remote_url;type:text"`
RemoteToken sql.NullString `gorm:"column:remote_token;type:text"`
RemoteConfig sql.NullString `gorm:"column:remote_config;type:text"`
}
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 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" }
type StatisticsFlow struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
UserID int64 `gorm:"column:user_id;not null" json:"userId"`
Flow int64 `gorm:"not null" json:"flow"`
TotalFlow int64 `gorm:"column:total_flow;not null" json:"totalFlow"`
Time string `gorm:"type:varchar(100);not null" json:"time"`
CreatedTime int64 `gorm:"column:created_time;not null" json:"-"`
}
func (StatisticsFlow) TableName() string { return "statistics_flow" }
type Tunnel struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"type:varchar(100);not null"`
TrafficRatio float64 `gorm:"column:traffic_ratio;not null;default:1.0"`
Type int `gorm:"not null"`
Protocol string `gorm:"type:varchar(10);not null;default:'tls'"`
Flow int64 `gorm:"not null"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime int64 `gorm:"column:updated_time;not null"`
Status int `gorm:"not null"`
InIP sql.NullString `gorm:"column:in_ip;type:text"`
Inx int `gorm:"not null;default:0"`
IPPreference string `gorm:"column:ip_preference;type:varchar(10);not null;default:''"`
}
func (Tunnel) TableName() string { return "tunnel" }
type ChainTunnel struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
TunnelID int64 `gorm:"column:tunnel_id;not null"`
ChainType string `gorm:"column:chain_type;type:varchar(10);not null"`
NodeID int64 `gorm:"column:node_id;not null"`
Port sql.NullInt64 `gorm:"column:port"`
Strategy sql.NullString `gorm:"type:varchar(10)"`
Inx sql.NullInt64 `gorm:"column:inx"`
Protocol sql.NullString `gorm:"type:varchar(10)"`
}
func (ChainTunnel) TableName() string { return "chain_tunnel" }
type UserTunnel struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
UserID int64 `gorm:"column:user_id;not null;uniqueIndex:idx_user_tunnel_unique"`
TunnelID int64 `gorm:"column:tunnel_id;not null;uniqueIndex:idx_user_tunnel_unique"`
SpeedID sql.NullInt64 `gorm:"column:speed_id"`
Num int `gorm:"not null"`
Flow int64 `gorm:"not null"`
InFlow int64 `gorm:"column:in_flow;not null;default:0"`
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
FlowResetTime int64 `gorm:"column:flow_reset_time;not null"`
ExpTime int64 `gorm:"column:exp_time;not null"`
Status int `gorm:"not null"`
}
func (UserTunnel) TableName() string { return "user_tunnel" }
type TunnelGroup struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"type:varchar(100);not null;uniqueIndex:idx_tunnel_group_name"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime int64 `gorm:"column:updated_time;not null"`
Status int `gorm:"not null"`
}
func (TunnelGroup) TableName() string { return "tunnel_group" }
type UserGroup struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"type:varchar(100);not null;uniqueIndex:idx_user_group_name"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime int64 `gorm:"column:updated_time;not null"`
Status int `gorm:"not null"`
}
func (UserGroup) TableName() string { return "user_group" }
type TunnelGroupTunnel struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
TunnelGroupID int64 `gorm:"column:tunnel_group_id;not null;uniqueIndex:idx_tunnel_group_tunnel_unique"`
TunnelID int64 `gorm:"column:tunnel_id;not null;uniqueIndex:idx_tunnel_group_tunnel_unique"`
CreatedTime int64 `gorm:"column:created_time;not null"`
}
func (TunnelGroupTunnel) TableName() string { return "tunnel_group_tunnel" }
type UserGroupUser struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
UserGroupID int64 `gorm:"column:user_group_id;not null;uniqueIndex:idx_user_group_user_unique"`
UserID int64 `gorm:"column:user_id;not null;uniqueIndex:idx_user_group_user_unique"`
CreatedTime int64 `gorm:"column:created_time;not null"`
}
func (UserGroupUser) TableName() string { return "user_group_user" }
type GroupPermission struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
UserGroupID int64 `gorm:"column:user_group_id;not null;uniqueIndex:idx_group_permission_unique"`
TunnelGroupID int64 `gorm:"column:tunnel_group_id;not null;uniqueIndex:idx_group_permission_unique"`
CreatedTime int64 `gorm:"column:created_time;not null"`
}
func (GroupPermission) TableName() string { return "group_permission" }
type GroupPermissionGrant struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
UserGroupID int64 `gorm:"column:user_group_id;not null;uniqueIndex:idx_group_permission_grant_unique"`
TunnelGroupID int64 `gorm:"column:tunnel_group_id;not null;uniqueIndex:idx_group_permission_grant_unique"`
UserTunnelID int64 `gorm:"column:user_tunnel_id;not null;uniqueIndex:idx_group_permission_grant_unique"`
CreatedByGroup int `gorm:"column:created_by_group;not null;default:0"`
CreatedTime int64 `gorm:"column:created_time;not null"`
}
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:text;not null" json:"value"`
Time int64 `gorm:"not null" json:"time"`
}
func (ViteConfig) TableName() string { return "vite_config" }
type Announcement struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
Content string `gorm:"type:text;not null" json:"content"`
Enabled int `gorm:"not null;default:1" json:"enabled"`
CreatedTime int64 `gorm:"column:created_time;not null" json:"created_time"`
UpdatedTime sql.NullInt64 `gorm:"column:updated_time" json:"updated_time,omitempty"`
}
func (Announcement) TableName() string { return "announcement" }
type SchemaVersion struct {
Version int `gorm:"not null;default:0"`
}
func (SchemaVersion) TableName() string { return "schema_version" }
type PeerShare struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"type:text;not null" json:"name"`
NodeID int64 `gorm:"column:node_id;not null" json:"nodeId"`
Token string `gorm:"type:text;not null;uniqueIndex" json:"token"`
MaxBandwidth int64 `gorm:"column:max_bandwidth;default:0" json:"maxBandwidth"`
ExpiryTime int64 `gorm:"column:expiry_time;default:0" json:"expiryTime"`
PortRangeStart int `gorm:"column:port_range_start;default:0" json:"portRangeStart"`
PortRangeEnd int `gorm:"column:port_range_end;default:0" json:"portRangeEnd"`
CurrentFlow int64 `gorm:"column:current_flow;default:0" json:"currentFlow"`
IsActive int `gorm:"column:is_active;default:1" json:"isActive"`
CreatedTime int64 `gorm:"column:created_time;not null" json:"createdTime"`
UpdatedTime int64 `gorm:"column:updated_time;not null" json:"updatedTime"`
AllowedDomains string `gorm:"column:allowed_domains;type:text;default:''" json:"allowedDomains"`
AllowedIPs string `gorm:"column:allowed_ips;type:text;default:''" json:"allowedIps"`
}
func (PeerShare) TableName() string { return "peer_share" }
type PeerShareRuntime struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
ShareID int64 `gorm:"column:share_id;not null;index:idx_peer_share_runtime_share_node_status"`
NodeID int64 `gorm:"column:node_id;not null;index:idx_peer_share_runtime_share_node_status"`
ReservationID string `gorm:"column:reservation_id;type:text;not null;uniqueIndex"`
ResourceKey string `gorm:"column:resource_key;type:text;not null;uniqueIndex"`
BindingID string `gorm:"column:binding_id;type:text;not null;default:'';index:idx_peer_share_runtime_binding_id"`
Role string `gorm:"type:text;not null;default:''"`
ChainName string `gorm:"column:chain_name;type:text;not null;default:''"`
ServiceName string `gorm:"column:service_name;type:text;not null;default:''"`
Protocol string `gorm:"type:text;not null;default:'tls'"`
Strategy string `gorm:"type:text;not null;default:'round'"`
Port int `gorm:"not null;default:0"`
Target string `gorm:"type:text;not null;default:''"`
Applied int `gorm:"not null;default:0"`
Status int `gorm:"not null;default:1;index:idx_peer_share_runtime_share_node_status"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime int64 `gorm:"column:updated_time;not null"`
}
func (PeerShareRuntime) TableName() string { return "peer_share_runtime" }
type FederationTunnelBinding struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
TunnelID int64 `gorm:"column:tunnel_id;not null;uniqueIndex:idx_federation_tunnel_binding_unique;index:idx_federation_tunnel_binding_tunnel"`
NodeID int64 `gorm:"column:node_id;not null;uniqueIndex:idx_federation_tunnel_binding_unique"`
ChainType int `gorm:"column:chain_type;not null;uniqueIndex:idx_federation_tunnel_binding_unique"`
HopInx int `gorm:"column:hop_inx;not null;default:0;uniqueIndex:idx_federation_tunnel_binding_unique"`
RemoteURL string `gorm:"column:remote_url;type:text;not null"`
ResourceKey string `gorm:"column:resource_key;type:text;not null;uniqueIndex"`
RemoteBindingID string `gorm:"column:remote_binding_id;type:text;not null"`
AllocatedPort int `gorm:"column:allocated_port;not null"`
Status int `gorm:"not null;default:1;index:idx_federation_tunnel_binding_tunnel"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime int64 `gorm:"column:updated_time;not null"`
}
func (FederationTunnelBinding) TableName() string { return "federation_tunnel_binding" }
// ─── Backup / Import-Export Structs ──────────────────────────────────
// These are not GORM models; they define the JSON wire format for the
// backup/restore API and MUST keep their existing json tags unchanged.
// BackupData represents the full backup structure.
type BackupData struct {
Version string `json:"version"`
ExportedAt int64 `json:"exportedAt"`
Users []UserBackup `json:"users,omitempty"`
Nodes []NodeBackup `json:"nodes,omitempty"`
Tunnels []TunnelBackup `json:"tunnels,omitempty"`
Forwards []ForwardBackup `json:"forwards,omitempty"`
UserTunnels []UserTunnelBackup `json:"userTunnels,omitempty"`
SpeedLimits []SpeedLimitBackup `json:"speedLimits,omitempty"`
TunnelGroups []TunnelGroupBackup `json:"tunnelGroups,omitempty"`
UserGroups []UserGroupBackup `json:"userGroups,omitempty"`
Permissions []PermissionBackup `json:"permissions,omitempty"`
Configs map[string]string `json:"configs,omitempty"`
}
type UserBackup struct {
ID int64 `json:"id"`
User string `json:"user"`
Pwd string `json:"pwd"`
RoleID int `json:"roleId"`
ExpTime int64 `json:"expTime"`
Flow int64 `json:"flow"`
InFlow int64 `json:"inFlow"`
OutFlow int64 `json:"outFlow"`
FlowResetTime int64 `json:"flowResetTime"`
Num int `json:"num"`
CreatedTime int64 `json:"createdTime"`
UpdatedTime int64 `json:"updatedTime,omitempty"`
Status int `json:"status"`
}
type NodeBackup struct {
ID int64 `json:"id"`
Name string `json:"name"`
Secret string `json:"secret"`
ServerIP string `json:"serverIp"`
ServerIPv4 string `json:"serverIpV4,omitempty"`
ServerIPv6 string `json:"serverIpV6,omitempty"`
Port string `json:"port"`
InterfaceName string `json:"interfaceName,omitempty"`
Version string `json:"version,omitempty"`
HTTP int `json:"http"`
TLS int `json:"tls"`
Socks int `json:"socks"`
CreatedTime int64 `json:"createdTime"`
UpdatedTime int64 `json:"updatedTime,omitempty"`
Status int `json:"status"`
TCPListenAddr string `json:"tcpListenAddr"`
UDPListenAddr string `json:"udpListenAddr"`
Inx int `json:"inx"`
IsRemote int `json:"isRemote"`
RemoteURL string `json:"remoteUrl,omitempty"`
RemoteToken string `json:"remoteToken,omitempty"`
RemoteConfig string `json:"remoteConfig,omitempty"`
}
type TunnelBackup struct {
ID int64 `json:"id"`
Name string `json:"name"`
TrafficRatio float64 `json:"trafficRatio"`
Type int `json:"type"`
Protocol string `json:"protocol"`
Flow int64 `json:"flow"`
CreatedTime int64 `json:"createdTime"`
UpdatedTime int64 `json:"updatedTime"`
Status int `json:"status"`
InIP string `json:"inIp,omitempty"`
Inx int `json:"inx"`
IPPreference string `json:"ipPreference,omitempty"`
ChainTunnels []ChainTunnelBackup `json:"chainTunnels,omitempty"`
}
type ChainTunnelBackup struct {
ID int64 `json:"id"`
TunnelID int64 `json:"tunnelId"`
ChainType string `json:"chainType"`
NodeID int64 `json:"nodeId"`
Port int `json:"port,omitempty"`
Strategy string `json:"strategy,omitempty"`
Inx int `json:"inx,omitempty"`
Protocol string `json:"protocol,omitempty"`
}
type ForwardBackup struct {
ID int64 `json:"id"`
UserID int64 `json:"userId"`
UserName string `json:"userName"`
Name string `json:"name"`
TunnelID int64 `json:"tunnelId"`
RemoteAddr string `json:"remoteAddr"`
Strategy string `json:"strategy"`
InFlow int64 `json:"inFlow"`
OutFlow int64 `json:"outFlow"`
CreatedTime int64 `json:"createdTime"`
UpdatedTime int64 `json:"updatedTime"`
Status int `json:"status"`
Inx int `json:"inx"`
SpeedID *int64 `json:"speedId,omitempty"`
ForwardPorts *[]ForwardPortBackup `json:"forwardPorts,omitempty"`
}
type ForwardPortBackup struct {
NodeID int64 `json:"nodeId"`
Port int `json:"port"`
}
type UserTunnelBackup struct {
ID int64 `json:"id"`
UserID int64 `json:"userId"`
TunnelID int64 `json:"tunnelId"`
SpeedID int64 `json:"speedId,omitempty"`
Num int `json:"num"`
Flow int64 `json:"flow"`
InFlow int64 `json:"inFlow"`
OutFlow int64 `json:"outFlow"`
FlowResetTime int64 `json:"flowResetTime"`
ExpTime int64 `json:"expTime"`
Status int `json:"status"`
}
type SpeedLimitBackup struct {
ID int64 `json:"id"`
Name string `json:"name"`
Speed int64 `json:"speed"`
TunnelID *int64 `json:"tunnelId,omitempty"`
TunnelName string `json:"tunnelName,omitempty"`
CreatedTime int64 `json:"createdTime"`
UpdatedTime int64 `json:"updatedTime,omitempty"`
Status int `json:"status"`
}
type TunnelGroupBackup struct {
ID int64 `json:"id"`
Name string `json:"name"`
CreatedTime int64 `json:"createdTime"`
UpdatedTime int64 `json:"updatedTime"`
Status int `json:"status"`
Tunnels []int64 `json:"tunnels,omitempty"`
}
type UserGroupBackup struct {
ID int64 `json:"id"`
Name string `json:"name"`
CreatedTime int64 `json:"createdTime"`
UpdatedTime int64 `json:"updatedTime"`
Status int `json:"status"`
Users []int64 `json:"users,omitempty"`
}
type PermissionBackup struct {
ID int64 `json:"id"`
UserGroupID int64 `json:"userGroupId"`
TunnelGroupID int64 `json:"tunnelGroupId"`
CreatedTime int64 `json:"createdTime"`
CreatedByGroup int `json:"createdByGroup"`
Grants []PermissionGrantBackup `json:"grants,omitempty"`
}
type PermissionGrantBackup struct {
ID int64 `json:"id"`
UserGroupID int64 `json:"userGroupId"`
TunnelGroupID int64 `json:"tunnelGroupId"`
UserTunnelID int64 `json:"userTunnelId"`
CreatedTime int64 `json:"createdTime"`
CreatedByGroup int `json:"createdByGroup"`
}
// ImportResult contains the result of an import operation.
type ImportResult struct {
UsersImported int `json:"usersImported"`
NodesImported int `json:"nodesImported"`
TunnelsImported int `json:"tunnelsImported"`
ForwardsImported int `json:"forwardsImported"`
UserTunnelsImported int `json:"userTunnelsImported"`
SpeedLimitsImported int `json:"speedLimitsImported"`
TunnelGroupsImported int `json:"tunnelGroupsImported"`
UserGroupsImported int `json:"userGroupsImported"`
PermissionsImported int `json:"permissionsImported"`
ConfigsImported int `json:"configsImported"`
AutoBackup *BackupData `json:"autoBackup,omitempty"`
}
// ─── View Structs (used by Repository, not GORM models) ─────────────
// These are used for JOIN query results that don't map 1:1 to a table.
// ForwardRecord is a minimal forward view used by control plane and flow policy.
type ForwardRecord struct {
ID int64
UserID int64
UserName string
Name string
TunnelID int64
RemoteAddr string
Strategy string
Status int
SpeedID sql.NullInt64
}
// TunnelRecord is a minimal tunnel view used by control plane.
type TunnelRecord struct {
ID int64
Type int
Status int
Flow int64
TrafficRatio float64
}
// ForwardPortRecord is a forward port mapping used by control plane.
type ForwardPortRecord struct {
NodeID int64
Port int
}
// NodeRecord is a node view used by control plane.
type NodeRecord struct {
ID int64
Name string
ServerIP string
ServerIPv4 string
ServerIPv6 string
Status int
PortRange string
TCPListenAddr string
UDPListenAddr string
InterfaceName string
IsRemote int
RemoteURL string
RemoteToken string
RemoteConfig string
}
type ChainNodeRecord struct {
ChainType int
Inx int64
NodeID int64
Port int
NodeName string
Protocol string
Strategy string
}
type UserTunnelLimiterInfo struct {
UserTunnelID int64
LimiterID *int64
Speed *int
}
// UserFlowSnapshot holds a user's current flow counters (used by stats job).
type UserFlowSnapshot struct {
UserID int64
InFlow int64
OutFlow int64
}
// ExpiredUserTunnel holds minimal info for an expired user_tunnel row.
type ExpiredUserTunnel struct {
ID int64
UserID int64
TunnelID int64
}
// UserTunnelDetail is a joined view of user_tunnel + tunnel + speed_limit.
type UserTunnelDetail struct {
ID int64
UserID int64
TunnelID int64
TunnelName string
TunnelFlow int
Flow int64
InFlow int64
OutFlow int64
Num int
FlowResetTime int64
ExpTime int64
SpeedID sql.NullInt64
SpeedLimit sql.NullString
Speed sql.NullInt64
}
// UserForwardDetail is a joined view of forward + tunnel.
type UserForwardDetail struct {
ID int64
Name string
TunnelID int64
TunnelName string
InIP string
InPort sql.NullInt64
RemoteAddr string
InFlow int64
OutFlow int64
Status int
CreatedAt int64
}
@@ -1,9 +0,0 @@
package postgres
import _ "embed"
//go:embed sql/schema.sql
var EmbeddedSchema string
//go:embed sql/data.sql
var EmbeddedSeedData string
@@ -1,18 +0,0 @@
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 (1, 'admin_user', '3c85cdebade1c51cf64ca9f3c09d182d', 0, 2727251700000, 99999, 0, 0, 1, 99999, 1748914865000, 1754011744252, 1)
ON CONFLICT DO NOTHING;
INSERT INTO vite_config (id, name, value, time)
VALUES (1, 'app_name', 'flux', 1755147963000)
ON CONFLICT DO NOTHING;
DO $$
BEGIN
IF to_regclass('public.user_id_seq') IS NOT NULL THEN
PERFORM setval('user_id_seq', (SELECT COALESCE(MAX(id), 0) FROM "user"));
END IF;
IF to_regclass('public.vite_config_id_seq') IS NOT NULL THEN
PERFORM setval('vite_config_id_seq', (SELECT COALESCE(MAX(id), 0) FROM vite_config));
END IF;
END
$$;
@@ -1,241 +0,0 @@
CREATE TABLE IF NOT EXISTS forward (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
user_name VARCHAR(100) NOT NULL,
name VARCHAR(100) NOT NULL,
tunnel_id INTEGER NOT NULL,
remote_addr TEXT NOT NULL,
strategy VARCHAR(100) NOT NULL DEFAULT 'fifo',
in_flow BIGINT NOT NULL DEFAULT 0,
out_flow BIGINT NOT NULL DEFAULT 0,
created_time BIGINT NOT NULL,
updated_time BIGINT NOT NULL,
status INTEGER NOT NULL,
inx INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS forward_port (
id SERIAL PRIMARY KEY,
forward_id INTEGER NOT NULL,
node_id INTEGER NOT NULL,
port INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS node (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
secret VARCHAR(100) NOT NULL,
server_ip VARCHAR(100) NOT NULL,
server_ip_v4 VARCHAR(100),
server_ip_v6 VARCHAR(100),
port TEXT NOT NULL,
interface_name VARCHAR(200),
version VARCHAR(100),
http INTEGER NOT NULL DEFAULT 0,
tls INTEGER NOT NULL DEFAULT 0,
socks INTEGER NOT NULL DEFAULT 0,
created_time BIGINT NOT NULL,
updated_time BIGINT,
status INTEGER NOT NULL,
tcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
udp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
inx INTEGER NOT NULL DEFAULT 0,
is_remote INTEGER DEFAULT 0,
remote_url TEXT,
remote_token TEXT,
remote_config TEXT
);
CREATE TABLE IF NOT EXISTS speed_limit (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
speed INTEGER NOT NULL,
tunnel_id INTEGER NOT NULL,
tunnel_name VARCHAR(100) NOT NULL,
created_time BIGINT NOT NULL,
updated_time BIGINT,
status INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS statistics_flow (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
flow BIGINT NOT NULL,
total_flow BIGINT NOT NULL,
time VARCHAR(100) NOT NULL,
created_time BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS tunnel (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
traffic_ratio DOUBLE PRECISION NOT NULL DEFAULT 1.0,
type INTEGER NOT NULL,
protocol VARCHAR(10) NOT NULL DEFAULT 'tls',
flow BIGINT NOT NULL,
created_time BIGINT NOT NULL,
updated_time BIGINT NOT NULL,
status INTEGER NOT NULL,
in_ip TEXT,
inx INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS chain_tunnel (
id SERIAL PRIMARY KEY,
tunnel_id INTEGER NOT NULL,
chain_type VARCHAR(10) NOT NULL,
node_id INTEGER NOT NULL,
port INTEGER,
strategy VARCHAR(10),
inx INTEGER,
protocol VARCHAR(10)
);
CREATE TABLE IF NOT EXISTS "user" (
id SERIAL PRIMARY KEY,
"user" VARCHAR(100) NOT NULL,
pwd VARCHAR(100) NOT NULL,
role_id INTEGER NOT NULL,
exp_time BIGINT NOT NULL,
flow BIGINT NOT NULL,
in_flow BIGINT NOT NULL DEFAULT 0,
out_flow BIGINT NOT NULL DEFAULT 0,
flow_reset_time BIGINT NOT NULL,
num INTEGER NOT NULL,
created_time BIGINT NOT NULL,
updated_time BIGINT,
status INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_tunnel (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
tunnel_id INTEGER NOT NULL,
speed_id INTEGER,
num INTEGER NOT NULL,
flow BIGINT NOT NULL,
in_flow BIGINT NOT NULL DEFAULT 0,
out_flow BIGINT NOT NULL DEFAULT 0,
flow_reset_time BIGINT NOT NULL,
exp_time BIGINT NOT NULL,
status INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tunnel_group (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
created_time BIGINT NOT NULL,
updated_time BIGINT NOT NULL,
status INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_group (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
created_time BIGINT NOT NULL,
updated_time BIGINT NOT NULL,
status INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tunnel_group_tunnel (
id SERIAL PRIMARY KEY,
tunnel_group_id INTEGER NOT NULL,
tunnel_id INTEGER NOT NULL,
created_time BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_group_user (
id SERIAL PRIMARY KEY,
user_group_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
created_time BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS group_permission (
id SERIAL PRIMARY KEY,
user_group_id INTEGER NOT NULL,
tunnel_group_id INTEGER NOT NULL,
created_time BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS group_permission_grant (
id SERIAL PRIMARY KEY,
user_group_id INTEGER NOT NULL,
tunnel_group_id INTEGER NOT NULL,
user_tunnel_id INTEGER NOT NULL,
created_by_group INTEGER NOT NULL DEFAULT 0,
created_time BIGINT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_name ON tunnel_group(name);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_name ON user_group(name);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_tunnel_unique ON tunnel_group_tunnel(tunnel_group_id, tunnel_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_user_unique ON user_group_user(user_group_id, user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_unique ON group_permission(user_group_id, tunnel_group_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_grant_unique ON group_permission_grant(user_group_id, tunnel_group_id, user_tunnel_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_tunnel_unique ON user_tunnel(user_id, tunnel_id);
CREATE TABLE IF NOT EXISTS vite_config (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL UNIQUE,
value VARCHAR(200) NOT NULL,
time BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS peer_share (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
node_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
max_bandwidth INTEGER DEFAULT 0,
expiry_time BIGINT DEFAULT 0,
port_range_start INTEGER DEFAULT 0,
port_range_end INTEGER DEFAULT 0,
current_flow BIGINT DEFAULT 0,
is_active INTEGER DEFAULT 1,
created_time BIGINT NOT NULL,
updated_time BIGINT NOT NULL,
allowed_domains TEXT DEFAULT '',
allowed_ips TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS peer_share_runtime (
id SERIAL PRIMARY KEY,
share_id INTEGER NOT NULL,
node_id INTEGER NOT NULL,
reservation_id TEXT NOT NULL UNIQUE,
resource_key TEXT NOT NULL UNIQUE,
binding_id TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT '',
chain_name TEXT NOT NULL DEFAULT '',
service_name TEXT NOT NULL DEFAULT '',
protocol TEXT NOT NULL DEFAULT 'tls',
strategy TEXT NOT NULL DEFAULT 'round',
port INTEGER NOT NULL DEFAULT 0,
target TEXT NOT NULL DEFAULT '',
applied INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 1,
created_time BIGINT NOT NULL,
updated_time BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_peer_share_runtime_share_node_status ON peer_share_runtime(share_id, node_id, status);
CREATE INDEX IF NOT EXISTS idx_peer_share_runtime_binding_id ON peer_share_runtime(binding_id);
CREATE TABLE IF NOT EXISTS federation_tunnel_binding (
id SERIAL PRIMARY KEY,
tunnel_id INTEGER NOT NULL,
node_id INTEGER NOT NULL,
chain_type INTEGER NOT NULL,
hop_inx INTEGER NOT NULL DEFAULT 0,
remote_url TEXT NOT NULL,
resource_key TEXT NOT NULL UNIQUE,
remote_binding_id TEXT NOT NULL,
allocated_port INTEGER NOT NULL,
status INTEGER NOT NULL DEFAULT 1,
created_time BIGINT NOT NULL,
updated_time BIGINT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_federation_tunnel_binding_unique ON federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx);
CREATE INDEX IF NOT EXISTS idx_federation_tunnel_binding_tunnel ON federation_tunnel_binding(tunnel_id, status);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,343 @@
package repo
import (
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"gorm.io/gorm"
"go-backend/internal/store/model"
)
func (r *Repository) UserTunnelExistsByUserAndTunnel(userID, tunnelID int64) (bool, error) {
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
}
var count int64
err := r.db.Model(&model.UserTunnel{}).
Where("user_id = ? AND tunnel_id = ? AND status = 1", userID, tunnelID).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *Repository) ListForwardsByTunnel(tunnelID int64) ([]model.ForwardRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var forwards []model.Forward
err := r.db.Where("tunnel_id = ?", tunnelID).Order("id ASC").Find(&forwards).Error
if err != nil {
return nil, err
}
rows := make([]model.ForwardRecord, 0, len(forwards))
for _, f := range forwards {
rows = append(rows, model.ForwardRecord{
ID: f.ID,
UserID: f.UserID,
UserName: f.UserName,
Name: f.Name,
TunnelID: f.TunnelID,
RemoteAddr: f.RemoteAddr,
Strategy: f.Strategy,
Status: f.Status,
SpeedID: f.SpeedID,
})
}
for i := range rows {
if strings.TrimSpace(rows[i].Strategy) == "" {
rows[i].Strategy = "fifo"
}
}
return rows, nil
}
func (r *Repository) ListActiveTunnelIDsByNode(nodeID int64) ([]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var ids []int64
err := r.db.Model(&model.ChainTunnel{}).
Joins("JOIN tunnel ON tunnel.id = chain_tunnel.tunnel_id").
Where("chain_tunnel.node_id = ? AND tunnel.status = 1", nodeID).
Select("DISTINCT chain_tunnel.tunnel_id").
Order("chain_tunnel.tunnel_id ASC").
Pluck("chain_tunnel.tunnel_id", &ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (r *Repository) ListActiveForwardIDsByNode(nodeID int64) ([]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var ids []int64
err := r.db.Model(&model.ForwardPort{}).
Joins("JOIN forward ON forward.id = forward_port.forward_id").
Where("forward_port.node_id = ? AND forward.status = 1", nodeID).
Select("DISTINCT forward_port.forward_id").
Order("forward_port.forward_id ASC").
Pluck("forward_port.forward_id", &ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var ports []model.ForwardPort
err := r.db.Where("forward_id = ?", forwardID).Order("id ASC").Find(&ports).Error
if err != nil {
return nil, err
}
rows := make([]model.ForwardPortRecord, 0, len(ports))
for _, p := range ports {
rows = append(rows, model.ForwardPortRecord{NodeID: p.NodeID, Port: p.Port})
}
return rows, nil
}
func (r *Repository) GetTunnelOutProtocol(tunnelID int64) (string, error) {
if r == nil || r.db == nil {
return "", errors.New("repository not initialized")
}
var ct model.ChainTunnel
err := r.db.Select("protocol").
Where("tunnel_id = ? AND chain_type = ?", tunnelID, "3").
Order("id ASC").
Take(&ct).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
return "", err
}
if ct.Protocol.Valid {
return ct.Protocol.String, nil
}
return "", nil
}
func (r *Repository) GetNodeRecord(nodeID int64) (*model.NodeRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var n model.Node
err := r.db.Where("id = ?", nodeID).First(&n).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return nodeRecordFromModel(&n), nil
}
func (r *Repository) GetNodeRecordTx(tx *gorm.DB, nodeID int64) (*model.NodeRecord, error) {
if tx == nil {
return nil, errors.New("database unavailable")
}
var n model.Node
err := tx.Where("id = ?", nodeID).First(&n).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return nodeRecordFromModel(&n), nil
}
func nodeRecordFromModel(n *model.Node) *model.NodeRecord {
if n == nil {
return nil
}
rec := &model.NodeRecord{
ID: n.ID,
Name: n.Name,
ServerIP: n.ServerIP,
Status: n.Status,
PortRange: n.Port,
TCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,
IsRemote: n.IsRemote,
}
if n.ServerIPV4.Valid {
rec.ServerIPv4 = strings.TrimSpace(n.ServerIPV4.String)
}
if n.ServerIPV6.Valid {
rec.ServerIPv6 = strings.TrimSpace(n.ServerIPV6.String)
}
if n.InterfaceName.Valid {
rec.InterfaceName = strings.TrimSpace(n.InterfaceName.String)
}
if n.RemoteURL.Valid {
rec.RemoteURL = strings.TrimSpace(n.RemoteURL.String)
}
if n.RemoteToken.Valid {
rec.RemoteToken = strings.TrimSpace(n.RemoteToken.String)
}
if n.RemoteConfig.Valid {
rec.RemoteConfig = strings.TrimSpace(n.RemoteConfig.String)
}
if rec.TCPListenAddr == "" {
rec.TCPListenAddr = "[::]"
}
if rec.UDPListenAddr == "" {
rec.UDPListenAddr = "[::]"
}
if strings.TrimSpace(rec.Name) == "" {
rec.Name = fmt.Sprintf("node_%d", rec.ID)
}
return rec
}
func (r *Repository) ResolveUserTunnelAndLimiter(userID, tunnelID int64) (*model.UserTunnelLimiterInfo, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
type row struct {
UserTunnelID int64 `gorm:"column:user_tunnel_id"`
LimiterID sql.NullInt64 `gorm:"column:limiter_id"`
Speed sql.NullInt64 `gorm:"column:speed"`
}
var rec row
err := r.db.Model(&model.UserTunnel{}).
Select("user_tunnel.id AS user_tunnel_id, speed_limit.id AS limiter_id, speed_limit.speed AS speed").
Joins("LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id").
Where("user_tunnel.user_id = ? AND user_tunnel.tunnel_id = ?", userID, tunnelID).
Order("user_tunnel.id ASC").
Limit(1).
Take(&rec).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &model.UserTunnelLimiterInfo{}, nil
}
return nil, err
}
info := &model.UserTunnelLimiterInfo{UserTunnelID: rec.UserTunnelID}
if rec.LimiterID.Valid && rec.LimiterID.Int64 > 0 {
v := rec.LimiterID.Int64
info.LimiterID = &v
s := int(rec.Speed.Int64)
info.Speed = &s
}
return info, nil
}
func (r *Repository) ListUserTunnelIDs(userID, tunnelID int64) ([]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var ids []int64
err := r.db.Model(&model.UserTunnel{}).
Where("user_id = ? AND tunnel_id = ?", userID, tunnelID).
Order("id ASC").Pluck("id", &ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (r *Repository) ListUserTunnelIDsByUser(userID int64) ([]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var ids []int64
err := r.db.Model(&model.UserTunnel{}).
Where("user_id = ?", userID).
Order("id ASC").Pluck("id", &ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (r *Repository) GetTunnelName(tunnelID int64) (string, error) {
if r == nil || r.db == nil {
return "", errors.New("repository not initialized")
}
var name string
err := r.db.Model(&model.Tunnel{}).Where("id = ?", tunnelID).Pluck("name", &name).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
return "", err
}
return name, nil
}
func (r *Repository) ListChainNodesForTunnel(tunnelID int64) ([]model.ChainNodeRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
type row struct {
ChainType string
Inx sql.NullInt64
NodeID int64
Port sql.NullInt64
Name sql.NullString
Protocol sql.NullString
Strategy sql.NullString
}
var rows []row
err := r.db.Model(&model.ChainTunnel{}).
Select("chain_tunnel.chain_type, chain_tunnel.inx, chain_tunnel.node_id, chain_tunnel.port, node.name, chain_tunnel.protocol, chain_tunnel.strategy").
Joins("LEFT JOIN node ON node.id = chain_tunnel.node_id").
Where("chain_tunnel.tunnel_id = ?", tunnelID).
Order("chain_tunnel.chain_type ASC, chain_tunnel.inx ASC, chain_tunnel.id ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
result := make([]model.ChainNodeRecord, 0, len(rows))
for _, row := range rows {
chainType := 0
if v := strings.TrimSpace(row.ChainType); v != "" {
if parsed, parseErr := strconv.Atoi(v); parseErr == nil {
chainType = parsed
}
}
inx := int64(0)
if row.Inx.Valid {
inx = row.Inx.Int64
}
port := 0
if row.Port.Valid {
port = int(row.Port.Int64)
}
item := model.ChainNodeRecord{
ChainType: chainType,
Inx: inx,
NodeID: row.NodeID,
Port: port,
}
if strings.TrimSpace(row.Name.String) == "" {
item.NodeName = fmt.Sprintf("node_%d", row.NodeID)
} else {
item.NodeName = row.Name.String
}
if strings.TrimSpace(row.Protocol.String) == "" {
item.Protocol = "tls"
} else {
item.Protocol = row.Protocol.String
}
if strings.TrimSpace(row.Strategy.String) == "" {
item.Strategy = "round"
} else {
item.Strategy = row.Strategy.String
}
result = append(result, item)
}
return result, nil
}
@@ -0,0 +1,294 @@
package repo
import (
"database/sql"
"errors"
"go-backend/internal/store/model"
"gorm.io/gorm"
)
// RemoteNodeRow holds the columns fetched for a remote node listing.
type RemoteNodeRow struct {
ID int64
Name string
RemoteURL sql.NullString
RemoteToken sql.NullString
RemoteConfig sql.NullString
}
// NodeBasicInfo holds name, server_ip, and status for a node.
type NodeBasicInfo struct {
Name string
ServerIP string
Status int
}
// FederationBindingRow holds the columns for an active federation tunnel binding.
type FederationBindingRow struct {
ID int64
TunnelID int64
TunnelName string
ChainType int
HopInx int
AllocatedPort int
ResourceKey string
RemoteBindingID string
UpdatedTime int64
}
type ActiveForwardPortRow struct {
ForwardID int64
TunnelID int64
TunnelName string
Port int
UpdatedTime int64
}
// ListRemoteNodes returns all nodes with is_remote=1, ordered by id desc.
func (r *Repository) ListRemoteNodes() ([]RemoteNodeRow, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var result []RemoteNodeRow
err := r.db.Model(&model.Node{}).
Select("id, name, remote_url, remote_token, remote_config").
Where("is_remote = 1").
Order("id DESC").
Find(&result).Error
if err != nil {
return nil, err
}
if result == nil {
result = make([]RemoteNodeRow, 0)
}
return result, nil
}
// UpdateNodeRemoteConfig sets the remote_config JSON for a given node.
func (r *Repository) UpdateNodeRemoteConfig(nodeID int64, configJSON string) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
return r.db.Model(&model.Node{}).Where("id = ?", nodeID).Update("remote_config", configJSON).Error
}
// ListActiveBindingsForNode returns active federation tunnel bindings for a node.
func (r *Repository) ListActiveBindingsForNode(nodeID int64) ([]FederationBindingRow, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var result []FederationBindingRow
err := r.db.Model(&model.FederationTunnelBinding{}).
Select("federation_tunnel_binding.id, federation_tunnel_binding.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, federation_tunnel_binding.chain_type, federation_tunnel_binding.hop_inx, federation_tunnel_binding.allocated_port, federation_tunnel_binding.resource_key, federation_tunnel_binding.remote_binding_id, federation_tunnel_binding.updated_time").
Joins("LEFT JOIN tunnel ON tunnel.id = federation_tunnel_binding.tunnel_id").
Where("federation_tunnel_binding.node_id = ? AND federation_tunnel_binding.status = 1", nodeID).
Order("federation_tunnel_binding.allocated_port ASC, federation_tunnel_binding.id ASC").
Find(&result).Error
if err != nil {
return nil, err
}
if result == nil {
result = make([]FederationBindingRow, 0)
}
return result, nil
}
func (r *Repository) ListActiveForwardPortsForNode(nodeID int64) ([]ActiveForwardPortRow, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var result []ActiveForwardPortRow
err := r.db.Model(&model.ForwardPort{}).
Select("forward_port.forward_id, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward_port.port, forward.updated_time").
Joins("JOIN forward ON forward.id = forward_port.forward_id").
Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
Where("forward_port.node_id = ? AND forward_port.port > 0", nodeID).
Order("forward_port.port ASC, forward_port.id ASC").
Find(&result).Error
if err != nil {
return nil, err
}
if result == nil {
result = make([]ActiveForwardPortRow, 0)
}
return result, nil
}
// GetNodeBasicInfo returns the name, server_ip, and status for a given node.
func (r *Repository) GetNodeBasicInfo(nodeID int64) (*NodeBasicInfo, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var n model.Node
err := r.db.Select("name", "server_ip", "status").Where("id = ?", nodeID).First(&n).Error
if err != nil {
return nil, err
}
return &NodeBasicInfo{Name: n.Name, ServerIP: n.ServerIP, Status: n.Status}, nil
}
// CreateFederationTunnel creates a tunnel and chain_tunnel entry in a transaction,
// returning the new tunnel ID.
func (r *Repository) CreateFederationTunnel(name string, tunnelType int, protocol string, now int64, nodeID int64, remotePort int) (int64, error) {
if r == nil || r.db == nil {
return 0, errors.New("repository not initialized")
}
tunnel := model.Tunnel{
Name: name,
Type: tunnelType,
Protocol: protocol,
Flow: 0,
CreatedTime: now,
UpdatedTime: now,
Status: 1,
InIP: sql.NullString{String: "", Valid: false},
}
err := r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&tunnel).Error; err != nil {
return err
}
ct := model.ChainTunnel{
TunnelID: tunnel.ID,
ChainType: "1",
NodeID: nodeID,
Port: sql.NullInt64{Int64: int64(remotePort), Valid: true},
Strategy: sql.NullString{String: "fifo", Valid: true},
Inx: sql.NullInt64{Int64: 0, Valid: true},
Protocol: sql.NullString{String: protocol, Valid: true},
}
if err := tx.Create(&ct).Error; err != nil {
return err
}
return nil
})
if err != nil {
return 0, err
}
return tunnel.ID, nil
}
// ListUsedPortsOnNode returns all ports in use on a given node from chain_tunnel and forward_port tables.
func (r *Repository) ListUsedPortsOnNode(nodeID int64) ([]int, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
used := make(map[int]struct{})
var chainPorts []int
err := r.db.Model(&model.ChainTunnel{}).
Where("node_id = ? AND port > 0", nodeID).
Pluck("port", &chainPorts).Error
if err != nil {
return nil, err
}
for _, p := range chainPorts {
if p > 0 {
used[p] = struct{}{}
}
}
var forwardPorts []int
err = r.db.Model(&model.ForwardPort{}).
Where("node_id = ? AND port > 0", nodeID).
Pluck("port", &forwardPorts).Error
if err != nil {
return nil, err
}
for _, p := range forwardPorts {
if p > 0 {
used[p] = struct{}{}
}
}
result := make([]int, 0, len(used))
for p := range used {
result = append(result, p)
}
return result, nil
}
// ListTunnelIDsByNamePrefix returns all tunnel IDs whose name starts with the given prefix.
func (r *Repository) ListTunnelIDsByNamePrefix(prefix string) ([]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var ids []int64
err := r.db.Model(&model.Tunnel{}).
Where("name LIKE ?", prefix+"%").
Order("id ASC").
Pluck("id", &ids).Error
if err != nil {
return nil, err
}
if ids == nil {
ids = make([]int64, 0)
}
return ids, nil
}
func (r *Repository) NextIndex(table string) int {
if r == nil || r.db == nil {
return 0
}
var modelRef interface{}
switch table {
case "node":
modelRef = &model.Node{}
case "tunnel":
modelRef = &model.Tunnel{}
case "forward":
modelRef = &model.Forward{}
default:
return 0
}
type inxRow struct {
Inx int
}
var row inxRow
err := r.db.Model(modelRef).
Select("inx").
Order("inx ASC, id ASC").
Limit(1).
Take(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0
}
if err != nil {
return 0
}
return row.Inx - 1
}
// CreateRemoteNode inserts a new remote node.
func (r *Repository) CreateRemoteNode(name, secret, serverIP, portRange string, now int64, status int, inx int, remoteURL, remoteToken, remoteConfigJSON string) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
node := model.Node{
Name: name,
Secret: secret,
ServerIP: serverIP,
ServerIPV4: sql.NullString{},
ServerIPV6: sql.NullString{},
Port: portRange,
InterfaceName: sql.NullString{},
Version: sql.NullString{},
HTTP: 0,
TLS: 0,
Socks: 0,
CreatedTime: now,
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
Status: status,
TCPListenAddr: "[::]",
UDPListenAddr: "[::]",
Inx: inx,
IsRemote: 1,
RemoteURL: sql.NullString{String: remoteURL, Valid: remoteURL != ""},
RemoteToken: sql.NullString{String: remoteToken, Valid: remoteToken != ""},
RemoteConfig: sql.NullString{String: remoteConfigJSON, Valid: remoteConfigJSON != ""},
}
return r.db.Create(&node).Error
}
@@ -0,0 +1,186 @@
package repo
import (
"errors"
"strings"
"gorm.io/gorm"
"go-backend/internal/store/model"
)
func (r *Repository) UpdateForwardStatus(forwardID int64, status int, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
return r.db.Model(&model.Forward{}).Where("id = ?", forwardID).Updates(map[string]interface{}{
"status": status, "updated_time": now,
}).Error
}
func (r *Repository) ListActiveForwardsByUser(userID int64) ([]model.ForwardRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var forwards []model.Forward
err := r.db.Where("user_id = ? AND status = 1", userID).Order("id ASC").Find(&forwards).Error
if err != nil {
return nil, err
}
rows := make([]model.ForwardRecord, 0, len(forwards))
for _, f := range forwards {
rows = append(rows, model.ForwardRecord{
ID: f.ID,
UserID: f.UserID,
UserName: f.UserName,
Name: f.Name,
TunnelID: f.TunnelID,
RemoteAddr: f.RemoteAddr,
Strategy: f.Strategy,
Status: f.Status,
SpeedID: f.SpeedID,
})
}
for i := range rows {
if strings.TrimSpace(rows[i].Strategy) == "" {
rows[i].Strategy = "fifo"
}
}
return rows, nil
}
func (r *Repository) ListActiveForwardsByUserTunnel(userID, tunnelID int64) ([]model.ForwardRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var forwards []model.Forward
err := r.db.Where("user_id = ? AND tunnel_id = ? AND status = 1", userID, tunnelID).Order("id ASC").Find(&forwards).Error
if err != nil {
return nil, err
}
rows := make([]model.ForwardRecord, 0, len(forwards))
for _, f := range forwards {
rows = append(rows, model.ForwardRecord{
ID: f.ID,
UserID: f.UserID,
UserName: f.UserName,
Name: f.Name,
TunnelID: f.TunnelID,
RemoteAddr: f.RemoteAddr,
Strategy: f.Strategy,
Status: f.Status,
SpeedID: f.SpeedID,
})
}
for i := range rows {
if strings.TrimSpace(rows[i].Strategy) == "" {
rows[i].Strategy = "fifo"
}
}
return rows, nil
}
func (r *Repository) GetForwardRecord(forwardID int64) (*model.ForwardRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var f model.Forward
err := r.db.Where("id = ?", forwardID).First(&f).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
fr := model.ForwardRecord{
ID: f.ID,
UserID: f.UserID,
UserName: f.UserName,
Name: f.Name,
TunnelID: f.TunnelID,
RemoteAddr: f.RemoteAddr,
Strategy: f.Strategy,
Status: f.Status,
SpeedID: f.SpeedID,
}
if strings.TrimSpace(fr.Strategy) == "" {
fr.Strategy = "fifo"
}
return &fr, nil
}
func (r *Repository) GetTunnelRecord(tunnelID int64) (*model.TunnelRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var t model.Tunnel
err := r.db.Where("id = ?", tunnelID).First(&t).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
tr := model.TunnelRecord{
ID: t.ID,
Type: t.Type,
Status: t.Status,
Flow: t.Flow,
TrafficRatio: t.TrafficRatio,
}
if tr.Flow <= 0 {
tr.Flow = 1
}
if tr.TrafficRatio <= 0 {
tr.TrafficRatio = 1
}
return &tr, nil
}
func (r *Repository) TunnelExists(tunnelID int64) (bool, error) {
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
}
var count int64
err := r.db.Model(&model.Tunnel{}).Where("id = ?", tunnelID).Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *Repository) ForwardExists(forwardID int64) (bool, error) {
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
}
var count int64
err := r.db.Model(&model.Forward{}).Where("id = ?", forwardID).Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *Repository) SpeedLimitExists(id int64) (bool, error) {
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
}
var count int64
err := r.db.Model(&model.SpeedLimit{}).Where("id = ?", id).Count(&count).Error
if err != nil {
return false, err
}
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
}
@@ -0,0 +1,78 @@
package repo
import (
"errors"
"go-backend/internal/store/model"
)
// ─── Semantic Group Queries (replacing QueryInt64List/QueryPairs passthrough) ─
// ListUserIDsByUserGroup returns all user IDs belonging to a user group.
func (r *Repository) ListUserIDsByUserGroup(userGroupID int64) ([]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var ids []int64
err := r.db.Model(&model.UserGroupUser{}).
Where("user_group_id = ?", userGroupID).
Pluck("user_id", &ids).Error
return ids, err
}
// ListTunnelIDsByTunnelGroup returns all tunnel IDs belonging to a tunnel group.
func (r *Repository) ListTunnelIDsByTunnelGroup(tunnelGroupID int64) ([]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var ids []int64
err := r.db.Model(&model.TunnelGroupTunnel{}).
Where("tunnel_group_id = ?", tunnelGroupID).
Pluck("tunnel_id", &ids).Error
return ids, err
}
// ListGroupPermissionPairsByUserGroup returns [userGroupID, tunnelGroupID] pairs
// for all group permissions associated with a user group.
func (r *Repository) ListGroupPermissionPairsByUserGroup(userGroupID int64) ([][2]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var perms []model.GroupPermission
err := r.db.Where("user_group_id = ?", userGroupID).Find(&perms).Error
if err != nil {
return nil, err
}
result := make([][2]int64, len(perms))
for i, p := range perms {
result[i] = [2]int64{p.UserGroupID, p.TunnelGroupID}
}
return result, err
}
func (r *Repository) GetUserGroupIDsByUserID(userID int64) ([]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var ids []int64
err := r.db.Model(&model.UserGroupUser{}).
Where("user_id = ?", userID).
Pluck("user_group_id", &ids).Error
return ids, err
}
func (r *Repository) ListGroupPermissionPairsByTunnelGroup(tunnelGroupID int64) ([][2]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var perms []model.GroupPermission
err := r.db.Where("tunnel_group_id = ?", tunnelGroupID).Find(&perms).Error
if err != nil {
return nil, err
}
result := make([][2]int64, len(perms))
for i, p := range perms {
result[i] = [2]int64{p.UserGroupID, p.TunnelGroupID}
}
return result, err
}
@@ -0,0 +1,252 @@
package repo
import (
"database/sql"
"errors"
"testing"
gsqlite "github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestMigrateSchemaRunsPostgresIDRepairEvenAtCurrentVersion(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(?)`, currentSchemaVersion).Error; err != nil {
t.Fatalf("seed schema_version: %v", err)
}
called := 0
original := ensurePostgresIDDefaultsFn
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
called++
return nil
}
t.Cleanup(func() {
ensurePostgresIDDefaultsFn = original
})
if err := migrateSchema(db); err != nil {
t.Fatalf("migrateSchema: %v", err)
}
if called != 1 {
t.Fatalf("expected postgres id repair to run once, got %d", called)
}
}
func TestMigrateSchemaReturnsPostgresIDRepairError(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(?)`, currentSchemaVersion).Error; err != nil {
t.Fatalf("seed schema_version: %v", err)
}
wantErr := errors.New("repair failed")
original := ensurePostgresIDDefaultsFn
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
return wantErr
}
t.Cleanup(func() {
ensurePostgresIDDefaultsFn = original
})
err = migrateSchema(db)
if !errors.Is(err, wantErr) {
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)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,5 +0,0 @@
INSERT OR IGNORE INTO user (id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES (1, 'admin_user', '3c85cdebade1c51cf64ca9f3c09d182d', 0, 2727251700000, 99999, 0, 0, 1, 99999, 1748914865000, 1754011744252, 1);
INSERT OR IGNORE INTO vite_config (id, name, value, time)
VALUES (1, 'app_name', 'flux', 1755147963000);
@@ -1,245 +0,0 @@
-- SQLite Auto-generated schema
-- This will be executed automatically on startup if tables don't exist
CREATE TABLE IF NOT EXISTS forward (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
user_name VARCHAR(100) NOT NULL,
name VARCHAR(100) NOT NULL,
tunnel_id INTEGER NOT NULL,
remote_addr TEXT NOT NULL,
strategy VARCHAR(100) NOT NULL DEFAULT 'fifo',
in_flow INTEGER NOT NULL DEFAULT 0,
out_flow INTEGER NOT NULL DEFAULT 0,
created_time INTEGER NOT NULL,
updated_time INTEGER NOT NULL,
status INTEGER NOT NULL,
inx INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS forward_port (
id INTEGER PRIMARY KEY AUTOINCREMENT,
forward_id INTEGER NOT NULL,
node_id INTEGER NOT NULL,
port INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS node (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
secret VARCHAR(100) NOT NULL,
server_ip VARCHAR(100) NOT NULL,
server_ip_v4 VARCHAR(100),
server_ip_v6 VARCHAR(100),
port TEXT NOT NULL,
interface_name VARCHAR(200),
version VARCHAR(100),
http INTEGER NOT NULL DEFAULT 0,
tls INTEGER NOT NULL DEFAULT 0,
socks INTEGER NOT NULL DEFAULT 0,
created_time INTEGER NOT NULL,
updated_time INTEGER,
status INTEGER NOT NULL,
tcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
udp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
inx INTEGER NOT NULL DEFAULT 0,
is_remote INTEGER DEFAULT 0,
remote_url TEXT,
remote_token TEXT,
remote_config TEXT
);
CREATE TABLE IF NOT EXISTS speed_limit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
speed INTEGER NOT NULL,
tunnel_id INTEGER NOT NULL,
tunnel_name VARCHAR(100) NOT NULL,
created_time INTEGER NOT NULL,
updated_time INTEGER,
status INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS statistics_flow (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
flow INTEGER NOT NULL,
total_flow INTEGER NOT NULL,
time VARCHAR(100) NOT NULL,
created_time INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tunnel (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
traffic_ratio REAL NOT NULL DEFAULT 1.0,
type INTEGER NOT NULL,
protocol VARCHAR(10) NOT NULL DEFAULT 'tls',
flow INTEGER NOT NULL,
created_time INTEGER NOT NULL,
updated_time INTEGER NOT NULL,
status INTEGER NOT NULL,
in_ip TEXT,
inx INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS chain_tunnel (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tunnel_id INTEGER NOT NULL ,
chain_type VARCHAR(10) NOT NULL,
node_id INTEGER NOT NULL ,
port INTEGER,
strategy VARCHAR(10),
inx INTEGER,
protocol VARCHAR(10)
);
CREATE TABLE IF NOT EXISTS user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user VARCHAR(100) NOT NULL,
pwd VARCHAR(100) NOT NULL,
role_id INTEGER NOT NULL,
exp_time INTEGER NOT NULL,
flow INTEGER NOT NULL,
in_flow INTEGER NOT NULL DEFAULT 0,
out_flow INTEGER NOT NULL DEFAULT 0,
flow_reset_time INTEGER NOT NULL,
num INTEGER NOT NULL,
created_time INTEGER NOT NULL,
updated_time INTEGER,
status INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_tunnel (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
tunnel_id INTEGER NOT NULL,
speed_id INTEGER,
num INTEGER NOT NULL,
flow INTEGER NOT NULL,
in_flow INTEGER NOT NULL DEFAULT 0,
out_flow INTEGER NOT NULL DEFAULT 0,
flow_reset_time INTEGER NOT NULL,
exp_time INTEGER NOT NULL,
status INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tunnel_group (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
created_time INTEGER NOT NULL,
updated_time INTEGER NOT NULL,
status INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_group (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
created_time INTEGER NOT NULL,
updated_time INTEGER NOT NULL,
status INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tunnel_group_tunnel (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tunnel_group_id INTEGER NOT NULL,
tunnel_id INTEGER NOT NULL,
created_time INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_group_user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_group_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
created_time INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS group_permission (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_group_id INTEGER NOT NULL,
tunnel_group_id INTEGER NOT NULL,
created_time INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS group_permission_grant (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_group_id INTEGER NOT NULL,
tunnel_group_id INTEGER NOT NULL,
user_tunnel_id INTEGER NOT NULL,
created_by_group INTEGER NOT NULL DEFAULT 0,
created_time INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_name ON tunnel_group(name);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_name ON user_group(name);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_tunnel_unique ON tunnel_group_tunnel(tunnel_group_id, tunnel_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_user_unique ON user_group_user(user_group_id, user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_unique ON group_permission(user_group_id, tunnel_group_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_grant_unique ON group_permission_grant(user_group_id, tunnel_group_id, user_tunnel_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_tunnel_unique ON user_tunnel(user_id, tunnel_id);
CREATE TABLE IF NOT EXISTS vite_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(200) NOT NULL UNIQUE,
value VARCHAR(200) NOT NULL,
time INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS peer_share (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
node_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
max_bandwidth INTEGER DEFAULT 0,
expiry_time INTEGER DEFAULT 0,
port_range_start INTEGER DEFAULT 0,
port_range_end INTEGER DEFAULT 0,
current_flow INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
created_time INTEGER NOT NULL,
updated_time INTEGER NOT NULL,
allowed_domains TEXT DEFAULT '',
allowed_ips TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS peer_share_runtime (
id INTEGER PRIMARY KEY AUTOINCREMENT,
share_id INTEGER NOT NULL,
node_id INTEGER NOT NULL,
reservation_id TEXT NOT NULL UNIQUE,
resource_key TEXT NOT NULL UNIQUE,
binding_id TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT '',
chain_name TEXT NOT NULL DEFAULT '',
service_name TEXT NOT NULL DEFAULT '',
protocol TEXT NOT NULL DEFAULT 'tls',
strategy TEXT NOT NULL DEFAULT 'round',
port INTEGER NOT NULL DEFAULT 0,
target TEXT NOT NULL DEFAULT '',
applied INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 1,
created_time INTEGER NOT NULL,
updated_time INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_peer_share_runtime_share_node_status ON peer_share_runtime(share_id, node_id, status);
CREATE INDEX IF NOT EXISTS idx_peer_share_runtime_binding_id ON peer_share_runtime(binding_id);
CREATE TABLE IF NOT EXISTS federation_tunnel_binding (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tunnel_id INTEGER NOT NULL,
node_id INTEGER NOT NULL,
chain_type INTEGER NOT NULL,
hop_inx INTEGER NOT NULL DEFAULT 0,
remote_url TEXT NOT NULL,
resource_key TEXT NOT NULL UNIQUE,
remote_binding_id TEXT NOT NULL,
allocated_port INTEGER NOT NULL,
status INTEGER NOT NULL DEFAULT 1,
created_time INTEGER NOT NULL,
updated_time INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_federation_tunnel_binding_unique ON federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx);
CREATE INDEX IF NOT EXISTS idx_federation_tunnel_binding_tunnel ON federation_tunnel_binding(tunnel_id, status);
+22 -5
View File
@@ -15,7 +15,7 @@ import (
"go-backend/internal/auth"
"go-backend/internal/security"
"go-backend/internal/store/sqlite"
"go-backend/internal/store/repo"
)
type encryptedMessage struct {
@@ -68,9 +68,10 @@ type CommandResult struct {
}
type Server struct {
repo *sqlite.Repository
jwtSecret string
upgrader websocket.Upgrader
repo *repo.Repository
jwtSecret string
upgrader websocket.Upgrader
onNodeOnline func(nodeID int64)
mu sync.RWMutex
admins map[*connWrap]struct{}
@@ -79,7 +80,16 @@ type Server struct {
pending map[string]pendingRequest
}
func NewServer(repo *sqlite.Repository, jwtSecret string) *Server {
func (s *Server) SetNodeOnlineHook(fn func(nodeID int64)) {
if s == nil {
return
}
s.mu.Lock()
s.onNodeOnline = fn
s.mu.Unlock()
}
func NewServer(repo *repo.Repository, jwtSecret string) *Server {
return &Server{
repo: repo,
jwtSecret: jwtSecret,
@@ -183,6 +193,13 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
_ = s.repo.UpdateNodeOnline(nodeID, 1, version, httpVal, tlsVal, socksVal)
s.broadcastStatus(nodeID, 1)
s.mu.RLock()
onlineHook := s.onNodeOnline
s.mu.RUnlock()
if onlineHook != nil {
go onlineHook(nodeID)
}
defer func() {
close(done)
needOfflineBroadcast := false
@@ -0,0 +1,161 @@
package contract_test
import (
"database/sql"
"strconv"
"strings"
"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
}
func mustQueryInt(t *testing.T, r *repo.Repository, query string, args ...interface{}) int {
t.Helper()
var v int
if err := r.DB().Raw(query, args...).Row().Scan(&v); err != nil {
t.Fatalf("query int failed: %v (query=%q)", err, query)
}
return v
}
func mustQueryInt64(t *testing.T, r *repo.Repository, query string, args ...interface{}) int64 {
t.Helper()
var v int64
if err := r.DB().Raw(query, args...).Row().Scan(&v); err != nil {
t.Fatalf("query int64 failed: %v (query=%q)", err, query)
}
return v
}
func mustQueryString(t *testing.T, r *repo.Repository, query string, args ...interface{}) string {
t.Helper()
var v string
if err := r.DB().Raw(query, args...).Row().Scan(&v); err != nil {
t.Fatalf("query string failed: %v (query=%q)", err, query)
}
return v
}
func mustQueryInt64Int(t *testing.T, r *repo.Repository, query string, args ...interface{}) (int64, int) {
t.Helper()
var a int64
var b int
if err := r.DB().Raw(query, args...).Row().Scan(&a, &b); err != nil {
t.Fatalf("query int64+int failed: %v (query=%q)", err, query)
}
return a, b
}
func tryQueryString(t *testing.T, r *repo.Repository, query string, args ...interface{}) (string, error) {
t.Helper()
var v string
err := r.DB().Raw(query, args...).Row().Scan(&v)
if err != nil {
return "", err
}
return v, nil
}
func mustQueryNullString(t *testing.T, r *repo.Repository, query string, args ...interface{}) sql.NullString {
t.Helper()
var v sql.NullString
if err := r.DB().Raw(query, args...).Row().Scan(&v); err != nil {
t.Fatalf("query null string failed: %v (query=%q)", err, query)
}
return v
}
func mustQueryTwoNullStrings(t *testing.T, r *repo.Repository, query string, args ...interface{}) (sql.NullString, sql.NullString) {
t.Helper()
var a sql.NullString
var b sql.NullString
if err := r.DB().Raw(query, args...).Row().Scan(&a, &b); err != nil {
t.Fatalf("query two null strings failed: %v (query=%q)", err, query)
}
return a, b
}
func mustQueryNodePorts(t *testing.T, r *repo.Repository, query string, args ...interface{}) map[int64]int {
t.Helper()
rows, err := r.DB().Raw(query, args...).Rows()
if err != nil {
t.Fatalf("query node ports failed: %v (query=%q)", err, query)
}
defer rows.Close()
out := make(map[int64]int)
for rows.Next() {
var nodeID int64
var port int
if err := rows.Scan(&nodeID, &port); err != nil {
t.Fatalf("scan node ports row failed: %v (query=%q)", err, query)
}
out[nodeID] = port
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate node ports rows failed: %v (query=%q)", err, query)
}
return out
}
func tryQueryInt(t *testing.T, r *repo.Repository, query string, args ...interface{}) (int, error) {
t.Helper()
var v int
err := r.DB().Raw(query, args...).Row().Scan(&v)
if err != nil {
return 0, err
}
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,85 +12,69 @@ 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/sqlite"
)
func TestDiagnosisChainCoverageContracts(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupDiagnosisContractRouter(t, secret)
router, r := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
if _, err := repo.DB().Exec(`
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, 'normal_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now); err != nil {
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
tunnelRes, err := repo.DB().Exec(`
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "diagnose-chain-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0)
if err != nil {
`, "diagnose-chain-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID, err := tunnelRes.LastInsertId()
if err != nil {
t.Fatalf("get tunnel id: %v", err)
}
tunnelID := mustLastInsertID(t, r, "diagnose-chain-tunnel")
insertNode := func(name, ip string) int64 {
res, err := repo.DB().Exec(`
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, name+"-secret", ip, ip, "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
if err != nil {
`, name, name+"-secret", ip, ip, "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("get node id %s: %v", name, err)
}
return id
return mustLastInsertID(t, r, name)
}
entryNodeID := insertNode("entry-node", "10.0.1.10")
chainNodeID := insertNode("chain-node", "10.0.1.20")
exitNodeID := insertNode("exit-node", "10.0.1.30")
if _, err := repo.DB().Exec(`
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 30001, 'round', 1, 'tls')
`, tunnelID, entryNodeID); err != nil {
`, tunnelID, entryNodeID).Error; err != nil {
t.Fatalf("insert entry chain: %v", err)
}
if _, err := repo.DB().Exec(`
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 2, ?, 30002, 'round', 1, 'tls')
`, tunnelID, chainNodeID); err != nil {
`, tunnelID, chainNodeID).Error; err != nil {
t.Fatalf("insert middle chain: %v", err)
}
if _, err := repo.DB().Exec(`
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 3, ?, 30003, 'round', 1, 'tls')
`, tunnelID, exitNodeID); err != nil {
`, tunnelID, exitNodeID).Error; err != nil {
t.Fatalf("insert exit chain: %v", err)
}
forwardRes, err := repo.DB().Exec(`
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(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
`, 2, "normal_user", "chain-forward", tunnelID, "8.8.8.8:53", "fifo", now, now, 0)
if err != nil {
`, 2, "normal_user", "chain-forward", tunnelID, "8.8.8.8:53", "fifo", now, now, 0).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID, err := forwardRes.LastInsertId()
if err != nil {
t.Fatalf("get forward id: %v", err)
}
forwardID := mustLastInsertID(t, r, "chain-forward")
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
if err != nil {
@@ -206,9 +189,129 @@ func TestDiagnosisChainCoverageContracts(t *testing.T) {
})
}
func TestForwardDiagnosisRespectsTunnelIPPreferenceContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
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, 'normal_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, ip_preference)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "diagnose-ip-pref-forward", 1.0, 2, "tls", 99999, now, now, 1, nil, 0, "v6").Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "diagnose-ip-pref-forward")
insertNode := func(name, v4, v6 string) int64 {
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, name+"-secret", v4, v4, v6, "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
return mustLastInsertID(t, r, name)
}
entryNodeID := insertNode("entry-node-v6", "10.10.1.10", "2001:db8:10::10")
chainNodeID := insertNode("chain-node-v6", "10.10.1.20", "2001:db8:10::20")
exitNodeID := insertNode("exit-node-v6", "10.10.1.30", "2001:db8:10::30")
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 30001, 'round', 1, 'tls')
`, tunnelID, entryNodeID).Error; err != nil {
t.Fatalf("insert entry chain: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 2, ?, 30002, 'round', 1, 'tls')
`, tunnelID, chainNodeID).Error; err != nil {
t.Fatalf("insert middle chain: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 3, ?, 30003, 'round', 1, 'tls')
`, tunnelID, exitNodeID).Error; err != nil {
t.Fatalf("insert exit chain: %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(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
`, 2, "normal_user", "ip-pref-forward", tunnelID, "8.8.8.8:53", "fifo", now, now, 0).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, "ip-pref-forward")
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
if err != nil {
t.Fatalf("generate user token: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/diagnose", bytes.NewBufferString(`{"forwardId":`+strconv.FormatInt(forwardID, 10)+`}`))
req.Header.Set("Authorization", userToken)
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 (%s)", out.Code, out.Msg)
}
payload, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected object payload, got %T", out.Data)
}
results, ok := payload["results"].([]interface{})
if !ok || len(results) == 0 {
t.Fatalf("expected non-empty results, got %v", payload["results"])
}
hasEntryToChain := false
hasChainToExit := false
for _, raw := range results {
item, ok := raw.(map[string]interface{})
if !ok {
continue
}
from := valueAsInt(item["fromChainType"])
to := valueAsInt(item["toChainType"])
targetIP := strings.TrimSpace(valueAsString(item["targetIp"]))
if from == 1 && to == 2 {
hasEntryToChain = true
if targetIP != "2001:db8:10::20" {
t.Fatalf("expected entry->chain diagnosis target to use IPv6, got %q", targetIP)
}
}
if from == 2 && to == 3 {
hasChainToExit = true
if targetIP != "2001:db8:10::30" {
t.Fatalf("expected chain->exit diagnosis target to use IPv6, got %q", targetIP)
}
}
}
if !hasEntryToChain || !hasChainToExit {
t.Fatalf("expected entry->chain and chain->exit steps, got entry=%v chain=%v", hasEntryToChain, hasChainToExit)
}
}
func TestDiagnosisUsesFederationRuntimeForRemoteNodes(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupDiagnosisContractRouter(t, secret)
router, r := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
remoteToken := "remote-diagnose-token"
@@ -256,67 +359,53 @@ func TestDiagnosisUsesFederationRuntimeForRemoteNodes(t *testing.T) {
defer remoteServer.Close()
insertLocalNode := func(name, ip string) int64 {
res, err := repo.DB().Exec(`
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, name+"-secret", ip, ip, "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
if err != nil {
`, name, name+"-secret", ip, ip, "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert local node %s: %v", name, err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("get local node id %s: %v", name, err)
}
return id
return mustLastInsertID(t, r, name)
}
insertRemoteNode := func(name, ip string) int64 {
res, err := repo.DB().Exec(`
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 1, ?, ?, ?, 1, ?, ?, ?)
`, name, name+"-secret", ip, "", "", "31000-31010", "", "", now, now, "[::]", "[::]", 1, remoteServer.URL, remoteToken, `{"shareId": 123}`)
if err != nil {
`, name, name+"-secret", ip, "", "", "31000-31010", "", "", now, now, "[::]", "[::]", 1, remoteServer.URL, remoteToken, `{"shareId": 123}`).Error; err != nil {
t.Fatalf("insert remote node %s: %v", name, err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("get remote node id %s: %v", name, err)
}
return id
return mustLastInsertID(t, r, name)
}
entryNodeID := insertLocalNode("entry-local", "10.50.0.10")
remoteChainNodeID := insertRemoteNode("middle-remote", "10.50.0.20")
exitNodeID := insertLocalNode("exit-local", "10.50.0.30")
tunnelRes, err := repo.DB().Exec(`
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "diagnose-remote-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0)
if err != nil {
`, "diagnose-remote-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID, err := tunnelRes.LastInsertId()
if err != nil {
t.Fatalf("get tunnel id: %v", err)
}
tunnelID := mustLastInsertID(t, r, "diagnose-remote-tunnel")
if _, err := repo.DB().Exec(`
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 30001, 'round', 1, 'tls')
`, tunnelID, entryNodeID); err != nil {
`, tunnelID, entryNodeID).Error; err != nil {
t.Fatalf("insert entry chain: %v", err)
}
if _, err := repo.DB().Exec(`
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 2, ?, 30002, 'round', 1, 'tls')
`, tunnelID, remoteChainNodeID); err != nil {
`, tunnelID, remoteChainNodeID).Error; err != nil {
t.Fatalf("insert middle chain: %v", err)
}
if _, err := repo.DB().Exec(`
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 3, ?, 30003, 'round', 1, 'tls')
`, tunnelID, exitNodeID); err != nil {
`, tunnelID, exitNodeID).Error; err != nil {
t.Fatalf("insert exit chain: %v", err)
}
@@ -372,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, *sqlite.Repository) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "diagnosis-contract.db")
repo, err := sqlite.Open(dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
_ = repo.Close()
})
h := handler.New(repo, jwtSecret)
return httpserver.NewRouter(h, jwtSecret), repo
}
@@ -17,7 +17,7 @@ import (
"go-backend/internal/auth"
"go-backend/internal/http/response"
"go-backend/internal/security"
"go-backend/internal/store/sqlite"
"go-backend/internal/store/repo"
)
func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
@@ -39,7 +39,7 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
providerMiddleNodeID := insertContractNode(t, providerRepo, "provider-middle", "198.51.100.12", "44000-44010", "provider-middle-secret", 1)
providerExitNodeID := insertContractNode(t, providerRepo, "provider-exit", "198.51.100.13", "45000-45010", "provider-exit-secret", 1)
entryShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
entryShareID := insertPeerShare(t, providerRepo, &repo.PeerShare{
Name: "entry-share",
NodeID: providerEntryNodeID,
Token: "share-entry-token",
@@ -49,7 +49,7 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
CreatedTime: now,
UpdatedTime: now,
})
middleShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
middleShareID := insertPeerShare(t, providerRepo, &repo.PeerShare{
Name: "middle-share",
NodeID: providerMiddleNodeID,
Token: "share-middle-token",
@@ -59,7 +59,7 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
CreatedTime: now,
UpdatedTime: now,
})
exitShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
exitShareID := insertPeerShare(t, providerRepo, &repo.PeerShare{
Name: "exit-share",
NodeID: providerExitNodeID,
Token: "share-exit-token",
@@ -78,6 +78,8 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-token")
exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-token")
stopEntry := startMockNodeSession(t, providerServer.URL, "provider-entry-secret")
defer stopEntry()
stopMiddle := startMockNodeSession(t, providerServer.URL, "provider-middle-secret")
defer stopMiddle()
stopExit := startMockNodeSession(t, providerServer.URL, "provider-exit-secret")
@@ -110,10 +112,7 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
consumerRouter.ServeHTTP(res, req)
assertCode(t, res, 0)
var tunnelID int64
if err := consumerRepo.DB().QueryRow(`SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, name).Scan(&tunnelID); err != nil {
t.Fatalf("query tunnel id (%s): %v", name, err)
}
tunnelID := mustQueryInt64(t, consumerRepo, `SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, name)
if tunnelID <= 0 {
t.Fatalf("invalid tunnel id for %s", name)
}
@@ -148,9 +147,26 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
assertTunnelPortInRange(t, consumerRepo, secondTunnelID, 2, middleRemoteNodeID, 44000, 44010)
assertTunnelPortInRange(t, consumerRepo, secondTunnelID, 3, exitRemoteNodeID, 45000, 45010)
forwardPayload := map[string]interface{}{
"name": "dual-panel-remote-entry-forward",
"tunnelId": secondTunnelID,
"remoteAddr": "1.1.1.1:443",
"strategy": "fifo",
}
forwardBody, err := json.Marshal(forwardPayload)
if err != nil {
t.Fatalf("marshal forward payload: %v", err)
}
forwardReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(forwardBody))
forwardReq.Header.Set("Authorization", consumerAdminToken)
forwardReq.Header.Set("Content-Type", "application/json")
forwardRes := httptest.NewRecorder()
consumerRouter.ServeHTTP(forwardRes, forwardReq)
assertCode(t, forwardRes, 0)
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, middleShareID, 1)
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, exitShareID, 1)
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ?`, entryShareID, 0)
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, entryShareID, 1)
}
func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
@@ -172,7 +188,7 @@ func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
providerMiddleNodeID := insertContractNode(t, providerRepo, "provider-middle-dx", "203.0.113.12", "54000-54010", "provider-middle-dx-secret", 1)
providerExitNodeID := insertContractNode(t, providerRepo, "provider-exit-dx", "203.0.113.13", "55000-55010", "provider-exit-dx-secret", 1)
entryShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
entryShareID := insertPeerShare(t, providerRepo, &repo.PeerShare{
Name: "entry-share-dx",
NodeID: providerEntryNodeID,
Token: "share-entry-dx-token",
@@ -182,7 +198,7 @@ func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
CreatedTime: now,
UpdatedTime: now,
})
middleShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
middleShareID := insertPeerShare(t, providerRepo, &repo.PeerShare{
Name: "middle-share-dx",
NodeID: providerMiddleNodeID,
Token: "share-middle-dx-token",
@@ -192,7 +208,7 @@ func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
CreatedTime: now,
UpdatedTime: now,
})
exitShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
exitShareID := insertPeerShare(t, providerRepo, &repo.PeerShare{
Name: "exit-share-dx",
NodeID: providerExitNodeID,
Token: "share-exit-dx-token",
@@ -242,10 +258,7 @@ func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
consumerRouter.ServeHTTP(createRes, createReq)
assertCode(t, createRes, 0)
var tunnelID int64
if err := consumerRepo.DB().QueryRow(`SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, "dual-panel-diagnose-remote").Scan(&tunnelID); err != nil {
t.Fatalf("query tunnel id: %v", err)
}
tunnelID := mustQueryInt64(t, consumerRepo, `SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, "dual-panel-diagnose-remote")
if tunnelID <= 0 {
t.Fatalf("invalid tunnel id")
}
@@ -297,32 +310,154 @@ func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
}
}
func insertContractNode(t *testing.T, repo *sqlite.Repository, name, ip, portRange, secret string, status int) int64 {
t.Helper()
func TestFederationDualPanelRemoteEntryRuntimeContract(t *testing.T) {
providerSecret := "provider-contract-jwt"
providerRouter, providerRepo := setupContractRouter(t, providerSecret)
providerServer := httptest.NewServer(providerRouter)
defer providerServer.Close()
consumerSecret := "consumer-contract-jwt"
consumerRouter, consumerRepo := setupContractRouter(t, consumerSecret)
consumerAdminToken, err := auth.GenerateToken(1, "consumer-admin", 0, consumerSecret)
if err != nil {
t.Fatalf("generate consumer admin token: %v", err)
}
now := time.Now().UnixMilli()
res, 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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, secret, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0)
if err != nil {
t.Fatalf("insert node %s: %v", name, err)
providerEntryNodeID := insertContractNode(t, providerRepo, "provider-entry-rt", "198.51.100.21", "43020-43030", "provider-entry-rt-secret", 1)
providerMiddleNodeID := insertContractNode(t, providerRepo, "provider-middle-rt", "198.51.100.22", "44020-44030", "provider-middle-rt-secret", 1)
providerExitNodeID := insertContractNode(t, providerRepo, "provider-exit-rt", "198.51.100.23", "45020-45030", "provider-exit-rt-secret", 1)
insertPeerShare(t, providerRepo, &repo.PeerShare{
Name: "entry-share-rt",
NodeID: providerEntryNodeID,
Token: "share-entry-rt-token",
PortRangeStart: 43020,
PortRangeEnd: 43030,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
})
insertPeerShare(t, providerRepo, &repo.PeerShare{
Name: "middle-share-rt",
NodeID: providerMiddleNodeID,
Token: "share-middle-rt-token",
PortRangeStart: 44020,
PortRangeEnd: 44030,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
})
insertPeerShare(t, providerRepo, &repo.PeerShare{
Name: "exit-share-rt",
NodeID: providerExitNodeID,
Token: "share-exit-rt-token",
PortRangeStart: 45020,
PortRangeEnd: 45030,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
})
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-entry-rt-token")
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-middle-rt-token")
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-exit-rt-token")
entryRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-entry-rt-token")
middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-rt-token")
exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-rt-token")
var commandMu sync.Mutex
entryCommands := make([]string, 0, 8)
stopEntry := startMockNodeSessionWithHook(t, providerServer.URL, "provider-entry-rt-secret", func(cmdType string) {
commandMu.Lock()
entryCommands = append(entryCommands, cmdType)
commandMu.Unlock()
})
defer stopEntry()
stopMiddle := startMockNodeSession(t, providerServer.URL, "provider-middle-rt-secret")
defer stopMiddle()
stopExit := startMockNodeSession(t, providerServer.URL, "provider-exit-rt-secret")
defer stopExit()
createTunnel := func(name string) int64 {
payload := map[string]interface{}{
"name": name,
"type": 2,
"flow": 99999,
"status": 1,
"inNodeId": []map[string]interface{}{
{"nodeId": entryRemoteNodeID, "protocol": "tls", "strategy": "round"},
},
"chainNodes": [][]map[string]interface{}{
{{"nodeId": middleRemoteNodeID, "protocol": "tls", "strategy": "round"}},
},
"outNodeId": []map[string]interface{}{
{"nodeId": exitRemoteNodeID, "protocol": "tls", "strategy": "round"},
},
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(body))
req.Header.Set("Authorization", consumerAdminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
consumerRouter.ServeHTTP(res, req)
assertCode(t, res, 0)
tunnelID := mustQueryInt64(t, consumerRepo, `SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, name)
if tunnelID <= 0 {
t.Fatalf("invalid tunnel id for %s", name)
}
return tunnelID
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("node id %s: %v", name, err)
createTunnel("dual-panel-remote-entry-online")
commandMu.Lock()
seenAddChains := false
seenCommands := append([]string(nil), entryCommands...)
for _, cmdType := range entryCommands {
if strings.EqualFold(strings.TrimSpace(cmdType), "AddChains") {
seenAddChains = true
break
}
}
return id
commandMu.Unlock()
if !seenAddChains {
t.Fatalf("expected entry remote node to receive AddChains, commands=%v", seenCommands)
}
stopEntry()
waitNodeStatus(t, providerRepo, providerEntryNodeID, 0)
createTunnel("dual-panel-remote-entry-offline")
}
func insertPeerShare(t *testing.T, repo *sqlite.Repository, share *sqlite.PeerShare) int64 {
func insertContractNode(t *testing.T, r *repo.Repository, name, ip, portRange, secret string, status int) int64 {
t.Helper()
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, secret, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
return mustLastInsertID(t, r, name)
}
func insertPeerShare(t *testing.T, r *repo.Repository, share *repo.PeerShare) int64 {
t.Helper()
if share == nil {
t.Fatalf("share is nil")
}
if err := repo.CreatePeerShare(share); err != nil {
if err := r.CreatePeerShare(share); err != nil {
t.Fatalf("create peer share %s: %v", share.Name, err)
}
saved, err := repo.GetPeerShareByToken(share.Token)
saved, err := r.GetPeerShareByToken(share.Token)
if err != nil {
t.Fatalf("query peer share %s: %v", share.Name, err)
}
@@ -349,47 +484,36 @@ func importRemoteNodeForContract(t *testing.T, router http.Handler, adminToken,
assertCode(t, res, 0)
}
func queryRemoteNodeIDByToken(t *testing.T, repo *sqlite.Repository, token string) int64 {
func queryRemoteNodeIDByToken(t *testing.T, r *repo.Repository, token string) int64 {
t.Helper()
var id int64
if err := repo.DB().QueryRow(`SELECT id FROM node WHERE is_remote = 1 AND remote_token = ? ORDER BY id DESC LIMIT 1`, token).Scan(&id); err != nil {
t.Fatalf("query remote node by token %s: %v", token, err)
}
id := mustQueryInt64(t, r, `SELECT id FROM node WHERE is_remote = 1 AND remote_token = ? ORDER BY id DESC LIMIT 1`, token)
if id <= 0 {
t.Fatalf("invalid remote node id for token %s", token)
}
return id
}
func assertTunnelPortInRange(t *testing.T, repo *sqlite.Repository, tunnelID int64, chainType int, nodeID int64, minPort int, maxPort int) {
func assertTunnelPortInRange(t *testing.T, r *repo.Repository, tunnelID int64, chainType int, nodeID int64, minPort int, maxPort int) {
t.Helper()
var port int
err := repo.DB().QueryRow(`
SELECT port
FROM chain_tunnel
WHERE tunnel_id = ? AND chain_type = ? AND node_id = ?
LIMIT 1
`, tunnelID, chainType, nodeID).Scan(&port)
if err != nil {
t.Fatalf("query tunnel=%d chainType=%d node=%d port: %v", tunnelID, chainType, nodeID, err)
}
port := mustQueryInt(t, r, `SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = ? AND node_id = ? LIMIT 1`, tunnelID, chainType, nodeID)
if port < minPort || port > maxPort {
t.Fatalf("expected port in range [%d,%d], got %d", minPort, maxPort, port)
}
}
func assertCount(t *testing.T, repo *sqlite.Repository, query string, arg interface{}, expected int) {
func assertCount(t *testing.T, r *repo.Repository, query string, arg interface{}, expected int) {
t.Helper()
var got int
if err := repo.DB().QueryRow(query, arg).Scan(&got); err != nil {
t.Fatalf("count query failed: %v", err)
}
got := mustQueryInt(t, r, query, arg)
if got != expected {
t.Fatalf("expected count %d, got %d (query: %s, arg: %v)", expected, got, query, arg)
}
}
func startMockNodeSession(t *testing.T, baseURL string, nodeSecret string) func() {
return startMockNodeSessionWithHook(t, baseURL, nodeSecret, nil)
}
func startMockNodeSessionWithHook(t *testing.T, baseURL string, nodeSecret string, onCommand func(cmdType string)) func() {
t.Helper()
u, err := url.Parse(baseURL)
if err != nil {
@@ -449,6 +573,9 @@ func startMockNodeSession(t *testing.T, baseURL string, nodeSecret string) func(
if strings.TrimSpace(cmd.RequestID) == "" {
continue
}
if onCommand != nil {
onCommand(strings.TrimSpace(cmd.Type))
}
respType := fmt.Sprintf("%sResponse", cmd.Type)
respPayload := map[string]interface{}{
@@ -473,44 +600,150 @@ func startMockNodeSession(t *testing.T, baseURL string, nodeSecret string) func(
}
}()
var stopOnce sync.Once
return func() {
_ = conn.Close()
wg.Wait()
stopOnce.Do(func() {
_ = conn.Close()
wg.Wait()
})
}
}
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 waitNodeStatus(t *testing.T, r *repo.Repository, nodeID int64, expectedStatus int) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
status, err := tryQueryInt(t, r, `SELECT status FROM node WHERE id = ?`, nodeID)
if err == nil && status == expectedStatus {
return
}
if time.Now().After(deadline) {
t.Fatalf("node %d status did not reach %d before timeout", nodeID, expectedStatus)
}
time.Sleep(20 * time.Millisecond)
}
}
func valueAsString(v interface{}) string {
s, _ := v.(string)
return s
}
func TestFederationRuntimeCommandPortRangeEnforcement(t *testing.T) {
providerSecret := "provider-portrange-jwt"
providerRouter, providerRepo := setupContractRouter(t, providerSecret)
providerServer := httptest.NewServer(providerRouter)
defer providerServer.Close()
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
now := time.Now().UnixMilli()
providerNodeID := insertContractNode(t, providerRepo, "provider-portrange-node", "198.51.100.50", "44000-44010", "provider-portrange-secret", 1)
insertPeerShare(t, providerRepo, &repo.PeerShare{
Name: "portrange-share",
NodeID: providerNodeID,
Token: "share-portrange-token",
PortRangeStart: 44000,
PortRangeEnd: 44010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
})
stopNode := startMockNodeSession(t, providerServer.URL, "provider-portrange-secret")
defer stopNode()
sendCommand := func(token string, cmdType string, data interface{}) *httptest.ResponseRecorder {
payload := map[string]interface{}{
"commandType": cmdType,
"data": data,
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal command payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/runtime/command", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
providerRouter.ServeHTTP(res, req)
return res
}
// Test: AddService with port OUTSIDE allowed range should be rejected
outOfRangeData := map[string]interface{}{
"services": []map[string]interface{}{
{
"name": "test_service_tcp",
"addr": "[::]:55555",
"handler": map[string]interface{}{
"type": "tcp",
},
"listener": map[string]interface{}{
"type": "tcp",
},
},
},
}
res := sendCommand("share-portrange-token", "AddService", outOfRangeData)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 403 {
t.Fatalf("expected code 403 for out-of-range port, got %d (msg: %s)", out.Code, out.Msg)
}
// Test: UpdateService with port OUTSIDE allowed range should be rejected
res = sendCommand("share-portrange-token", "UpdateService", outOfRangeData)
out = response.R{}
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 403 {
t.Fatalf("expected code 403 for out-of-range UpdateService, got %d (msg: %s)", out.Code, out.Msg)
}
// Test: AddService with port INSIDE allowed range should succeed
inRangeData := map[string]interface{}{
"services": []map[string]interface{}{
{
"name": "test_service_ok_tcp",
"addr": "[::]:44005",
"handler": map[string]interface{}{
"type": "tcp",
},
"listener": map[string]interface{}{
"type": "tcp",
},
},
},
}
res = sendCommand("share-portrange-token", "AddService", inRangeData)
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 in-range port, got %d (msg: %s)", out.Code, out.Msg)
}
// 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 {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0 for reload command, got %d (msg: %s)", out.Code, out.Msg)
}
}
@@ -0,0 +1,693 @@
package contract_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
"go-backend/internal/store/repo"
)
func TestFederationForwardCardFlowLinkageContract(t *testing.T) {
secret := "federation-forward-flow-contract-jwt"
router, r := setupContractRouter(t, secret)
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "flow-local-node", "flow-local-secret", "10.20.30.40", "10.20.30.40", "", "32000-32020", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
t.Fatalf("insert local node: %v", err)
}
nodeID := mustLastInsertID(t, r, "flow-local-node")
shareToken := "flow-linkage-share-token"
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "flow-linkage-share",
NodeID: nodeID,
Token: shareToken,
MaxBandwidth: 0,
CurrentFlow: 1536,
ExpiryTime: 0,
PortRangeStart: 32000,
PortRangeEnd: 32020,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken(shareToken)
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
tunnelName := fmt.Sprintf("Share-%d-Port-%d", share.ID, 32001)
if err := r.DB().Exec(`
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
`, tunnelName, 1, "tcp", 1, now, now, 1, "", 0).Error; err != nil {
t.Fatalf("insert share tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "flow-share-tunnel")
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, 1, "admin_user", "flow-linkage-forward", tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, "flow-linkage-forward")
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, 32001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
forwardOut := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
if forwardOut.Code != 0 {
t.Fatalf("forward list failed: code=%d msg=%q", forwardOut.Code, forwardOut.Msg)
}
forwardRows := mustContractSlice(t, forwardOut.Data, "forward list data")
var targetForward map[string]interface{}
for _, row := range forwardRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
if contractValueAsInt64(m["id"]) == forwardID {
targetForward = m
break
}
}
if targetForward == nil {
t.Fatalf("target forward %d not found in /forward/list response", forwardID)
}
shareOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/list", nil)
if shareOut.Code != 0 {
t.Fatalf("share list failed: code=%d msg=%q", shareOut.Code, shareOut.Msg)
}
localShareRows := mustContractSlice(t, shareOut.Data, "share list data")
remoteUsageOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/remote-usage/list", nil)
if remoteUsageOut.Code != 0 {
t.Fatalf("remote usage list failed: code=%d msg=%q", remoteUsageOut.Code, remoteUsageOut.Msg)
}
remoteUsageRows := mustContractSlice(t, remoteUsageOut.Data, "remote usage data")
if len(remoteUsageRows) != 0 {
t.Fatalf("expected no remote usage rows in local-only fixture, got %d", len(remoteUsageRows))
}
flowByShare := make(map[int64]int64)
for _, row := range remoteUsageRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
shareID := contractValueAsInt64(m["shareId"])
currentFlow := contractValueAsInt64(m["currentFlow"])
if shareID > 0 && currentFlow > 0 {
if currentFlow > flowByShare[shareID] {
flowByShare[shareID] = currentFlow
}
}
}
for _, row := range localShareRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
shareID := contractValueAsInt64(m["id"])
currentFlow := contractValueAsInt64(m["currentFlow"])
if shareID > 0 && currentFlow > 0 {
if currentFlow > flowByShare[shareID] {
flowByShare[shareID] = currentFlow
}
}
}
parsedShareID := contractParseShareIDFromTunnelName(contractValueAsString(targetForward["tunnelName"]))
if parsedShareID != share.ID {
t.Fatalf("expected parsed shareID=%d, got %d (tunnelName=%q)", share.ID, parsedShareID, contractValueAsString(targetForward["tunnelName"]))
}
forwardCountByShare := make(map[int64]int)
for _, row := range forwardRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
sid := contractParseShareIDFromTunnelName(contractValueAsString(m["tunnelName"]))
if sid > 0 && flowByShare[sid] > 0 {
forwardCountByShare[sid] = forwardCountByShare[sid] + 1
}
}
directFlow := contractValueAsInt64(targetForward["inFlow"]) + contractValueAsInt64(targetForward["outFlow"])
if directFlow != 0 {
t.Fatalf("fixture expectation failed: directFlow should be 0, got %d", directFlow)
}
shareFlow := flowByShare[parsedShareID]
if shareFlow <= 0 {
t.Fatalf("expected merged share flow > 0 for share %d", parsedShareID)
}
count := forwardCountByShare[parsedShareID]
if count <= 0 {
count = 1
}
estimated := shareFlow / int64(count)
if estimated < 1 {
estimated = 1
}
displayFlow := estimated
if displayFlow <= 0 {
t.Fatalf("expected displayFlow > 0 after frontend-style merge, got %d", displayFlow)
}
if displayFlow != share.CurrentFlow {
t.Fatalf("expected displayFlow=%d, got %d", share.CurrentFlow, displayFlow)
}
}
func TestFederationForwardCardFlowLinkageContractSplitShareFlowAcrossMultipleForwards(t *testing.T) {
secret := "federation-forward-split-flow-contract-jwt"
router, r := setupContractRouter(t, secret)
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "flow-split-local-node", "flow-split-local-secret", "10.21.31.41", "10.21.31.41", "", "32100-32120", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
t.Fatalf("insert local node: %v", err)
}
nodeID := mustLastInsertID(t, r, "flow-split-local-node")
shareToken := "flow-split-share-token"
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "flow-split-share",
NodeID: nodeID,
Token: shareToken,
MaxBandwidth: 0,
CurrentFlow: 4097,
ExpiryTime: 0,
PortRangeStart: 32100,
PortRangeEnd: 32120,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken(shareToken)
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
createShareForward := func(name string, port int) int64 {
t.Helper()
tunnelName := fmt.Sprintf("Share-%d-Port-%d", share.ID, port)
if err := r.DB().Exec(`
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
`, tunnelName, 1, "tcp", 1, now, now, 1, "", 0).Error; err != nil {
t.Fatalf("insert share tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "flow-split-tunnel")
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, 1, "admin_user", name, tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, name)
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
return forwardID
}
forwardIDA := createShareForward("flow-split-forward-a", 32101)
forwardIDB := createShareForward("flow-split-forward-b", 32102)
forwardOut := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
if forwardOut.Code != 0 {
t.Fatalf("forward list failed: code=%d msg=%q", forwardOut.Code, forwardOut.Msg)
}
forwardRows := mustContractSlice(t, forwardOut.Data, "forward list data")
shareOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/list", nil)
if shareOut.Code != 0 {
t.Fatalf("share list failed: code=%d msg=%q", shareOut.Code, shareOut.Msg)
}
localShareRows := mustContractSlice(t, shareOut.Data, "share list data")
remoteUsageOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/remote-usage/list", nil)
if remoteUsageOut.Code != 0 {
t.Fatalf("remote usage list failed: code=%d msg=%q", remoteUsageOut.Code, remoteUsageOut.Msg)
}
remoteUsageRows := mustContractSlice(t, remoteUsageOut.Data, "remote usage data")
if len(remoteUsageRows) != 0 {
t.Fatalf("expected no remote usage rows in local-only fixture, got %d", len(remoteUsageRows))
}
flowByShare := make(map[int64]int64)
for _, row := range remoteUsageRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
shareID := contractValueAsInt64(m["shareId"])
currentFlow := contractValueAsInt64(m["currentFlow"])
if shareID > 0 && currentFlow > 0 {
if currentFlow > flowByShare[shareID] {
flowByShare[shareID] = currentFlow
}
}
}
for _, row := range localShareRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
shareID := contractValueAsInt64(m["id"])
currentFlow := contractValueAsInt64(m["currentFlow"])
if shareID > 0 && currentFlow > 0 {
if currentFlow > flowByShare[shareID] {
flowByShare[shareID] = currentFlow
}
}
}
shareFlow := flowByShare[share.ID]
if shareFlow <= 0 {
t.Fatalf("expected merged share flow > 0 for share %d", share.ID)
}
forwardCountByShare := make(map[int64]int)
for _, row := range forwardRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
sid := contractParseShareIDFromTunnelName(contractValueAsString(m["tunnelName"]))
if sid > 0 && flowByShare[sid] > 0 {
forwardCountByShare[sid] = forwardCountByShare[sid] + 1
}
}
count := forwardCountByShare[share.ID]
if count != 2 {
t.Fatalf("expected 2 forwards sharing share %d, got %d", share.ID, count)
}
expectedEach := shareFlow / int64(count)
if expectedEach < 1 {
expectedEach = 1
}
findForward := func(forwardID int64) map[string]interface{} {
t.Helper()
for _, row := range forwardRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
if contractValueAsInt64(m["id"]) == forwardID {
return m
}
}
t.Fatalf("forward %d not found in /forward/list response", forwardID)
return nil
}
for _, forwardID := range []int64{forwardIDA, forwardIDB} {
forward := findForward(forwardID)
sid := contractParseShareIDFromTunnelName(contractValueAsString(forward["tunnelName"]))
if sid != share.ID {
t.Fatalf("expected parsed shareID=%d, got %d for forward %d", share.ID, sid, forwardID)
}
directFlow := contractValueAsInt64(forward["inFlow"]) + contractValueAsInt64(forward["outFlow"])
if directFlow != 0 {
t.Fatalf("fixture expectation failed: directFlow should be 0 for forward %d, got %d", forwardID, directFlow)
}
displayFlow := int64(0)
if directFlow > 0 {
displayFlow = directFlow
} else {
shareFlowForForward := flowByShare[sid]
if shareFlowForForward > 0 {
cnt := forwardCountByShare[sid]
if cnt <= 0 {
cnt = 1
}
estimated := shareFlowForForward / int64(cnt)
if estimated < 1 {
estimated = 1
}
displayFlow = estimated
}
}
if displayFlow <= 0 {
t.Fatalf("expected displayFlow > 0 for forward %d, got %d", forwardID, displayFlow)
}
if displayFlow != expectedEach {
t.Fatalf("expected displayFlow=%d for forward %d, got %d", expectedEach, forwardID, displayFlow)
}
}
}
func TestFederationForwardCardFlowLinkageContractResolvesShareByTunnelBindingWhenTunnelNameIsCustom(t *testing.T) {
secret := "federation-forward-binding-flow-contract-jwt"
router, r := setupContractRouter(t, secret)
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
remoteShareID := int64(901)
remoteShareFlow := int64(5000)
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
"flow-binding-remote-node", "flow-binding-remote-secret", "10.31.41.51", "10.31.41.51", "", "33000-33020", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "", "", fmt.Sprintf(`{"shareId":%d,"maxBandwidth":0,"currentFlow":%d,"portRangeStart":33000,"portRangeEnd":33020}`, remoteShareID, remoteShareFlow),
).Error; err != nil {
t.Fatalf("insert remote node: %v", err)
}
remoteNodeID := mustLastInsertID(t, r, "flow-binding-remote-node")
if err := r.DB().Exec(`
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "federation-port-forward-custom-name", 1, "tcp", 1, now, now, 1, "", 0).Error; err != nil {
t.Fatalf("insert custom tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "flow-binding-custom-tunnel")
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, 1, "admin_user", "flow-binding-forward", tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, "flow-binding-forward")
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, remoteNodeID, 33001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
forwardOut := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
if forwardOut.Code != 0 {
t.Fatalf("forward list failed: code=%d msg=%q", forwardOut.Code, forwardOut.Msg)
}
forwardRows := mustContractSlice(t, forwardOut.Data, "forward list data")
shareOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/list", nil)
if shareOut.Code != 0 {
t.Fatalf("share list failed: code=%d msg=%q", shareOut.Code, shareOut.Msg)
}
localShareRows := mustContractSlice(t, shareOut.Data, "share list data")
remoteUsageOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/remote-usage/list", nil)
if remoteUsageOut.Code != 0 {
t.Fatalf("remote usage list failed: code=%d msg=%q", remoteUsageOut.Code, remoteUsageOut.Msg)
}
remoteUsageRows := mustContractSlice(t, remoteUsageOut.Data, "remote usage data")
if len(remoteUsageRows) == 0 {
t.Fatalf("expected non-empty remote usage rows")
}
findForward := func(id int64) map[string]interface{} {
t.Helper()
for _, row := range forwardRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
if contractValueAsInt64(m["id"]) == id {
return m
}
}
t.Fatalf("forward %d not found in /forward/list response", id)
return nil
}
flowByShare := make(map[int64]int64)
shareIDsByTunnel := make(map[int64]map[int64]struct{})
for _, row := range remoteUsageRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
shareID := contractValueAsInt64(m["shareId"])
currentFlow := contractValueAsInt64(m["currentFlow"])
if shareID > 0 && currentFlow > 0 {
if currentFlow > flowByShare[shareID] {
flowByShare[shareID] = currentFlow
}
}
bindings, _ := m["bindings"].([]interface{})
for _, bindingRaw := range bindings {
binding, ok := bindingRaw.(map[string]interface{})
if !ok {
continue
}
tunnelIDVal := contractValueAsInt64(binding["tunnelId"])
chainType := contractValueAsInt64(binding["chainType"])
if shareID <= 0 || tunnelIDVal <= 0 {
continue
}
if chainType != 1 {
continue
}
setByTunnel, ok := shareIDsByTunnel[tunnelIDVal]
if !ok {
setByTunnel = make(map[int64]struct{})
shareIDsByTunnel[tunnelIDVal] = setByTunnel
}
setByTunnel[shareID] = struct{}{}
}
}
for _, row := range localShareRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
shareID := contractValueAsInt64(m["id"])
currentFlow := contractValueAsInt64(m["currentFlow"])
if shareID > 0 && currentFlow > 0 {
if currentFlow > flowByShare[shareID] {
flowByShare[shareID] = currentFlow
}
}
}
targetForward := findForward(forwardID)
parsedByName := contractParseShareIDFromTunnelName(contractValueAsString(targetForward["tunnelName"]))
if parsedByName != 0 {
t.Fatalf("expected custom tunnel name cannot be parsed as Share-*-Port-*, got %d", parsedByName)
}
resolveShareIDForForward := func(forward map[string]interface{}) int64 {
candidates := make(map[int64]struct{})
shareIDFromName := contractParseShareIDFromTunnelName(contractValueAsString(forward["tunnelName"]))
if shareIDFromName > 0 {
candidates[shareIDFromName] = struct{}{}
}
tunnelIDVal := contractValueAsInt64(forward["tunnelId"])
if setByTunnel, ok := shareIDsByTunnel[tunnelIDVal]; ok {
for sid := range setByTunnel {
candidates[sid] = struct{}{}
}
}
var bestShareID int64
bestFlow := int64(0)
for sid := range candidates {
flow := flowByShare[sid]
if flow > bestFlow {
bestFlow = flow
bestShareID = sid
}
}
return bestShareID
}
resolvedShareID := resolveShareIDForForward(targetForward)
if resolvedShareID != remoteShareID {
t.Fatalf("expected resolved shareID=%d via tunnel binding, got %d", remoteShareID, resolvedShareID)
}
forwardCountByShare := make(map[int64]int)
resolvedByForwardID := make(map[int64]int64)
for _, row := range forwardRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
fid := contractValueAsInt64(m["id"])
sid := resolveShareIDForForward(m)
if sid > 0 {
resolvedByForwardID[fid] = sid
}
if sid > 0 && flowByShare[sid] > 0 {
forwardCountByShare[sid] = forwardCountByShare[sid] + 1
}
}
directFlow := contractValueAsInt64(targetForward["inFlow"]) + contractValueAsInt64(targetForward["outFlow"])
if directFlow != 0 {
t.Fatalf("fixture expectation failed: directFlow should be 0, got %d", directFlow)
}
shareFlow := flowByShare[resolvedByForwardID[forwardID]]
if shareFlow <= 0 {
t.Fatalf("expected merged share flow > 0 for resolved share %d", resolvedByForwardID[forwardID])
}
count := forwardCountByShare[resolvedByForwardID[forwardID]]
if count <= 0 {
count = 1
}
estimated := shareFlow / int64(count)
if estimated < 1 {
estimated = 1
}
displayFlow := estimated
if displayFlow <= 0 {
t.Fatalf("expected displayFlow > 0 after tunnel-binding-based merge, got %d", displayFlow)
}
if displayFlow != remoteShareFlow {
t.Fatalf("expected displayFlow=%d, got %d", remoteShareFlow, displayFlow)
}
}
func requestContractEnvelope(t *testing.T, router http.Handler, token string, path string, body interface{}) response.R {
t.Helper()
payload := []byte("{}")
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal request body for %s: %v", path, err)
}
payload = raw
}
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload))
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected http 200 for %s, got %d", path, res.Code)
}
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response for %s: %v", path, err)
}
return out
}
func mustContractSlice(t *testing.T, data interface{}, label string) []interface{} {
t.Helper()
rows, ok := data.([]interface{})
if !ok {
t.Fatalf("expected %s to be []interface{}, got %T", label, data)
}
return rows
}
func contractParseShareIDFromTunnelName(tunnelName string) int64 {
normalized := strings.TrimSpace(tunnelName)
if !strings.HasPrefix(normalized, "Share-") {
return 0
}
raw := strings.TrimPrefix(normalized, "Share-")
idx := strings.Index(raw, "-Port-")
if idx <= 0 {
return 0
}
shareID, err := strconv.ParseInt(strings.TrimSpace(raw[:idx]), 10, 64)
if err != nil || shareID <= 0 {
return 0
}
return shareID
}
func contractValueAsInt64(v interface{}) int64 {
switch n := v.(type) {
case int64:
return n
case int:
return int64(n)
case float64:
return int64(n)
case json.Number:
i, err := n.Int64()
if err == nil {
return i
}
f, err := n.Float64()
if err == nil {
return int64(f)
}
return 0
case string:
i, err := strconv.ParseInt(strings.TrimSpace(n), 10, 64)
if err == nil {
return i
}
return 0
default:
return 0
}
}
func contractValueAsString(v interface{}) string {
s, _ := v.(string)
return s
}
+309 -131
View File
@@ -2,6 +2,7 @@ package contract_test
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -18,67 +19,51 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) {
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
if _, err := repo.DB().Exec(`
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, 'normal_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now); err != nil {
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
res, err := repo.DB().Exec(`
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "contract-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0)
if err != nil {
`, "contract-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID, err := res.LastInsertId()
if err != nil {
t.Fatalf("get tunnel id: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "contract-tunnel")
nodeRes, err := repo.DB().Exec(`
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "entry-node", "entry-secret", "10.0.0.10", "10.0.0.10", "", "20000-20010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
if err != nil {
`, "entry-node", "entry-secret", "10.0.0.10", "10.0.0.10", "", "20000-20010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
entryNodeID, err := nodeRes.LastInsertId()
if err != nil {
t.Fatalf("get node id: %v", err)
}
entryNodeID := mustLastInsertID(t, repo, "entry-node")
if _, err := repo.DB().Exec(`
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 20001, 'round', 1, 'tls')
`, tunnelID, entryNodeID); err != nil {
`, tunnelID, entryNodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
resAdmin, err := repo.DB().Exec(`
if err := repo.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(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
`, 1, "admin_user", "admin-forward", tunnelID, "1.1.1.1:443", "fifo", now, now, 0)
if err != nil {
`, 1, "admin_user", "admin-forward", tunnelID, "1.1.1.1:443", "fifo", now, now, 0).Error; err != nil {
t.Fatalf("insert admin forward: %v", err)
}
adminForwardID, err := resAdmin.LastInsertId()
if err != nil {
t.Fatalf("get admin forward id: %v", err)
}
adminForwardID := mustLastInsertID(t, repo, "admin-forward")
resUser, err := repo.DB().Exec(`
if err := repo.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(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
`, 2, "normal_user", "user-forward", tunnelID, "8.8.8.8:53", "fifo", now, now, 1)
if err != nil {
`, 2, "normal_user", "user-forward", tunnelID, "8.8.8.8:53", "fifo", now, now, 1).Error; err != nil {
t.Fatalf("insert user forward: %v", err)
}
userForwardID, err := resUser.LastInsertId()
if err != nil {
t.Fatalf("get user forward id: %v", err)
}
userForwardID := mustLastInsertID(t, repo, "user-forward")
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
if err != nil {
@@ -124,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)
}
})
@@ -159,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)
}
})
@@ -207,41 +200,31 @@ func TestForwardSwitchTunnelRollbackOnSyncFailure(t *testing.T) {
t.Fatalf("generate admin token: %v", err)
}
if _, err := repo.DB().Exec(`
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, 'switch_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now); err != nil {
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
insertTunnel := func(name string, inx int) int64 {
res, err := repo.DB().Exec(`
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, 1.0, 1, "tls", 99999, now, now, 1, nil, inx)
if err != nil {
`, name, 1.0, 1, "tls", 99999, now, now, 1, nil, inx).Error; err != nil {
t.Fatalf("insert tunnel %s: %v", name, err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("get tunnel id %s: %v", name, err)
}
return id
return mustLastInsertID(t, repo, name)
}
insertNode := func(name, ip, portRange string, inx int) int64 {
res, err := repo.DB().Exec(`
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx)
if err != nil {
`, name, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx).Error; err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("get node id %s: %v", name, err)
}
return id
return mustLastInsertID(t, repo, name)
}
tunnelA := insertTunnel("switch-tunnel-a", 0)
@@ -249,45 +232,41 @@ func TestForwardSwitchTunnelRollbackOnSyncFailure(t *testing.T) {
nodeA := insertNode("switch-node-a", "10.10.0.1", "21000-21010", 0)
nodeB := insertNode("switch-node-b", "10.10.0.2", "22000-22010", 1)
if _, err := repo.DB().Exec(`
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 21001, 'round', 1, 'tls')
`, tunnelA, nodeA); err != nil {
`, tunnelA, nodeA).Error; err != nil {
t.Fatalf("insert chain_tunnel tunnelA: %v", err)
}
if _, err := repo.DB().Exec(`
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 22001, 'round', 1, 'tls')
`, tunnelB, nodeB); err != nil {
`, tunnelB, nodeB).Error; err != nil {
t.Fatalf("insert chain_tunnel tunnelB: %v", err)
}
if _, err := repo.DB().Exec(`
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(10, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelA); err != nil {
`, tunnelA).Error; err != nil {
t.Fatalf("insert user_tunnel A: %v", err)
}
if _, err := repo.DB().Exec(`
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(11, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelB); err != nil {
`, tunnelB).Error; err != nil {
t.Fatalf("insert user_tunnel B: %v", err)
}
forwardRes, err := repo.DB().Exec(`
if err := repo.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, 'switch_user', 'switch-forward', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
`, tunnelA, now, now)
if err != nil {
`, tunnelA, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID, err := forwardRes.LastInsertId()
if err != nil {
t.Fatalf("get forward id: %v", err)
}
forwardID := mustLastInsertID(t, repo, "switch-forward")
if _, err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeA, 21001); err != nil {
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeA, 21001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
@@ -307,19 +286,12 @@ func TestForwardSwitchTunnelRollbackOnSyncFailure(t *testing.T) {
t.Fatalf("expected update failure when node is offline")
}
var tunnelAfter int64
if err := repo.DB().QueryRow(`SELECT tunnel_id FROM forward WHERE id = ?`, forwardID).Scan(&tunnelAfter); err != nil {
t.Fatalf("query forward tunnel_id: %v", err)
}
tunnelAfter := mustQueryInt64(t, repo, `SELECT tunnel_id FROM forward WHERE id = ?`, forwardID)
if tunnelAfter != tunnelA {
t.Fatalf("expected tunnel rollback to %d, got %d", tunnelA, tunnelAfter)
}
var nodeAfter int64
var portAfter int
if err := repo.DB().QueryRow(`SELECT node_id, port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID).Scan(&nodeAfter, &portAfter); err != nil {
t.Fatalf("query forward_port: %v", err)
}
nodeAfter, portAfter := mustQueryInt64Int(t, repo, `SELECT node_id, port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID)
if nodeAfter != nodeA || portAfter != 21001 {
t.Fatalf("expected forward_port rollback to node=%d port=21001, got node=%d port=%d", nodeA, nodeAfter, portAfter)
}
@@ -335,73 +307,68 @@ func TestForwardBatchChangeTunnelRollbackOnSyncFailure(t *testing.T) {
t.Fatalf("generate admin token: %v", err)
}
if _, err := repo.DB().Exec(`
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, 'batch_switch_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now); err != nil {
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
tunnelResA, err := repo.DB().Exec(`
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES('batch-switch-tunnel-a', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
`, now, now)
if err != nil {
`, now, now).Error; err != nil {
t.Fatalf("insert tunnel A: %v", err)
}
tunnelA, _ := tunnelResA.LastInsertId()
tunnelA := mustLastInsertID(t, repo, "batch-switch-tunnel-a")
tunnelResB, err := repo.DB().Exec(`
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES('batch-switch-tunnel-b', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 1)
`, now, now)
if err != nil {
`, now, now).Error; err != nil {
t.Fatalf("insert tunnel B: %v", err)
}
tunnelB, _ := tunnelResB.LastInsertId()
tunnelB := mustLastInsertID(t, repo, "batch-switch-tunnel-b")
nodeResA, err := repo.DB().Exec(`
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('batch-switch-node-a', 'batch-switch-node-a-secret', '10.11.0.1', '10.11.0.1', '', '23000-23010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
`, now, now)
if err != nil {
`, now, now).Error; err != nil {
t.Fatalf("insert node A: %v", err)
}
nodeA, _ := nodeResA.LastInsertId()
nodeA := mustLastInsertID(t, repo, "batch-switch-node-a")
nodeResB, err := repo.DB().Exec(`
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('batch-switch-node-b', 'batch-switch-node-b-secret', '10.11.0.2', '10.11.0.2', '', '24000-24010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 1)
`, now, now)
if err != nil {
`, now, now).Error; err != nil {
t.Fatalf("insert node B: %v", err)
}
nodeB, _ := nodeResB.LastInsertId()
nodeB := mustLastInsertID(t, repo, "batch-switch-node-b")
if _, err := repo.DB().Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 1, ?, 23001, 'round', 1, 'tls')`, tunnelA, nodeA); err != nil {
if err := repo.DB().Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 1, ?, 23001, 'round', 1, 'tls')`, tunnelA, nodeA).Error; err != nil {
t.Fatalf("insert chain_tunnel A: %v", err)
}
if _, err := repo.DB().Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 1, ?, 24001, 'round', 1, 'tls')`, tunnelB, nodeB); err != nil {
if err := repo.DB().Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 1, ?, 24001, 'round', 1, 'tls')`, tunnelB, nodeB).Error; err != nil {
t.Fatalf("insert chain_tunnel B: %v", err)
}
if _, err := repo.DB().Exec(`INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(20, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)`, tunnelA); err != nil {
if err := repo.DB().Exec(`INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(20, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)`, tunnelA).Error; err != nil {
t.Fatalf("insert user_tunnel A: %v", err)
}
if _, err := repo.DB().Exec(`INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(21, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)`, tunnelB); err != nil {
if err := repo.DB().Exec(`INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(21, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)`, tunnelB).Error; err != nil {
t.Fatalf("insert user_tunnel B: %v", err)
}
forwardRes, err := repo.DB().Exec(`
if err := repo.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, 'batch_switch_user', 'batch-switch-forward', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, tunnelA, now, now)
if err != nil {
`, tunnelA, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID, _ := forwardRes.LastInsertId()
forwardID := mustLastInsertID(t, repo, "batch-switch-forward")
if _, err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeA, 23001); err != nil {
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeA, 23001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
@@ -429,19 +396,12 @@ func TestForwardBatchChangeTunnelRollbackOnSyncFailure(t *testing.T) {
t.Fatalf("expected failCount=1, got %v", result["failCount"])
}
var tunnelAfter int64
if err := repo.DB().QueryRow(`SELECT tunnel_id FROM forward WHERE id = ?`, forwardID).Scan(&tunnelAfter); err != nil {
t.Fatalf("query forward tunnel_id: %v", err)
}
tunnelAfter := mustQueryInt64(t, repo, `SELECT tunnel_id FROM forward WHERE id = ?`, forwardID)
if tunnelAfter != tunnelA {
t.Fatalf("expected tunnel rollback to %d, got %d", tunnelA, tunnelAfter)
}
var nodeAfter int64
var portAfter int
if err := repo.DB().QueryRow(`SELECT node_id, port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID).Scan(&nodeAfter, &portAfter); err != nil {
t.Fatalf("query forward_port: %v", err)
}
nodeAfter, portAfter := mustQueryInt64Int(t, repo, `SELECT node_id, port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID)
if nodeAfter != nodeA || portAfter != 23001 {
t.Fatalf("expected forward_port rollback to node=%d port=23001, got node=%d port=%d", nodeA, nodeAfter, portAfter)
}
@@ -457,21 +417,20 @@ func TestUserTunnelReassignmentKeepsStableID(t *testing.T) {
t.Fatalf("generate admin token: %v", err)
}
if _, err := repo.DB().Exec(`
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(100, 'stable_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now); err != nil {
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
tunnelRes, err := repo.DB().Exec(`
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES('stable-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
`, now, now)
if err != nil {
`, now, now).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID, _ := tunnelRes.LastInsertId()
tunnelID := mustLastInsertID(t, repo, "stable-tunnel")
// 1. Assign permission (creates new user_tunnel)
// userTunnelBatchAssign expects structure: {userId: 123, tunnels: [{tunnelId: 456, ...}]}
@@ -490,10 +449,7 @@ func TestUserTunnelReassignmentKeepsStableID(t *testing.T) {
t.Fatalf("expected code 0, got %d msg=%q", out.Code, out.Msg)
}
var initialID int64
if err := repo.DB().QueryRow(`SELECT id FROM user_tunnel WHERE user_id = 100 AND tunnel_id = ?`, tunnelID).Scan(&initialID); err != nil {
t.Fatalf("query initial user_tunnel id: %v", err)
}
initialID := mustQueryInt64(t, repo, `SELECT id FROM user_tunnel WHERE user_id = 100 AND tunnel_id = ?`, tunnelID)
// 2. Re-assign permission (should UPDATE, not INSERT)
reassignPayload := `{"userId":100,"tunnels":[{"tunnelId":` + jsonNumber(tunnelID) + `}]}`
@@ -512,24 +468,246 @@ func TestUserTunnelReassignmentKeepsStableID(t *testing.T) {
}
// 3. Verify stable ID and no duplicates
var count int
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM user_tunnel WHERE user_id = 100 AND tunnel_id = ?`, tunnelID).Scan(&count); err != nil {
t.Fatalf("query count: %v", err)
}
count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM user_tunnel WHERE user_id = 100 AND tunnel_id = ?`, tunnelID)
if count != 1 {
t.Fatalf("expected exactly 1 user_tunnel record, got %d", count)
}
var currentID int64
if err := repo.DB().QueryRow(`SELECT id FROM user_tunnel WHERE user_id = 100 AND tunnel_id = ?`, tunnelID).Scan(&currentID); err != nil {
t.Fatalf("query current user_tunnel: %v", err)
}
currentID := mustQueryInt64(t, repo, `SELECT id FROM user_tunnel WHERE user_id = 100 AND tunnel_id = ?`, tunnelID)
if currentID != initialID {
t.Fatalf("user_tunnel ID changed from %d to %d (unstable ID!)", initialID, currentID)
}
}
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,167 @@
package contract_test
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"time"
"go-backend/internal/auth"
)
func TestGroupUserUnbindRevokesInheritedTunnelPermission(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
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(200, 'group_user_contract', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert test 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('group-contract-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
`, now, now).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "group-contract-tunnel")
if err := repo.DB().Exec(`INSERT INTO user_group(name, created_time, updated_time, status) VALUES('ug-contract', ?, ?, 1)`, now, now).Error; err != nil {
t.Fatalf("insert user_group: %v", err)
}
userGroupID := mustLastInsertID(t, repo, "ug-contract")
if err := repo.DB().Exec(`INSERT INTO tunnel_group(name, created_time, updated_time, status) VALUES('tg-contract', ?, ?, 1)`, now, now).Error; err != nil {
t.Fatalf("insert tunnel_group: %v", err)
}
tunnelGroupID := mustLastInsertID(t, repo, "tg-contract")
if err := repo.DB().Exec(`INSERT INTO tunnel_group_tunnel(tunnel_group_id, tunnel_id, created_time) VALUES(?, ?, ?)`, tunnelGroupID, tunnelID, now).Error; err != nil {
t.Fatalf("insert tunnel_group_tunnel: %v", err)
}
if err := repo.DB().Exec(`INSERT INTO group_permission(user_group_id, tunnel_group_id, created_time) VALUES(?, ?, ?)`, userGroupID, tunnelGroupID, now).Error; err != nil {
t.Fatalf("insert group_permission: %v", err)
}
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
bindReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/user/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(userGroupID)+`,"userIds":[200]}`))
bindReq.Header.Set("Authorization", adminToken)
bindRes := httptest.NewRecorder()
router.ServeHTTP(bindRes, bindReq)
assertCode(t, bindRes, 0)
userTunnelID := mustQueryInt64(t, repo, `SELECT id FROM user_tunnel WHERE user_id = 200 AND tunnel_id = ?`, tunnelID)
grantCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID)
if grantCount == 0 {
t.Fatalf("expected non-zero grants after bind")
}
unbindReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/user/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(userGroupID)+`,"userIds":[]}`))
unbindReq.Header.Set("Authorization", adminToken)
unbindRes := httptest.NewRecorder()
router.ServeHTTP(unbindRes, unbindReq)
assertCode(t, unbindRes, 0)
grantCount = mustQueryInt(t, repo, `SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID)
if grantCount != 0 {
t.Fatalf("expected grants revoked after unbind, got %d", grantCount)
}
userTunnelCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM user_tunnel WHERE id = ?`, userTunnelID)
if userTunnelCount != 0 {
t.Fatalf("expected user_tunnel revoked after unbind, got %d", userTunnelCount)
}
}
func TestGroupPermissionRemoveRevokesInheritedTunnelPermission(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
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(201, 'group_user_permission_remove', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert test 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('group-remove-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
`, now, now).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "group-remove-tunnel")
if err := repo.DB().Exec(`INSERT INTO user_group(name, created_time, updated_time, status) VALUES('ug-remove-contract', ?, ?, 1)`, now, now).Error; err != nil {
t.Fatalf("insert user_group: %v", err)
}
userGroupID := mustLastInsertID(t, repo, "ug-remove-contract")
if err := repo.DB().Exec(`INSERT INTO tunnel_group(name, created_time, updated_time, status) VALUES('tg-remove-contract', ?, ?, 1)`, now, now).Error; err != nil {
t.Fatalf("insert tunnel_group: %v", err)
}
tunnelGroupID := mustLastInsertID(t, repo, "tg-remove-contract")
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
assignTunnelReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/tunnel/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(tunnelGroupID)+`,"tunnelIds":[`+jsonNumber(tunnelID)+`]}`))
assignTunnelReq.Header.Set("Authorization", adminToken)
assignTunnelRes := httptest.NewRecorder()
router.ServeHTTP(assignTunnelRes, assignTunnelReq)
assertCode(t, assignTunnelRes, 0)
assignUserReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/user/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(userGroupID)+`,"userIds":[201]}`))
assignUserReq.Header.Set("Authorization", adminToken)
assignUserRes := httptest.NewRecorder()
router.ServeHTTP(assignUserRes, assignUserReq)
assertCode(t, assignUserRes, 0)
assignPermissionReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/permission/assign", bytes.NewBufferString(`{"userGroupId":`+jsonNumber(userGroupID)+`,"tunnelGroupId":`+jsonNumber(tunnelGroupID)+`}`))
assignPermissionReq.Header.Set("Authorization", adminToken)
assignPermissionRes := httptest.NewRecorder()
router.ServeHTTP(assignPermissionRes, assignPermissionReq)
assertCode(t, assignPermissionRes, 0)
permissionID := mustQueryInt64(t, repo, `SELECT id FROM group_permission WHERE user_group_id = ? AND tunnel_group_id = ?`, userGroupID, tunnelGroupID)
userTunnelID := mustQueryInt64(t, repo, `SELECT id FROM user_tunnel WHERE user_id = 201 AND tunnel_id = ?`, tunnelID)
grantCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID)
if grantCount == 0 {
t.Fatalf("expected non-zero grants after permission assign")
}
removeReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/permission/remove", bytes.NewBufferString(`{"id":`+jsonNumber(permissionID)+`}`))
removeReq.Header.Set("Authorization", adminToken)
removeRes := httptest.NewRecorder()
router.ServeHTTP(removeRes, removeReq)
assertCode(t, removeRes, 0)
permissionCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM group_permission WHERE id = ?`, permissionID)
if permissionCount != 0 {
t.Fatalf("expected group_permission removed, got %d", permissionCount)
}
grantCount = mustQueryInt(t, repo, `SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID)
if grantCount != 0 {
t.Fatalf("expected grants removed after permission remove, got %d", grantCount)
}
userTunnelCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM user_tunnel WHERE id = ?`, userTunnelID)
if userTunnelCount != 0 {
t.Fatalf("expected user_tunnel revoked after permission remove, got %d", userTunnelCount)
}
}
@@ -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()
})
}
}
@@ -17,26 +17,25 @@ import (
httpserver "go-backend/internal/http"
"go-backend/internal/http/handler"
"go-backend/internal/http/response"
"go-backend/internal/store"
"go-backend/internal/store/sqlite"
"go-backend/internal/store/repo"
_ "modernc.org/sqlite"
"gorm.io/gorm"
)
func TestCaptchaVerifyLoginContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
router, r := setupContractRouter(t, secret)
verifiedToken := ""
_, err := repo.DB().Exec(`
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
`, "captcha_enabled", "true", time.Now().UnixMilli())
if err != nil {
`, "captcha_enabled", "true", time.Now().UnixMilli()).Error; err != nil {
t.Fatalf("enable captcha: %v", err)
}
t.Run("login denied without verified captcha token", func(t *testing.T) {
t.Run("login allowed when cloudflare keys are missing", func(t *testing.T) {
body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
req.Header.Set("Content-Type", "application/json")
@@ -44,10 +43,10 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
router.ServeHTTP(resp, req)
assertCodeMsg(t, resp, -1, "验证码校验失败")
assertCode(t, resp, 0)
})
t.Run("captcha token is one-time and consumed by login", func(t *testing.T) {
t.Run("captcha verify remains compatible without cloudflare secret", func(t *testing.T) {
verifyReq := httptest.NewRequest(http.MethodPost, "/api/v1/captcha/verify", bytes.NewBufferString(`{"id":"captcha-token-1","data":"ok"}`))
verifyReq.Header.Set("Content-Type", "application/json")
verifyResp := httptest.NewRecorder()
@@ -67,14 +66,60 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
t.Fatalf("unexpected captcha verify payload: success=%v token=%q", verifyOut.Success, verifyOut.Data.ValidToken)
}
loginBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"captcha-token-1"}`)
verifiedToken = verifyOut.Data.ValidToken
})
if err := r.DB().Exec(`
INSERT INTO vite_config(name, value, time)
VALUES(?, ?, ?)
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
`, "cloudflare_site_key", "test-site-key", time.Now().UnixMilli()).Error; err != nil {
t.Fatalf("set cloudflare site key: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO vite_config(name, value, time)
VALUES(?, ?, ?)
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
`, "cloudflare_secret_key", "test-secret-key", time.Now().UnixMilli()).Error; err != nil {
t.Fatalf("set cloudflare secret key: %v", err)
}
t.Run("login denied without verified captcha token", func(t *testing.T) {
body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertCodeMsg(t, resp, -1, "验证码校验失败")
})
t.Run("whmcs api client bypasses captcha", func(t *testing.T) {
body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-FLVX-API-Client", "whmcs")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertCode(t, resp, 0)
})
t.Run("captcha token is one-time and consumed by login", func(t *testing.T) {
if strings.TrimSpace(verifiedToken) == "" {
t.Fatalf("expected verified token from compatibility captcha verify")
}
loginBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"` + verifiedToken + `"}`)
loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", loginBody)
loginReq.Header.Set("Content-Type", "application/json")
loginResp := httptest.NewRecorder()
router.ServeHTTP(loginResp, loginReq)
assertCode(t, loginResp, 0)
replayBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"captcha-token-1"}`)
replayBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"` + verifiedToken + `"}`)
replayReq := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", replayBody)
replayReq.Header.Set("Content-Type", "application/json")
replayResp := httptest.NewRecorder()
@@ -84,7 +129,7 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
}
func TestOpenAPISubStoreContracts(t *testing.T) {
router, repo := setupContractRouter(t, "contract-jwt-secret")
router, r := setupContractRouter(t, "contract-jwt-secret")
const tunnelFlowGB = int64(500)
const tunnelInFlow = int64(123)
@@ -92,17 +137,13 @@ func TestOpenAPISubStoreContracts(t *testing.T) {
const tunnelExpTimeMs = int64(2727251700000)
now := time.Now().UnixMilli()
res, err := repo.DB().Exec(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"contract-tunnel", 1.0, 1, "tls", 1, now, now, 1, nil, 0)
if err != nil {
if err := r.DB().Exec(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"contract-tunnel", 1.0, 1, "tls", 1, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID, err := res.LastInsertId()
if err != nil {
t.Fatalf("last insert id: %v", err)
}
if _, err := repo.DB().Exec(`INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)`,
1, tunnelID, 99999, tunnelFlowGB, tunnelInFlow, tunnelOutFlow, 1, tunnelExpTimeMs, 1); err != nil {
tunnelID := mustLastInsertID(t, r, "contract-tunnel")
if err := r.DB().Exec(`INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)`,
1, tunnelID, 99999, tunnelFlowGB, tunnelInFlow, tunnelOutFlow, 1, tunnelExpTimeMs, 1).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
@@ -168,54 +209,403 @@ 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)
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)
if resp.Code != http.StatusNotFound {
t.Fatalf("expected status 404 after route removal, got %d", resp.Code)
}
}
func TestBackupExportImportRestoreContracts(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)
}
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
if err != nil {
t.Fatalf("generate user token: %v", err)
}
key := "backup_contract_key"
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
`, key, "v1", time.Now().UnixMilli()).Error; err != nil {
t.Fatalf("seed config for backup contract: %v", err)
}
t.Run("non-admin is blocked on backup export", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/export", nil)
req.Header.Set("Authorization", userToken)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertCodeMsg(t, resp, 401, "未登录或token已过期")
assertCodeMsg(t, resp, 403, "权限不足,仅管理员可操作")
})
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)
t.Run("standard and duplicate export routes both work", func(t *testing.T) {
payloadA := exportBackupPayload(t, router, "/api/v1/backup/export", adminToken)
if len(payloadA.Configs) == 0 {
t.Fatalf("expected exported configs, got none")
}
if _, ok := payloadA.Configs[key]; !ok {
t.Fatalf("expected %q in exported configs", key)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/tunnels", nil)
req.Header.Set("Authorization", token)
payloadB := exportBackupPayload(t, router, "/api/v1/api/v1/backup/export", adminToken)
if len(payloadB.Configs) == 0 {
t.Fatalf("expected exported configs from duplicate-prefix route, got none")
}
})
t.Run("backup import applies exported data", func(t *testing.T) {
payload := exportBackupPayload(t, router, "/api/v1/backup/export", adminToken)
payload.Configs[key] = "v2"
raw, err := json.Marshal(backupImportPayload{Types: []string{"configs"}, backupExportPayload: payload})
if err != nil {
t.Fatalf("marshal import payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", bytes.NewReader(raw))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
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)
t.Fatalf("decode import response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
t.Fatalf("expected import code 0, got %d (%s)", out.Code, out.Msg)
}
cfg, err := r.GetConfigByName(key)
if err != nil {
t.Fatalf("query imported config: %v", err)
}
if cfg == nil || cfg.Value != "v2" {
t.Fatalf("expected imported config value v2, got %+v", cfg)
}
})
t.Run("backup restore alias applies exported data", func(t *testing.T) {
payload := exportBackupPayload(t, router, "/api/v1/backup/export", adminToken)
payload.Configs[key] = "v3"
raw, err := json.Marshal(backupImportPayload{Types: []string{"configs"}, backupExportPayload: payload})
if err != nil {
t.Fatalf("marshal restore payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/restore", bytes.NewReader(raw))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
var out response.R
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode restore response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected restore code 0, got %d (%s)", out.Code, out.Msg)
}
cfg, err := r.GetConfigByName(key)
if err != nil {
t.Fatalf("query restored config: %v", err)
}
if cfg == nil || cfg.Value != "v3" {
t.Fatalf("expected restored config value v3, got %+v", cfg)
}
})
t.Run("backup export and import preserve forward ports", func(t *testing.T) {
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "backup-forward-tunnel", 1.0, 1, "tls", 0, now, now, 1, "", 88).Error; err != nil {
t.Fatalf("seed tunnel for forward backup: %v", err)
}
tunnelID := mustLastInsertID(t, r, "backup-forward-tunnel")
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, 1, "admin_user", "backup-forward", tunnelID, "127.0.0.1:9000", "fifo", 0, 0, now, now, 1, 88).Error; err != nil {
t.Fatalf("seed forward for backup: %v", err)
}
forwardID := mustLastInsertID(t, r, "backup-forward")
expected := map[int64]int{
2001: 21001,
2002: 21002,
}
for nodeID, port := range expected {
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port).Error; err != nil {
t.Fatalf("seed forward_port %d:%d: %v", nodeID, port, err)
}
}
exportReq := httptest.NewRequest(http.MethodPost, "/api/v1/backup/export", bytes.NewBufferString(`{"types":["forwards"]}`))
exportReq.Header.Set("Authorization", adminToken)
exportReq.Header.Set("Content-Type", "application/json")
exportResp := httptest.NewRecorder()
router.ServeHTTP(exportResp, exportReq)
if exportResp.Code != http.StatusOK {
t.Fatalf("expected export status 200, got %d", exportResp.Code)
}
exportBody, err := io.ReadAll(exportResp.Body)
if err != nil {
t.Fatalf("read forwards backup body: %v", err)
}
var payload map[string]interface{}
if err := json.Unmarshal(exportBody, &payload); err != nil {
t.Fatalf("decode forwards backup payload: %v", err)
}
version, _ := payload["version"].(string)
if strings.TrimSpace(version) == "" {
t.Fatalf("expected backup payload version, body=%s", string(exportBody))
}
forwardsRaw, ok := payload["forwards"].([]interface{})
if !ok {
t.Fatalf("expected forwards array in payload, body=%s", string(exportBody))
}
foundForward := false
foundPorts := map[int64]int{}
for _, item := range forwardsRaw {
forwardMap, ok := item.(map[string]interface{})
if !ok {
continue
}
idValue, ok := forwardMap["id"].(float64)
if !ok || int64(idValue) != forwardID {
continue
}
foundForward = true
portsRaw, ok := forwardMap["forwardPorts"].([]interface{})
if !ok {
t.Fatalf("expected forwardPorts for forward %d in payload", forwardID)
}
for _, p := range portsRaw {
portMap, ok := p.(map[string]interface{})
if !ok {
continue
}
nodeID, nodeOK := portMap["nodeId"].(float64)
port, portOK := portMap["port"].(float64)
if nodeOK && portOK {
foundPorts[int64(nodeID)] = int(port)
}
}
break
}
if !foundForward {
t.Fatalf("expected forward %d in exported forwards payload", forwardID)
}
if len(foundPorts) != len(expected) {
t.Fatalf("expected %d exported forward ports, got %d", len(expected), len(foundPorts))
}
for nodeID, port := range expected {
if got, ok := foundPorts[nodeID]; !ok || got != port {
t.Fatalf("expected exported forward port node=%d port=%d, got %v", nodeID, port, foundPorts)
}
}
if err := r.DB().Exec(`DELETE FROM forward_port WHERE forward_id = ?`, forwardID).Error; err != nil {
t.Fatalf("clear forward_port before import: %v", err)
}
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, 9999, 39999).Error; err != nil {
t.Fatalf("seed wrong forward_port before import: %v", err)
}
payload["types"] = []string{"forwards"}
importBody, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal forwards import payload: %v", err)
}
importReq := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", bytes.NewReader(importBody))
importReq.Header.Set("Authorization", adminToken)
importReq.Header.Set("Content-Type", "application/json")
importResp := httptest.NewRecorder()
router.ServeHTTP(importResp, importReq)
var out response.R
if err := json.NewDecoder(importResp.Body).Decode(&out); err != nil {
t.Fatalf("decode forwards import response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected forwards import code 0, got %d (%s)", out.Code, out.Msg)
}
after := mustQueryNodePorts(t, r, `SELECT node_id, port FROM forward_port WHERE forward_id = ? ORDER BY id ASC`, forwardID)
if len(after) != len(expected) {
t.Fatalf("expected %d forward ports after import, got %d (%v)", len(expected), len(after), after)
}
for nodeID, port := range expected {
if got, ok := after[nodeID]; !ok || got != port {
t.Fatalf("expected forward_port node=%d port=%d after import, got %v", nodeID, port, after)
}
}
})
t.Run("backup export tolerates nullable legacy tunnel chain fields", func(t *testing.T) {
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "legacy-null-chain", 1.0, 1, "tls", 1000, now, now, 1, nil, 1).Error; err != nil {
t.Fatalf("seed tunnel for nullable chain export: %v", err)
}
tunnelID := mustLastInsertID(t, r, "legacy-null-chain")
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, ?, ?, ?, ?, ?, ?)
`, tunnelID, "1", 1, nil, nil, nil, nil).Error; err != nil {
t.Fatalf("seed nullable chain_tunnel row: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/export", bytes.NewBufferString(`{"types":["tunnels"]}`))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", resp.Code)
}
var payload struct {
Version string `json:"version"`
Tunnels []struct {
ID int64 `json:"id"`
ChainTunnels []struct {
Inx int `json:"inx"`
Strategy string `json:"strategy"`
Protocol string `json:"protocol"`
} `json:"chainTunnels"`
} `json:"tunnels"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatalf("decode tunnels backup payload: %v", err)
}
if strings.TrimSpace(payload.Version) == "" {
t.Fatalf("expected backup payload version, got empty")
}
found := false
for _, tunnel := range payload.Tunnels {
if tunnel.ID != tunnelID {
continue
}
if len(tunnel.ChainTunnels) != 1 {
t.Fatalf("expected one chain tunnel for seeded tunnel %d, got %d", tunnelID, len(tunnel.ChainTunnels))
}
if tunnel.ChainTunnels[0].Inx != 0 {
t.Fatalf("expected nullable chain inx to export as 0, got %d", tunnel.ChainTunnels[0].Inx)
}
if tunnel.ChainTunnels[0].Strategy != "" {
t.Fatalf("expected nullable chain strategy to export as empty string, got %q", tunnel.ChainTunnels[0].Strategy)
}
if tunnel.ChainTunnels[0].Protocol != "" {
t.Fatalf("expected nullable chain protocol to export as empty string, got %q", tunnel.ChainTunnels[0].Protocol)
}
found = true
break
}
if !found {
t.Fatalf("expected seeded tunnel %d in backup export", tunnelID)
}
})
}
func setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *sqlite.Repository) {
type backupExportPayload struct {
Version string `json:"version"`
ExportedAt int64 `json:"exportedAt"`
Configs map[string]string `json:"configs"`
}
type backupImportPayload struct {
Types []string `json:"types"`
backupExportPayload
}
func exportBackupPayload(t *testing.T, router http.Handler, path, token string) backupExportPayload {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(`{"types":["configs"]}`))
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("expected status 200 on %s, got %d", path, resp.Code)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read backup payload from %s: %v", path, err)
}
var payload backupExportPayload
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("decode backup payload from %s: %v", path, err)
}
if strings.TrimSpace(payload.Version) == "" {
var out response.R
if err := json.Unmarshal(body, &out); err == nil {
t.Fatalf("expected backup payload on %s, got envelope code=%d msg=%q", path, out.Code, out.Msg)
}
t.Fatalf("expected non-empty backup payload version on %s, body=%s", path, string(body))
}
if payload.Configs == nil {
t.Fatalf("expected configs map in backup payload on %s", path)
}
return payload
}
func setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *repo.Repository) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "contract.db")
repo, err := sqlite.Open(dbPath)
r, err := repo.Open(dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
_ = repo.Close()
_ = r.Close()
})
h := handler.New(repo, jwtSecret)
return httpserver.NewRouter(h, jwtSecret), repo
h := handler.New(r, jwtSecret)
return httpserver.NewRouter(h, jwtSecret), r
}
func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {
@@ -276,15 +666,15 @@ func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {
t.Fatalf("seed legacy node row: %v", err)
}
repo, err := sqlite.Open(dbPath)
r, err := repo.Open(dbPath)
if err != nil {
t.Fatalf("open migrated sqlite: %v", err)
}
t.Cleanup(func() {
_ = repo.Close()
_ = r.Close()
})
nodes, err := repo.ListNodes()
nodes, err := r.ListNodes()
if err != nil {
t.Fatalf("list nodes after migration: %v", err)
}
@@ -292,7 +682,7 @@ func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {
t.Fatalf("expected 1 node after migration, got %d", len(nodes))
}
columns := readTableColumns(t, repo.DB(), "node")
columns := readTableColumns(t, r.DB(), "node")
for _, required := range []string{"server_ip_v4", "server_ip_v6", "inx"} {
if !columns[required] {
@@ -300,34 +690,28 @@ func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {
}
}
tunnelColumns := readTableColumns(t, repo.DB(), "tunnel")
tunnelColumns := readTableColumns(t, r.DB(), "tunnel")
if !tunnelColumns["inx"] {
t.Fatalf("expected tunnel column %q to exist after migration", "inx")
}
}
func readTableColumns(t *testing.T, db *store.DB, table string) map[string]bool {
func readTableColumns(t *testing.T, db *gorm.DB, table string) map[string]bool {
t.Helper()
rows, err := db.Query("PRAGMA table_info(" + table + ")")
columnTypes, err := db.Migrator().ColumnTypes(table)
if err != nil {
t.Fatalf("inspect %s columns: %v", table, err)
}
defer rows.Close()
columns := map[string]bool{}
for rows.Next() {
var cid, notNull, pk int
var name, typ string
var defaultValue sql.NullString
if err := rows.Scan(&cid, &name, &typ, &notNull, &defaultValue, &pk); err != nil {
t.Fatalf("scan %s pragma row: %v", table, err)
for _, col := range columnTypes {
name := strings.TrimSpace(col.Name())
if name == "" {
continue
}
columns[name] = true
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate %s pragma rows: %v", table, err)
}
return columns
}
@@ -0,0 +1,109 @@
package contract_test
import (
"database/sql"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strconv"
"strings"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"go-backend/internal/auth"
httpserver "go-backend/internal/http"
"go-backend/internal/http/handler"
"go-backend/internal/store/repo"
)
func TestPostgresNodeCreateRepairsMissingIDDefaultContract(t *testing.T) {
baseDSN := strings.TrimSpace(os.Getenv("FLVX_POSTGRES_TEST_DSN"))
if baseDSN == "" {
t.Skip("set FLVX_POSTGRES_TEST_DSN to run postgres contract tests")
}
schemaName := "contract_node_id_" + strconv.FormatInt(time.Now().UnixNano(), 36)
adminDB, err := sql.Open("pgx", baseDSN)
if err != nil {
t.Fatalf("open postgres admin connection: %v", err)
}
t.Cleanup(func() {
_, _ = adminDB.Exec(`DROP SCHEMA IF EXISTS "` + schemaName + `" CASCADE`)
_ = adminDB.Close()
})
if _, err := adminDB.Exec(`CREATE SCHEMA "` + schemaName + `"`); err != nil {
t.Fatalf("create schema %s: %v", schemaName, err)
}
testDSN, err := withSearchPath(baseDSN, schemaName)
if err != nil {
t.Fatalf("build schema dsn: %v", err)
}
r, err := repo.OpenPostgres(testDSN)
if err != nil {
t.Fatalf("open postgres repository: %v", err)
}
if err := r.DB().Exec(`ALTER TABLE node ALTER COLUMN id DROP DEFAULT`).Error; err != nil {
_ = r.Close()
t.Fatalf("drop node.id default to simulate drift: %v", err)
}
if err := r.Close(); err != nil {
t.Fatalf("close repository before reopen: %v", err)
}
r, err = repo.OpenPostgres(testDSN)
if err != nil {
t.Fatalf("reopen postgres repository: %v", err)
}
t.Cleanup(func() {
_ = r.Close()
})
columnDefault := mustQueryNullString(t, r, `
SELECT column_default
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'node'
AND column_name = 'id'
LIMIT 1
`)
if !columnDefault.Valid || !strings.Contains(strings.ToLower(columnDefault.String), "nextval(") {
t.Fatalf("expected node.id default to be nextval(...), got %q", columnDefault.String)
}
jwtSecret := "postgres-contract-secret"
router := httpserver.NewRouter(handler.New(r, jwtSecret), jwtSecret)
token, err := auth.GenerateToken(1, "admin_user", 0, jwtSecret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
body := strings.NewReader(`{"name":"pg-repair-node","serverIp":"10.77.0.10"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/node/create", body)
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertCode(t, resp, 0)
nodeID := mustQueryInt64(t, r, `SELECT id FROM node WHERE name = ? ORDER BY id DESC LIMIT 1`, "pg-repair-node")
if nodeID <= 0 {
t.Fatalf("expected positive node id, got %d", nodeID)
}
}
func withSearchPath(dsn, schema string) (string, error) {
u, err := url.Parse(dsn)
if err != nil {
return "", err
}
q := u.Query()
q.Set("search_path", schema)
u.RawQuery = q.Encode()
return u.String(), nil
}
@@ -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
}
@@ -25,18 +25,13 @@ func TestTunnelCreateRuntimeRollbackContract(t *testing.T) {
}
insertNode := func(name, ip, portRange string) int64 {
res, err := repo.DB().Exec(`
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
if err != nil {
`, name, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("get node id %s: %v", name, err)
}
return id
return mustLastInsertID(t, repo, name)
}
entryID := insertNode("create-entry", "10.20.0.1", "30000-30010")
@@ -62,18 +57,12 @@ func TestTunnelCreateRuntimeRollbackContract(t *testing.T) {
t.Fatalf("expected node-related error, got %q", out.Msg)
}
var tunnelCount int
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM tunnel WHERE name = ?`, "runtime-rollback-tunnel").Scan(&tunnelCount); err != nil {
t.Fatalf("count tunnel: %v", err)
}
tunnelCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE name = ?`, "runtime-rollback-tunnel")
if tunnelCount != 0 {
t.Fatalf("expected tunnel rollback, found %d records", tunnelCount)
}
var chainCount int
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM chain_tunnel`).Scan(&chainCount); err != nil {
t.Fatalf("count chain_tunnel: %v", err)
}
chainCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM chain_tunnel`)
if chainCount != 0 {
t.Fatalf("expected chain_tunnel rollback, found %d records", chainCount)
}
@@ -90,35 +79,26 @@ func TestTunnelUpdateAssignsChainPortsContract(t *testing.T) {
}
insertNode := func(name, ip, portRange string) int64 {
res, err := repo.DB().Exec(`
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
if err != nil {
`, name, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("get node id %s: %v", name, err)
}
return id
return mustLastInsertID(t, repo, name)
}
entryID := insertNode("update-entry", "10.30.0.1", "40000-40010")
chainID := insertNode("update-chain", "10.30.0.2", "41000-41010")
exitID := insertNode("update-exit", "10.30.0.3", "42000-42010")
tunnelRes, err := repo.DB().Exec(`
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "update-port-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0)
if err != nil {
`, "update-port-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID, err := tunnelRes.LastInsertId()
if err != nil {
t.Fatalf("get tunnel id: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "update-port-tunnel")
payload := `{"id":` + jsonInt(tunnelID) + `,"name":"update-port-tunnel","type":2,"flow":99999,"trafficRatio":1.0,"status":1,"inNodeId":[{"nodeId":` + jsonInt(entryID) + `,"protocol":"tls"}],"chainNodes":[[{"nodeId":` + jsonInt(chainID) + `,"protocol":"tls","strategy":"round"}]],"outNodeId":[{"nodeId":` + jsonInt(exitID) + `,"protocol":"tls"}]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewBufferString(payload))
@@ -129,21 +109,23 @@ func TestTunnelUpdateAssignsChainPortsContract(t *testing.T) {
router.ServeHTTP(res, req)
assertCode(t, res, 0)
var chainPort int
if err := repo.DB().QueryRow(`SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 2 LIMIT 1`, tunnelID).Scan(&chainPort); err != nil {
t.Fatalf("query chain port: %v", err)
}
chainPort := mustQueryInt(t, repo, `SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 2 LIMIT 1`, tunnelID)
if chainPort <= 0 {
t.Fatalf("expected chain node port to be assigned, got %d", chainPort)
}
var outPort int
if err := repo.DB().QueryRow(`SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 3 LIMIT 1`, tunnelID).Scan(&outPort); err != nil {
t.Fatalf("query out port: %v", err)
}
outPort := mustQueryInt(t, repo, `SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 3 LIMIT 1`, tunnelID)
if outPort <= 0 {
t.Fatalf("expected out node port to be assigned, got %d", outPort)
}
entryStrategy := mustQueryNullString(t, repo, `SELECT strategy FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 1 LIMIT 1`, tunnelID)
if !entryStrategy.Valid || strings.TrimSpace(entryStrategy.String) == "" {
t.Fatalf("expected entry strategy to be non-null and non-empty")
}
if entryStrategy.String != "round" {
t.Fatalf("expected entry strategy round, got %q", entryStrategy.String)
}
}
func jsonInt(v int64) string {
@@ -0,0 +1,263 @@
package contract_test
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
func TestTunnelCreateWithIPPreferenceContract(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)
}
insertDualStackNode := func(name, v4, v6, portRange string) int64 {
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, name+"-secret", v4, v4, v6, portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
return mustLastInsertID(t, repo, name)
}
entryID := insertDualStackNode("ip-pref-entry", "10.50.0.1", "2001:db8::1", "50000-50010")
exitID := insertDualStackNode("ip-pref-exit", "10.50.0.2", "2001:db8::2", "51000-51010")
for _, tc := range []struct {
name string
preference string
}{
{"v4-preference", "v4"},
{"v6-preference", "v6"},
{"empty-preference", ""},
} {
t.Run(tc.name, func(t *testing.T) {
payload := `{"name":"tunnel-` + tc.name + `","type":2,"flow":99999,"status":1,"ipPreference":"` + tc.preference + `","inNodeId":[{"nodeId":` + jsonInt(entryID) + `,"protocol":"tls"}],"chainNodes":[],"outNodeId":[{"nodeId":` + jsonInt(exitID) + `,"protocol":"tls"}]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewBufferString(payload))
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)
}
stored, err := tryQueryString(t, repo, `SELECT COALESCE(ip_preference, '') FROM tunnel WHERE name = ?`, "tunnel-"+tc.name)
if err != nil {
if err == sql.ErrNoRows {
t.Skipf("tunnel not created (nodes offline), skipping DB verification")
}
t.Fatalf("query ip_preference: %v", err)
}
if stored != tc.preference {
t.Fatalf("expected ip_preference=%q in DB, got %q", tc.preference, stored)
}
})
}
}
func TestTunnelUpdateIPPreferenceContract(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)
}
insertDualStackNode := func(name, v4, v6, portRange string) int64 {
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, name+"-secret", v4, v4, v6, portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
return mustLastInsertID(t, repo, name)
}
entryID := insertDualStackNode("upd-entry", "10.60.0.1", "2001:db8:1::1", "60000-60010")
exitID := insertDualStackNode("upd-exit", "10.60.0.2", "2001:db8:1::2", "61000-61010")
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "update-ip-pref-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0, "").Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "update-ip-pref-tunnel")
payload := `{"id":` + jsonInt(tunnelID) + `,"name":"update-ip-pref-tunnel","type":2,"flow":99999,"trafficRatio":1.0,"status":1,"ipPreference":"v6","inNodeId":[{"nodeId":` + jsonInt(entryID) + `,"protocol":"tls"}],"chainNodes":[],"outNodeId":[{"nodeId":` + jsonInt(exitID) + `,"protocol":"tls"}]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewBufferString(payload))
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)
}
stored := mustQueryString(t, repo, `SELECT COALESCE(ip_preference, '') FROM tunnel WHERE id = ?`, tunnelID)
if stored != "v6" {
t.Fatalf("expected ip_preference='v6' after update, got %q", stored)
}
}
func TestTunnelListReturnsIPPreferenceContract(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)
}
err = repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "list-ip-pref-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0, "v6").Error
if err != nil {
t.Fatalf("insert tunnel: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/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 (msg=%s)", out.Code, out.Msg)
}
tunnels, ok := out.Data.([]interface{})
if !ok || len(tunnels) == 0 {
t.Fatalf("expected non-empty tunnel list, got %v", out.Data)
}
found := false
for _, raw := range tunnels {
tm, ok := raw.(map[string]interface{})
if !ok {
continue
}
if tm["name"] == "list-ip-pref-tunnel" {
found = true
pref, _ := tm["ipPreference"].(string)
if pref != "v6" {
t.Fatalf("expected ipPreference='v6' in list response, got %q", pref)
}
break
}
}
if !found {
t.Fatal("tunnel 'list-ip-pref-tunnel' not found in list response")
}
}
func TestIPPreferenceColumnDefaultContract(t *testing.T) {
_, repo := setupContractRouter(t, "contract-jwt-secret")
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "no-pref-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel without ip_preference: %v", err)
}
stored := mustQueryString(t, repo, `SELECT COALESCE(ip_preference, '') FROM tunnel WHERE name = ?`, "no-pref-tunnel")
if stored != "" {
t.Fatalf("expected default ip_preference='', got %q", stored)
}
}
func TestIPPreferenceColumnMigrationContract(t *testing.T) {
_, repo := setupContractRouter(t, "contract-jwt-secret")
colCount := mustQueryInt(t, repo, `SELECT COUNT(*) FROM pragma_table_info('tunnel') WHERE name = 'ip_preference'`)
if colCount != 1 {
t.Fatalf("expected ip_preference column to exist in tunnel table, found %d", colCount)
}
}
func TestIPPreferenceCoalesceNullSafety(t *testing.T) {
_, repo := setupContractRouter(t, "contract-jwt-secret")
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, ip_preference)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
`, "null-pref-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Skipf("DB does not allow NULL ip_preference (NOT NULL constraint): %v", err)
}
stored := mustQueryString(t, repo, `SELECT COALESCE(ip_preference, '') FROM tunnel WHERE name = ?`, "null-pref-tunnel")
if stored != "" {
t.Fatalf("COALESCE should convert NULL to empty string, got %q", stored)
}
}
func TestDualStackNodeIPFieldsStoredContract(t *testing.T) {
_, repo := setupContractRouter(t, "contract-jwt-secret")
now := time.Now().UnixMilli()
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "ds-verify-node", "ds-secret", "10.70.0.1", "10.70.0.1", "2001:db8:2::1", "70000-70010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert dual-stack node: %v", err)
}
v4, v6 := mustQueryTwoNullStrings(t, repo, `SELECT server_ip_v4, server_ip_v6 FROM node WHERE name = ?`, "ds-verify-node")
if !v4.Valid || v4.String != "10.70.0.1" {
t.Fatalf("expected server_ip_v4='10.70.0.1', got %v", v4)
}
if !v6.Valid || v6.String != "2001:db8:2::1" {
t.Fatalf("expected server_ip_v6='2001:db8:2::1', got %v", v6)
}
}
func TestIPPreferenceValidValuesContract(t *testing.T) {
_, repo := setupContractRouter(t, "contract-jwt-secret")
now := time.Now().UnixMilli()
for _, pref := range []string{"", "v4", "v6"} {
name := "valid-pref-" + pref
if pref == "" {
name = "valid-pref-empty"
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, 1.0, 2, "tls", 99999, now, now, 1, nil, 0, pref).Error; err != nil {
t.Fatalf("insert tunnel with ip_preference=%q: %v", pref, err)
}
stored := mustQueryString(t, repo, `SELECT COALESCE(ip_preference, '') FROM tunnel WHERE name = ?`, name)
if stored != pref {
t.Fatalf("expected ip_preference=%q, got %q for %s", pref, stored, name)
}
}
}
@@ -1,4 +1,4 @@
package contract
package contract_test
import (
"encoding/json"
@@ -13,51 +13,46 @@ 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(`
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, 'normal_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now); err != nil {
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
insertTunnel := func(name string, status int, inx int64) int64 {
res, err := repo.DB().Exec(`
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, 1.0, 1, "tls", 99999, now, now, status, nil, inx)
if err != nil {
`, name, 1.0, 1, "tls", 99999, now, now, status, nil, inx).Error; err != nil {
t.Fatalf("insert tunnel %s: %v", name, err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("get tunnel id %s: %v", name, err)
}
return id
return mustLastInsertID(t, repo, name)
}
enabledA := insertTunnel("enabled-A", 1, 1)
enabledB := insertTunnel("enabled-B", 1, 2)
disabledC := insertTunnel("disabled-C", 0, 3)
if _, err := repo.DB().Exec(`
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(?, ?, NULL, ?, ?, 0, 0, ?, ?, ?)
`, 2, enabledA, 100, 1000, 1, 2727251700000, 0); err != nil {
`, 2, enabledA, 100, 1000, 1, 2727251700000, 0).Error; err != nil {
t.Fatalf("insert user_tunnel enabledA: %v", err)
}
if _, err := repo.DB().Exec(`
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(?, ?, NULL, ?, ?, 0, 0, ?, ?, ?)
`, 2, enabledB, 100, 1000, 1, 2727251700000, 1); err != nil {
`, 2, enabledB, 100, 1000, 1, 2727251700000, 1).Error; err != nil {
t.Fatalf("insert user_tunnel enabledB: %v", err)
}
if _, err := repo.DB().Exec(`
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(?, ?, NULL, ?, ?, 0, 0, ?, ?, ?)
`, 2, disabledC, 100, 1000, 1, 2727251700000, 1); err != nil {
`, 2, disabledC, 100, 1000, 1, 2727251700000, 1).Error; err != nil {
t.Fatalf("insert user_tunnel disabledC: %v", err)
}
@@ -131,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
}
+12 -5
View File
@@ -1,6 +1,6 @@
# GO-GOST SERVICE KNOWLEDGE BASE
**Generated:** Mon Feb 02 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,14 +19,21 @@ 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/`.
## COMMANDS
```bash
+14 -11
View File
@@ -1,36 +1,39 @@
# GO-GOST/X KNOWLEDGE BASE
## OVERVIEW
Local fork of `github.com/go-gost/x` used by `go-gost/` via `replace github.com/go-gost/x => ./x`. Most protocol/runtime behavior changes happen here.
Local fork of `github.com/go-gost/x` used by `go-gost/` via `replace github.com/go-gost/x => ./x`. Most protocol/runtime behavior changes happen here. 30+ top-level packages - framework-style layout.
## 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.
- Generated gRPC/proto code lives under `go-gost/x/internal/util/grpc/proto/`.
- Handlers/listeners/dialers follow consistent pattern: `{type}.go` + `metadata.go` per protocol.
- OS-specific code uses `name_[os].go` suffix (e.g., `tun_linux.go`, `tun_darwin.go`).
## ANTI-PATTERNS
- Do not edit generated files in `go-gost/x/internal/util/grpc/proto/` (`*.pb.go`, `*_grpc.pb.go`).
@@ -39,4 +42,4 @@ go-gost/x/
```bash
cd go-gost/x
go test ./...
```
```
+39
View File
@@ -0,0 +1,39 @@
# GOST CONNECTOR KNOWLEDGE BASE
**Generated:** Fri Feb 13 2026
## OVERVIEW
Connection initiators (clients) for various protocols in GOST forwarding.
**Stack:** Go, GOST core.
## STRUCTURE
```
connector/
├── direct/ # Direct connection
├── forward/ # Forward proxy
├── http/ # HTTP connector
├── http2/ # HTTP/2 connector
├── relay/ # Relay protocol
├── router/ # Router connector
├── serial/ # Serial port
├── sni/ # SNI routing
├── socks/ # SOCKS4/5
├── ss/ # Shadowsocks
├── sshd/ # SSH daemon
├── tcp/ # TCP connector
├── tunnel/ # Tunnel mode
└── unix/ # Unix socket
```
## CONVENTIONS
- Inherits from parent `go-gost/x/` conventions.
- Each subdir implements `Connector` interface from GOST core.
## ANTI-PATTERNS
- DO NOT EDIT generated protobuf in `go-gost/x/internal/util/grpc/proto/`.
## COMMANDS
```bash
cd go-gost
go test ./x/connector/...
```
+9 -7
View File
@@ -54,10 +54,10 @@ var needWrap = false
// SetProtocolBlock sets protocol blocking switches and recomputes wrapper need
func SetProtocolBlock(httpOn int, tlsOn int, socksOn int) {
isHttp = httpOn
isTls = tlsOn
isSocks = socksOn
needWrap = isTls+isSocks+isHttp > 0
isHttp = httpOn
isTls = tlsOn
isSocks = socksOn
needWrap = isTls+isSocks+isHttp > 0
}
type Option func(opts *options)
@@ -292,7 +292,9 @@ func (s *defaultService) Serve() error {
}
if err := s.handler.Handle(ctx, conn); err != nil {
log.Error(err)
if !errors.Is(err, net.ErrClosed) {
log.Error(err)
}
if v := xmetrics.GetCounter(xmetrics.MetricServiceHandlerErrorsCounter,
metrics.Labels{"service": s.name, "client": clientIP}); v != nil {
v.Inc()
@@ -403,12 +405,12 @@ func (s *defaultService) observeStats(ctx context.Context) {
TotalErrs: st.Get(stats.KindTotalErrs),
},
}
// 将流量累积到全局管理器,而不是立即上报
if outputBytes > 0 || inputBytes > 0 {
globalManager := GetGlobalTrafficManager()
globalManager.AddTraffic(s.name, int64(outputBytes), int64(inputBytes))
// 立即重置流量计数(因为已经记录到全局管理器中)
if xstats, ok := st.(*xstats.Stats); ok {
xstats.ResetTraffic(st.Get(stats.KindInputBytes)-inputBytes, st.Get(stats.KindOutputBytes)-outputBytes)
+38
View File
@@ -0,0 +1,38 @@
# GOST SOCKET KNOWLEDGE BASE
**Generated:** Sun Feb 15 2026
## OVERVIEW
WebSocket reporter and socket utilities for panel integration.
**Stack:** Go, GOST core, gorilla/websocket.
## STRUCTURE
```
socket/
├── websocket_reporter.go # Agent-to-panel telemetry (1504 LOC)
├── service.go # Socket service orchestration (534 LOC)
├── socket.go # Core socket interface
├── udp.go # UDP socket handling
├── packet.go # Packet framing
└── packetconn.go # Packet connection wrapper
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| **Panel Reporting** | `websocket_reporter.go` | Real-time system info (CPU, mem, uptime) every 2s |
| **Command Handling** | `websocket_reporter.go` | Processes `AddService`, `UpgradeAgent`, etc. |
## CONVENTIONS
- Inherits from parent `go-gost/x/` conventions.
- Low-level network primitives.
- All panel communication is AES-encrypted using node `secret`.
## ANTI-PATTERNS
- DO NOT EDIT generated protobuf.
## COMMANDS
```bash
cd go-gost
go test ./x/socket/...
```
+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:
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-17
@@ -0,0 +1,29 @@
## Context
FLVX is a distributed system consisting of a central management panel (Backend + Frontend) and multiple forwarding agents (Nodes). The backend manages configuration, users, and billing, while agents handle the actual traffic forwarding using a modified GOST v3 stack. Communication between the panel and agents is secured and synchronized.
## Goals / Non-Goals
**Goals:**
- Document the high-level architecture of the system.
- Describe the data model for users, tunnels, and nodes.
- Explain the communication protocol between Panel and Agent.
- Detail the authentication and authorization mechanisms.
**Non-Goals:**
- Refactoring the existing architecture.
- Detailed code-level documentation of every function.
- Changing the database schema.
## Decisions
- **Architecture**: The system follows a client-server model where the Panel acts as the server and Agents act as clients that pull configuration and push status.
- **Data Model**: Core entities are Users, Nodes (Agents), Tunnels (Groups of rules), and Forwarding Rules.
- **Communication**: Agents use a heartbeat mechanism to report status and fetch configuration updates. The protocol uses AES encryption with a pre-shared key (Node Secret).
- **Authentication**: JWT for Frontend-Backend communication; API Key (Node Secret) for Agent-Backend communication.
## Risks / Trade-offs
- **Security**: The security of the agent communication relies heavily on the secrecy of the Node Secret.
- **Scalability**: Centralized management might become a bottleneck with a very large number of agents.
- **Complexity**: Synchronizing state across distributed agents introduces complexity in handling failures and inconsistencies.
@@ -0,0 +1,28 @@
## Why
The current system lacks formal specification documents describing its capabilities. This makes it difficult for new developers to understand the intended behavior and for existing developers to ensure consistency when adding new features. Documenting the existing functionality will serve as a baseline for future changes and help in identifying gaps or inconsistencies.
## What Changes
- Create formal specification documents for core system capabilities.
- Document user management features (roles, limits).
- Document tunnel and forwarding management (protocols, rules).
- Document agent interactions and management.
- Document system-level configurations.
## Capabilities
### New Capabilities
- `user-management`: Authentication, user roles, and resource limits.
- `tunnel-management`: Creation and management of traffic tunnels (TCP/UDP).
- `forwarding-rules`: Configuration of port forwarding and tunnel forwarding rules, including rate limiting.
- `agent-management`: Management of forwarding agents, including installation and configuration synchronization.
- `system-config`: Global system settings and configurations.
### Modified Capabilities
<!-- None, as this is a documentation effort for existing features. -->
## Impact
- **Documentation**: New spec files in `openspec/specs/`.
- **No Code Changes**: This change is purely documentation-focused.
@@ -0,0 +1,29 @@
## ADDED Requirements
### Requirement: Agent Registration
The system SHALL require new agents (Nodes) to register using a unique node key/secret.
#### Scenario: Node Connection
- **WHEN** a new agent starts up with a valid configuration
- **THEN** it connects to the backend and is registered as active.
### Requirement: Heartbeat Monitoring
The system SHALL monitor the status of all registered agents using periodic heartbeats.
#### Scenario: Agent Status
- **WHEN** an agent sends periodic heartbeats
- **THEN** the system updates its last-seen timestamp and marks it as online.
### Requirement: Configuration Sync
The system MUST synchronize configuration changes (tunnels, rules) to agents securely and reliably.
#### Scenario: Push Config
- **WHEN** a configuration change is made in the panel
- **THEN** the agent receives the updated configuration via the next heartbeat or push mechanism.
### Requirement: Version Management
The system SHOULD track the version of the agent software running on each node.
#### Scenario: Version Reporting
- **WHEN** an agent connects
- **THEN** it reports its version number to the backend for tracking.
@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: Port Forwarding Rules
The system SHALL support configuring port forwarding rules, defining the listening port on the node and the destination IP/port.
#### Scenario: Rule Configuration
- **WHEN** an admin creates a port forwarding rule
- **THEN** the rule is stored and synchronized to the assigned node.
### Requirement: Rate Limiting
The system SHALL support configuring bandwidth rate limits for tunnels and users.
#### Scenario: Bandwidth Restriction
- **WHEN** a rate limit is applied to a user
- **THEN** their total bandwidth usage does not exceed the specified limit across all their tunnels.
### Requirement: Traffic Accounting
The system MUST track incoming and outgoing traffic volume for each tunnel and user for billing and quota enforcement.
#### Scenario: Traffic Calculation
- **WHEN** traffic flows through a tunnel
- **THEN** the system increments the user's traffic usage counter accurately.
@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: Site Settings
The system SHALL allow customization of the site title, logo, and other branding elements.
#### Scenario: Update Branding
- **WHEN** an administrator changes the site logo
- **THEN** the new logo is displayed across the interface.
### Requirement: Notification Settings
The system SHALL support configuring notifications for user registration, traffic limits, and other events.
#### Scenario: User Limit Alert
- **WHEN** a user approaches their traffic quota
- **THEN** a notification is sent to the user/admin.
### Requirement: Backup & Restore
The system SHOULD provide a mechanism to backup and restore database configurations.
#### Scenario: Restore Database
- **WHEN** initiating a restore operation
- **THEN** the system accepts a valid backup file and overwrites the current database state.
@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: Tunnel Creation
The system SHALL allow administrators to create tunnels, specifying protocols (TCP, UDP), listening ports, and destination endpoints.
#### Scenario: Create TCP Tunnel
- **WHEN** an admin creates a new TCP tunnel configuration
- **THEN** the backend stores the tunnel definition and assigns it to a node.
### Requirement: Tunnel Forwarding Configuration
The system SHALL support both standard port forwarding (listening on a port and forwarding to a destination) and tunnel forwarding modes.
#### Scenario: Configure Port Forwarding
- **WHEN** configuring a tunnel for port forwarding
- **THEN** traffic arriving at the specified port is forwarded to the destination IP:port.
### Requirement: Tunnel Assignment
The system SHALL allow tunnels to be assigned to specific users, tracking their usage against the user's quota.
#### Scenario: User Tunnel Usage
- **WHEN** a user is assigned a tunnel
- **THEN** traffic passing through that tunnel is accounted for under the user's usage.
@@ -0,0 +1,29 @@
## ADDED Requirements
### Requirement: User Registration
The system SHALL allow new users to register an account with a username and password.
#### Scenario: Successful Registration
- **WHEN** a user submits valid registration details
- **THEN** a new user account is created and the user can log in.
### Requirement: User Authentication
The system MUST authenticate users using JWT tokens. The `Authorization` header MUST contain the raw token without a `Bearer` prefix.
#### Scenario: Valid Login
- **WHEN** a user provides correct credentials
- **THEN** the system returns a valid JWT token.
### Requirement: Role Management
The system SHALL support different user roles, specifically Administrator and Regular User, with distinct permissions.
#### Scenario: Admin Access
- **WHEN** an administrator logs in
- **THEN** they have access to system-wide settings and all user management functions.
### Requirement: Resource Quotas
The system SHALL allow administrators to set traffic limits and connection limits for individual users.
#### Scenario: Traffic Limit Enforcement
- **WHEN** a user exceeds their traffic quota
- **THEN** the system prevents further traffic forwarding for that user.
@@ -0,0 +1,30 @@
## 1. User Management Verification
- [ ] 1.1 Verify User Registration logic in backend
- [ ] 1.2 Verify JWT Authentication implementation
- [ ] 1.3 Verify Role Management checks
- [ ] 1.4 Verify Quota Enforcement logic
## 2. Tunnel Management Verification
- [ ] 2.1 Verify Tunnel Creation API
- [ ] 2.2 Verify Forwarding Configuration parsing
- [ ] 2.3 Verify Tunnel Assignment logic
## 3. Forwarding Rules Verification
- [ ] 3.1 Verify Port Forwarding rule processing
- [ ] 3.2 Verify Rate Limiting implementation (token bucket/leaky bucket?)
- [ ] 3.3 Verify Traffic Accounting mechanisms
## 4. Agent Management Verification
- [ ] 4.1 Verify Agent Registration handshake
- [ ] 4.2 Verify Heartbeat processing
- [ ] 4.3 Verify Config Sync protocol
## 5. System Config Verification
- [ ] 5.1 Verify Site Settings API
- [ ] 5.2 Verify Notification triggers
- [ ] 5.3 Verify Backup/Restore functionality
+20
View File
@@ -0,0 +1,20 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours
+52
View File
@@ -0,0 +1,52 @@
# Project Overview
**Name**: FLVX (Flux Panel)
**Description**: Traffic forwarding management system built on a forked GOST v3 stack. It provides a web-based panel for managing traffic tunnels, users, and forwarding rules.
**Repository**: Monorepo containing Admin API, Web UI, and Forwarding Agent.
## Tech Stack
### Backend (`go-backend/`)
- **Language**: Go
- **Database**: SQLite (default), PostgreSQL (supported)
- **Framework**: Standard library `net/http` (no heavy framework)
- **ORM**: None (Raw SQL via `database/sql`)
### Frontend (`vite-frontend/`)
- **Framework**: React
- **Build Tool**: Vite (using `rolldown-vite` experimental bundler)
- **UI Library**: HeroUI
- **Styling**: Tailwind CSS
- **Mode**: Hybrid (Desktop + Mobile WebView support)
### Agent (`go-gost/`)
- **Language**: Go
- **Base**: Fork of `gost` v3
- **Extensions**: Custom extensions in `go-gost/x/`
### Infrastructure
- **Containerization**: Docker, Docker Compose (v4/v6)
- **CI/CD**: GitHub Actions
- **Installers**: Shell scripts (`panel_install.sh`, `install.sh`)
## Architecture
- **Panel**: Central management server (Go Backend + React Frontend).
- **Agent**: Forwarding node running on remote servers.
- **Communication**:
- Frontend -> Backend: REST API (JWT Auth, raw token in header).
- Agent -> Backend: AES-encrypted heartbeat/config sync.
## Conventions
- **Authentication**: `Authorization` header expects raw JWT token (do NOT add `Bearer ` prefix).
- **API Response**: Standard envelope `{code, msg, data, ts}` (code 0 = success).
- **Database**: Backend uses raw SQL queries. Do not introduce an ORM.
- **File Structure**: Flat monorepo with language-prefixed directories (`go-backend`, `go-gost`).
- **Protobuf**: Do not edit generated `.pb.go` files manually.
## Development
- **Backend Build**: `cd go-backend && make build`
- **Frontend Dev**: `cd vite-frontend && npm run dev`
- **Agent Run**: `cd go-gost && go run .`
+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
}'
```
+4
View File
@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}

Some files were not shown because too many files have changed in this diff Show More