Compare commits

...

179 Commits

Author SHA1 Message Date
sagit 32e338d295 fix: include missing field in repository (#358)
Included missing field in repository.
2026-03-20 22:20:15 +08:00
sagitchu f6a753baa3 fix: include missing field in repository 2026-03-20 22:18:45 +08:00
sagit 322a10bb9d chore: release 2.1.9-beta7 (#357)
Sync current changes to 2.1.9-beta7.
2026-03-20 22:18:17 +08:00
sagitchu 27c13d6c47 chore: release 2.1.9-beta7 2026-03-20 22:15:51 +08:00
sagit f45f96063a fix: hide remote nodes from monitor view (#356)
Filter out nodes with is_remote=1 from the monitor API endpoint, as per
user requirement.
2026-03-20 18:42:58 +08:00
sagitchu 1780be73b9 fix(monitor): hide remote nodes from monitor view 2026-03-20 18:41:31 +08:00
sagit addf8e2089 feat: implement Uptime Kuma style historic quality bars for tunnel monitor (#355) 2026-03-20 14:17:16 +08:00
sagit 75cd60ea3e feat(ui): update list view tables styling for Node, Monitor, User, Tunnel, and Limit (#354) 2026-03-20 13:15:15 +08:00
sagit fe42a77409 feat: periodic tunnel quality probing (#353)
Implement tunnel quality metrics collection and display
2026-03-20 12:38:08 +08:00
sagitchu ce9abf457f fix(frontend): recharts Legend export and implicit any in monitoring 2026-03-20 12:36:48 +08:00
sagitchu 3c57a5ac84 feat: periodic tunnel quality probing and monitoring 2026-03-20 12:34:09 +08:00
sagit ff7c91d277 chore: optimize agent-panel metrics communication (#352) 2026-03-20 03:39:49 +00:00
sagit 1498f3052d feat: align node and user list view UI (#351)
Match monitor page list view style per user request.
2026-03-20 11:03:01 +08:00
sagitchu 6b1264ae90 feat: align node and user list view UI 2026-03-20 11:01:37 +08:00
sagit 18445ec063 feat(ui): add list view for node and user pages (#350)
add list view feature
2026-03-20 10:02:11 +08:00
sagitchu 08bc91e5c9 feat(ui): add list view for node and user pages 2026-03-20 10:00:56 +08:00
sagit 2f424bea31 feat: monitor tunnel top level (#347)
Merge chore/force-update sync
2026-03-19 15:46:02 +08:00
sagitchu bc75ed745d feat: monitor tunnel top level 2026-03-19 15:44:25 +08:00
sagit 78fb9a31d6 chore: Delete openspec documentation, AI agent configurations, and development plans. (#346) 2026-03-19 06:22:51 +00:00
sagitchu d3ed2e8856 chore: Delete openspec documentation, AI agent configurations, and development plans. 2026-03-19 14:16:07 +08:00
sagit db21ce6bb4 feat: implement monitor page list view (#345) 2026-03-19 13:54:12 +08:00
sagit 76ad841231 chore: release 2.1.9-alpha5 (#344)
Release 2.1.9-alpha5
2026-03-19 11:49:43 +08:00
sagitchu d0535707dc chore: release 2.1.9-alpha5 and update project knowledge base 2026-03-19 11:48:03 +08:00
sagit 6458b5af00 feat: beautify monitor tab and improve user page (#343) 2026-03-18 22:46:02 +08:00
sagit 555039e028 feat: monitor page redesign and node card cleanup (#340) 2026-03-18 18:57:23 +08:00
sagit 7134253b2c feat: redesign monitor view (#339)
Redesign monitor view
2026-03-18 17:40:19 +08:00
sagitchu 58d29b440a fix(ci): remove unused variables to fix TS build 2026-03-18 17:39:09 +08:00
sagitchu 6a3a9add08 feat: redesign monitor view 2026-03-18 17:21:39 +08:00
sagitchu 6d986524f1 fix: remaining changes in forward 2026-03-18 15:51:27 +08:00
sagitchu 92a8fed796 feat: redesign monitor page to nezha-style server grid 2026-03-18 15:48:48 +08:00
sagit b314192621 feat(monitoring): add node/tunnel metrics, service monitors, and health checks (#331)
## Summary
- Add comprehensive monitoring system with
NodeMetric/TunnelMetric/ServiceMonitor models
- Implement metrics ingestion service with per-minute bucket aggregation
and upsert support
- Add health checker for node connectivity monitoring with configurable
intervals
- Wire node metrics from WebSocket SystemInfo messages to metrics
service
- Add tunnel metrics ingestion from flow upload endpoint with
transaction support
- Create monitoring REST API endpoints for nodes, tunnels, and services
- Implement service monitor CRUD and execution (TCP/ICMP health checks)
- Add MonitorPermission model for non-admin access control to monitoring
features
- Create frontend monitor page with node/tunnel/service views
- Include schema migration (v6) for tunnel_metric unique index and
deduplication
- Fix tunnel entry port conflict validation to use transaction (Tx
variants)
2026-03-18 15:12:22 +08:00
sagit 1e5f9bfb04 Merge branch 'main' into opencode/shiny-falcon 2026-03-18 14:17:06 +08:00
sagitchu 5972378897 fix: resolve merge conflicts and fix monitoring bugs
- Add missing 'uptime' field to NodeMetricApiItem type definition
- Fix WS message handling: non-UpgradeProgress typed messages now
  broadcast via broadcastInfo instead of being silently dropped
- Strengthen looksLikeSystemInfoMessage heuristic to require ≥3
  matching keys to avoid false positives
- Fix tab/space indentation inconsistency in admin.tsx useEffect
- Remove duplicate method declarations from merge (repository_control,
  mutations)
- Update tunnel_entry_sqlite_test to use renamed Tx suffix function
2026-03-18 14:12:47 +08:00
sagitchu 455900ba41 Merge branch 'main' into opencode/shiny-falcon
# Conflicts:
#	go-backend/internal/http/handler/mutations.go
#	go-backend/tests/contract/issue313_entry_port_conflict_contract_test.go
2026-03-18 14:09:00 +08:00
sagit 85e57213ee chore: update knowledge base metadata for release 2.1.8 (#337)
Updating AGENTS.md with new release version and current commit hash.
2026-03-18 13:52:28 +08:00
sagitchu 1377061234 chore: update knowledge base metadata for release 2.1.8 2026-03-18 13:49:41 +08:00
sagit ea21a7deef fix(user): improve tunnel selector contrast in dark mode (#336) 2026-03-18 04:10:32 +00:00
sagit 9b98194a0a feat(tunnel): add delete rule resolution settings (#335)
- Add backend API for tunnel delete rule resolution (allow, deny, confirm)
- Add contract tests for delete resolution endpoint
- Add frontend API types and endpoints for delete resolution
- Add tunnel delete resolution settings UI with resolution mode selector
- Support per-tunnel and global delete resolution configuration
2026-03-18 10:05:03 +08:00
sagit 2df061a19f fix(backend): resolve SQLite deadlock in tunnel entry updates (#334)
- Fix deadlock when updating tunnel entries with offline nodes
- Add test file for tunnel entry SQLite operations
- Update contract tests for entry port conflict and limiter sync
- Add plan documents for SQLite deadlock fix and contract semantics
2026-03-18 09:00:20 +08:00
sagitchu 46a60376c4 Merge remote-tracking branch 'origin/main' into opencode/shiny-falcon
# Conflicts:
#	vite-frontend/src/pages/node.tsx
#	vite-frontend/src/pages/user.tsx
2026-03-17 15:18:25 +08:00
sagitchu 9de240f034 feat(monitoring): add node/tunnel metrics, service monitors, and health checks
- Add NodeMetric/TunnelMetric/ServiceMonitor models and repository methods
- Implement metrics ingestion service with per-minute bucket aggregation
- Add health checker for node connectivity monitoring
- Wire node metrics from WebSocket SystemInfo messages
- Add tunnel metrics ingestion from flow upload endpoint
- Create monitoring REST API endpoints for nodes, tunnels, services
- Implement service monitor CRUD and execution (TCP/ICMP checks)
- Add MonitorPermission for non-admin access control
- Create frontend monitor page with node/tunnel/service views
- Add tunnel metrics ingestion from agent flow reports
- Include schema migration for tunnel_metric unique index
- Fix tunnel entry port conflict validation to use transaction

Entire-Checkpoint: 030821a7c8e3
2026-03-17 14:59:09 +08:00
sagit 41ef814643 fix(forward): make force-delete work with offline nodes
Bypass DeleteService in force-delete and remove forward records directly, allowing deletion when nodes are offline.
2026-03-16 14:15:28 +00:00
sagit 6c7b4817f9 fix(forward): stabilize selection in non-compact grouped view
Prevent cascading checkbox toggles and scope select-all per tunnel group; update grouped styling to neutral gray.
2026-03-16 12:04:29 +00:00
sagit 7507507fd9 fix(forward): show all users by default in non-compact mode
Restore 2.1.8-beta9 admin default filtering when compact mode is off; keep compact mode focused on self.
2026-03-16 10:06:41 +00:00
sagit ff94406945 feat(node): restore info button popover style for remark/renewal info (#327)
Restore the 2.1.8-beta9 style of displaying node remark and renewal
info via an info button (ℹ️) in the CardHeader with a hover popover,
instead of inline display in the CardBody.

- Add infoPopoverPlacement state and updateInfoPopoverPlacement callback
- Add info button with hover popover showing expiry reminder and remark
- Restore drag handle with touch support and responsive visibility
- Remove inline info display from CardBody
2026-03-16 11:42:23 +08:00
sagit 9aa13c4dfb fix: remove tunnel name from card view (#326)
## Summary
- Remove tunnel name display from card view since it's already shown in
the group header
2026-03-16 10:59:18 +08:00
sagitchu 4a8c400944 fix: remove tunnel name and ratio from card view (shown in group header) 2026-03-16 10:57:56 +08:00
sagit 18e7ec94a8 fix: restore copy functionality for entry/target fields in non-compact table view and hide redundant tunnel name in grouped card view (#325) 2026-03-16 02:36:06 +00:00
sagit 02f2a1c8b3 fix: add missing renderCard prop to SortableForwardCard in non-compact card view (#324)
## Summary
- Fix TypeError "renderCard is not a function" when using non-compact
card view mode on the rules page
- The `SortableForwardCard` component was missing the required
`renderCard` prop in the non-compact grouped view
2026-03-16 09:28:11 +08:00
sagitchu 681a0bef48 fix: add missing renderCard prop to SortableForwardCard in non-compact card view 2026-03-16 09:26:42 +08:00
sagit 67bf5be0f2 Restore removed features and keep UI improvements (#322) 2026-03-15 23:35:25 +08:00
sagitchu efb613b0b5 Fix TypeScript compilation errors
- Add isIndeterminate support to Checkbox component
- Use showAddressModal in SortableTableRow for multi-address display
- Remove unused onClose parameter in search modal
- Remove unused infoPopoverPlacement and related code in node.tsx
2026-03-15 23:31:39 +08:00
sagitchu 8124e59de5 Roll back port column separation in forward table
- Merge entry address:port into single '入口' column
- Merge target address:port into single '目标' column
- Remove separate port columns from compact table
- Keep UI improvements: always show checkbox, selection highlight
2026-03-15 23:12:48 +08:00
sagitchu ac8c293ff3 Document forward.tsx refactoring review results 2026-03-15 23:01:29 +08:00
sagitchu 4e38b73cac Keep UI improvements: Modal styles, expiryReminderDismissed, BatchActionResultModal 2026-03-15 22:51:09 +08:00
sagitchu 8f336377f6 Revert user page search bar to inline implementation for consistency 2026-03-15 22:28:44 +08:00
sagitchu 5f78dd66fc Update plan: all restoration tasks completed 2026-03-15 22:12:51 +08:00
sagitchu f2ee939006 Restore BatchActionResultModal usage in tunnel.tsx 2026-03-15 22:12:33 +08:00
sagitchu 23d2060742 Restore BatchActionResultModal usage in forward.tsx
- Add BatchOperationFailure type import
- Add BatchActionResultModal component import
- Add BatchResultModalState interface and empty state constant
- Add batchResultModal state
- Add presentBatchOutcome callback for unified batch operation result handling
- Update handleBatchDelete to use presentBatchOutcome
- Add BatchActionResultModal rendering at end of component
2026-03-15 21:59:26 +08:00
sagitchu bb0da0b769 Restore version badge and FLVX branding 2026-03-15 21:33:35 +08:00
sagitchu e51af4be1f Revert backend address description to main version 2026-03-15 21:18:11 +08:00
sagitchu a82f3a75b0 Update plan document for PR #322 restoration 2026-03-15 21:08:11 +08:00
sagitchu 7d07fe08b7 Restore removed features from PR #322
- Add back BatchOperationFailure type and batch failure handling functions
- Add back dismissNodeExpiryReminder API endpoint
- Add back update channel selection UI in config page
- Keep simplified version display in version-footer.tsx
2026-03-15 21:07:08 +08:00
abai569ok 375877b223 2.1.8-beta1.7 2026-03-15 20:33:53 +08:00
sagit 004daeadb6 fix: add retry logic for tunnel chain and federation middle-hop failover (#321) 2026-03-15 11:13:46 +08:00
sagit 3bcb80d7a2 feat: use DatePicker for expiry time and add ExpiryReminderDismissed migration (#320)
## Summary
- Replace datetime-local input with DatePicker component for expiry time
selection
- Add ExpiryReminderDismissed field to SQLite migration column check
list
2026-03-14 12:38:04 +08:00
sagitchu 5ff9621227 feat: use DatePicker for expiry time and add ExpiryReminderDismissed migration
Entire-Checkpoint: af1825430330
2026-03-14 12:36:26 +08:00
sagit 84db9711bc fix: preserve scroll position after editing forward rules (#319)
* fix(dialog): prevent both open and close auto focus to avoid page scroll

Add onOpenAutoFocus handler to prevent Radix Dialog from auto-focusing
content on open, which can cause unwanted scroll behavior.

* fix(dialog): remove onOpenAutoFocus, keep only onCloseAutoFocus

Remove onOpenAutoFocus handler that was causing scroll issues on first open.
Keep onCloseAutoFocus to prevent scroll to trigger element on close.

Key fix: Move {...props} before onCloseAutoFocus to prevent override.

* fix(dialog): prevent scroll to top on modal close

- Move {...props} before onCloseAutoFocus to prevent override
- Simplify handler to just e.preventDefault()
- Apply fix to both dialog.tsx and modal.tsx

* fix: preserve scroll position after editing forward rules

Move getForwardDisplayFlow outside component to prevent unnecessary
re-renders that reset scroll position on save.
2026-03-14 12:14:15 +08:00
sagit 2f97e892d5 Merge branch 'main' into opencode/gentle-mountain 2026-03-14 12:12:55 +08:00
sagitchu 17fd1e4ad4 fix: preserve scroll position after editing forward rules
Move getForwardDisplayFlow outside component to prevent unnecessary
re-renders that reset scroll position on save.
2026-03-14 12:10:32 +08:00
sagit e56dd898ef fix(dialog): prevent scroll to top on modal close (#318)
* fix(dialog): prevent both open and close auto focus to avoid page scroll

Add onOpenAutoFocus handler to prevent Radix Dialog from auto-focusing
content on open, which can cause unwanted scroll behavior.

* fix(dialog): remove onOpenAutoFocus, keep only onCloseAutoFocus

Remove onOpenAutoFocus handler that was causing scroll issues on first open.
Keep onCloseAutoFocus to prevent scroll to trigger element on close.

Key fix: Move {...props} before onCloseAutoFocus to prevent override.

* fix(dialog): prevent scroll to top on modal close

- Move {...props} before onCloseAutoFocus to prevent override
- Simplify handler to just e.preventDefault()
- Apply fix to both dialog.tsx and modal.tsx
2026-03-14 02:32:35 +00:00
sagitchu 06bb8b3b04 fix(dialog): prevent scroll to top on modal close
- Move {...props} before onCloseAutoFocus to prevent override
- Simplify handler to just e.preventDefault()
- Apply fix to both dialog.tsx and modal.tsx
2026-03-14 10:29:22 +08:00
sagitchu 42a775c3bb fix(dialog): remove onOpenAutoFocus, keep only onCloseAutoFocus
Remove onOpenAutoFocus handler that was causing scroll issues on first open.
Keep onCloseAutoFocus to prevent scroll to trigger element on close.

Key fix: Move {...props} before onCloseAutoFocus to prevent override.
2026-03-14 10:12:21 +08:00
sagit 05c3b5842e fix(dialog): prevent both open and close auto focus to avoid page scroll (#317)
Add onOpenAutoFocus handler to prevent Radix Dialog from auto-focusing
content on open, which can cause unwanted scroll behavior.
2026-03-14 09:38:09 +08:00
sagitchu 149e10ee66 fix(dialog): prevent both open and close auto focus to avoid page scroll
Add onOpenAutoFocus handler to prevent Radix Dialog from auto-focusing
content on open, which can cause unwanted scroll behavior.
2026-03-14 09:36:39 +08:00
sagit e194813f3b fix(modal): prevent auto focus restore on close to avoid page scroll (#316)
* fix(dialog): prevent auto focus restore on close to avoid page scroll

When a modal is closed, Radix Dialog by default restores focus to the
trigger element. This causes the page to scroll to that element if it's
not in the viewport, leading to unexpected scrolling behavior after
editing forwards or other items.

Entire-Checkpoint: 78b48b74a841

* fix(modal): prevent auto focus restore on close in ModalContent

Ensure onCloseAutoFocus is applied after props spread to prevent override.
This fixes page scroll to top after closing modal on second edit.
2026-03-13 17:35:18 +08:00
sagit f1cad30f44 fix(dialog): prevent auto focus restore on close to avoid page scroll (#315)
When a modal is closed, Radix Dialog by default restores focus to the
trigger element. This causes the page to scroll to that element if it's
not in the viewport, leading to unexpected scrolling behavior after
editing forwards or other items.

Entire-Checkpoint: 78b48b74a841
2026-03-13 17:04:54 +08:00
sagit 2e05df288b fix(tunnel): validate entry port conflicts before adding new entry nodes (#314)
* fix(tunnel): validate entry port conflicts before adding new entry nodes

- Add validateTunnelEntryPortConflictsForNewEntries to check cross-tunnel
  port conflicts when adding new entry nodes to a tunnel
- Move validation before tx.Commit() to prevent partial success state
- Add contract test for issue #313 regression
- Update panel backend address description to note CDN/HTTPS support

Entire-Checkpoint: eb85eb0c9f2a

* fix: use single quotes to escape Chinese quotation marks in description
2026-03-13 14:24:18 +08:00
sagit 3e5bb8fc0b feat(batch): add failure details to batch operations with expandable result modal (#312)
## Summary
- Backend: Return per-item failure details (id, name, reason) for all
batch operations (delete/pause/resume/redeploy/change-tunnel) on
forwards and tunnels
- Frontend: Add `BatchActionResultModal` component to display failures
in an expandable list
- Add contract tests for batch action failure details
2026-03-13 10:22:15 +08:00
sagitchu d1e3c59537 feat(batch): add failure details to batch operations with expandable result modal
- Backend: return per-item failure details (id, name, reason) for all batch operations
- Frontend: add BatchActionResultModal component to display failures
- Support delete/pause/resume/redeploy/change-tunnel for forwards and tunnels
2026-03-13 10:20:36 +08:00
sagit 8b8ebb6092 refactor(quota): migrate traffic quota from tunnel to user level (#311)
## Summary

- Replace tunnel-level traffic quota with user-level quota system
- Add `user_quota` table with daily/monthly limits and usage tracking
- Remove `tunnel_quota` table and related code
- Update user create/update API to accept quota configuration
- Migrate backup/restore to use user quota fields
- Update frontend to manage user quota instead of tunnel quota

## Test Plan

- [x] Backend unit tests pass
- [x] Contract tests updated for user quota
- [ ] Manual verification of quota enforcement
2026-03-12 14:54:14 +08:00
sagitchu 0195a2a01b refactor(tunnel): remove unused editingTunnel variable 2026-03-12 14:51:36 +08:00
sagitchu ad9b336fb9 refactor(quota): migrate traffic quota from tunnel to user level
- Replace tunnel_quota table with user_quota table
- Add user-level daily/monthly quota tracking and enforcement
- Update user CRUD to include quota configuration
- Migrate backup/restore to use user quota fields
- Update frontend API and UI for user quota management
2026-03-12 14:17:57 +08:00
sagitchu 30d9552207 fix(backend): release old port listeners on tunnel switch
Delete stale forward services on old/kept entry nodes during tunnel changes so ports are freed and rebinds don't hit address-in-use.
2026-03-12 10:55:53 +08:00
sagit 5e96a8de72 feat(quota): add tunnel traffic quota with daily/monthly limits (#291) (#308)
Implement per-tunnel traffic quota feature:
- Add TunnelQuota model with daily/monthly usage tracking
- Integrate quota enforcement into flow accumulation path
- Pause forwards and disable tunnel when quota exceeded
- Block new forward creation/resume when tunnel quota disabled
- Auto-reset daily/monthly windows at 00:05 via maintenance job
- Add manual reset API endpoint for admins
- Include quota config in tunnel backup/restore
- Add frontend UI for quota settings and usage display

Entire-Checkpoint: e629b27ca437
2026-03-11 16:09:03 +08:00
sagit 69faeaa9a6 fix(backend): clean stale forward runtimes on entry updates (#307)
Entire-Checkpoint: 6a6b91fb5f0c
2026-03-11 05:53:31 +00:00
sagit e8bfe52104 fix(backend): sync forward ports when tunnel entry changes (#304)
## What
- When a tunnel's entry node set changes, automatically rebuild all
forwards' `forward_port` rows under that tunnel to match the latest
entry nodes.
- Preserves existing forward port (uses current min port from
`forward_port`).
- Preserves `in_ip` only for single-entry tunnels; clears it for
multi-entry tunnels.

## Why
Forward runtime dispatch is keyed by `forward_port`.
If a tunnel entry node goes offline or is removed, existing forwards
could remain mapped to stale entry nodes and become impossible to
redeploy cleanly.

## Notes
- Plan doc: `plans/028-tunnel-entry-change-sync-forward-ports.md`

Refs #285
2026-03-11 11:25:20 +08:00
sagit 9767cc3247 Merge branch 'main' into fix/issue-285-sync-forward-ports 2026-03-11 11:24:19 +08:00
sagit e8a7f999c8 fix(node): keep info popover above sidebar (#306)
## What
- Raise the node-card info popover z-index so it renders above the left
sidebar.

## Why
- The sidebar uses a higher stacking context (z-50), causing the popover
(z-30) to be covered when it opens to the left.

## Notes
- Verified: `vite-frontend` `npm run build`.

Closes #305
2026-03-11 11:24:02 +08:00
sagitchu 4d4f5f8b1f fix(node): keep info popover above sidebar
Entire-Checkpoint: ea4d99cf1c43
2026-03-11 11:21:44 +08:00
sagitchu d2a425d761 fix(backend): sync forward ports on tunnel entry change 2026-03-11 11:19:53 +08:00
sagit 673d38a089 fix(backend): enforce traffic quota on forwards (#303)
## What
- Block creating/resuming forwards when user or user_tunnel traffic
quota is exceeded (or expired/disabled).
- Keep paused forwards paused after service sync (UpdateService restarts
services on agent side).

## Why
Traffic limit could be bypassed by manually resuming/creating forwards
after quota is exceeded.

## Tests
- (cd go-backend && go test ./...)

Closes #295
2026-03-11 09:38:25 +08:00
sagitchu 2e8c0530a9 fix(backend): block forwards when flow exceeded 2026-03-11 09:34:36 +08:00
sagit 32ee511eac fix(node): compact card metadata for mobile (#300)
## Summary
- move secondary node metadata into a compact info dropdown so cards fit
better on smaller screens
- replace the full connection status chip in the header with a status
dot while keeping detailed status inside the info panel
- keep remarks and renewal metadata accessible without permanently
increasing card height

## Testing
- not run
2026-03-10 20:13:11 +08:00
sagitchu f410640862 fix: calculate info popover placement relative to card container
Changed info popover position calculation to use the card container
as reference instead of viewport, ensuring consistent placement within
card boundaries.
2026-03-10 20:11:49 +08:00
sagitchu 6427b830ea refactor(node): improve card metadata popup with hover trigger
Entire-Checkpoint: 7a033056acc0
2026-03-10 17:19:21 +08:00
sagitchu 5e7bf3ba5c fix(node): compact card metadata for mobile
Entire-Checkpoint: b3c79b0fd2f9
2026-03-10 09:48:11 +08:00
sagit 27d6691232 feat: 支持节点 wss/https 并在失败时回退 ws(兼容旧 ws) (#294)
## 变更说明

本 PR 聚焦节点与面板通信协议兼容性增强:

- 支持节点优先使用 `wss`(WebSocket over TLS)连接后端
- 保持原有 `ws` 连接逻辑完全兼容,不破坏现有节点
- 当 `wss` 握手失败时自动回退到 `ws`
- HTTP 上报链路支持 `https/http` 自动识别与回退
- 兼容旧格式地址输入,避免已有配置失效

## 兼容性

- 旧节点配置(`ws`)可继续正常工作
- 新场景可直接使用 `wss/https`(含 CDN 场景)
- 回退策略可避免因 TLS/CDN 配置差异导致节点离线

## 额外说明

- 本 PR 仅包含协议兼容与回退相关改动
- 不包含安装脚本仓库指向调整相关提交
2026-03-09 18:21:25 +08:00
sagit cc4b8a916a Merge branch 'main' into pr/wss-https-fallback 2026-03-09 18:20:07 +08:00
sagit d9dd5131b2 feat(node): split node page into local and remote sections (#292)
## Summary
- split node page into local/remote sections (tab switch)
- keep per-tab search and count
- keep sorting/actions behavior under the selected section
- include remote usage detail rendering improvements on remote cards

## Scope
- only touches `vite-frontend/src/pages/node.tsx`

## Verify
- `cd vite-frontend && npm install && npm run build` passed locally

## Notes
- this PR intentionally avoids unrelated layout/sidebar customizations
and keeps changes focused on node page UX
2026-03-09 17:56:58 +08:00
sagitchu a98c9f4f59 merge(main): resolve node page conflicts with renewal UX updates 2026-03-09 17:52:18 +08:00
sagit 5cb935e0e5 fix(frontend): refine node renewal UI and filters (#299)
* style: restore Prettier formatting in renewal.ts

* fix(frontend): refine node renewal UI and filters

Entire-Checkpoint: 88c8ae47fb7d

* refactor(nodes): remove unused tags field from node model

* refactor(node): improve card layout and reorder elements

Entire-Checkpoint: fb89f3ebef5c
2026-03-09 08:37:57 +00:00
sagit 6f59e4be0c Merge branch 'main' into pr/wss-https-fallback 2026-03-09 12:38:47 +08:00
sagitchu 647446a2a2 fix(node): isolate tab search and selection state 2026-03-09 12:17:25 +08:00
sagit 42d6249af5 style: restore Prettier formatting in renewal.ts (#298) 2026-03-09 10:29:33 +08:00
sagit de9ab51def feat: node management enhancements and UX improvements (#297)
* feat(nodes): add renewal cycle and auto-advance scheduling

- Add renewal_cycle field to nodes for tracking paid vs free cycles
- Implement auto-advance scheduling when renewal is processed
- Add migration test for renewal_cycle column
- Update dashboard to show renewal cycle count
- Add renewal status display on node detail page

* fix(frontend): show rule count in compact view mode for forward pages

* style(frontend): format code and improve node card layout

Entire-Checkpoint: 08b760b76538

* fix: restore renewal.ts from main to resolve build errors
2026-03-09 10:01:55 +08:00
qimaoww a2ec08f033 fix(tunnel): restore upstream ipv6 address normalization logic 2026-03-09 03:33:40 +08:00
qimaoww f8809d73fb Merge branch 'main' into pr/wss-https-fallback 2026-03-08 22:38:26 +08:00
sagit 413081f72a feat(nodes): add renewal cycle and auto-advance scheduling (#296)
- Add renewal_cycle field to nodes for tracking paid vs free cycles
- Implement auto-advance scheduling when renewal is processed
- Add migration test for renewal_cycle column
- Update dashboard to show renewal cycle count
- Add renewal status display on node detail page
2026-03-08 22:05:25 +08:00
qimaoww e5339a8072 chore: improve websocket wss->ws fallback diagnostics 2026-03-08 20:07:37 +08:00
qimaoww fbb4d82a44 feat: add wss/https auto-detect with fallback for node-backend comm 2026-03-08 20:07:37 +08:00
sagit 508a37a84c feat(nodes): add node metadata and expiry reminders (#293)
## Summary

- 为节点新增备注、标签、到期时间字段,并贯通后端存储、导入导出与前端 API
- 节点管理页支持编辑、搜索、筛选并高亮即将到期或已过期节点
- Dashboard 为管理员新增节点到期提醒卡片,集中展示 7 天内到期和已过期节点

## Changes

### Backend
- `node/create` 和 `node/update` 支持 `remark`、`tags`、`expiryTime`
- 节点列表接口返回新增字段
- 节点导入导出保留备注、标签与到期时间

### Frontend
- 节点页面新增备注、标签、到期时间表单项
- 节点列表支持按备注/标签搜索,并支持到期状态筛选
- 节点卡片展示备注、标签、到期时间以及过期提醒样式
- Dashboard 管理员视图新增节点到期提醒模块

## Plans

- `plans/021-node-remarks-tags-expiry.md`
- `plans/022-node-expiry-highlights-dashboard-reminders.md`

## Issue

Closes #246
2026-03-08 20:02:36 +08:00
sagitchu d60655045a feat(forward): display tunnel traffic ratio on forward page 2026-03-08 20:00:26 +08:00
sagitchu 31ef861504 feat(nodes): add node metadata and expiry reminders (#246)
Entire-Checkpoint: d8b429492cbe
2026-03-08 19:44:46 +08:00
sagitchu f1bdb2e2ef feat(frontend): implement AJAX no-refresh UX improvements (#276)
- Add dashboard auto-refresh with 5s polling and visibility-aware pause
- Harden node realtime reconnection with exponential backoff and polling fallback
- Implement local state patching for forward/tunnel/user mutations
- Add batch operation progress feedback UI
- Add shared list-state helpers for replace/remove operations
- Preserve derived UI state during server payload merges

Closes #276

Entire-Checkpoint: 7d6188355d7f
2026-03-08 19:15:02 +08:00
Su-cyber-art 61b71a11c7 feat(node): split node page into local and remote sections 2026-03-08 18:45:56 +08:00
Su-cyber-art 4c69ff491d feat(node): render remote usage details on remote node cards
(cherry picked from commit 67294fa76422f994de8d95ec88f8f03e02ea6b9f)
2026-03-08 18:42:40 +08:00
Su-cyber-art 0ad4904e20 feat(node): split local and remote nodes into separate tabs
(cherry picked from commit 30793b2997040dd29ff5fe6397d0bcab174ecc9e)
2026-03-08 18:42:36 +08:00
sagit bd30b61018 fix(backend): migrate PostgreSQL traffic columns from int4 to bigint (#290)
## Summary

- Widens legacy PostgreSQL traffic/quota columns from `integer` to
`bigint` to prevent int4 overflow
- Fixes federation share creation failure when traffic limits exceed 2GB
(e.g., `536870912000` bytes = 500GB)
- Bumps schema version from 4 to 5 with auto-migration on backend
startup

## Affected Tables

- `user`: `flow`, `in_flow`, `out_flow`
- `forward`: `in_flow`, `out_flow`
- `statistics_flow`: `flow`, `total_flow`
- `tunnel`: `flow`
- `user_tunnel`: `flow`, `in_flow`, `out_flow`
- `peer_share`: `max_bandwidth`, `current_flow`

## Test Plan

- ✅ Unit tests added for migration execution and error handling
- ✅ Contract tests passed
- ✅ Repository tests passed

Commands:
```bash
cd go-backend && go test ./internal/store/repo/...
cd go-backend && go test ./tests/contract/...
```
2026-03-08 11:50:14 +08:00
sagitchu e0dd70a054 fix(backend): migrate PostgreSQL traffic columns from int4 to bigint
Widens legacy PostgreSQL traffic/quota columns to BIGINT to prevent
int4 overflow when storing large values like 536870912000 (500GB).

Affected tables:
- user (flow, in_flow, out_flow)
- forward (in_flow, out_flow)
- statistics_flow (flow, total_flow)
- tunnel (flow)
- user_tunnel (flow, in_flow, out_flow)
- peer_share (max_bandwidth, current_flow)

Schema version bumped from 4 to 5 with auto-migration on startup.
2026-03-08 11:48:36 +08:00
sagit 4966a8aad1 fix(backend): sync user tunnel status and relax forward speedId permission check (#288)
## Summary

- Return actual `user_tunnel.status` in admin permission list instead of
hardcoded enabled state (1)
- Allow non-admin users to update forwards when keeping the same
`speedId` selection
- Add contract tests for user tunnel status mapping and forward
permission edge case

## Test Plan

- [x] Contract tests pass: `cd go-backend && go test
./tests/contract/...`
- [x] User tunnel permission list returns correct status values
(enabled/disabled)
- [x] Non-admin users can update forward details when keeping existing
speedId
2026-03-08 00:57:57 +08:00
sagitchu 3e11549370 fix(backend): sync user tunnel status and relax forward speedId permission check
- Return actual user_tunnel.status in admin permission list instead of hardcoded 1
- Allow non-admin users to update forwards when keeping the same speedId selection
- Add contract tests for user tunnel status mapping and forward permission edge case

Entire-Checkpoint: deb90fb942ee
2026-03-08 00:56:13 +08:00
sagit addf83a249 fix(ui): improve date input parsing and add missing back navigation (#284)
## Summary
- Improve DatePicker text parsing to accept more input formats
(including `YYYYMMDD`) without requiring explicit separators
- Add missing H5 back-navigation for panel-sharing by using simple
layout route
- Add a back button on config page with history fallback (`navigate(-1)`
then `/profile`)

## Why
These are usability/accessibility improvements that are generic and not
project-brand specific:
- Mobile keyboards may not easily input `-` in date fields
- Certain pages in H5 lacked a consistent return path
- Config page had no explicit in-page back action

## Scope
- `vite-frontend/src/shadcn-bridge/heroui/date-picker.tsx`
- `vite-frontend/src/App.tsx`
- `vite-frontend/src/pages/config.tsx`

## Notes
No branding/identity/ownership/visual-theme customizations included in
this PR.
2026-03-07 18:07:07 +08:00
sagitchu c3e35fd416 fix(ui): tighten date parsing and back navigation
Entire-Checkpoint: fea62b38d8c7
2026-03-07 18:02:00 +08:00
sagit 775dfe19f1 Merge branch 'main' into fix/upstream-friendly-ui-3pack 2026-03-07 17:36:29 +08:00
sagit db3b2f651b fix: improve bind-conflict detection and forward cleanup reliability (#287)
## Summary
- Normalize whitespace in bind-conflict error messages to handle
collapsed variants (e.g., "address alreadyin use")
- Update forward cleanup to delete all service name variants (_tcp,
_udp, base) instead of stopping after first success
- Add comprehensive test coverage for edge cases with missing-space
error variants

## Test plan
- ✅ Unit tests: `cd go-backend && go test ./internal/http/handler/...`
- ✅ Contract tests: `cd go-backend && go test ./tests/contract/... -run
'TestForwardUpdateRecoversFromAddressInUseContract|TestTunnelUpdateRecoversFromAddressInUseContract'`
2026-03-07 17:21:00 +08:00
sagitchu 669323f926 fix: improve bind-conflict detection and forward cleanup reliability
- Normalize whitespace in error messages to handle collapsed variants (e.g., 'address alreadyin use')
- Delete all forward service name variants (_tcp, _udp, base) during cleanup instead of stopping after first success
- Add comprehensive test coverage for edge cases
2026-03-07 17:19:28 +08:00
𝓐𝓵𝓽𝓲𝓸𝓷 7202b69e4e Merge branch 'main' into fix/upstream-friendly-ui-3pack 2026-03-07 17:00:31 +08:00
sagit 31977a62e6 fix: add retry mechanism for tunnel service bind conflicts (#286)
## Summary
- Add retry logic for tunnel service creation/update when encountering
"address already in use" bind errors
- Automatically cleanup stale service and retry once before failing
- Add comprehensive unit and contract tests for bind conflict scenarios

## Changes
- `mutations.go`: Add `retryTunnelServiceAddWithCleanup` helper and
`addTunnelServiceOnNode` wrapper
- `control_plane_test.go`: Unit tests for retry behavior on address
conflicts
- `dual_stack_test.go`: Test fallback to node listen address
- `forward_contract_test.go`: Contract test for forward update with bind
retry
- `limiter_sync_failure_contract_test.go`: Contract test for tunnel
update with bind retry
- `plans/016-tunnel-runtime-bind-conflict-retry.md`: Implementation plan
document

## Test Plan
- Unit tests verify retry logic executes correctly
- Contract tests validate end-to-end behavior with mock nodes
- All tests pass with race detector enabled
2026-03-07 16:23:13 +08:00
sagitchu 87479c2ac1 fix: add retry mechanism for tunnel service bind conflicts
When tunnel services encounter 'address already in use' errors during
creation/update, automatically cleanup and retry once instead of failing
immediately. This handles race conditions during rapid tunnel reconfiguration.

Entire-Checkpoint: 39e6fb9de836
2026-03-07 16:21:44 +08:00
Su-cyber-art ffda0fb71a fix(ui): improve date input parsing and add missing back navigation 2026-03-07 14:19:23 +08:00
sagit 9c0e7341c3 feat: add helpful hints for form fields in tunnel and node management (#279)
Entire-Checkpoint: 20cc01a9700d
2026-03-06 08:21:19 +00:00
sagit 1db5452be9 fix: add forward port occupancy validation and runtime residual cleanup (#278)
* fix: tolerate service not found during forward deletion

- Refactor deleteForwardServicesOnNode to handle not-found errors gracefully
- Extract deleteForwardServiceCandidates helper for reuse
- Add tests for not-found tolerance scenarios
- Ensures compatibility with legacy node versions

Entire-Checkpoint: a3bacf836c57

* fix: add forward port occupancy validation and runtime residual cleanup

- Add forward port occupancy validation on create/update paths
- Extend self-occupy recovery to clean residual candidate service names
- Add regression tests for address-in-use recovery with legacy runtime residue

Fixes port conflict issues when upgrading from 2.1.6 to later versions

Entire-Checkpoint: fb0a2aee4cb5
2026-03-06 10:57:07 +08:00
sagit c10f894afd fix: tolerate service not found during forward deletion (#277)
- Refactor deleteForwardServicesOnNode to handle not-found errors gracefully
- Extract deleteForwardServiceCandidates helper for reuse
- Add tests for not-found tolerance scenarios
- Ensures compatibility with legacy node versions

Entire-Checkpoint: a3bacf836c57
2026-03-06 09:03:28 +08:00
sagit 7fb75baa73 feat: allow user custom inport with range validation (#274)
## Summary
- Allow users to specify custom inlet ports within a defined range
- Add port range validation for tunnel configurations
- Implement UI controls for custom port selection
- Add contract tests for custom port functionality
2026-03-05 17:04:58 +08:00
sagitchu 15e6cd69eb feat: allow user custom inport with range validation
Entire-Checkpoint: fcd76aac10e9
2026-03-05 17:03:19 +08:00
sagit f6eb88d75e refactor: 统一术语,将'转发'改为'规则' (#273)
- 更新所有页面中的'转发'术语为'规则'
- 统一UI文案,提升用户体验一致性
- 关联 #269 #271

Entire-Checkpoint: 5646e42aa33b
2026-03-05 15:07:42 +08:00
sagit f45b580984 fix: prevent effect execution when forwards list is empty (#272)
## Summary
- 添加空列表检查,防止在forwards为空时执行effect
- 避免不必要的groupOrder状态更新和持久化操作
2026-03-05 14:09:19 +08:00
sagitchu 4f50c47550 fix: prevent effect execution when forwards list is empty
Entire-Checkpoint: 69eeade13bf9
2026-03-05 14:07:25 +08:00
sagit 2e1d75dc36 fix: add self-healing for forward service name migration (#270)
## Summary
- Fix `service not found` errors when upgrading from older versions
where service names migrated from placeholder IDs (`forward_user_0`) to
real `user_tunnel_id`
- Add fallback cleanup+rebuild logic on `UpdateService` when service not
found during upgrade transition
- Add self-healing retry on `Pause/Resume` operations when all service
variants are missing
- Refactor `controlForwardServicesOnNode` to support unit testing
- Add tests for the new helper functions

## Test plan
- [x] Unit tests pass: `cd go-backend && go test
./internal/http/handler/...`

## Upgrade path
1. Deploy this backend patch first (no need to wait for all agents)
2. Gradually upgrade agents in batches (10-20%)
3. Run "forward batch redeploy" after each batch to unify service naming
4. Monitor logs for `service .* not found` errors
2026-03-05 12:42:04 +08:00
sagitchu f496f58a4d fix: add self-healing for forward service name migration
When upgrading from older versions, service names changed from
placeholder IDs (forward_user_0) to real user_tunnel IDs, causing
service not found errors during control operations.

- Add fallback cleanup+rebuild logic on UpdateService when service
  not found during the upgrade transition period.
- Add self-healing retry on Pause/Resume when all variants are missing.
- Refactor controlForwardServicesOnNode to support unit testing.
- Add tests for shouldSelfHealForwardServiceControl and
  controlForwardServiceCommand helper functions.

Entire-Checkpoint: a7f0c3175d06
2026-03-05 12:40:20 +08:00
sagit 32474bec20 fix: resolve user tunnel early to use real ID in service name (#265)
## Summary
- Fix service name generation to use the actual user_tunnel ID instead
of 0
- Move user tunnel resolution before building service base name
- Add `buildForwardServiceBaseWithResolvedUserTunnel` helper function

## Details
Previously, the service base name was built with `userTunnelID=0` before
the actual user tunnel was resolved. This caused the runtime service
name to not carry the real user_tunnel ID.

The fix resolves the user tunnel early and passes the resolved ID to the
service name builder, ensuring proper service identification.
2026-03-04 19:35:47 +08:00
sagitchu 581cda7edc fix: resolve user tunnel early to use real ID in service name
- Move user tunnel resolution before building service base name
- Add buildForwardServiceBaseWithResolvedUserTunnel helper
- Ensure service names carry the actual user_tunnel ID instead of 0

Entire-Checkpoint: 9559e6447fda
2026-03-04 19:34:16 +08:00
sagit 96aebb8d61 fix: handle drag-and-drop order correctly in compact mode (#264) 2026-03-04 16:49:03 +08:00
sagit 735fd40786 fix: correct SortableContext nesting for table drag-and-drop (#263)
## Summary
- Fixed incorrect nesting of SortableContext component in forward table
- Moved SortableContext wrapper to properly wrap the entire Table
component instead of wrapping individual rows
- This ensures drag-and-drop functionality works correctly with the
table structure
2026-03-04 16:01:38 +08:00
sagitchu a3b0bf4898 fix: correct SortableContext nesting for table drag-and-drop
Entire-Checkpoint: 356ceb26d6bc
2026-03-04 16:00:07 +08:00
sagit 9703e4a081 feat: restrict user permissions and multi-node IP constraints (#262)
* feat: restrict user permissions and multi-node IP constraints

- Non-admin users cannot set speedId or inPort on forward create/update
- Multi-entrance tunnels disable custom listen IP for forwards
- Multi-exit tunnels disable custom connect IP
- Multi-node hop chains disable custom connect IP per hop
- Remove tunnel-first-IP fallback in forward ingress resolution
- Add contract tests for non-admin permission restrictions

Entire-Checkpoint: 133693290660

* fix: allow non-admin users to submit null speedId and zero inPort

- Backend: Check speedId is not nil before rejecting non-admin requests
- Backend: Only reject inPort if value > 0 for non-admin users
- Frontend: Only include speedId and inPort in payload for admin users
- Tests: Add contract tests for null speedId and zero inPort cases

* refactor: simplify forward mutation payload construction
2026-03-04 15:05:05 +08:00
sagit a43653f252 fix: permission checks for speedId and inPort + multi-node IP constraints (#261)
* feat: restrict user permissions and multi-node IP constraints

- Non-admin users cannot set speedId or inPort on forward create/update
- Multi-entrance tunnels disable custom listen IP for forwards
- Multi-exit tunnels disable custom connect IP
- Multi-node hop chains disable custom connect IP per hop
- Remove tunnel-first-IP fallback in forward ingress resolution
- Add contract tests for non-admin permission restrictions

Entire-Checkpoint: 133693290660

* fix: allow non-admin users to submit null speedId and zero inPort

- Backend: Check speedId is not nil before rejecting non-admin requests
- Backend: Only reject inPort if value > 0 for non-admin users
- Frontend: Only include speedId and inPort in payload for admin users
- Tests: Add contract tests for null speedId and zero inPort cases
2026-03-04 14:50:36 +08:00
sagit 348900de01 feat: restrict user permissions and multi-node IP constraints (#260)
- Non-admin users cannot set speedId or inPort on forward create/update
- Multi-entrance tunnels disable custom listen IP for forwards
- Multi-exit tunnels disable custom connect IP
- Multi-node hop chains disable custom connect IP per hop
- Remove tunnel-first-IP fallback in forward ingress resolution
- Add contract tests for non-admin permission restrictions

Entire-Checkpoint: 133693290660
2026-03-04 14:04:22 +08:00
sagit b93c259fac fix: preserve speed_limit and auto_clear when saving forwards and user tunnels (#259)
## Summary
- Add `speed_limit` and `auto_clear` fields to forward update mutation
to prevent data loss on save
- Update user tunnel save mutation to preserve these fields when editing
tunnels
- Add contract test to verify forward save preserves `speed_limit`
- Add plan documents (006, 007, 008) tracking the fix

## Changes
- `go-backend/internal/http/handler/mutations.go`: Add missing fields to
forward and user tunnel update logic
- `go-backend/tests/contract/forward_contract_test.go`: Add test case
for speed_limit preservation
- `vite-frontend/src/pages/forward.tsx`: Pass speed_limit and auto_clear
on save
- `vite-frontend/src/pages/user.tsx`: Pass speed_limit and auto_clear on
user tunnel save
2026-03-03 22:11:05 +08:00
sagitchu 2e3d5c9249 fix: preserve speed_limit and auto_clear when saving forwards and user tunnels
- Add speed_limit and auto_clear fields to forward update mutation
- Update user tunnel save to preserve these fields
- Add contract test for forward save preserving speed_limit
- Add plan documents for the fixes
2026-03-03 22:10:33 +08:00
sagit c8c1841058 feat: forward enhancements and auto-fallback for invalid bind IP (#258)
## Summary

This PR introduces comprehensive enhancements to the forward service
management system, including:

- **Auto-fallback for invalid bind IP**: When a forward service is
updated with a bind IP that doesn't exist on the host network
interfaces, the system automatically falls back to the default bind
address (listening on all interfaces) instead of failing. Users receive
warning toasts when fallback occurs.

- **Bind IP preservation**: Forward services now preserve their explicit
bind IP when editing without explicit inIp changes.

- **Port rebind handling**: Fixed forward service rebind when the port
is self-occupied by updating instead of adding.

- **NY format import support**: Added support for importing forwards in
NY format with node-based tunnel matching and auto port assignment.

- **Custom IP selection**: Enabled custom IP selection for nodes,
tunnels, and forwards with proper UI controls.

- **Compact mode**: Added global compact mode for forward list with
alpha8 layout and tunnel-group collapse/ordering.

## Changes

### Backend
- Added `syncForwardServicesWithWarnings` to collect fallback warnings
- Implemented `fallbackForwardPortToDefaultBind` for graceful
degradation
- Added `UpdateForwardPortBindIP` repository method to persist fallback
- Enhanced error detection for 'cannot assign requested address' errors
- Fixed bind IP preservation during forward edits
- Fixed port rebind on self-occupied addresses

### Frontend
- Added warning toast display when bind IP fallback occurs
- Implemented IP selection dropdowns for tunnels and forwards
- Added compact mode toggle in settings
- Enhanced forward list with tunnel-group collapse and drag sorting

### Tests
- Added comprehensive unit tests for error detection functions
- Added migration tests for legacy columns

## Commits Since Last Merge
- feat: auto-fallback to default bind IP when invalid bind address
detected
- fix: handle forward service rebind on self-occupied port
- fix: preserve bind IP when editing forward without explicit inIp
change
- feat: add ny import compatibility with auto port assignment
- refactor: simplify forward import tunnel selection
- feat: add ny format support for forward import with node-based tunnel
matching
- feat: custom IP selection and connectIp diagnosis fixes
- feat: add comprehensive migration test for legacy columns
- feat: add custom IP selection for nodes, tunnels, and forwards
- feat(forward): support tunnel-group collapse and ordering in full mode
- feat(forward): add global compact mode with alpha8 list layout
2026-03-03 21:34:49 +08:00
sagitchu 1c596fae4b feat: auto-fallback to default bind IP when invalid bind address detected
When a forward service is updated with a bind IP that doesn't exist on the
host network interfaces, the system now automatically falls back to the
default bind address (listening on all interfaces) instead of failing.

- Added syncForwardServicesWithWarnings to collect fallback warnings
- Implemented fallbackForwardPortToDefaultBind for graceful degradation
- Added UpdateForwardPortBindIP repository method to persist fallback
- Enhanced error detection for 'cannot assign requested address' errors
- Frontend displays warning toasts when fallback occurs
- Added comprehensive unit tests for new error detection functions
2026-03-03 21:34:12 +08:00
sagit 2ff52e3275 feat: 2.1.7-beta4 release - forward service stability and UI enhancements (#257)
## Summary

This PR consolidates multiple features and fixes for the 2.1.7-beta4
release:

**Forward Service Stability:**
- Handle forward service rebind on self-occupied port conflicts
- Preserve bind IP when editing forward without explicit inIp change

**Import Enhancements:**
- Add ny format support for forward import with node-based tunnel
matching
- Add ny import compatibility with auto port assignment

**Custom IP Selection:**
- Add custom IP selection for nodes, tunnels, and forwards
- Use configured connectIp for tunnel chain diagnosis

**UI Improvements:**
- Add tunnel group collapse and drag sorting in full mode
- Add global compact mode with alpha8 list layout
- Expose forward compact mode switch in settings

**Infrastructure:**
- Add comprehensive migration test for legacy columns

## Commits

- 7efb49b fix: handle forward service rebind on self-occupied port
- 1450b25 fix: preserve bind IP when editing forward without explicit
inIp change
- 7c54192 feat: add ny import compatibility with auto port assignment
- 7ba6877 refactor: simplify forward import tunnel selection
- ef613c1 feat: add ny format support for forward import with node-based
tunnel matching
- 1c10347 fix: use configured connectIp for tunnel chain diagnosis
- e383359 fix: apply custom IP binding to forward and tunnel chain
services
- 9cf9f4f feat: add comprehensive migration test for legacy columns
- b819341 feat: add custom IP selection for nodes, tunnels, and forwards
- 634c6cd feat(forward): add tunnel group collapse and drag sorting in
full mode
- 98a9e5c fix(config): expose forward compact mode switch in settings
- 77e4387 feat(forward): add global compact mode with alpha8 list layout
2026-03-03 20:54:26 +08:00
sagitchu 7efb49bdab fix: handle forward service rebind on self-occupied port
When UpdateService encounters bind address conflicts (port already in use),
the handler now automatically deletes existing forward services and retries
the AddService operation. This resolves issues where a forward's own stale
listener prevents the update.

- Add isBindAddressInUseError() to detect port bind conflicts
- Add rebindForwardServiceOnSelfOccupiedPort() for automatic cleanup and retry
- Add HasOtherForwardOnNodePort() repository method to verify port ownership
- Add unit tests for bind conflict detection
2026-03-03 20:53:55 +08:00
sagit a00b20abf3 feat: forward management enhancements and bind IP preservation (#256)
## Summary
- Fix bind IP preservation when editing forwards without explicit inIp
changes
- Add ny format import support with node-based tunnel matching and auto
port assignment
- Add custom IP selection for nodes, tunnels, and forwards
- Add tunnel group collapse and drag sorting in full mode
- Add global compact mode with alpha8 list layout
- Various bug fixes and improvements

## Test plan
- [x] Unit tests for forward port replacement with preserved InIP
- [x] Manual testing of forward edit flow
- [x] Verified bind IP is preserved when editing forwards without
touching the inIp field
2026-03-03 20:23:29 +08:00
sagitchu 1450b25475 fix: preserve bind IP when editing forward without explicit inIp change
- Add replaceForwardPortsPreservingInIP to maintain existing InIP values
- Track inIpTouched state in frontend to distinguish user changes
- Only send inIp in update request when user explicitly changed it
- Add unit tests for forward port replacement with preserved InIP
2026-03-03 20:22:58 +08:00
sagit b815be54b8 feat: ny import compatibility and forward enhancements (#252)
## Summary
- **ny import compatibility**: 支持可选的 `listen_port` 字段自动分配端口
- **alias field mapping**: 支持字段别名映射 (dest/dst/target, listenPort/port,
name/forward_name)
- **help text update**: 更新帮助文本说明自动端口分配功能
- **parser tests**: 添加解析器测试覆盖别名字段和缺失端口处理
- **tunnel selection refactor**: 简化转发导入隧道选择逻辑
- **custom IP selection**: 为节点、隧道和转发添加自定义IP选择
- **compact mode**: 添加全局紧凑模式和隧道组折叠排序

## Changes
- `vite-frontend/src/pages/forward/import-format.ts`:
ny格式解析器增强,支持字段别名和可选端口
- `vite-frontend/src/pages/forward/import-format.test.ts`: 添加解析器测试
- `vite-frontend/src/pages/forward.tsx`: 更新UI帮助文本
2026-03-03 16:55:31 +08:00
sagitchu 75edeb9afa Merge remote-tracking branch 'origin/main' into opencode/mighty-nebula
# Conflicts:
#	vite-frontend/src/pages/forward.tsx
#	vite-frontend/src/pages/forward/import-format.test.ts
#	vite-frontend/src/pages/forward/import-format.ts
2026-03-03 16:55:14 +08:00
sagitchu 7c54192055 feat: add ny import compatibility with auto port assignment
- Support optional listen_port field for automatic port assignment
- Add alias field mapping (dest/dst/target, listenPort/port, name/forward_name)
- Update help text to document auto port assignment
- Add parser tests for alias fields and missing port handling

Entire-Checkpoint: efae74a1f03c
2026-03-03 16:54:05 +08:00
sagitchu 7ba68778c1 refactor: simplify forward import tunnel selection
- Remove separate entry node selection for ny format
- Unify tunnel selection for both flvx and ny formats
- Remove unused tunnel select modal component
- Simplify import button validation logic
2026-03-03 16:17:36 +08:00
sagit 7b736b2e60 feat: add ny format support for forward import with node-based tunnel matching (#250) 2026-03-03 15:39:07 +08:00
sagitchu ef613c1518 feat: add ny format support for forward import with node-based tunnel matching 2026-03-03 15:38:03 +08:00
sagit b62df6ffa3 feat: custom IP selection and connectIp diagnosis fixes (#248)
## Summary
- Add custom IP selection dropdown for nodes, tunnels, and forwards
(supports IPv4/IPv6 dual-stack)
- Fix connectIp not being used in tunnel chain diagnosis (resolves #211)
- Reconstruct tunnel state with connectIp field preserved
- Fix forward service config when bindIP already contains port
- Add comprehensive migration tests for legacy columns
- Support tunnel-group collapse and ordering in forward full mode
- Add global compact mode for forward list display

## Changes
### Backend
- `control_plane.go`: Pass connectIp through resolveChainProbeTarget in
diagnosis
- `mutations.go`: Include connectIp in tunnel state reconstruction
- `model.go`: Add migration for connect_ip columns
- `repository.go`: Support connect_ip in CRUD operations

### Frontend
- `node.tsx`, `tunnel.tsx`, `forward.tsx`: IP selection dropdowns
- `settings.tsx`: Forward compact mode switch
- `config.tsx`: Expose compact mode setting

### Tests
- Contract tests for connectIp diagnosis scenarios
- Migration tests for legacy column handling
- Unit tests for bindIP with port

## Test Plan
- [x] Contract tests pass (`go test ./tests/contract/...`)
- [x] Unit tests pass (`go test ./...`)
- [x] Manual testing: tunnel diagnosis uses configured connectIp
- [x] Manual testing: IP selection dropdowns work correctly
2026-03-03 14:19:35 +08:00
sagitchu be9d8773ce merge: resolve conflicts with main branch 2026-03-03 14:19:18 +08:00
sagitchu 1c10347357 fix: use configured connectIp for tunnel chain diagnosis
- Pass connectIp through resolveChainProbeTarget in diagnosis stream start items
- Pass connectIp in appendChainHopDiagnosis for full chain probes
- Reconstruct tunnel state with connectIp field preserved
- Fix forward service config when bindIP already contains port
- Add contract tests for connectIp diagnosis scenarios
- Add unit test for bindIP with port in buildForwardServiceConfigs
- Update AGENTS.md with plan document rules

Entire-Checkpoint: 35a2e61c2431
2026-03-03 14:17:36 +08:00
sagit 5bd21e2ac1 feat: custom IP selection and forward list enhancements (#247)
* feat: add comprehensive migration test for legacy columns

- Add ExtraIPs, TCPListenAddr, UDPListenAddr to Node migration
- Add ip_preference to Tunnel migration
- Add test for very legacy database migration (1.x schema)
- Include issue #211 tracking document

Entire-Checkpoint: 0d086883c34a

* fix: apply custom IP binding to forward and tunnel chain services

Entire-Checkpoint: ceff329d4cf4
2026-03-03 10:59:00 +08:00
sagitchu e38335973d fix: apply custom IP binding to forward and tunnel chain services
Entire-Checkpoint: ceff329d4cf4
2026-03-03 10:58:15 +08:00
sagit 95929bf82e feat: add comprehensive migration test for legacy columns (#245)
- Add ExtraIPs, TCPListenAddr, UDPListenAddr to Node migration
- Add ip_preference to Tunnel migration
- Add test for very legacy database migration (1.x schema)
- Include issue #211 tracking document

Entire-Checkpoint: 0d086883c34a
2026-03-03 10:28:10 +08:00
sagitchu 9cf9f4f1f7 feat: add comprehensive migration test for legacy columns
- Add ExtraIPs, TCPListenAddr, UDPListenAddr to Node migration
- Add ip_preference to Tunnel migration
- Add test for very legacy database migration (1.x schema)
- Include issue #211 tracking document

Entire-Checkpoint: 0d086883c34a
2026-03-03 10:27:29 +08:00
sagit ae8dbdd77f feat: add custom IP selection for nodes, tunnels, and forwards (#244)
## Summary

- Add `extra_ips` field to nodes for multi-IP servers (comma-separated)
- Add `connect_ip` field to `chain_tunnel` for specifying which IP to
connect to on multi-IP nodes
- Add `in_ip` field to `forward_port` for specifying which IP to listen
on
- Frontend: add UI controls for extra IPs on node form
- Frontend: add connect IP input for tunnel chain nodes (both relay hops
and exit nodes)
- Frontend: add listen IP input for forward creation/editing
- Backend: resolve forward ingress with custom listen IP priority
(per-port IP > tunnel IP > node IP)

This enables fine-grained control over IP selection on multi-homed
servers.
2026-03-03 09:47:17 +08:00
sagitchu 05bd6a686d feat: add IP selection dropdown for tunnels and forwards
Entire-Checkpoint: fde43d8c94e5
2026-03-03 09:16:05 +08:00
sagitchu b8193417f5 feat: add custom IP selection for nodes, tunnels, and forwards
- Add extra_ips field to nodes for multi-IP servers
- Add connect_ip field to chain_tunnel for specifying connection address
- Add in_ip field to forward_port for specifying listen address
- Frontend: add UI controls for extra IPs on node form
- Frontend: add connect IP input for tunnel chain nodes
- Frontend: add listen IP input for forward form
- Backend: resolve forward ingress with custom listen IP priority

Entire-Checkpoint: 557563462c16
2026-03-03 08:24:15 +08:00
sagit 15e4508be4 feat(forward): support tunnel-group collapse and ordering in full mode (#243)
## Summary
- add collapsible tunnel groups in the forward page when compact mode is
disabled
- add drag-and-drop ordering for tunnel groups within each user section
in full mode
- persist group order/collapse by current login user (admins: local +
global config, normal users: local only)

## Testing
- npm run build (vite-frontend)
2026-03-02 22:25:31 +08:00
sagitchu 634c6cd620 feat(forward): add tunnel group collapse and drag sorting in full mode 2026-03-02 22:24:39 +08:00
sagit 4eaecb289b fix(config): expose forward compact mode switch in settings (#241)
## Summary
- Add `forward_compact_mode` to the `/config` settings item list so the
compact-mode switch is visible in the main settings page.
- Include `forward_compact_mode` in initial config cache keys to keep
switch state consistent on load.

## Verification
- `npm run build` (vite-frontend)
2026-03-02 21:37:10 +08:00
sagitchu 98a9e5c666 fix(config): expose forward compact mode switch in settings 2026-03-02 21:36:33 +08:00
sagit d244920dd4 feat(forward): add global compact mode with alpha8 list layout (#240)
## Summary
- Add a global forward compact mode toggle in settings, persisted via
`config` key `forward_compact_mode`.
- Make forward page read and react to this global setting in real time
through a browser event.
- In compact mode, render forward list using the 2.1.6-alpha8 style
(single grouped table / global direct card grid) while keeping non-list
interactions unchanged.

## Verification
- Installed frontend dependencies with `npm install`.
- Built frontend successfully with `npm run build`.
2026-03-02 21:11:39 +08:00
sagitchu 77e4387b35 feat(forward): add global compact mode with alpha8 list layout 2026-03-02 21:10:56 +08:00
195 changed files with 28123 additions and 3091 deletions
-84
View File
@@ -1,84 +0,0 @@
{
"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
@@ -1,4 +0,0 @@
{
"enabled": true,
"telemetry": false
}
@@ -0,0 +1,62 @@
# 功能请求:在规则页面显示隧道倍率
## 问题描述
当前规则(Forward)页面在列表中显示隧道名称,但**不显示隧道的流量倍率(trafficRatio)**。管理员在管理规则时无法快速查看该规则所使用的隧道倍率信息,需要跳转到隧道页面才能查看。
## 期望行为
在规则列表页面中,在隧道名称旁边或单独列显示该隧道的流量倍率(例如:`1x`, `0.5x`, `2x`)。
## 建议实现位置
### 前端修改
1. **`vite-frontend/src/pages/forward.tsx`**
- 在 `Forward` interface 中添加 `tunnelTrafficRatio?: number` 字段
- 在表格列中添加倍率显示(可以在隧道名称 Chip 旁边或单独一列)
- 从 `userTunnel` 或 `getTunnelList` API 获取隧道倍率信息
2. **显示格式建议**
```tsx
<Chip className="...">
{forward.tunnelName} ({forward.tunnelTrafficRatio}x)
</Chip>
```
或者单独一列:
```tsx
<TableCell>
{forward.tunnelTrafficRatio}x
</TableCell>
```
### 后端修改
1. **`go-backend/internal/http/handler/handler.go`**
- 在 `forwardList` 接口返回中添加隧道的 `trafficRatio` 字段
- 需要在查询 Forward 时 JOIN Tunnel 表获取倍率信息
2. **或者在前端加载规则后,批量获取隧道信息**
- 调用 `getTunnelList` 获取所有隧道信息
- 根据 `tunnelId` 匹配倍率
## 相关文件
- 前端:`vite-frontend/src/pages/forward.tsx`
- 前端类型:`vite-frontend/src/api/types.ts`
- 后端:`go-backend/internal/http/handler/handler.go`
- 隧道类型定义:`vite-frontend/src/api/types.ts` (TunnelApiItem)
## 优先级
中等 - 不影响核心功能,但能提升管理效率
## 截图参考
隧道页面已显示倍率:
- 位置:隧道卡片统计信息区域
- 显示格式:`流量倍率 {trafficRatio}x`
---
**Labels**: `enhancement`, `frontend`, `backend`, `ui/ux`
+3
View File
@@ -62,6 +62,9 @@ go-gost/ss/
.classpath
.project
.settings/
# OpenCode session metadata
.entire/
bin/
tmp/
*.swp
@@ -1,71 +0,0 @@
# Plan: 搭建开发环境
## 目标
为 Flux Panel 项目安装所有缺失的开发依赖,使 3 个子项目都能本地开发和构建。
## 当前状态
### ✅ 已安装
| 工具 | 版本 | 用途 |
|------|------|------|
| Node.js | v20.19.2 | vite-frontend |
| npm | 9.2.0 | vite-frontend |
| Go | 1.24.4 | go-gost |
| Docker | 29.1.4 | 容器化部署 |
### ❌ 缺失
| 工具 | 需求版本 | 用途 |
|------|----------|------|
| Java | 21 | springboot-backend |
| Maven | 3.x | 构建后端 |
| Docker Compose | v2 | 容器编排 |
---
## 执行任务
### Task 1: 安装 Java 21
```bash
apt-get update && apt-get install -y openjdk-21-jdk
```
**验证**: `java -version` 应显示 openjdk 21
### Task 2: 安装 Maven
```bash
apt-get install -y maven
```
**验证**: `mvn -v` 应显示 Maven 3.x
### Task 3: 安装 Docker Compose Plugin
```bash
apt-get install -y docker-compose-plugin
```
**验证**: `docker compose version` 应显示版本号
### Task 4: 安装前端依赖
```bash
cd /root/flux-panel/vite-frontend && npm install
```
**验证**: `node_modules/` 目录存在
### Task 5: 验证后端可构建
```bash
cd /root/flux-panel/springboot-backend && mvn clean compile -q
```
**验证**: 编译成功无错误
### Task 6: 验证 Go 模块
```bash
cd /root/flux-panel/go-gost && go mod download
```
**验证**: 依赖下载成功
---
## 完成标准
- [ ] `java -version` → openjdk 21
- [ ] `mvn -v` → Maven 3.x
- [ ] `docker compose version` → v2.x
- [ ] 前端: `npm run dev` 可启动
- [ ] 后端: `mvn compile` 成功
- [ ] Go: `go build .` 成功
+12 -4
View File
@@ -1,9 +1,9 @@
# PROJECT KNOWLEDGE BASE
**Generated:** Thu Feb 26 2026
**Commit:** 21008cc
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.5-rc15
**Tag:** 2.1.9-beta6
## 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/PostgreSQL) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
@@ -53,6 +53,7 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
| `websocket_reporter` | Func | `go-gost/x/socket/websocket_reporter.go` | Panel Telemetry |
## CONVENTIONS
- **Skills & MCP**: Always prefer using available skills (via `skill` tool) and MCP tools when applicable. Check for relevant skills before implementing from scratch.
- **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.
@@ -112,4 +113,11 @@ docker compose -f docker-compose-v6.yml up -d
- 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.
- Button visual parity relies on `vite-frontend/src/shadcn-bridge/heroui/button.tsx` color mapping + `vite-frontend/src/styles/tailwind-theme.pcss` token export.
## PLAN DOCUMENT RULE
- Every new implementation plan must have a dedicated Markdown plan document.
- Store plan documents under `plans/`.
- Use an incrementing numeric prefix and a short plan-summary name: `NNN-<plan-summary>.md` (for example, `001-auth-refactor.md`, `002-federation-api-cleanup.md`).
- The numeric prefix must increase by 1 for each new plan.
- In each plan document, keep a task checklist and mark each task as completed immediately after finishing it.
-148
View File
@@ -1,148 +0,0 @@
# 限速功能重构实施计划
## 一、需求概述
**原始需求**: 限速功能当前绑定到具体隧道,需要改为不绑定隧道,创建限速后可以自由在隧道上限速,也可以在转发上限速。
**核心变更**:
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
+4 -4
View File
@@ -33,13 +33,13 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -
#### 安装特定版本
从 [Releases](https://github.com/Sagit-chu/flux-panel/releases) 页面复制对应版本的安装命令,脚本会自动安装该版本而非最新版。
面板端(以 2.1.0 为例):
面板端(以 2.1.9-beta6 为例):
```bash
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.9-beta6/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
```
节点端(以 2.1.0 为例):
节点端(以 2.1.9-beta6 为例):
```bash
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/install.sh -o install.sh && chmod +x install.sh && ./install.sh
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.9-beta6/install.sh -o install.sh && chmod +x install.sh && ./install.sh
```
#### PostgreSQL 部署(Docker Compose)
+5
View File
@@ -1,5 +1,10 @@
# GO BACKEND KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
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).
+360
View File
@@ -0,0 +1,360 @@
package health
import (
"context"
"errors"
"fmt"
"log"
"net"
"strings"
"sync"
"time"
"go-backend/internal/monitoring"
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
"go-backend/internal/ws"
)
type nodeCommander interface {
SendCommand(nodeID int64, cmdType string, data interface{}, timeout time.Duration) (ws.CommandResult, error)
}
type Checker struct {
repo *repo.Repository
commander nodeCommander
lastRun map[int64]int64
inFlight map[int64]struct{}
mu sync.RWMutex
cancel context.CancelFunc
wg sync.WaitGroup
}
func NewChecker(repo *repo.Repository, commander nodeCommander) *Checker {
return &Checker{
repo: repo,
commander: commander,
lastRun: make(map[int64]int64),
inFlight: make(map[int64]struct{}),
}
}
func (c *Checker) Start(ctx context.Context) {
c.mu.Lock()
ctx, cancel := context.WithCancel(ctx)
c.cancel = cancel
c.mu.Unlock()
c.runChecks(ctx)
for {
limits := c.loadServiceMonitorLimits()
scanInterval := time.Duration(limits.CheckerScanIntervalSec) * time.Second
if scanInterval <= 0 {
scanInterval = 30 * time.Second
}
timer := time.NewTimer(scanInterval)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
c.runChecks(ctx)
}
}
}
func (c *Checker) Stop() {
c.mu.Lock()
if c.cancel != nil {
c.cancel()
}
c.mu.Unlock()
c.wg.Wait()
}
func (c *Checker) RunOnce(m *model.ServiceMonitor) (*model.ServiceMonitorResult, error) {
if c == nil {
return nil, errors.New("checker not initialized")
}
if m == nil {
return nil, errors.New("monitor is nil")
}
limits := c.loadServiceMonitorLimits()
return c.executeCheck(m, time.Now().UnixMilli(), limits), nil
}
func (c *Checker) runChecks(ctx context.Context) {
if c == nil || c.repo == nil {
return
}
limits := c.loadServiceMonitorLimits()
monitors, err := c.repo.ListEnabledServiceMonitors()
if err != nil {
log.Printf("service monitor scheduler failed op=list_enabled err=%v", err)
return
}
if len(monitors) == 0 {
return
}
// Use persisted result timestamps to avoid restart bursts.
latest, err := c.repo.GetLatestServiceMonitorResults()
if err != nil {
log.Printf("service monitor scheduler failed op=get_latest_results err=%v", err)
latest = nil
}
persistedLast := make(map[int64]int64, len(latest))
for _, r := range latest {
if r.MonitorID <= 0 || r.Timestamp <= 0 {
continue
}
persistedLast[r.MonitorID] = r.Timestamp
}
now := time.Now().UnixMilli()
due := make([]model.ServiceMonitor, 0, len(monitors))
for _, m := range monitors {
select {
case <-ctx.Done():
return
default:
}
intervalSec := m.IntervalSec
if intervalSec <= 0 {
intervalSec = limits.DefaultIntervalSec
}
if intervalSec < limits.MinIntervalSec {
intervalSec = limits.MinIntervalSec
}
intervalMs := int64(intervalSec) * 1000
c.mu.Lock()
if _, ok := c.inFlight[m.ID]; ok {
c.mu.Unlock()
continue
}
lastSeen := persistedLast[m.ID]
if v := c.lastRun[m.ID]; v > lastSeen {
lastSeen = v
}
if lastSeen > 0 && intervalMs > 0 && now-lastSeen < intervalMs {
c.mu.Unlock()
continue
}
c.inFlight[m.ID] = struct{}{}
// Use now as a best-effort guard against overlapping scans; the final
// timestamp is updated again when the result is persisted.
c.lastRun[m.ID] = now
c.mu.Unlock()
due = append(due, m)
}
if len(due) == 0 {
return
}
workerLimit := limits.WorkerLimit
if workerLimit <= 0 {
workerLimit = 1
}
if workerLimit > len(due) {
workerLimit = len(due)
}
jobs := make(chan model.ServiceMonitor, len(due))
for _, m := range due {
jobs <- m
}
close(jobs)
for i := 0; i < workerLimit; i++ {
c.wg.Add(1)
go func() {
defer c.wg.Done()
for {
select {
case <-ctx.Done():
return
case m, ok := <-jobs:
if !ok {
return
}
ts := time.Now().UnixMilli()
result := c.executeCheck(&m, ts, limits)
if err := c.repo.InsertServiceMonitorResult(result); err != nil {
log.Printf("monitoring write failed op=service_monitor_result.insert monitor_id=%d err=%v", result.MonitorID, err)
}
c.mu.Lock()
c.lastRun[m.ID] = result.Timestamp
delete(c.inFlight, m.ID)
c.mu.Unlock()
}
}
}()
}
}
func (c *Checker) executeCheck(m *model.ServiceMonitor, timestamp int64, limits monitoring.ServiceMonitorLimits) *model.ServiceMonitorResult {
result := &model.ServiceMonitorResult{
MonitorID: m.ID,
NodeID: m.NodeID,
Timestamp: timestamp,
}
timeoutSec := m.TimeoutSec
if timeoutSec <= 0 {
timeoutSec = limits.DefaultTimeoutSec
}
if timeoutSec < limits.MinTimeoutSec {
timeoutSec = limits.MinTimeoutSec
}
if timeoutSec > limits.MaxTimeoutSec {
timeoutSec = limits.MaxTimeoutSec
}
timeout := time.Duration(timeoutSec) * time.Second
// When nodeId is set, run checks on the specified node.
if m.NodeID > 0 {
c.checkOnNode(m, timeoutSec, timeout, result)
return result
}
switch strings.ToLower(strings.TrimSpace(m.Type)) {
case "tcp":
c.checkTCP(m.Target, timeout, result)
case "icmp":
result.Success = 0
result.ErrorMessage = "ICMP 监控必须指定执行节点"
default:
result.Success = 0
result.ErrorMessage = fmt.Sprintf("不支持的检查类型: %s", m.Type)
}
return result
}
func (c *Checker) loadServiceMonitorLimits() monitoring.ServiceMonitorLimits {
defaults := monitoring.DefaultServiceMonitorLimits()
if c == nil || c.repo == nil {
return defaults
}
cfg, err := c.repo.GetConfigsByNames([]string{
monitoring.ConfigServiceMonitorCheckerScanIntervalSec,
monitoring.ConfigServiceMonitorWorkerLimit,
monitoring.ConfigServiceMonitorMinIntervalSec,
monitoring.ConfigServiceMonitorDefaultIntervalSec,
monitoring.ConfigServiceMonitorMinTimeoutSec,
monitoring.ConfigServiceMonitorDefaultTimeoutSec,
monitoring.ConfigServiceMonitorMaxTimeoutSec,
})
if err != nil {
return defaults
}
return monitoring.ServiceMonitorLimitsFromConfigMap(cfg)
}
type serviceMonitorCheckRequest struct {
MonitorID int64 `json:"monitorId"`
Type string `json:"type"`
Target string `json:"target"`
TimeoutSec int `json:"timeoutSec"`
}
func (c *Checker) checkOnNode(m *model.ServiceMonitor, timeoutSec int, timeout time.Duration, result *model.ServiceMonitorResult) {
if c == nil || m == nil || result == nil {
return
}
if c.commander == nil {
result.Success = 0
result.ErrorMessage = "节点检查不可用"
return
}
checkType := strings.ToLower(strings.TrimSpace(m.Type))
if checkType != "tcp" && checkType != "icmp" {
result.Success = 0
result.ErrorMessage = fmt.Sprintf("不支持的检查类型: %s", m.Type)
return
}
if strings.TrimSpace(m.Target) == "" {
result.Success = 0
result.ErrorMessage = "检查目标为空"
return
}
req := serviceMonitorCheckRequest{
MonitorID: m.ID,
Type: checkType,
Target: m.Target,
TimeoutSec: timeoutSec,
}
cmdTimeout := timeout
if cmdTimeout < 2*time.Second {
cmdTimeout = 2 * time.Second
}
cmdTimeout = cmdTimeout + 2*time.Second
cmdRes, err := c.commander.SendCommand(m.NodeID, "ServiceMonitorCheck", req, cmdTimeout)
if err != nil {
result.Success = 0
result.ErrorMessage = err.Error()
return
}
if cmdRes.Data == nil {
result.Success = 0
result.ErrorMessage = "节点返回为空"
return
}
if v, ok := cmdRes.Data["success"]; ok {
if b, ok := v.(bool); ok {
if b {
result.Success = 1
} else {
result.Success = 0
}
}
}
if v, ok := cmdRes.Data["latencyMs"]; ok {
if f, ok := v.(float64); ok {
result.LatencyMs = f
}
}
if v, ok := cmdRes.Data["statusCode"]; ok {
if f, ok := v.(float64); ok {
result.StatusCode = int(f)
}
}
if v, ok := cmdRes.Data["errorMessage"]; ok {
if s, ok := v.(string); ok {
result.ErrorMessage = s
}
}
}
func (c *Checker) checkTCP(target string, timeout time.Duration, result *model.ServiceMonitorResult) {
start := time.Now()
conn, err := net.DialTimeout("tcp", target, timeout)
latency := time.Since(start)
result.LatencyMs = float64(latency.Milliseconds())
if err != nil {
result.Success = 0
result.ErrorMessage = err.Error()
return
}
_ = conn.Close()
result.Success = 1
}
+477
View File
@@ -0,0 +1,477 @@
package health
import (
"context"
"net"
"testing"
"time"
"go-backend/internal/monitoring"
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
"go-backend/internal/ws"
)
type fakeCommander struct {
lastNodeID int64
lastType string
lastData interface{}
res ws.CommandResult
err error
}
type delayedCommander struct {
delayByMonitorID map[int64]time.Duration
}
func (d *delayedCommander) SendCommand(nodeID int64, cmdType string, data interface{}, _ time.Duration) (ws.CommandResult, error) {
_ = nodeID
_ = cmdType
if req, ok := data.(serviceMonitorCheckRequest); ok {
if delay := d.delayByMonitorID[req.MonitorID]; delay > 0 {
time.Sleep(delay)
}
}
return ws.CommandResult{
Success: true,
Data: map[string]interface{}{
"success": true,
"latencyMs": float64(1),
},
}, nil
}
func (f *fakeCommander) SendCommand(nodeID int64, cmdType string, data interface{}, _ time.Duration) (ws.CommandResult, error) {
f.lastNodeID = nodeID
f.lastType = cmdType
f.lastData = data
return f.res, f.err
}
func TestTCPHealthCheckViaMonitor(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer listener.Close()
addr := listener.Addr().String()
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
conn.Close()
}
}()
t.Run("successful tcp check", func(t *testing.T) {
checker := NewChecker(nil, nil)
limits := checker.loadServiceMonitorLimits()
now := time.Now().UnixMilli()
monitor := &model.ServiceMonitor{
Type: "tcp",
Target: addr,
TimeoutSec: 5,
}
result := checker.executeCheck(monitor, now, limits)
if result.Success != 1 {
t.Fatalf("expected success, got error: %s", result.ErrorMessage)
}
if result.LatencyMs < 0 {
t.Fatalf("expected non-negative latency, got %f", result.LatencyMs)
}
})
t.Run("failed tcp check - connection refused", func(t *testing.T) {
checker := NewChecker(nil, nil)
limits := checker.loadServiceMonitorLimits()
now := time.Now().UnixMilli()
monitor := &model.ServiceMonitor{
Type: "tcp",
Target: "127.0.0.1:1",
TimeoutSec: 1,
}
result := checker.executeCheck(monitor, now, limits)
if result.Success == 1 {
t.Fatalf("expected failure for connection refused")
}
if result.ErrorMessage == "" {
t.Fatalf("expected error message")
}
})
}
func TestCheckerRunChecks(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer listener.Close()
tcpAddr := listener.Addr().String()
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
conn.Close()
}
}()
now := time.Now().UnixMilli()
monitors := []*model.ServiceMonitor{
{
Name: "TCP Monitor",
Type: "tcp",
Target: tcpAddr,
IntervalSec: 60,
TimeoutSec: 5,
NodeID: 0,
Enabled: 1,
CreatedTime: now,
UpdatedTime: now,
},
{
Name: "TCP Monitor 2",
Type: "tcp",
Target: tcpAddr,
IntervalSec: 60,
TimeoutSec: 5,
NodeID: 0,
Enabled: 1,
CreatedTime: now,
UpdatedTime: now,
},
{
Name: "Disabled Monitor",
Type: "tcp",
Target: "127.0.0.1:1",
IntervalSec: 60,
TimeoutSec: 5,
NodeID: 0,
Enabled: 0,
CreatedTime: now,
UpdatedTime: now,
},
}
for _, m := range monitors {
if err := r.CreateServiceMonitor(m); err != nil {
t.Fatalf("create monitor: %v", err)
}
}
monitors[2].Enabled = 0
if err := r.UpdateServiceMonitor(monitors[2]); err != nil {
t.Fatalf("update disabled monitor: %v", err)
}
enabledMonitors, err := r.ListEnabledServiceMonitors()
if err != nil {
t.Fatalf("list enabled monitors: %v", err)
}
if len(enabledMonitors) != 2 {
t.Fatalf("expected 2 enabled monitors, got %d", len(enabledMonitors))
}
checker := NewChecker(r, nil)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go checker.Start(ctx)
time.Sleep(500 * time.Millisecond)
results, err := r.GetServiceMonitorResults(monitors[0].ID, 10)
if err != nil {
t.Fatalf("get tcp results: %v", err)
}
if len(results) == 0 {
t.Fatalf("expected at least one result for tcp monitor")
}
for _, res := range results {
if res.Success != 1 {
t.Fatalf("expected success for tcp monitor, got failure: %s", res.ErrorMessage)
}
}
results2, err := r.GetServiceMonitorResults(monitors[1].ID, 10)
if err != nil {
t.Fatalf("get tcp results 2: %v", err)
}
if len(results2) == 0 {
t.Fatalf("expected at least one result for tcp monitor 2")
}
for _, res := range results2 {
if res.Success != 1 {
t.Fatalf("expected success for tcp monitor 2, got failure: %s", res.ErrorMessage)
}
}
disabledResults, err := r.GetServiceMonitorResults(monitors[2].ID, 10)
if err != nil {
t.Fatalf("get disabled results: %v", err)
}
if len(disabledResults) != 0 {
t.Fatalf("expected no results for disabled monitor, got %d", len(disabledResults))
}
}
func TestCheckerUnsupportedType(t *testing.T) {
checker := NewChecker(nil, nil)
limits := checker.loadServiceMonitorLimits()
now := time.Now().UnixMilli()
monitor := &model.ServiceMonitor{
Type: "http",
Target: "https://example.com",
TimeoutSec: 5,
}
result := checker.executeCheck(monitor, now, limits)
if result.Success == 1 {
t.Fatalf("expected failure for unsupported type")
}
if result.ErrorMessage == "" {
t.Fatalf("expected error message for unsupported type")
}
}
func TestCheckerDefaultTimeout(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer listener.Close()
addr := listener.Addr().String()
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
conn.Close()
}
}()
checker := NewChecker(nil, nil)
limits := checker.loadServiceMonitorLimits()
now := time.Now().UnixMilli()
monitor := &model.ServiceMonitor{
Type: "tcp",
Target: addr,
TimeoutSec: 0,
}
result := checker.executeCheck(monitor, now, limits)
if result.Success != 1 {
t.Fatalf("expected success with default timeout, got error: %s", result.ErrorMessage)
}
}
func TestCheckerStop(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer listener.Close()
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
conn.Close()
}
}()
now := time.Now().UnixMilli()
monitor := &model.ServiceMonitor{
Name: "Test Monitor",
Type: "tcp",
Target: listener.Addr().String(),
IntervalSec: 60,
TimeoutSec: 5,
NodeID: 0,
Enabled: 1,
CreatedTime: now,
UpdatedTime: now,
}
if err := r.CreateServiceMonitor(monitor); err != nil {
t.Fatalf("create monitor: %v", err)
}
checker := NewChecker(r, nil)
ctx := context.Background()
go checker.Start(ctx)
time.Sleep(100 * time.Millisecond)
checker.Stop()
results, err := r.GetServiceMonitorResults(monitor.ID, 10)
if err != nil {
t.Fatalf("get results: %v", err)
}
if len(results) == 0 {
t.Fatalf("expected at least one result before stop")
}
}
func TestCheckerRunsOnNodeWhenNodeIDSet(t *testing.T) {
fake := &fakeCommander{
res: ws.CommandResult{
Success: true,
Data: map[string]interface{}{
"success": false,
"latencyMs": float64(12),
"errorMessage": "unreachable",
},
},
}
checker := NewChecker(nil, fake)
limits := checker.loadServiceMonitorLimits()
now := time.Now().UnixMilli()
monitor := &model.ServiceMonitor{
ID: 99,
Type: "icmp",
Target: "8.8.8.8",
TimeoutSec: 2,
NodeID: 123,
}
res := checker.executeCheck(monitor, now, limits)
if fake.lastNodeID != 123 {
t.Fatalf("expected command to be sent to node 123, got %d", fake.lastNodeID)
}
if fake.lastType != "ServiceMonitorCheck" {
t.Fatalf("expected ServiceMonitorCheck command, got %s", fake.lastType)
}
if res.Success != 0 {
t.Fatalf("expected failed result from node check")
}
if res.ErrorMessage != "unreachable" {
t.Fatalf("expected errorMessage unreachable, got %q", res.ErrorMessage)
}
}
func TestCheckerDoesNotBurstOnRestartWhenRecentResultsExist(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
monitor := &model.ServiceMonitor{
Name: "recent-monitor",
Type: "tcp",
Target: "127.0.0.1:1",
IntervalSec: 60,
TimeoutSec: 1,
NodeID: 0,
Enabled: 1,
CreatedTime: now,
UpdatedTime: now,
}
if err := r.CreateServiceMonitor(monitor); err != nil {
t.Fatalf("create monitor: %v", err)
}
if err := r.InsertServiceMonitorResult(&model.ServiceMonitorResult{
MonitorID: monitor.ID,
NodeID: 0,
Timestamp: now - 10_000,
Success: 1,
}); err != nil {
t.Fatalf("seed recent result: %v", err)
}
checker := NewChecker(r, nil)
ctx, cancel := context.WithCancel(context.Background())
go checker.Start(ctx)
// Give the initial scan a chance to run.
time.Sleep(200 * time.Millisecond)
cancel()
checker.Stop()
results, err := r.GetServiceMonitorResults(monitor.ID, 10)
if err != nil {
t.Fatalf("get results: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected no immediate rerun (1 result), got %d", len(results))
}
}
func TestCheckerConcurrencyPreventsSlowMonitorBlockingOthers(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
// Force worker limit to at least 2 for this test.
_ = r.UpsertConfig(monitoring.ConfigServiceMonitorWorkerLimit, "2", now)
slow := &model.ServiceMonitor{
Name: "slow",
Type: "icmp",
Target: "8.8.8.8",
IntervalSec: 60,
TimeoutSec: 1,
NodeID: 123,
Enabled: 1,
CreatedTime: now,
UpdatedTime: now,
}
if err := r.CreateServiceMonitor(slow); err != nil {
t.Fatalf("create slow monitor: %v", err)
}
fast := &model.ServiceMonitor{
Name: "fast",
Type: "icmp",
Target: "1.1.1.1",
IntervalSec: 60,
TimeoutSec: 1,
NodeID: 123,
Enabled: 1,
CreatedTime: now,
UpdatedTime: now,
}
if err := r.CreateServiceMonitor(fast); err != nil {
t.Fatalf("create fast monitor: %v", err)
}
cmd := &delayedCommander{delayByMonitorID: map[int64]time.Duration{slow.ID: 800 * time.Millisecond}}
checker := NewChecker(r, cmd)
ctx, cancel := context.WithCancel(context.Background())
go checker.Start(ctx)
// Fast monitor should complete even while slow one is still running.
time.Sleep(250 * time.Millisecond)
results, err := r.GetServiceMonitorResults(fast.ID, 10)
if err != nil {
t.Fatalf("get fast results: %v", err)
}
if len(results) == 0 {
t.Fatalf("expected fast monitor to have results without waiting for slow")
}
cancel()
checker.Stop()
}
+4 -1
View File
@@ -1,6 +1,9 @@
# BACKEND HTTP HANDLER KNOWLEDGE BASE
**Generated:** Thu Feb 26 2026
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
HTTP request handlers for FLVX Admin API. Core business logic layer.
+332 -51
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
@@ -72,7 +73,7 @@ func (h *Handler) buildDiagnosisStreamStartItems(workItems []diagnosisWorkItem)
fromNode, _ := h.cachedNode(nodeCache, workItem.fromNodeID)
targetNode, err := h.cachedNode(nodeCache, workItem.toNode.NodeID)
if err == nil {
resolvedIP, resolvedPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, workItem.toNode.Port, workItem.ipPreference)
resolvedIP, resolvedPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, workItem.toNode.Port, workItem.ipPreference, workItem.toNode.ConnectIP)
if resolveErr == nil {
targetIP = resolvedIP
targetPort = resolvedPort
@@ -223,20 +224,32 @@ func (h *Handler) listUserTunnelIDsByUser(userID int64) ([]int64, error) {
}
func (h *Handler) syncForwardServices(forward *forwardRecord, method string, allowFallbackAdd bool) error {
_, err := h.syncForwardServicesWithWarnings(forward, method, allowFallbackAdd)
return err
}
func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method string, allowFallbackAdd bool) ([]string, error) {
if h == nil || forward == nil {
return errors.New("invalid forward sync context")
return nil, errors.New("invalid forward sync context")
}
tunnel, err := h.getTunnelRecord(forward.TunnelID)
if err != nil {
return err
return nil, err
}
ports, err := h.listForwardPorts(forward.ID)
if err != nil {
return err
return nil, err
}
if len(ports) == 0 {
return errors.New("转发入口端口不存在")
return nil, errors.New("转发入口端口不存在")
}
warnings := make([]string, 0)
// Resolve user tunnel first so runtime service name can carry the real user_tunnel id.
userTunnelID, utLimiterID, utSpeed, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
if err != nil {
return nil, err
}
// Determine limiter from forward's SpeedID first, fallback to UserTunnel's limiter
@@ -254,45 +267,171 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
if limiterID == nil {
// Fall back to UserTunnel speed limit
var utLimiterID *int64
var utSpeed *int
_, utLimiterID, utSpeed, err = h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
if err != nil {
return err
}
limiterID = utLimiterID
speed = utSpeed
}
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, 0)
serviceBase := buildForwardServiceBaseWithResolvedUserTunnel(forward.ID, forward.UserID, userTunnelID)
tunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)
if err != nil {
return err
return nil, err
}
for _, fp := range ports {
if limiterID != nil && speed != nil {
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {
return err
return nil, err
}
}
node, err := h.getNodeRecord(fp.NodeID)
if err != nil {
return err
return nil, err
}
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiterID, tunnelTLSProtocol)
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, tunnelTLSProtocol)
_, err = h.sendNodeCommand(node.ID, method, services, true, false)
if err != nil && allowFallbackAdd && method == "UpdateService" {
if isNotFoundError(err) {
if delErr := h.deleteForwardServicesOnNode(forward, node.ID); delErr != nil && !isNotFoundError(delErr) {
return warnings, fmt.Errorf("节点 %s 清理旧服务失败: %w", node.Name, delErr)
}
}
_, err = h.sendNodeCommand(node.ID, "AddService", services, true, false)
}
if err != nil && strings.EqualFold(strings.TrimSpace(method), "UpdateService") && isAddressAlreadyInUseError(err) {
err = h.rebindForwardServiceOnSelfOccupiedPort(forward, node, fp.Port, services)
}
if err != nil && strings.EqualFold(strings.TrimSpace(method), "UpdateService") && isCannotAssignRequestedAddressError(err) {
var warning string
warning, err = h.fallbackForwardPortToDefaultBind(forward, tunnel, node, fp, serviceBase, limiterID, tunnelTLSProtocol)
if err == nil && warning != "" {
warnings = append(warnings, warning)
}
}
if err != nil {
return fmt.Errorf("节点 %s 下发失败: %w", node.Name, err)
return warnings, fmt.Errorf("节点 %s 下发失败: %w", node.Name, err)
}
}
// Keep paused forwards paused after UpdateService/AddService, since agent-side UpdateService
// always restarts services.
if forward.Status != 1 {
if err := h.controlForwardServices(forward, "PauseService", false); err != nil {
return warnings, err
}
}
return warnings, nil
}
func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, fp forwardPortRecord, serviceBase string, limiterID *int64, tunnelTLSProtocol bool) (string, error) {
if h == nil || forward == nil || tunnel == nil || node == nil {
return "", errors.New("invalid bind fallback context")
}
if fp.Port <= 0 {
return "", errors.New("invalid forward port")
}
explicitBindIP := strings.TrimSpace(fp.InIP)
if explicitBindIP == "" {
return "", errors.New("default bind address cannot be assigned")
}
if err := h.deleteForwardServicesOnNode(forward, node.ID); err != nil {
return "", err
}
time.Sleep(150 * time.Millisecond)
defaultServices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, "", limiterID, tunnelTLSProtocol)
if _, err := h.sendNodeCommand(node.ID, "AddService", defaultServices, true, false); err != nil {
return "", err
}
if err := h.repo.UpdateForwardPortBindIP(forward.ID, node.ID, fp.Port, ""); err != nil {
return "", err
}
warning := fmt.Sprintf("节点 %s 监听IP %s 不在主机网卡地址中,已自动回退为默认监听IP", strings.TrimSpace(node.Name), explicitBindIP)
return warning, nil
}
func (h *Handler) rebindForwardServiceOnSelfOccupiedPort(forward *forwardRecord, node *nodeRecord, port int, services []map[string]interface{}) error {
if h == nil || forward == nil || node == nil {
return errors.New("invalid self-occupy rebind context")
}
if port <= 0 {
return errors.New("invalid forward port")
}
hasOtherForward, err := h.repo.HasOtherForwardOnNodePort(node.ID, port, forward.ID)
if err != nil {
return err
}
if hasOtherForward {
return fmt.Errorf("端口 %d 已被其他转发占用", port)
}
bases, err := h.forwardServiceBaseCandidates(forward)
if err != nil {
return err
}
if err := h.deleteForwardServiceBasesOnNode(node.ID, bases); err != nil {
return err
}
time.Sleep(150 * time.Millisecond)
_, err = h.sendNodeCommand(node.ID, "AddService", services, true, false)
if err != nil {
return err
}
return nil
}
func (h *Handler) deleteForwardServicesOnNode(forward *forwardRecord, nodeID int64) error {
if h == nil || forward == nil {
return errors.New("invalid forward delete context")
}
bases, err := h.forwardServiceBaseCandidates(forward)
if err != nil {
return err
}
return h.deleteForwardServiceBasesOnNode(nodeID, bases)
}
func (h *Handler) forwardServiceBaseCandidates(forward *forwardRecord) ([]string, error) {
if h == nil || forward == nil {
return nil, errors.New("invalid forward service base context")
}
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
if err != nil {
return nil, err
}
userTunnelIDs, err := h.listUserTunnelIDs(forward.UserID, forward.TunnelID)
if err != nil {
return nil, err
}
allUserTunnelIDs, err := h.listUserTunnelIDsByUser(forward.UserID)
if err != nil {
return nil, err
}
candidateTunnelIDs := make([]int64, 0, len(userTunnelIDs)+len(allUserTunnelIDs))
candidateTunnelIDs = append(candidateTunnelIDs, userTunnelIDs...)
candidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)
return buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs), nil
}
func (h *Handler) deleteForwardServiceBasesOnNode(nodeID int64, bases []string) error {
return deleteForwardServiceCandidates(bases, func(name string) error {
payload := map[string]interface{}{
"services": []string{name},
}
_, err := h.sendNodeCommand(nodeID, "DeleteService", payload, false, false)
return err
})
}
func (h *Handler) controlForwardServices(forward *forwardRecord, commandType string, tolerateNotFound bool) error {
if h == nil || forward == nil {
return errors.New("invalid forward control context")
@@ -321,40 +460,26 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
candidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)
bases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)
seen := map[int64]struct{}{}
healed := false
for _, fp := range ports {
if _, ok := seen[fp.NodeID]; ok {
continue
}
seen[fp.NodeID] = struct{}{}
var lastNotFoundErr error
nodeHandled := false
nodeHandled, lastNotFoundErr, err := h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
if err != nil {
return err
}
for _, base := range bases {
variants := []string{base + "_tcp", base + "_udp"}
if shouldTryLegacySingleService(commandType) || strings.EqualFold(strings.TrimSpace(commandType), "DeleteService") {
variants = append(variants, base)
if !nodeHandled && lastNotFoundErr != nil && !healed && shouldSelfHealForwardServiceControl(commandType) {
if healErr := h.syncForwardServices(forward, "UpdateService", true); healErr != nil {
return healErr
}
candidateHandled := false
for _, name := range variants {
payload := map[string]interface{}{
"services": []string{name},
}
_, err := h.sendNodeCommand(fp.NodeID, commandType, payload, false, false)
if err == nil {
candidateHandled = true
continue
}
if !isNotFoundError(err) {
return err
}
lastNotFoundErr = err
}
if candidateHandled {
nodeHandled = true
break
healed = true
nodeHandled, lastNotFoundErr, err = h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
if err != nil {
return err
}
}
@@ -372,6 +497,65 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
return nil
}
func (h *Handler) controlForwardServicesOnNode(nodeID int64, bases []string, commandType string) (bool, error, error) {
return controlForwardServiceCommand(bases, commandType, func(name string) error {
payload := map[string]interface{}{
"services": []string{name},
}
_, err := h.sendNodeCommand(nodeID, commandType, payload, false, false)
return err
})
}
func controlForwardServiceCommand(bases []string, commandType string, send func(name string) error) (bool, error, error) {
var lastNotFoundErr error
for _, base := range bases {
variants := []string{base + "_tcp", base + "_udp"}
if shouldTryLegacySingleService(commandType) || strings.EqualFold(strings.TrimSpace(commandType), "DeleteService") {
variants = append(variants, base)
}
candidateHandled := false
for _, name := range variants {
err := send(name)
if err == nil {
candidateHandled = true
continue
}
if !isNotFoundError(err) {
return false, lastNotFoundErr, err
}
lastNotFoundErr = err
}
if candidateHandled {
return true, nil, nil
}
}
return false, lastNotFoundErr, nil
}
func deleteForwardServiceCandidates(bases []string, send func(name string) error) error {
for _, base := range bases {
for _, name := range append([]string{base + "_tcp", base + "_udp", base}, []string{}...) {
err := send(name)
if err == nil {
continue
}
if isNotFoundError(err) {
continue
}
return err
}
}
return nil
}
func shouldSelfHealForwardServiceControl(commandType string) bool {
cmd := strings.ToLower(strings.TrimSpace(commandType))
return cmd == "pauseservice" || cmd == "resumeservice"
}
func (h *Handler) applyNodeProtocolChange(nodeID int64, httpVal, tlsVal, socksVal int) error {
_, err := h.sendNodeCommand(nodeID, "SetProtocol", map[string]interface{}{
"http": httpVal,
@@ -1099,7 +1283,7 @@ func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nod
h.appendFailedDiagnosis(results, nodeCache, fromNodeID, "", 0, description, metadata, err.Error())
return
}
targetIP, targetPort, err := resolveChainProbeTarget(fromNode, targetNode, toNode.Port, ipPreference)
targetIP, targetPort, err := resolveChainProbeTarget(fromNode, targetNode, toNode.Port, ipPreference, toNode.ConnectIP)
if err != nil {
h.appendFailedDiagnosis(results, nodeCache, fromNodeID, strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]"), toNode.Port, description, metadata, err.Error())
return
@@ -1107,11 +1291,11 @@ func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nod
h.appendPathDiagnosis(results, nodeCache, fromNodeID, targetIP, targetPort, description, metadata, options)
}
func resolveChainProbeTarget(fromNode, targetNode *nodeRecord, preferredPort int, ipPreference string) (string, int, error) {
func resolveChainProbeTarget(fromNode, targetNode *nodeRecord, preferredPort int, ipPreference string, connectIp string) (string, int, error) {
if targetNode == nil {
return "", 0, errors.New("目标节点不存在")
}
host, err := selectTunnelDialHost(fromNode, targetNode, ipPreference)
host, err := selectTunnelDialHost(fromNode, targetNode, ipPreference, connectIp)
if err != nil {
host = strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]")
}
@@ -1246,6 +1430,13 @@ func buildForwardServiceBase(forwardID, userID, userTunnelID int64) string {
return fmt.Sprintf("%d_%d_%d", forwardID, userID, userTunnelID)
}
func buildForwardServiceBaseWithResolvedUserTunnel(forwardID, userID, resolvedUserTunnelID int64) string {
if resolvedUserTunnelID <= 0 {
return buildForwardServiceBase(forwardID, userID, 0)
}
return buildForwardServiceBase(forwardID, userID, resolvedUserTunnelID)
}
func buildForwardServiceBaseCandidates(forwardID, userID, preferredUserTunnelID int64, userTunnelIDs []int64) []string {
orderedIDs := make([]int64, 0, len(userTunnelIDs)+2)
seen := make(map[int64]struct{}, len(userTunnelIDs)+2)
@@ -1297,13 +1488,64 @@ func isAlreadyExistsMessage(message string) bool {
if msg == "" {
return false
}
if strings.Contains(msg, "address already in use") {
if isAddressAlreadyInUseMessage(msg) {
return false
}
return strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在")
compact := compactErrorMessage(msg)
return strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在") || strings.Contains(compact, "alreadyexists")
}
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {
func isBindAddressInUseError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(strings.TrimSpace(err.Error()))
if msg == "" {
return false
}
return isAddressAlreadyInUseMessage(msg) || strings.Contains(msg, "cannot assign requested address")
}
func isAddressAlreadyInUseError(err error) bool {
if err == nil {
return false
}
return isAddressAlreadyInUseMessage(strings.ToLower(strings.TrimSpace(err.Error())))
}
func isAddressAlreadyInUseMessage(msg string) bool {
if msg == "" {
return false
}
if strings.Contains(msg, "address already in use") {
return true
}
return strings.Contains(compactErrorMessage(msg), "addressalreadyinuse")
}
func isCannotAssignRequestedAddressError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(strings.TrimSpace(err.Error()))
if msg == "" {
return false
}
if strings.Contains(msg, "cannot assign requested address") {
return true
}
return strings.Contains(compactErrorMessage(msg), "cannotassignrequestedaddress")
}
func compactErrorMessage(msg string) string {
msg = strings.TrimSpace(msg)
if msg == "" {
return ""
}
return strings.Join(strings.Fields(strings.ToLower(msg)), "")
}
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {
protocols := []string{"tcp", "udp"}
services := make([]map[string]interface{}, 0, 2)
targets := splitRemoteTargets(forward.RemoteAddr)
@@ -1317,9 +1559,19 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
if protocol == "udp" {
listenerAddr = node.UDPListenAddr
}
var serviceAddr string
if bindIP != "" {
if strings.Contains(bindIP, ":") {
serviceAddr = processServerAddress(bindIP)
} else {
serviceAddr = processServerAddress(fmt.Sprintf("%s:%d", bindIP, port))
}
} else {
serviceAddr = processServerAddress(fmt.Sprintf("%s:%d", listenerAddr, port))
}
service := map[string]interface{}{
"name": fmt.Sprintf("%s_%s", baseName, protocol),
"addr": fmt.Sprintf("%s:%d", listenerAddr, port),
"addr": serviceAddr,
"handler": map[string]interface{}{
"type": protocol,
},
@@ -1369,13 +1621,21 @@ func buildForwarderNodes(targets []string) []map[string]interface{} {
}
func processServerAddress(serverAddr string) string {
serverAddr = strings.TrimSpace(serverAddr)
serverAddr = normalizeServerAddressInput(serverAddr)
if serverAddr == "" {
return serverAddr
}
if strings.HasPrefix(serverAddr, "[") {
return serverAddr
}
// If the input is a bare IPv6 host (no port), bracket it.
// IPv6-with-port must be provided in bracket form: [::1]:443.
if looksLikeIPv6(serverAddr) {
if ip := net.ParseIP(serverAddr); ip != nil && ip.To4() == nil {
return "[" + serverAddr + "]"
}
}
idx := strings.LastIndex(serverAddr, ":")
if idx < 0 {
if looksLikeIPv6(serverAddr) {
@@ -1394,6 +1654,27 @@ func processServerAddress(serverAddr string) string {
return serverAddr
}
func normalizeServerAddressInput(serverAddr string) string {
serverAddr = strings.TrimSpace(serverAddr)
if serverAddr == "" {
return serverAddr
}
if idx := strings.Index(serverAddr, "://"); idx > 0 {
if parsed, err := url.Parse(serverAddr); err == nil {
if host := strings.TrimSpace(parsed.Host); host != "" {
return host
}
}
serverAddr = serverAddr[idx+3:]
}
if idx := strings.IndexAny(serverAddr, "/?#"); idx >= 0 {
serverAddr = serverAddr[:idx]
}
return strings.TrimSpace(serverAddr)
}
func looksLikeIPv6(address string) bool {
return strings.Count(address, ":") >= 2
}
@@ -1,8 +1,11 @@
package handler
import (
"errors"
"reflect"
"testing"
"go-backend/internal/store/repo"
)
func TestBuildForwardControlServiceNamesPauseResume(t *testing.T) {
@@ -42,6 +45,20 @@ func TestBuildForwardServiceBaseCandidatesWithZeroPreferred(t *testing.T) {
}
}
func TestBuildForwardServiceBaseWithResolvedUserTunnel(t *testing.T) {
got := buildForwardServiceBaseWithResolvedUserTunnel(12, 34, 56)
if got != "12_34_56" {
t.Fatalf("expected 12_34_56, got %s", got)
}
}
func TestBuildForwardServiceBaseWithResolvedUserTunnelFallbackToZero(t *testing.T) {
got := buildForwardServiceBaseWithResolvedUserTunnel(12, 34, 0)
if got != "12_34_0" {
t.Fatalf("expected 12_34_0, got %s", got)
}
}
func TestShouldTryLegacySingleService(t *testing.T) {
if !shouldTryLegacySingleService("PauseService") {
t.Fatalf("PauseService should require legacy fallback")
@@ -54,6 +71,184 @@ func TestShouldTryLegacySingleService(t *testing.T) {
}
}
func TestShouldSelfHealForwardServiceControl(t *testing.T) {
if !shouldSelfHealForwardServiceControl("PauseService") {
t.Fatalf("PauseService should trigger self-heal")
}
if !shouldSelfHealForwardServiceControl(" resumeService ") {
t.Fatalf("ResumeService should trigger self-heal")
}
if shouldSelfHealForwardServiceControl("DeleteService") {
t.Fatalf("DeleteService should not trigger self-heal")
}
}
func TestControlForwardServiceCommandHandledOnKnownVariant(t *testing.T) {
bases := []string{"12_34_56"}
called := make([]string, 0)
handled, lastNotFoundErr, err := controlForwardServiceCommand(bases, "PauseService", func(name string) error {
called = append(called, name)
if name == "12_34_56_udp" {
return nil
}
return errors.New("service " + name + " not found")
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !handled {
t.Fatalf("expected handled=true")
}
if lastNotFoundErr != nil {
t.Fatalf("expected lastNotFoundErr=nil when handled")
}
wantCalls := []string{"12_34_56_tcp", "12_34_56_udp", "12_34_56"}
if !reflect.DeepEqual(called, wantCalls) {
t.Fatalf("expected calls %v, got %v", wantCalls, called)
}
}
func TestControlForwardServiceCommandReturnsLastNotFoundWhenAllMissing(t *testing.T) {
bases := []string{"12_34_56"}
handled, lastNotFoundErr, err := controlForwardServiceCommand(bases, "PauseService", func(name string) error {
return errors.New("service " + name + " not found")
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if handled {
t.Fatalf("expected handled=false")
}
if lastNotFoundErr == nil {
t.Fatalf("expected lastNotFoundErr when all variants are missing")
}
}
func TestDeleteForwardServiceCandidatesSkipsNotFoundUntilLegacyMatch(t *testing.T) {
bases := []string{"12_34_56", "12_34_0"}
called := make([]string, 0)
err := deleteForwardServiceCandidates(bases, func(name string) error {
called = append(called, name)
if name == "12_34_0" {
return nil
}
return errors.New("service " + name + " not found")
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
wantCalls := []string{"12_34_56_tcp", "12_34_56_udp", "12_34_56", "12_34_0_tcp", "12_34_0_udp", "12_34_0"}
if !reflect.DeepEqual(called, wantCalls) {
t.Fatalf("expected calls %v, got %v", wantCalls, called)
}
}
func TestDeleteForwardServiceCandidatesTreatsAllMissingAsSuccess(t *testing.T) {
bases := []string{"12_34_56", "12_34_0"}
err := deleteForwardServiceCandidates(bases, func(name string) error {
return errors.New("service " + name + " not found")
})
if err != nil {
t.Fatalf("all-missing delete should be tolerated, got %v", err)
}
}
func TestForwardServiceBaseCandidatesIncludesResolvedAndLegacyZero(t *testing.T) {
bases := buildForwardServiceBaseCandidates(46, 9, 123, []int64{123, 77, 0})
want := []string{"46_9_123", "46_9_77", "46_9_0"}
if !reflect.DeepEqual(bases, want) {
t.Fatalf("expected %v, got %v", want, bases)
}
}
func TestDeleteForwardServiceBasesOnNodeRetriesLegacyZeroResidue(t *testing.T) {
bases := []string{"46_9_123", "46_9_0"}
called := make([]string, 0)
err := deleteForwardServiceCandidates(bases, func(name string) error {
called = append(called, name)
if name == "46_9_0_tcp" || name == "46_9_0_udp" {
return nil
}
return errors.New("service " + name + " not found")
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"46_9_123_tcp", "46_9_123_udp", "46_9_123", "46_9_0_tcp", "46_9_0_udp", "46_9_0"}
if !reflect.DeepEqual(called, want) {
t.Fatalf("expected calls %v, got %v", want, called)
}
}
func TestDeleteForwardServiceCandidatesDeletesAllMatchingVariants(t *testing.T) {
bases := []string{"57_7_7", "57_7_0"}
called := make([]string, 0)
err := deleteForwardServiceCandidates(bases, func(name string) error {
called = append(called, name)
switch name {
case "57_7_7_tcp", "57_7_7_udp", "57_7_0_tcp", "57_7_0_udp":
return nil
default:
return errors.New("service " + name + " not found")
}
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"57_7_7_tcp", "57_7_7_udp", "57_7_7", "57_7_0_tcp", "57_7_0_udp", "57_7_0"}
if !reflect.DeepEqual(called, want) {
t.Fatalf("expected calls %v, got %v", want, called)
}
}
func TestValidateForwardPortAvailabilityRejectsOtherForwardOccupancy(t *testing.T) {
h := &Handler{repo: nil}
node := &nodeRecord{ID: 9, Name: "test-node"}
_ = h
_ = node
rawRepo, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
h = &Handler{repo: rawRepo}
if err := rawRepo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(1, 9, 2000)`).Error; err != nil {
t.Fatalf("insert forward port: %v", err)
}
err = h.validateForwardPortAvailability(&nodeRecord{ID: 9, Name: "test-node"}, 2000, 2)
if err == nil {
t.Fatalf("expected occupancy error")
}
if err.Error() != "节点 test-node 端口 2000 已被其他转发占用" {
t.Fatalf("unexpected error: %v", err)
}
err = h.validateForwardPortAvailability(&nodeRecord{ID: 9, Name: "test-node"}, 2000, 1)
if err != nil {
t.Fatalf("same forward should be allowed, got %v", err)
}
}
func TestControlForwardServiceCommandReturnsHardError(t *testing.T) {
bases := []string{"12_34_56"}
handled, lastNotFoundErr, err := controlForwardServiceCommand(bases, "PauseService", func(name string) error {
if name == "12_34_56_tcp" {
return errors.New("network timeout")
}
return nil
})
if err == nil {
t.Fatalf("expected hard error")
}
if handled {
t.Fatalf("expected handled=false on hard error")
}
if lastNotFoundErr != nil {
t.Fatalf("did not expect not-found error alongside hard error")
}
}
func TestIsAlreadyExistsMessage(t *testing.T) {
if !isAlreadyExistsMessage("service demo already exists") {
t.Fatalf("expected already exists message to be tolerated")
@@ -61,7 +256,232 @@ func TestIsAlreadyExistsMessage(t *testing.T) {
if !isAlreadyExistsMessage("服务已存在") {
t.Fatalf("expected Chinese already exists message to be tolerated")
}
if !isAlreadyExistsMessage("service demo alreadyexists") {
t.Fatalf("missing-space alreadyexists should 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")
}
if isAlreadyExistsMessage("create service 57_7_7_tcp failed: listen tcp4 0.0.0.0:46222: bind: address alreadyin use") {
t.Fatalf("alreadyin-use variant must not be treated as already exists")
}
}
func TestIsBindAddressInUseError(t *testing.T) {
if !isBindAddressInUseError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
t.Fatalf("address already in use should be detected")
}
if !isBindAddressInUseError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
t.Fatalf("cannot assign requested address should be detected")
}
if isBindAddressInUseError(errors.New("service demo already exists")) {
t.Fatalf("already exists should not be treated as bind conflict")
}
if isBindAddressInUseError(nil) {
t.Fatalf("nil error should not be treated as bind conflict")
}
}
func TestIsAddressAlreadyInUseError(t *testing.T) {
if !isAddressAlreadyInUseError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
t.Fatalf("address already in use should be detected")
}
if !isAddressAlreadyInUseError(errors.New("create service 57_7_7_tcp failed: listen tcp4 0.0.0.0:46222: bind: address alreadyin use")) {
t.Fatalf("missing-space alreadyin-use variant should be detected")
}
if isAddressAlreadyInUseError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
t.Fatalf("cannot assign requested address should not be treated as address-in-use")
}
}
func TestIsCannotAssignRequestedAddressError(t *testing.T) {
if !isCannotAssignRequestedAddressError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
t.Fatalf("cannot assign requested address should be detected")
}
if !isCannotAssignRequestedAddressError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannotassignrequestedaddress")) {
t.Fatalf("missing-space cannotassignrequestedaddress variant should be detected")
}
if isCannotAssignRequestedAddressError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
t.Fatalf("address already in use should not be treated as cannot-assign")
}
}
func TestRetryTunnelServiceAddWithCleanupRetriesOnAddressInUse(t *testing.T) {
addCalls := 0
cleanupCalls := 0
err := retryTunnelServiceAddWithCleanup(
func() error {
addCalls++
if addCalls == 1 {
return errors.New("listen tcp 10.0.0.1:32000: bind: address already in use")
}
return nil
},
func() error {
cleanupCalls++
return nil
},
0,
)
if err != nil {
t.Fatalf("expected retry to succeed, got %v", err)
}
if addCalls != 2 {
t.Fatalf("expected 2 add attempts, got %d", addCalls)
}
if cleanupCalls != 1 {
t.Fatalf("expected 1 cleanup attempt, got %d", cleanupCalls)
}
}
func TestRetryTunnelServiceAddWithCleanupSkipsCleanupOnNonBindError(t *testing.T) {
addCalls := 0
cleanupCalls := 0
err := retryTunnelServiceAddWithCleanup(
func() error {
addCalls++
return errors.New("network timeout")
},
func() error {
cleanupCalls++
return nil
},
0,
)
if err == nil {
t.Fatalf("expected hard error")
}
if addCalls != 1 {
t.Fatalf("expected 1 add attempt, got %d", addCalls)
}
if cleanupCalls != 0 {
t.Fatalf("expected 0 cleanup attempts, got %d", cleanupCalls)
}
}
func TestRetryTunnelServiceAddWithCleanupReturnsCleanupError(t *testing.T) {
cleanupErr := errors.New("delete failed")
err := retryTunnelServiceAddWithCleanup(
func() error {
return errors.New("listen tcp 10.0.0.1:32000: bind: address already in use")
},
func() error {
return cleanupErr
},
0,
)
if !errors.Is(err, cleanupErr) {
t.Fatalf("expected cleanup error %v, got %v", cleanupErr, err)
}
}
func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22000, "10.9.8.7", nil, false)
if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services))
}
for _, svc := range services {
addr, _ := svc["addr"].(string)
if addr != "10.9.8.7:22000" {
t.Fatalf("expected bind IP address 10.9.8.7:22000, got %q", addr)
}
}
}
func TestBuildForwardServiceConfigs_DefaultListenAddrWhenBindIPEmpty(t *testing.T) {
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
node := &nodeRecord{TCPListenAddr: "0.0.0.0", UDPListenAddr: "[::]"}
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22001, "", nil, false)
if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services))
}
tcpAddr, _ := services[0]["addr"].(string)
udpAddr, _ := services[1]["addr"].(string)
if tcpAddr != "0.0.0.0:22001" {
t.Fatalf("expected tcp addr 0.0.0.0:22001, got %q", tcpAddr)
}
if udpAddr != "[::]:22001" {
t.Fatalf("expected udp addr [::]:22001, got %q", udpAddr)
}
}
func TestBuildForwardServiceConfigs_BindIPAlreadyContainsPort(t *testing.T) {
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 55555, "3.3.3.3:12345", nil, false)
if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services))
}
for _, svc := range services {
addr, _ := svc["addr"].(string)
if addr != "3.3.3.3:12345" {
t.Fatalf("expected bind IP with port 3.3.3.3:12345, got %q", addr)
}
}
}
func TestProcessServerAddress_StripsURLSchemeAndPath(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "https with path",
in: "https://panel.example.com:8443/api/v1",
want: "panel.example.com:8443",
},
{
name: "wss with query",
in: "wss://panel.example.com:443/system-info?x=1",
want: "panel.example.com:443",
},
{
name: "http without port",
in: "http://panel.example.com",
want: "panel.example.com",
},
{
name: "manual host with trailing path",
in: "panel.example.com:8080/path",
want: "panel.example.com:8080",
},
}
for _, tt := range tests {
if got := processServerAddress(tt.in); got != tt.want {
t.Fatalf("%s: expected %q, got %q", tt.name, tt.want, got)
}
}
}
func TestProcessServerAddress_NormalizesIPv6(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "ipv6 host only",
in: "2001:db8::1",
want: "[2001:db8::1]",
},
{
name: "ipv6 host and port",
in: "https://[2001:db8::1]:8443/path",
want: "[2001:db8::1]:8443",
},
{
name: "already bracketed",
in: "[2001:db8::2]:9000",
want: "[2001:db8::2]:9000",
},
}
for _, tt := range tests {
if got := processServerAddress(tt.in); got != tt.want {
t.Fatalf("%s: expected %q, got %q", tt.name, tt.want, got)
}
}
}
@@ -8,9 +8,100 @@ import (
// nodeSupportsV4 / nodeSupportsV6
// ---------------------------------------------------------------------------
func TestNodeSupportsV4_Nil(t *testing.T) {
if nodeSupportsV4(nil) {
t.Fatal("nil node must not support v4")
func TestSelectTunnelDialHost_ConnectIpPriority(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
// Empty connectIp should be ignored, IP preference takes effect
host, err := selectTunnelDialHost(from, to, "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "10.0.0.2" {
t.Fatalf("empty connectIp should be ignored (v4 preference applies), got %q", host)
}
// Non-empty connectIp should override IP preference
host, err = selectTunnelDialHost(from, to, "v6", "192.168.0.3")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "192.168.0.3" {
t.Fatalf("connectIp should override v6 preference, got %q", host)
}
}
func TestBuildTunnelChainServiceConfig_UsesConnectIPForListen(t *testing.T) {
node := &nodeRecord{TCPListenAddr: "[::]"}
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21000, ConnectIP: "2001:db8::88"}
services := buildTunnelChainServiceConfig(99, chain, node, 1)
if len(services) != 1 {
t.Fatalf("expected 1 service, got %d", len(services))
}
addr, _ := services[0]["addr"].(string)
if addr != "[2001:db8::88]:21000" {
t.Fatalf("expected connectIp listen [2001:db8::88]:21000, got %q", addr)
}
}
func TestBuildTunnelChainServiceConfig_FallsBackToNodeListenAddr(t *testing.T) {
node := &nodeRecord{TCPListenAddr: "10.8.0.5"}
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21002}
services := buildTunnelChainServiceConfig(99, chain, node, 1)
if len(services) != 1 {
t.Fatalf("expected 1 service, got %d", len(services))
}
addr, _ := services[0]["addr"].(string)
if addr != "10.8.0.5:21002" {
t.Fatalf("expected node listen addr 10.8.0.5:21002, got %q", addr)
}
}
func TestBuildTunnelChainServiceConfig_DefaultListenAddrWhenConnectIPEmpty(t *testing.T) {
node := &nodeRecord{TCPListenAddr: "[::]"}
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21001}
services := buildTunnelChainServiceConfig(99, chain, node, 1)
if len(services) != 1 {
t.Fatalf("expected 1 service, got %d", len(services))
}
addr, _ := services[0]["addr"].(string)
if addr != "[::]:21001" {
t.Fatalf("expected default listen [::]:21001, got %q", addr)
}
}
func TestBuildTunnelChainServiceConfig_SetsRetriesWhenMultipleCandidates(t *testing.T) {
node := &nodeRecord{TCPListenAddr: "[::]"}
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21001}
services := buildTunnelChainServiceConfig(99, chain, node, 3)
if len(services) != 1 {
t.Fatalf("expected 1 service, got %d", len(services))
}
handler, _ := services[0]["handler"].(map[string]interface{})
if handler == nil {
t.Fatal("expected handler config")
}
retries, ok := handler["retries"].(int)
if !ok {
t.Fatal("expected retries to be set when nextHopCandidateCount > 1")
}
if retries != 2 {
t.Fatalf("expected retries=2 (candidates-1), got %d", retries)
}
}
func TestBuildTunnelChainServiceConfig_NoRetriesWhenSingleCandidate(t *testing.T) {
node := &nodeRecord{TCPListenAddr: "[::]"}
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21001}
services := buildTunnelChainServiceConfig(99, chain, node, 1)
if len(services) != 1 {
t.Fatalf("expected 1 service, got %d", len(services))
}
handler, _ := services[0]["handler"].(map[string]interface{})
if handler == nil {
t.Fatal("expected handler config")
}
if _, hasRetries := handler["retries"]; hasRetries {
t.Fatal("expected no retries when nextHopCandidateCount is 1")
}
}
@@ -23,14 +114,14 @@ func TestNodeSupportsV6_Nil(t *testing.T) {
func TestNodeSupportsV4_ExplicitV4(t *testing.T) {
n := &nodeRecord{ServerIPv4: "10.0.0.1"}
if !nodeSupportsV4(n) {
t.Fatal("explicit server_ip_v4 must support v4")
t.Fatal("explicit server_ip_v4 needs support v4")
}
}
func TestNodeSupportsV6_ExplicitV6(t *testing.T) {
n := &nodeRecord{ServerIPv6: "2001:db8::1"}
if !nodeSupportsV6(n) {
t.Fatal("explicit server_ip_v6 must support v6")
t.Fatal("explicit server_ip_v6 needs support v6")
}
}
@@ -68,7 +159,7 @@ func TestNodeSupportsV4_LegacyV4Only(t *testing.T) {
t.Fatal("legacy v4 ip in server_ip must support v4")
}
if nodeSupportsV6(n) {
t.Fatal("legacy v4 ip in server_ip must not support v6")
t.Fatal("legacy v4 ip in server_ip should not support v6")
}
}
@@ -78,7 +169,7 @@ func TestNodeSupportsV6_LegacyV6Only(t *testing.T) {
t.Fatal("legacy v6 ip in server_ip must support v6")
}
if nodeSupportsV4(n) {
t.Fatal("legacy v6 ip in server_ip must not support v4")
t.Fatal("legacy v6 ip in server_ip should not support v4")
}
}
@@ -177,15 +268,15 @@ func v6OnlyNode(name, v6 string) *nodeRecord {
}
func TestSelectTunnelDialHost_NilNodes(t *testing.T) {
_, err := selectTunnelDialHost(nil, nil, "")
_, err := selectTunnelDialHost(nil, nil, "", "")
if err == nil {
t.Fatal("expected error for nil nodes")
}
_, err = selectTunnelDialHost(dualStackNode("a", "1.1.1.1", "::1"), nil, "")
_, err = selectTunnelDialHost(dualStackNode("a", "1.1.1.1", "::1"), nil, "", "")
if err == nil {
t.Fatal("expected error for nil toNode")
}
_, err = selectTunnelDialHost(nil, dualStackNode("b", "1.1.1.1", "::1"), "")
_, err = selectTunnelDialHost(nil, dualStackNode("b", "1.1.1.1", "::1"), "", "")
if err == nil {
t.Fatal("expected error for nil fromNode")
}
@@ -194,8 +285,7 @@ func TestSelectTunnelDialHost_NilNodes(t *testing.T) {
func TestSelectTunnelDialHost_DualStack_DefaultPreference(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
host, err := selectTunnelDialHost(from, to, "")
host, err := selectTunnelDialHost(from, to, "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -208,8 +298,7 @@ func TestSelectTunnelDialHost_DualStack_DefaultPreference(t *testing.T) {
func TestSelectTunnelDialHost_DualStack_PreferV4(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
host, err := selectTunnelDialHost(from, to, "v4")
host, err := selectTunnelDialHost(from, to, "v4", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -221,8 +310,7 @@ func TestSelectTunnelDialHost_DualStack_PreferV4(t *testing.T) {
func TestSelectTunnelDialHost_DualStack_PreferV6(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
host, err := selectTunnelDialHost(from, to, "v6")
host, err := selectTunnelDialHost(from, to, "v6", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -234,9 +322,8 @@ func TestSelectTunnelDialHost_DualStack_PreferV6(t *testing.T) {
func TestSelectTunnelDialHost_V4Only_PreferV6Fallback(t *testing.T) {
from := v4OnlyNode("from", "10.0.0.1")
to := v4OnlyNode("to", "10.0.0.2")
// User prefers v6, but both nodes are v4-only — should fallback to v4
host, err := selectTunnelDialHost(from, to, "v6")
host, err := selectTunnelDialHost(from, to, "v6", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -248,9 +335,8 @@ func TestSelectTunnelDialHost_V4Only_PreferV6Fallback(t *testing.T) {
func TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {
from := v6OnlyNode("from", "2001:db8::1")
to := v6OnlyNode("to", "2001:db8::2")
// User prefers v4, but both nodes are v6-only — should fallback to v6
host, err := selectTunnelDialHost(from, to, "v4")
host, err := selectTunnelDialHost(from, to, "v4", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -262,8 +348,7 @@ func TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {
func TestSelectTunnelDialHost_Incompatible(t *testing.T) {
from := v4OnlyNode("from", "10.0.0.1")
to := v6OnlyNode("to", "2001:db8::2")
_, err := selectTunnelDialHost(from, to, "")
_, err := selectTunnelDialHost(from, to, "", "")
if err == nil {
t.Fatal("expected error for incompatible nodes (v4-only -> v6-only)")
}
@@ -272,8 +357,7 @@ func TestSelectTunnelDialHost_Incompatible(t *testing.T) {
func TestSelectTunnelDialHost_Incompatible_Reverse(t *testing.T) {
from := v6OnlyNode("from", "2001:db8::1")
to := v4OnlyNode("to", "10.0.0.2")
_, err := selectTunnelDialHost(from, to, "")
_, err := selectTunnelDialHost(from, to, "", "")
if err == nil {
t.Fatal("expected error for incompatible nodes (v6-only -> v4-only)")
}
@@ -282,9 +366,8 @@ func TestSelectTunnelDialHost_Incompatible_Reverse(t *testing.T) {
func TestSelectTunnelDialHost_WhitespacePreference(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
// Whitespace should be trimmed, treated as "v6"
host, err := selectTunnelDialHost(from, to, " v6 ")
host, err := selectTunnelDialHost(from, to, " v6 ", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -296,9 +379,8 @@ func TestSelectTunnelDialHost_WhitespacePreference(t *testing.T) {
func TestSelectTunnelDialHost_MixedStack_FromDualToV4(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := v4OnlyNode("to", "10.0.0.2")
// v6 preferred, but target only has v4 — should succeed with v4
host, err := selectTunnelDialHost(from, to, "v6")
host, err := selectTunnelDialHost(from, to, "v6", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -310,9 +392,8 @@ func TestSelectTunnelDialHost_MixedStack_FromDualToV4(t *testing.T) {
func TestSelectTunnelDialHost_MixedStack_FromDualToV6(t *testing.T) {
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
to := v6OnlyNode("to", "2001:db8::2")
// v4 preferred, but target only has v6 — should succeed with v6
host, err := selectTunnelDialHost(from, to, "v4")
host, err := selectTunnelDialHost(from, to, "v4", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -324,9 +405,8 @@ func TestSelectTunnelDialHost_MixedStack_FromDualToV6(t *testing.T) {
func TestSelectTunnelDialHost_MixedStack_FromV4ToDual(t *testing.T) {
from := v4OnlyNode("from", "10.0.0.1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
// v6 preferred, but from only has v4 — should use v4 (from can only reach v4 of target)
host, err := selectTunnelDialHost(from, to, "v6")
host, err := selectTunnelDialHost(from, to, "v6", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -338,9 +418,8 @@ func TestSelectTunnelDialHost_MixedStack_FromV4ToDual(t *testing.T) {
func TestSelectTunnelDialHost_MixedStack_FromV6ToDual(t *testing.T) {
from := v6OnlyNode("from", "2001:db8::1")
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
// v4 preferred, but from only has v6 — should use v6
host, err := selectTunnelDialHost(from, to, "v4")
host, err := selectTunnelDialHost(from, to, "v4", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -367,7 +446,6 @@ func TestNodeDisplayName_Named(t *testing.T) {
t.Fatalf("expected 'hk-node', got %q", got)
}
}
func TestNodeDisplayName_Unnamed(t *testing.T) {
n := &nodeRecord{ID: 42}
got := nodeDisplayName(n)
+36 -19
View File
@@ -141,6 +141,32 @@ type remoteUsageNodeItem struct {
SyncError string `json:"syncError,omitempty"`
}
func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName string, targetCount int, interfaceName string) map[string]interface{} {
service := map[string]interface{}{
"name": serviceName,
"addr": addr,
"handler": map[string]interface{}{
"type": "relay",
},
"listener": map[string]interface{}{
"type": protocol,
},
}
if isTLSTunnelProtocol(protocol) {
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{"nodelay": true}
}
if role == "middle" {
service["handler"].(map[string]interface{})["chain"] = chainName
if targetCount > 1 {
service["handler"].(map[string]interface{})["retries"] = targetCount - 1
}
}
if role == "exit" && strings.TrimSpace(interfaceName) != "" {
service["metadata"] = map[string]interface{}{"interface": interfaceName}
}
return service
}
func (h *Handler) federationShareList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("Invalid method"))
@@ -1096,25 +1122,16 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
}
}
service := map[string]interface{}{
"name": serviceName,
"addr": fmt.Sprintf("%s:%d", node.TCPListenAddr, runtime.Port),
"handler": map[string]interface{}{
"type": "relay",
},
"listener": map[string]interface{}{
"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
}
if req.Role == "exit" && strings.TrimSpace(node.InterfaceName) != "" {
service["metadata"] = map[string]interface{}{"interface": node.InterfaceName}
}
targetCount := len(req.Targets)
service := buildFederationServiceConfig(
serviceName,
fmt.Sprintf("%s:%d", node.TCPListenAddr, runtime.Port),
protocol,
req.Role,
chainName,
targetCount,
node.InterfaceName,
)
if _, err := h.sendNodeCommand(share.NodeID, "AddService", []map[string]interface{}{service}, true, false); err != nil {
if req.Role == "middle" {
_, _ = h.sendNodeCommand(share.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
@@ -227,6 +227,60 @@ func TestPrepareTunnelCreateStateAllowsOfflineRemoteMiddleNode(t *testing.T) {
}
}
func TestBuildFederationServiceConfig_MiddleRoleWithMultipleTargets_SetsRetries(t *testing.T) {
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 3, "")
handler := service["handler"].(map[string]interface{})
if handler["chain"] != "chain-next" {
t.Fatalf("expected chain 'chain-next', got %v", handler["chain"])
}
if handler["retries"] != 2 {
t.Fatalf("expected retries 2 for 3 targets, got %v", handler["retries"])
}
}
func TestBuildFederationServiceConfig_MiddleRoleWithSingleTarget_NoRetries(t *testing.T) {
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 1, "")
handler := service["handler"].(map[string]interface{})
if handler["chain"] != "chain-next" {
t.Fatalf("expected chain 'chain-next', got %v", handler["chain"])
}
if _, hasRetries := handler["retries"]; hasRetries {
t.Fatalf("expected no retries for single target, got %v", handler["retries"])
}
}
func TestBuildFederationServiceConfig_ExitRole_NoRetriesRegardlessOfTargets(t *testing.T) {
service := buildFederationServiceConfig("svc-exit", ":40000", "tls", "exit", "", 3, "eth0")
handler := service["handler"].(map[string]interface{})
if _, hasChain := handler["chain"]; hasChain {
t.Fatalf("expected no chain for exit role, got %v", handler["chain"])
}
if _, hasRetries := handler["retries"]; hasRetries {
t.Fatalf("expected no retries for exit role, got %v", handler["retries"])
}
metadata := service["metadata"].(map[string]interface{})
if metadata["interface"] != "eth0" {
t.Fatalf("expected interface 'eth0', got %v", metadata["interface"])
}
}
func TestBuildFederationServiceConfig_TLSTunnelProtocol_SetsNodelay(t *testing.T) {
service := buildFederationServiceConfig("svc-tls", ":40000", "tls", "middle", "chain-next", 2, "")
handler := service["handler"].(map[string]interface{})
meta := handler["metadata"].(map[string]interface{})
if meta["nodelay"] != true {
t.Fatalf("expected nodelay=true for TLS protocol, got %v", meta["nodelay"])
}
}
func TestBuildFederationServiceConfig_NonTLSProtocol_NoNodelay(t *testing.T) {
service := buildFederationServiceConfig("svc-tcp", ":40000", "tcp", "middle", "chain-next", 2, "")
handler := service["handler"].(map[string]interface{})
if _, hasMeta := handler["metadata"]; hasMeta {
t.Fatalf("expected no metadata for non-TLS protocol, got %v", handler["metadata"])
}
}
func TestFederationRuntimeReservePortRejectsWhenShareFlowExceeded(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
@@ -2,6 +2,7 @@ package handler
import (
"encoding/json"
"errors"
"log"
"strconv"
"strings"
@@ -43,6 +44,9 @@ func (h *Handler) processFlowItem(nodeID int64, item flowItem) {
if ok {
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
if quota, quotaErr := h.repo.AddUserQuotaUsage(userID, inFlow+outFlow, time.Now()); quotaErr == nil {
h.enforceUserQuotaIfNeeded(userID, quota)
}
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
if userTunnelID > 0 {
@@ -327,6 +331,70 @@ func (h *Handler) enforceFlowPolicies(userID int64, userTunnelID int64) {
}
}
func (h *Handler) ensureUserTunnelForwardAllowed(userID int64, tunnelID int64, now int64) error {
if h == nil || h.repo == nil {
return errors.New("invalid flow policy context")
}
if userID <= 0 || tunnelID <= 0 {
return nil
}
user, err := h.repo.GetUserByID(userID)
if err != nil {
return err
}
if user == nil {
return errors.New("用户不存在")
}
if user.Status != 1 {
return errors.New("账号已禁用")
}
if user.ExpTime > 0 && user.ExpTime <= now {
return errors.New("账号已过期")
}
flowLimit := user.Flow * bytesPerGB
current := user.InFlow + user.OutFlow
if flowLimit < current {
return errors.New("流量已超额,禁止开启转发")
}
if err := h.ensureUserForwardAllowedByQuota(userID, now); err != nil {
return err
}
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(userID, tunnelID)
if err != nil {
return err
}
if userTunnelID <= 0 {
return nil
}
policy, err := h.getUserTunnelPolicy(userTunnelID)
if err != nil {
return err
}
if policy == nil {
return nil
}
if policy.Status != 1 {
return errors.New("该隧道已禁用")
}
if policy.ExpTime > 0 && policy.ExpTime <= now {
return errors.New("该隧道已过期")
}
utFlowLimit := policy.Flow * bytesPerGB
utCurrent := policy.InFlow + policy.OutFlow
if utCurrent >= utFlowLimit {
return errors.New("该隧道流量已超额,禁止开启转发")
}
return nil
}
func (h *Handler) shouldPauseUser(userID int64, now int64) bool {
user, err := h.repo.GetUserByID(userID)
if err != nil || user == nil {
+75 -6
View File
@@ -16,17 +16,21 @@ import (
"time"
"go-backend/internal/auth"
"go-backend/internal/health"
"go-backend/internal/http/middleware"
"go-backend/internal/http/response"
"go-backend/internal/metrics"
"go-backend/internal/security"
"go-backend/internal/store/repo"
"go-backend/internal/ws"
)
type Handler struct {
repo *repo.Repository
jwtSecret string
wsServer *ws.Server
repo *repo.Repository
jwtSecret string
wsServer *ws.Server
metrics *metrics.IngestionService
healthCheck *health.Checker
captchaMu sync.Mutex
captchaTokens map[string]int64
@@ -38,6 +42,8 @@ type Handler struct {
upgradeMu sync.Mutex
pendingUpgradeRedeploy map[int64]struct{}
qualityProber *tunnelQualityProber
}
type loginRequest struct {
@@ -83,10 +89,32 @@ func New(repo *repo.Repository, jwtSecret string) *Handler {
repo: repo,
jwtSecret: jwtSecret,
wsServer: ws.NewServer(repo, jwtSecret),
metrics: metrics.NewIngestionService(repo),
healthCheck: nil,
captchaTokens: make(map[string]int64),
pendingUpgradeRedeploy: make(map[int64]struct{}),
}
h.healthCheck = health.NewChecker(repo, h.wsServer)
h.qualityProber = newTunnelQualityProber(h)
h.wsServer.SetNodeOnlineHook(h.onNodeOnline)
h.wsServer.SetNodeMetricHook(func(nodeID int64, info ws.SystemInfo) {
metricInfo := metrics.SystemInfo{
Uptime: info.Uptime,
BytesReceived: info.BytesReceived,
BytesTransmitted: info.BytesTransmitted,
CPUUsage: info.CPUUsage,
MemoryUsage: info.MemoryUsage,
DiskUsage: info.DiskUsage,
Load1: info.Load1,
Load5: info.Load5,
Load15: info.Load15,
TCPConns: info.TCPConns,
UDPConns: info.UDPConns,
NetInSpeed: info.NetInSpeed,
NetOutSpeed: info.NetOutSpeed,
}
h.metrics.RecordNodeMetric(nodeID, metricInfo)
})
return h
}
@@ -101,6 +129,7 @@ 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/quota/reset", h.userQuotaReset)
mux.HandleFunc("/api/v1/user/groups", h.userGroups)
mux.HandleFunc("/api/v1/config/get", h.getConfigByName)
mux.HandleFunc("/api/v1/config/list", h.getConfigs)
@@ -122,6 +151,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/node/delete", h.nodeDelete)
mux.HandleFunc("/api/v1/node/install", h.nodeInstall)
mux.HandleFunc("/api/v1/node/update-order", h.nodeUpdateOrder)
mux.HandleFunc("/api/v1/node/dismiss-expiry-reminder", h.nodeDismissExpiryReminder)
mux.HandleFunc("/api/v1/node/batch-delete", h.nodeBatchDelete)
mux.HandleFunc("/api/v1/node/check-status", h.nodeCheckStatus)
mux.HandleFunc("/api/v1/node/upgrade", h.nodeUpgrade)
@@ -133,6 +163,10 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/tunnel/get", h.tunnelGet)
mux.HandleFunc("/api/v1/tunnel/update", h.tunnelUpdate)
mux.HandleFunc("/api/v1/tunnel/delete", h.tunnelDelete)
mux.HandleFunc("/api/v1/tunnel/delete-preview", h.tunnelDeletePreview)
mux.HandleFunc("/api/v1/tunnel/delete-with-forwards", h.tunnelDeleteWithForwards)
mux.HandleFunc("/api/v1/tunnel/batch-delete-preview", h.tunnelBatchDeletePreview)
mux.HandleFunc("/api/v1/tunnel/batch-delete-with-forwards", h.tunnelBatchDeleteWithForwards)
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)
@@ -194,6 +228,24 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/announcement/get", h.getAnnouncement)
mux.HandleFunc("/api/v1/announcement/update", h.updateAnnouncement)
mux.HandleFunc("/api/v1/monitor/access", h.monitorAccessHandler)
mux.HandleFunc("/api/v1/monitor/nodes/", h.monitorNodeMetricsHandler)
mux.HandleFunc("/api/v1/monitor/nodes", h.monitorNodeListHandler)
mux.HandleFunc("/api/v1/monitor/tunnels", h.monitorTunnelListHandler)
mux.HandleFunc("/api/v1/monitor/tunnels/quality", h.monitorTunnelQualityHandler)
mux.HandleFunc("/api/v1/monitor/tunnels/", h.monitorTunnelMetrics)
mux.HandleFunc("/api/v1/monitor/services", h.monitorServiceListHandler)
mux.HandleFunc("/api/v1/monitor/services/create", h.monitorServiceCreate)
mux.HandleFunc("/api/v1/monitor/services/update", h.monitorServiceUpdate)
mux.HandleFunc("/api/v1/monitor/services/delete", h.monitorServiceDelete)
mux.HandleFunc("/api/v1/monitor/services/run", h.monitorServiceRun)
mux.HandleFunc("/api/v1/monitor/services/latest-results", h.monitorServiceLatestResultsHandler)
mux.HandleFunc("/api/v1/monitor/services/limits", h.monitorServiceLimitsHandler)
mux.HandleFunc("/api/v1/monitor/services/", h.monitorServiceResultsHandler)
mux.HandleFunc("/api/v1/monitor/permission/list", h.monitorPermissionList)
mux.HandleFunc("/api/v1/monitor/permission/assign", h.monitorPermissionAssign)
mux.HandleFunc("/api/v1/monitor/permission/remove", h.monitorPermissionRemove)
mux.HandleFunc("/flow/test", h.flowTest)
mux.HandleFunc("/flow/config", h.flowConfig)
mux.HandleFunc("/flow/upload", h.flowUpload)
@@ -571,7 +623,7 @@ func (h *Handler) userTunnelList(w http.ResponseWriter, r *http.Request) {
"userId": t.UserID,
"tunnelId": t.TunnelID,
"tunnelName": t.TunnelName,
"status": 1,
"status": t.Status,
"flow": t.Flow,
"num": t.Num,
"expTime": t.ExpTime,
@@ -722,6 +774,8 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
if err == nil && strings.TrimSpace(raw) != "" {
var items []flowItem
if json.Unmarshal([]byte(raw), &items) == nil {
nowMs := time.Now().UnixMilli()
h.recordTunnelMetricsFromFlowItems(node.ID, items, nowMs)
for _, item := range items {
h.processFlowItem(node.ID, item)
}
@@ -1184,6 +1238,21 @@ func nullableNullInt64(v sql.NullInt64) interface{} {
return nil
}
// flowCryptoCache caches AES crypto instances by secret to avoid per-request SHA256+GCM init.
var flowCryptoCache sync.Map
func getOrCreateFlowCrypto(secret string) *security.AESCrypto {
if v, ok := flowCryptoCache.Load(secret); ok {
return v.(*security.AESCrypto)
}
c, err := security.NewAESCrypto(secret)
if err != nil {
return nil
}
flowCryptoCache.Store(secret, c)
return c
}
func readAndDecryptFlowBody(body io.ReadCloser, secret string) (string, error) {
defer body.Close()
raw, err := io.ReadAll(body)
@@ -1204,8 +1273,8 @@ func readAndDecryptFlowBody(body io.ReadCloser, secret string) (string, error) {
return text, nil
}
crypto, err := security.NewAESCrypto(secret)
if err != nil {
crypto := getOrCreateFlowCrypto(secret)
if crypto == nil {
return text, nil
}
plain, err := crypto.Decrypt(wrap.Data)
+63 -1
View File
@@ -18,11 +18,15 @@ func (h *Handler) StartBackgroundJobs() {
ctx, cancel := context.WithCancel(context.Background())
h.jobsCancel = cancel
h.jobsStarted = true
h.jobsWG.Add(2)
h.jobsWG.Add(6)
h.jobsMu.Unlock()
go h.runHourlyStatsLoop(ctx)
go h.runDailyMaintenanceLoop(ctx)
go h.runNodeRenewalCycleLoop(ctx)
go h.runMetricsIngestion(ctx)
go h.runHealthChecks(ctx)
go h.runTunnelQualityProber(ctx)
}
func (h *Handler) StopBackgroundJobs() {
@@ -46,6 +50,27 @@ func (h *Handler) StopBackgroundJobs() {
h.jobsWG.Wait()
}
func (h *Handler) runMetricsIngestion(ctx context.Context) {
defer h.jobsWG.Done()
if h.metrics != nil {
h.metrics.Start(ctx)
}
}
func (h *Handler) runHealthChecks(ctx context.Context) {
defer h.jobsWG.Done()
if h.healthCheck != nil {
h.healthCheck.Start(ctx)
}
}
func (h *Handler) runTunnelQualityProber(ctx context.Context) {
defer h.jobsWG.Done()
if h.qualityProber != nil {
h.qualityProber.Start(ctx)
}
}
func (h *Handler) runHourlyStatsLoop(ctx context.Context) {
defer h.jobsWG.Done()
@@ -135,6 +160,7 @@ func (h *Handler) runResetAndExpiryJob(now time.Time) {
}
h.resetMonthlyFlow(now)
h.resetUserQuotaWindows(now)
h.disableExpiredUsers(now.UnixMilli())
h.disableExpiredUserTunnels(now.UnixMilli())
}
@@ -176,3 +202,39 @@ func (h *Handler) disableExpiredUserTunnels(nowMs int64) {
_ = h.repo.DisableUserTunnel(item.ID)
}
}
func (h *Handler) runNodeRenewalCycleLoop(ctx context.Context) {
defer h.jobsWG.Done()
for {
wait := durationUntilNextNodeRenewalCycle(time.Now())
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return
case <-timer.C:
h.runNodeRenewalCycleJob(time.Now())
}
}
}
func durationUntilNextNodeRenewalCycle(now time.Time) time.Duration {
next := now.Truncate(6 * time.Hour).Add(6 * time.Hour)
return next.Sub(now)
}
func (h *Handler) runNodeRenewalCycleJob(now time.Time) {
if h == nil || h.repo == nil {
return
}
advanced, err := h.repo.AdvanceNodeRenewalCycles(now.UnixMilli())
if err != nil {
return
}
_ = advanced
}
@@ -0,0 +1,55 @@
package handler
import (
"database/sql"
"testing"
"time"
"go-backend/internal/store/repo"
)
func TestRunNodeRenewalCycleJob_AdvancesOverdueAnchorTimes(t *testing.T) {
dbPath := t.TempDir() + "/renewal-test.db"
r, err := repo.Open(dbPath)
if err != nil {
t.Fatalf("open repo: %v", err)
}
t.Cleanup(func() {
_ = r.Close()
})
now := time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC)
nowMs := now.UnixMilli()
nodeID := int64(101)
err = r.DB().Exec(`
INSERT INTO node (id, name, secret, server_ip, port, http, tls, socks, created_time, status, renewal_cycle, expiry_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, nodeID, "no-cycle-node", "test-secret", "192.168.1.1", "1000-65535", 1, 1, 1, nowMs, 1, "", nil).Error
if err != nil {
t.Fatalf("insert test node: %v", err)
}
quarterNodeID := int64(102)
err = r.DB().Exec(`
INSERT INTO node (id, name, secret, server_ip, port, http, tls, socks, created_time, status, renewal_cycle, expiry_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, quarterNodeID, "quarter-node", "test-secret", "192.168.1.1", "1000-65535", 1, 1, 1, nowMs, 1, "quarter", now.AddDate(0, -4, 0).UnixMilli()).Error
if err != nil {
t.Fatalf("insert test node: %v", err)
}
h := &Handler{repo: r}
h.runNodeRenewalCycleJob(now)
var anchor sql.NullInt64
err = r.DB().Raw(`SELECT expiry_time FROM node WHERE id = ?`, quarterNodeID).Row().Scan(&anchor)
if err != nil {
t.Fatalf("query expiry_time: %v", err)
}
expectedAnchor := now.AddDate(0, 2, 0).UnixMilli()
if !anchor.Valid || anchor.Int64 != expectedAnchor {
t.Fatalf("expected anchor %d (2026-05-08), got %d", expectedAnchor, anchor.Int64)
}
}
@@ -143,3 +143,40 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
t.Fatalf("expected non-expiring forward to remain enabled, got status=%d", nonExpForwardStatus)
}
}
func TestRunResetAndExpiryJobResetsUserQuotaAndUnblocksUser(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "jobs-quota-reset.db")
r, err := repo.Open(dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "secret")
now := time.Date(2026, 3, 12, 0, 0, 5, 0, time.UTC)
nowMs := 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, 'quota-reset-user', 'x', 1, 0, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user_quota(user_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time)
VALUES(2, 10, 0, ?, ?, 20260311, 202603, 1, ?, '', ?, ?)
`, 11*int64(1024*1024*1024), 11*int64(1024*1024*1024), nowMs, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert user quota: %v", err)
}
h.runResetAndExpiryJob(now)
dailyUsed := mustQueryInt(t, r, `SELECT daily_used_bytes FROM user_quota WHERE user_id = 2`)
if dailyUsed != 0 {
t.Fatalf("expected daily quota usage reset, got %d", dailyUsed)
}
quotaDisabled := mustQueryInt(t, r, `SELECT disabled_by_quota FROM user_quota WHERE user_id = 2`)
if quotaDisabled != 0 {
t.Fatalf("expected quota disabled flag cleared, got %d", quotaDisabled)
}
}
@@ -0,0 +1,927 @@
package handler
import (
"log"
"net/http"
"strconv"
"strings"
"time"
"go-backend/internal/http/response"
"go-backend/internal/monitoring"
"go-backend/internal/store/model"
)
const (
defaultMetricsRangeMs = int64(60 * 60 * 1000) // 1h
maxMetricsRangeMs = int64(24 * 60 * 60 * 1000) // 24h
)
func (h *Handler) resolveServiceMonitorLimits() monitoring.ServiceMonitorLimits {
defaults := monitoring.DefaultServiceMonitorLimits()
if h == nil || h.repo == nil {
return defaults
}
cfg, err := h.repo.GetConfigsByNames([]string{
monitoring.ConfigServiceMonitorCheckerScanIntervalSec,
monitoring.ConfigServiceMonitorWorkerLimit,
monitoring.ConfigServiceMonitorMinIntervalSec,
monitoring.ConfigServiceMonitorDefaultIntervalSec,
monitoring.ConfigServiceMonitorMinTimeoutSec,
monitoring.ConfigServiceMonitorDefaultTimeoutSec,
monitoring.ConfigServiceMonitorMaxTimeoutSec,
})
if err != nil {
return defaults
}
return monitoring.ServiceMonitorLimitsFromConfigMap(cfg)
}
func (h *Handler) monitorNodeMetricsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
path := r.URL.Path
prefix := "/api/v1/monitor/nodes/"
if !strings.HasPrefix(path, prefix) {
response.WriteJSON(w, response.ErrDefault("无效的路径"))
return
}
rest := strings.TrimPrefix(path, prefix)
if strings.HasSuffix(rest, "/metrics/latest") {
h.handleNodeMetricsLatest(w, r, strings.TrimSuffix(rest, "/metrics/latest"))
return
}
if strings.HasSuffix(rest, "/metrics") {
h.handleNodeMetrics(w, r, strings.TrimSuffix(rest, "/metrics"))
return
}
response.WriteJSON(w, response.ErrDefault("无效的路径"))
}
type monitorNodeListItem struct {
ID int64 `json:"id"`
Inx int `json:"inx"`
Name string `json:"name"`
Status int `json:"status"`
UpdatedTime int64 `json:"updatedTime"`
}
func (h *Handler) monitorNodeListHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
nodes, err := h.repo.ListMonitorNodes()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
items := make([]monitorNodeListItem, 0, len(nodes))
for _, n := range nodes {
updated := int64(0)
if n.UpdatedTime.Valid {
updated = n.UpdatedTime.Int64
}
items = append(items, monitorNodeListItem{
ID: n.ID,
Inx: n.Inx,
Name: n.Name,
Status: n.Status,
UpdatedTime: updated,
})
}
response.WriteJSON(w, response.OK(items))
}
type monitorTunnelListItem struct {
ID int64 `json:"id"`
Inx int `json:"inx"`
Name string `json:"name"`
Status int `json:"status"`
UpdatedTime int64 `json:"updatedTime"`
}
func (h *Handler) monitorTunnelListHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
tunnels, err := h.repo.ListMonitorTunnels()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
items := make([]monitorTunnelListItem, 0, len(tunnels))
for _, t := range tunnels {
items = append(items, monitorTunnelListItem{
ID: t.ID,
Inx: t.Inx,
Name: t.Name,
Status: t.Status,
UpdatedTime: t.UpdatedTime,
})
}
response.WriteJSON(w, response.OK(items))
}
func (h *Handler) handleNodeMetrics(w http.ResponseWriter, r *http.Request, nodeIDStr string) {
nodeID, err := strconv.ParseInt(nodeIDStr, 10, 64)
if err != nil || nodeID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的节点ID"))
return
}
now := time.Now().UnixMilli()
startMs := now - defaultMetricsRangeMs
endMs := now
if s := r.URL.Query().Get("start"); s != "" {
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
startMs = v
}
}
if e := r.URL.Query().Get("end"); e != "" {
if v, err := strconv.ParseInt(e, 10, 64); err == nil {
endMs = v
}
}
if startMs <= 0 || endMs <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
return
}
if endMs < startMs {
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
return
}
if endMs-startMs > maxMetricsRangeMs {
response.WriteJSON(w, response.ErrDefault("时间范围过大"))
return
}
metrics, err := h.repo.GetNodeMetrics(nodeID, startMs, endMs)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(metrics))
}
func (h *Handler) handleNodeMetricsLatest(w http.ResponseWriter, _ *http.Request, nodeIDStr string) {
nodeID, err := strconv.ParseInt(nodeIDStr, 10, 64)
if err != nil || nodeID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的节点ID"))
return
}
metric, err := h.repo.GetLatestNodeMetric(nodeID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if metric == nil {
response.WriteJSON(w, response.OK(nil))
return
}
response.WriteJSON(w, response.OK(metric))
}
func (h *Handler) monitorTunnelQualityHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
// Try in-memory cache first
if h.qualityProber != nil {
items := h.qualityProber.GetAll()
if len(items) > 0 {
response.WriteJSON(w, response.OK(items))
return
}
}
// Fallback to database (latest per tunnel)
qualities, err := h.repo.GetLatestTunnelQualities()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
snapshots := make([]tunnelQualitySnapshot, 0, len(qualities))
for _, q := range qualities {
snapshots = append(snapshots, tunnelQualitySnapshot{
TunnelID: q.TunnelID,
EntryToExitLatency: q.EntryToExitLatency,
ExitToBingLatency: q.ExitToBingLatency,
EntryToExitLoss: q.EntryToExitLoss,
ExitToBingLoss: q.ExitToBingLoss,
Success: q.Success == 1,
ErrorMessage: q.ErrorMessage,
Timestamp: q.Timestamp,
})
}
response.WriteJSON(w, response.OK(snapshots))
}
// monitorTunnelQualityHistory returns quality probe history for charting.
// GET /api/v1/monitor/tunnels/{id}/quality?start=...&end=...
// Mirrors monitorTunnelMetrics / monitorServiceResultsHandler pattern.
func (h *Handler) monitorTunnelQualityHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
tunnelIDStr := extractPathParam(r.URL.Path, "/api/v1/monitor/tunnels/", "/quality")
tunnelID, err := strconv.ParseInt(tunnelIDStr, 10, 64)
if err != nil || tunnelID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的隧道ID"))
return
}
now := time.Now().UnixMilli()
startMs := now - defaultMetricsRangeMs
endMs := now
if s := r.URL.Query().Get("start"); s != "" {
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
startMs = v
}
}
if e := r.URL.Query().Get("end"); e != "" {
if v, err := strconv.ParseInt(e, 10, 64); err == nil {
endMs = v
}
}
if startMs <= 0 || endMs <= 0 || endMs < startMs {
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
return
}
if endMs-startMs > maxMetricsRangeMs {
response.WriteJSON(w, response.ErrDefault("时间范围过大"))
return
}
results, err := h.repo.GetTunnelQualityHistory(tunnelID, startMs, endMs)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(results))
}
func (h *Handler) monitorTunnelMetrics(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
path := r.URL.Path
prefix := "/api/v1/monitor/tunnels/"
if !strings.HasPrefix(path, prefix) {
response.WriteJSON(w, response.ErrDefault("无效的路径"))
return
}
rest := strings.TrimPrefix(path, prefix)
// Route: /api/v1/monitor/tunnels/{id}/quality
if strings.HasSuffix(rest, "/quality") {
h.monitorTunnelQualityHistory(w, r)
return
}
// Route: /api/v1/monitor/tunnels/{id}/metrics (original)
tunnelIDStr := extractPathParam(path, prefix, "/metrics")
tunnelID, err := strconv.ParseInt(tunnelIDStr, 10, 64)
if err != nil || tunnelID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的隧道ID"))
return
}
now := time.Now().UnixMilli()
startMs := now - defaultMetricsRangeMs
endMs := now
if s := r.URL.Query().Get("start"); s != "" {
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
startMs = v
}
}
if e := r.URL.Query().Get("end"); e != "" {
if v, err := strconv.ParseInt(e, 10, 64); err == nil {
endMs = v
}
}
if startMs <= 0 || endMs <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
return
}
if endMs < startMs {
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
return
}
if endMs-startMs > maxMetricsRangeMs {
response.WriteJSON(w, response.ErrDefault("时间范围过大"))
return
}
metrics, err := h.repo.GetTunnelMetricsAggregated(tunnelID, startMs, endMs)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(metrics))
}
func (h *Handler) monitorServiceListHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
monitors, err := h.repo.ListServiceMonitors()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(monitors))
}
type createServiceMonitorRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Target string `json:"target"`
IntervalSec int `json:"intervalSec"`
TimeoutSec int `json:"timeoutSec"`
NodeID int64 `json:"nodeId"`
Enabled *int `json:"enabled"`
}
func (h *Handler) monitorServiceCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
var req createServiceMonitorRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
response.WriteJSON(w, response.ErrDefault("名称不能为空"))
return
}
monitorType := strings.ToLower(strings.TrimSpace(req.Type))
if monitorType != "tcp" && monitorType != "icmp" {
response.WriteJSON(w, response.ErrDefault("类型必须是 tcp 或 icmp"))
return
}
target := strings.TrimSpace(req.Target)
if target == "" {
response.WriteJSON(w, response.ErrDefault("目标地址不能为空"))
return
}
limits := h.resolveServiceMonitorLimits()
intervalSec := req.IntervalSec
if intervalSec <= 0 {
intervalSec = limits.DefaultIntervalSec
}
if intervalSec < limits.MinIntervalSec {
intervalSec = limits.MinIntervalSec
}
timeoutSec := req.TimeoutSec
if timeoutSec <= 0 {
timeoutSec = limits.DefaultTimeoutSec
}
if timeoutSec < limits.MinTimeoutSec {
timeoutSec = limits.MinTimeoutSec
}
if timeoutSec > limits.MaxTimeoutSec {
timeoutSec = limits.MaxTimeoutSec
}
enabled := 1
if req.Enabled != nil {
if *req.Enabled == 0 || *req.Enabled == 1 {
enabled = *req.Enabled
}
}
now := time.Now().UnixMilli()
if req.NodeID > 0 {
n, err := h.repo.GetNodeByID(req.NodeID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if n == nil {
response.WriteJSON(w, response.ErrDefault("节点不存在"))
return
}
}
m := &model.ServiceMonitor{
Name: name,
Type: monitorType,
Target: target,
IntervalSec: intervalSec,
TimeoutSec: timeoutSec,
NodeID: req.NodeID,
Enabled: enabled,
CreatedTime: now,
UpdatedTime: now,
}
if m.Type == "icmp" && m.NodeID <= 0 {
response.WriteJSON(w, response.ErrDefault("ICMP 监控必须选择执行节点"))
return
}
// enabled is already normalized above.
if err := h.repo.CreateServiceMonitor(m); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(m))
}
type updateServiceMonitorRequest struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Target string `json:"target"`
IntervalSec int `json:"intervalSec"`
TimeoutSec int `json:"timeoutSec"`
NodeID *int64 `json:"nodeId"`
Enabled *int `json:"enabled"`
}
func (h *Handler) monitorServiceUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
var req updateServiceMonitorRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if req.ID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的监控ID"))
return
}
existing, err := h.repo.GetServiceMonitor(req.ID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if existing == nil {
response.WriteJSON(w, response.ErrDefault("监控不存在"))
return
}
name := strings.TrimSpace(req.Name)
if name != "" {
existing.Name = name
}
monitorType := strings.ToLower(strings.TrimSpace(req.Type))
if monitorType == "tcp" || monitorType == "icmp" {
existing.Type = monitorType
}
target := strings.TrimSpace(req.Target)
if target != "" {
existing.Target = target
}
limits := h.resolveServiceMonitorLimits()
if req.IntervalSec > 0 {
intervalSec := req.IntervalSec
if intervalSec < limits.MinIntervalSec {
intervalSec = limits.MinIntervalSec
}
existing.IntervalSec = intervalSec
}
if req.TimeoutSec > 0 {
timeoutSec := req.TimeoutSec
if timeoutSec < limits.MinTimeoutSec {
timeoutSec = limits.MinTimeoutSec
}
if timeoutSec > limits.MaxTimeoutSec {
timeoutSec = limits.MaxTimeoutSec
}
existing.TimeoutSec = timeoutSec
}
if req.NodeID != nil {
existing.NodeID = *req.NodeID
}
if req.Enabled != nil {
if *req.Enabled == 0 || *req.Enabled == 1 {
existing.Enabled = *req.Enabled
}
}
existing.UpdatedTime = time.Now().UnixMilli()
if existing.Type == "icmp" && existing.NodeID <= 0 {
response.WriteJSON(w, response.ErrDefault("ICMP 监控必须选择执行节点"))
return
}
if existing.NodeID > 0 {
n, err := h.repo.GetNodeByID(existing.NodeID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if n == nil {
response.WriteJSON(w, response.ErrDefault("节点不存在"))
return
}
}
if err := h.repo.UpdateServiceMonitor(existing); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(existing))
}
type deleteServiceMonitorRequest struct {
ID int64 `json:"id"`
}
func (h *Handler) monitorServiceDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
var req deleteServiceMonitorRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if req.ID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的监控ID"))
return
}
if err := h.repo.DeleteServiceMonitor(req.ID); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) monitorServiceRun(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
if h.healthCheck == nil {
response.WriteJSON(w, response.ErrDefault("监控服务不可用"))
return
}
var req deleteServiceMonitorRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if req.ID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的监控ID"))
return
}
m, err := h.repo.GetServiceMonitor(req.ID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if m == nil {
response.WriteJSON(w, response.ErrDefault("监控不存在"))
return
}
res, err := h.healthCheck.RunOnce(m)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.repo.InsertServiceMonitorResult(res); err != nil {
log.Printf("monitoring write failed op=service_monitor_result.manual_insert monitor_id=%d err=%v", res.MonitorID, err)
}
response.WriteJSON(w, response.OK(res))
}
func (h *Handler) monitorServiceResultsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
monitorIDStr := extractPathParam(r.URL.Path, "/api/v1/monitor/services/", "/results")
monitorID, err := strconv.ParseInt(monitorIDStr, 10, 64)
if err != nil || monitorID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的监控ID"))
return
}
// If start/end time range is provided, use time-based query (mirrors node metrics / tunnel quality pattern).
startStr := r.URL.Query().Get("start")
endStr := r.URL.Query().Get("end")
if startStr != "" && endStr != "" {
startMs, err1 := strconv.ParseInt(startStr, 10, 64)
endMs, err2 := strconv.ParseInt(endStr, 10, 64)
if err1 != nil || err2 != nil || startMs <= 0 || endMs <= 0 || endMs < startMs {
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
return
}
if endMs-startMs > maxMetricsRangeMs {
response.WriteJSON(w, response.ErrDefault("时间范围过大"))
return
}
results, err := h.repo.GetServiceMonitorResultsByTimeRange(monitorID, startMs, endMs)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(results))
return
}
// Fallback: count-based limit query (backward compat).
limit := 100
if l := r.URL.Query().Get("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil && v > 0 && v <= 1000 {
limit = v
}
}
results, err := h.repo.GetServiceMonitorResults(monitorID, limit)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(results))
}
func (h *Handler) monitorServiceLatestResultsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
results, err := h.repo.GetLatestServiceMonitorResults()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(results))
}
func (h *Handler) monitorServiceLimitsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
response.WriteJSON(w, response.OK(h.resolveServiceMonitorLimits()))
}
func extractPathParam(path, prefix, suffix string) string {
if !strings.HasPrefix(path, prefix) {
return ""
}
rest := strings.TrimPrefix(path, prefix)
if suffix != "" {
rest = strings.TrimSuffix(rest, suffix)
}
return rest
}
type monitorAccessData struct {
Allowed bool `json:"allowed"`
Reason string `json:"reason,omitempty"`
}
// monitorAccessHandler is a lightweight capability check for frontend navigation.
// It does NOT replace authorization on the actual monitoring endpoints.
func (h *Handler) monitorAccessHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
userID, roleID, err := userRoleFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return
}
if roleID == 0 {
response.WriteJSON(w, response.OK(monitorAccessData{Allowed: true}))
return
}
allowed, err := h.repo.HasMonitorPermission(userID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
data := monitorAccessData{Allowed: allowed}
if !allowed {
data.Reason = "need_admin_grant"
}
response.WriteJSON(w, response.OK(data))
}
func (h *Handler) ensureAdminAccess(w http.ResponseWriter, r *http.Request) bool {
_, roleID, err := userRoleFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return false
}
if roleID != 0 {
response.WriteJSON(w, response.Err(403, "权限不足,仅管理员可操作"))
return false
}
return true
}
func (h *Handler) ensureMonitoringAccess(w http.ResponseWriter, r *http.Request) bool {
userID, roleID, err := userRoleFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return false
}
if roleID == 0 {
return true
}
allowed, err := h.repo.HasMonitorPermission(userID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return false
}
if !allowed {
response.WriteJSON(w, response.Err(403, "权限不足:当前账户非管理员,且未被授予监控权限。请联系管理员在用户管理中授权监控权限。"))
return false
}
return true
}
func (h *Handler) monitorPermissionList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureAdminAccess(w, r) {
return
}
items, err := h.repo.ListMonitorPermissions()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(items))
}
type monitorPermissionMutationRequest struct {
UserID int64 `json:"userId"`
}
func (h *Handler) monitorPermissionAssign(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureAdminAccess(w, r) {
return
}
var req monitorPermissionMutationRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if req.UserID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的用户ID"))
return
}
u, err := h.repo.GetUserByID(req.UserID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if u == nil {
response.WriteJSON(w, response.ErrDefault("用户不存在"))
return
}
if err := h.repo.InsertMonitorPermission(req.UserID, time.Now().UnixMilli()); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) monitorPermissionRemove(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureAdminAccess(w, r) {
return
}
var req monitorPermissionMutationRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if req.UserID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的用户ID"))
return
}
if err := h.repo.DeleteMonitorPermission(req.UserID); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,39 @@
package handler
import "testing"
func TestBuildForwardPortEntriesWithPreservedInIP(t *testing.T) {
entryNodeIDs := []int64{10, 20, 30}
oldPorts := []forwardPortRecord{
{NodeID: 10, Port: 10001, InIP: ""},
{NodeID: 10, Port: 10002, InIP: "10.0.0.10"},
{NodeID: 20, Port: 10003, InIP: "10.0.0.20"},
}
entries := buildForwardPortEntriesWithPreservedInIP(entryNodeIDs, oldPorts, 18080)
if len(entries) != 3 {
t.Fatalf("expected 3 entries, got %d", len(entries))
}
if entries[0].NodeID != 10 || entries[0].Port != 18080 || entries[0].InIP != "10.0.0.10" {
t.Fatalf("unexpected first entry: %+v", entries[0])
}
if entries[1].NodeID != 20 || entries[1].Port != 18080 || entries[1].InIP != "10.0.0.20" {
t.Fatalf("unexpected second entry: %+v", entries[1])
}
if entries[2].NodeID != 30 || entries[2].Port != 18080 || entries[2].InIP != "" {
t.Fatalf("unexpected third entry: %+v", entries[2])
}
}
func TestBuildForwardPortEntriesWithPreservedInIP_EmptyOldPorts(t *testing.T) {
entryNodeIDs := []int64{99}
entries := buildForwardPortEntriesWithPreservedInIP(entryNodeIDs, nil, 17000)
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
if entries[0].NodeID != 99 || entries[0].Port != 17000 || entries[0].InIP != "" {
t.Fatalf("unexpected entry: %+v", entries[0])
}
}
@@ -0,0 +1,79 @@
package handler
import (
"path/filepath"
"testing"
"time"
"go-backend/internal/store/repo"
)
func TestReconstructTunnelState_PreservesConnectIP(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "reconstruct-connect-ip.db")
r, err := repo.Open(dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "secret")
now := time.Now().UnixMilli()
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, 'reconstruct-tunnel', 1.0, 2, 'tls', 1, ?, ?, 1, NULL, 0)
`, now, now).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
insertNode := func(id int64, name, ip string) {
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)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, id, 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)
}
}
insertNode(101, "entry", "10.90.0.10")
insertNode(102, "middle", "10.90.0.20")
insertNode(103, "exit", "10.90.0.30")
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(1, '1', 101, 30001, 'round', 1, 'tls')
`).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, connect_ip)
VALUES(1, '2', 102, 30002, 'round', 1, 'tls', '10.99.9.22')
`).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, connect_ip)
VALUES(1, '3', 103, 30003, 'round', 1, 'tls', '10.99.9.33')
`).Error; err != nil {
t.Fatalf("insert exit chain: %v", err)
}
state, err := h.reconstructTunnelState(1)
if err != nil {
t.Fatalf("reconstructTunnelState: %v", err)
}
if len(state.ChainHops) != 1 || len(state.ChainHops[0]) != 1 {
t.Fatalf("unexpected chain hops: %+v", state.ChainHops)
}
if got := state.ChainHops[0][0].ConnectIP; got != "10.99.9.22" {
t.Fatalf("expected middle connectIp 10.99.9.22, got %q", got)
}
if len(state.OutNodes) != 1 {
t.Fatalf("unexpected out nodes: %+v", state.OutNodes)
}
if got := state.OutNodes[0].ConnectIP; got != "10.99.9.33" {
t.Fatalf("expected exit connectIp 10.99.9.33, got %q", got)
}
}
@@ -0,0 +1,655 @@
package handler
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"go-backend/internal/http/response"
)
const tunnelDeletePreviewSampleLimit = 5
const (
tunnelDeleteActionReplace = "replace"
tunnelDeleteActionDeleteForwards = "delete_forwards"
)
var (
errInvalidTunnelDeleteTarget = errors.New("invalid tunnel delete target")
)
type tunnelDeleteForwardPreviewItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
UserID int64 `json:"userId"`
UserName string `json:"userName"`
InPort int `json:"inPort"`
}
type tunnelDeletePreviewData struct {
TunnelID int64 `json:"tunnelId"`
TunnelName string `json:"tunnelName"`
ForwardCount int `json:"forwardCount"`
SampleForwards []tunnelDeleteForwardPreviewItem `json:"sampleForwards"`
}
type tunnelBatchDeletePreviewData struct {
TunnelCount int `json:"tunnelCount"`
TotalForwardCount int `json:"totalForwardCount"`
Items []tunnelDeletePreviewData `json:"items"`
}
type tunnelDeleteWithForwardsRequest struct {
ID int64 `json:"id"`
Action string `json:"action"`
TargetTunnelID int64 `json:"targetTunnelId"`
}
type tunnelBatchDeleteWithForwardsRequest struct {
IDs []int64 `json:"ids"`
Action string `json:"action"`
TargetTunnelID int64 `json:"targetTunnelId"`
}
type tunnelDeleteWithForwardsResult struct {
ForwardCount int `json:"forwardCount"`
MigratedCount int `json:"migratedCount"`
DeletedForwardCount int `json:"deletedForwardCount"`
PortAdjustedCount int `json:"portAdjustedCount"`
Warnings []string `json:"warnings,omitempty"`
}
type tunnelBatchDeleteWithForwardsResult struct {
SuccessCount int `json:"successCount"`
FailCount int `json:"failCount"`
Failures []batchFailureDetail `json:"failures,omitempty"`
DeletedForwardCount int `json:"deletedForwardCount"`
MigratedCount int `json:"migratedCount"`
PortAdjustedCount int `json:"portAdjustedCount"`
Warnings []string `json:"warnings,omitempty"`
}
type tunnelForwardMigrationPlan struct {
forward *forwardRecord
oldPorts []forwardPortRecord
targetTunnelID int64
targetPort int
keptNodeIDs []int64
removedNodeIDs []int64
portAdjusted bool
}
func (h *Handler) tunnelDeletePreview(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
id := idFromBody(r, w)
if id <= 0 {
return
}
preview, err := h.buildTunnelDeletePreview(id)
if err != nil {
if strings.Contains(err.Error(), "不存在") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(preview))
}
func (h *Handler) tunnelBatchDeletePreview(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req struct {
IDs []int64 `json:"ids"`
}
if err := decodeJSON(r.Body, &req); err != nil || len(req.IDs) == 0 {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
preview, err := h.buildTunnelBatchDeletePreview(req.IDs)
if err != nil {
if strings.Contains(err.Error(), "不存在") {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(preview))
}
func (h *Handler) tunnelDeleteWithForwards(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req tunnelDeleteWithForwardsRequest
if err := decodeJSON(r.Body, &req); err != nil || req.ID <= 0 {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
action, err := normalizeTunnelDeleteAction(req.Action)
if err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if action == tunnelDeleteActionReplace {
if _, _, authErr := userRoleFromRequest(r); authErr != nil {
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
return
}
}
result, failures, err := h.processTunnelDeleteWithForwards(req.ID, action, req.TargetTunnelID)
if err != nil {
if err == errInvalidTunnelDeleteTarget {
response.WriteJSON(w, response.ErrDefault("目标隧道不能为空"))
return
}
if strings.Contains(err.Error(), "目标隧道不能与当前隧道相同") || 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
}
if len(failures) > 0 {
response.WriteJSON(w, response.R{
Code: -2,
Msg: "部分规则迁移失败",
TS: time.Now().UnixMilli(),
Data: batchOperationResult{SuccessCount: 0, FailCount: len(failures), Failures: failures},
})
return
}
response.WriteJSON(w, response.OK(result))
}
func (h *Handler) tunnelBatchDeleteWithForwards(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req tunnelBatchDeleteWithForwardsRequest
if err := decodeJSON(r.Body, &req); err != nil || len(req.IDs) == 0 {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
action, err := normalizeTunnelDeleteAction(req.Action)
if err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if action == tunnelDeleteActionReplace {
if _, _, authErr := userRoleFromRequest(r); authErr != nil {
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
return
}
}
normalizedIDs := normalizeTunnelIDs(req.IDs)
if len(normalizedIDs) == 0 {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if action == tunnelDeleteActionReplace {
if req.TargetTunnelID <= 0 {
response.WriteJSON(w, response.ErrDefault("目标隧道不能为空"))
return
}
for _, id := range normalizedIDs {
if id == req.TargetTunnelID {
response.WriteJSON(w, response.ErrDefault("目标隧道不能包含在删除列表中"))
return
}
}
}
result := tunnelBatchDeleteWithForwardsResult{}
for _, tunnelID := range normalizedIDs {
tunnelName, _ := h.repo.GetTunnelName(tunnelID)
singleResult, failures, processErr := h.processTunnelDeleteWithForwards(tunnelID, action, req.TargetTunnelID)
if processErr != nil {
result.FailCount++
result.Failures = appendBatchFailure(result.Failures, tunnelID, tunnelName, processErr)
continue
}
if len(failures) > 0 {
result.FailCount++
result.Failures = appendBatchFailureReason(
result.Failures,
tunnelID,
tunnelName,
summarizeTunnelDeleteRuleFailures(failures),
)
continue
}
result.SuccessCount++
result.DeletedForwardCount += singleResult.DeletedForwardCount
result.MigratedCount += singleResult.MigratedCount
result.PortAdjustedCount += singleResult.PortAdjustedCount
if len(singleResult.Warnings) > 0 {
result.Warnings = append(result.Warnings, singleResult.Warnings...)
}
}
response.WriteJSON(w, response.OK(result))
}
func (h *Handler) buildTunnelDeletePreview(tunnelID int64) (*tunnelDeletePreviewData, error) {
if _, err := h.getTunnelRecord(tunnelID); err != nil {
return nil, err
}
tunnelName, err := h.repo.GetTunnelName(tunnelID)
if err != nil {
return nil, err
}
forwards, err := h.listForwardsByTunnel(tunnelID)
if err != nil {
return nil, err
}
samples := make([]tunnelDeleteForwardPreviewItem, 0, minInt(len(forwards), tunnelDeletePreviewSampleLimit))
for i, forward := range forwards {
if i >= tunnelDeletePreviewSampleLimit {
break
}
ports, portsErr := h.listForwardPorts(forward.ID)
if portsErr != nil {
return nil, portsErr
}
inPort := 0
if len(ports) > 0 {
inPort = ports[0].Port
}
samples = append(samples, tunnelDeleteForwardPreviewItem{
ID: forward.ID,
Name: forward.Name,
UserID: forward.UserID,
UserName: forward.UserName,
InPort: inPort,
})
}
return &tunnelDeletePreviewData{
TunnelID: tunnelID,
TunnelName: tunnelName,
ForwardCount: len(forwards),
SampleForwards: samples,
}, nil
}
func (h *Handler) buildTunnelBatchDeletePreview(ids []int64) (*tunnelBatchDeletePreviewData, error) {
normalizedIDs := normalizeTunnelIDs(ids)
items := make([]tunnelDeletePreviewData, 0, len(normalizedIDs))
totalForwardCount := 0
for _, id := range normalizedIDs {
preview, err := h.buildTunnelDeletePreview(id)
if err != nil {
return nil, err
}
items = append(items, *preview)
totalForwardCount += preview.ForwardCount
}
return &tunnelBatchDeletePreviewData{
TunnelCount: len(items),
TotalForwardCount: totalForwardCount,
Items: items,
}, nil
}
func normalizeTunnelDeleteAction(action string) (string, error) {
normalized := strings.TrimSpace(action)
if normalized == "" {
return tunnelDeleteActionDeleteForwards, nil
}
if normalized != tunnelDeleteActionReplace && normalized != tunnelDeleteActionDeleteForwards {
return "", errors.New("invalid tunnel delete action")
}
return normalized, nil
}
func normalizeTunnelIDs(ids []int64) []int64 {
seen := make(map[int64]struct{}, len(ids))
out := make([]int64, 0, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func summarizeTunnelDeleteRuleFailures(failures []batchFailureDetail) string {
if len(failures) == 0 {
return "未知错误"
}
parts := make([]string, 0, minInt(len(failures), 3))
for i, failure := range failures {
if i >= 3 {
break
}
name := strings.TrimSpace(failure.Name)
if name == "" {
name = fmt.Sprintf("规则 #%d", failure.ID)
}
parts = append(parts, fmt.Sprintf("%s: %s", name, strings.TrimSpace(failure.Reason)))
}
if len(failures) > 3 {
parts = append(parts, fmt.Sprintf("另有 %d 条规则失败", len(failures)-3))
}
return strings.Join(parts, ";")
}
func (h *Handler) processTunnelDeleteWithForwards(tunnelID int64, action string, targetTunnelID int64) (tunnelDeleteWithForwardsResult, []batchFailureDetail, error) {
preview, err := h.buildTunnelDeletePreview(tunnelID)
if err != nil {
return tunnelDeleteWithForwardsResult{}, nil, err
}
result := tunnelDeleteWithForwardsResult{ForwardCount: preview.ForwardCount}
if preview.ForwardCount == 0 {
if err := h.deleteTunnelAndCleanup(tunnelID); err != nil {
return tunnelDeleteWithForwardsResult{}, nil, err
}
return result, nil, nil
}
if action == tunnelDeleteActionDeleteForwards {
result.DeletedForwardCount = preview.ForwardCount
if err := h.deleteTunnelAndCleanup(tunnelID); err != nil {
return tunnelDeleteWithForwardsResult{}, nil, err
}
return result, nil, nil
}
if targetTunnelID <= 0 {
return tunnelDeleteWithForwardsResult{}, nil, errInvalidTunnelDeleteTarget
}
if targetTunnelID == tunnelID {
return tunnelDeleteWithForwardsResult{}, nil, errors.New("目标隧道不能与当前隧道相同")
}
return h.processTunnelDeleteReplaceAction(tunnelID, targetTunnelID, result)
}
func (h *Handler) processTunnelDeleteReplaceAction(tunnelID, targetTunnelID int64, result tunnelDeleteWithForwardsResult) (tunnelDeleteWithForwardsResult, []batchFailureDetail, error) {
targetTunnel, err := h.getTunnelRecord(targetTunnelID)
if err != nil {
return tunnelDeleteWithForwardsResult{}, nil, errors.New("目标隧道不存在")
}
if targetTunnel.Status != 1 {
return tunnelDeleteWithForwardsResult{}, nil, errors.New("目标隧道已禁用")
}
plans, failures, err := h.planTunnelDeleteForwardMigrations(tunnelID, targetTunnelID)
if err != nil {
return tunnelDeleteWithForwardsResult{}, nil, err
}
if len(failures) > 0 {
return tunnelDeleteWithForwardsResult{}, failures, nil
}
portAdjustedCount := 0
warnings, execErr, execFailure := h.executeTunnelDeleteForwardMigrations(plans)
for _, plan := range plans {
if plan.portAdjusted {
portAdjustedCount++
}
}
if execErr != nil {
failures = append(failures, execFailure)
return tunnelDeleteWithForwardsResult{}, failures, nil
}
if err := h.deleteTunnelAndCleanup(tunnelID); err != nil {
h.rollbackTunnelForwardMigrationPlans(plans)
_ = h.redeployTunnelAndForwards(tunnelID)
return tunnelDeleteWithForwardsResult{}, nil, err
}
result.MigratedCount = len(plans)
result.PortAdjustedCount = portAdjustedCount
if len(warnings) > 0 {
result.Warnings = warnings
}
return result, nil, nil
}
func (h *Handler) planTunnelDeleteForwardMigrations(sourceTunnelID, targetTunnelID int64) ([]tunnelForwardMigrationPlan, []batchFailureDetail, error) {
forwards, err := h.listForwardsByTunnel(sourceTunnelID)
if err != nil {
return nil, nil, err
}
entryNodes, err := h.tunnelEntryNodeIDs(targetTunnelID)
if err != nil {
return nil, nil, err
}
if len(entryNodes) == 0 {
return nil, nil, errors.New("目标隧道缺少入口节点")
}
plans := make([]tunnelForwardMigrationPlan, 0, len(forwards))
failures := make([]batchFailureDetail, 0)
reservedPorts := make(map[int64]map[int]bool)
for _, forward := range forwards {
plan, planErr := h.planSingleTunnelDeleteForwardMigration(&forward, targetTunnelID, entryNodes, reservedPorts)
if planErr != nil {
failures = appendBatchFailure(failures, forward.ID, forward.Name, planErr)
continue
}
plans = append(plans, plan)
}
return plans, failures, nil
}
func (h *Handler) planSingleTunnelDeleteForwardMigration(forward *forwardRecord, targetTunnelID int64, targetEntryNodes []int64, reservedPorts map[int64]map[int]bool) (tunnelForwardMigrationPlan, error) {
if forward == nil {
return tunnelForwardMigrationPlan{}, errors.New("转发不存在")
}
oldPorts, err := h.listForwardPorts(forward.ID)
if err != nil {
return tunnelForwardMigrationPlan{}, err
}
if len(oldPorts) == 0 {
return tunnelForwardMigrationPlan{}, errors.New("转发入口端口不存在")
}
minPort := h.repo.GetMinForwardPort(forward.ID)
targetPort := 0
if minPort.Valid {
targetPort = int(minPort.Int64)
}
if targetPort <= 0 {
targetPort = h.pickTunnelPort(targetTunnelID)
}
if targetPort <= 0 {
targetPort = 10000
}
hasCustomInIP := false
for _, oldPort := range oldPorts {
if strings.TrimSpace(oldPort.InIP) != "" {
hasCustomInIP = true
break
}
}
if hasCustomInIP && len(targetEntryNodes) > 1 {
return tunnelForwardMigrationPlan{}, errors.New("多入口隧道的转发不支持保留自定义监听IP,请先手动调整该规则")
}
for _, nodeID := range targetEntryNodes {
node, nodeErr := h.getNodeRecord(nodeID)
if nodeErr != nil {
return tunnelForwardMigrationPlan{}, nodeErr
}
if err := validateRemoteNodePort(node, targetPort); err != nil {
return tunnelForwardMigrationPlan{}, err
}
if err := validateLocalNodePort(node, targetPort); err != nil {
return tunnelForwardMigrationPlan{}, err
}
if err := h.validateForwardPortAvailability(node, targetPort, forward.ID); err != nil {
return tunnelForwardMigrationPlan{}, err
}
if reservedOnNode, ok := reservedPorts[nodeID]; ok && reservedOnNode[targetPort] {
return tunnelForwardMigrationPlan{}, fmt.Errorf("目标隧道入口节点端口 %d 已被本次迁移中的其他规则占用", targetPort)
}
}
for _, nodeID := range targetEntryNodes {
reservedOnNode := reservedPorts[nodeID]
if reservedOnNode == nil {
reservedOnNode = make(map[int]bool)
reservedPorts[nodeID] = reservedOnNode
}
reservedOnNode[targetPort] = true
}
oldNodeIDs := forwardPortNodeIDs(oldPorts)
newNodeIDs := uniqueInt64s(targetEntryNodes)
removedNodeIDs := diffInt64s(oldNodeIDs, newNodeIDs)
keptNodeIDs := diffInt64s(oldNodeIDs, removedNodeIDs)
previousPort := 0
if len(oldPorts) > 0 {
previousPort = oldPorts[0].Port
}
return tunnelForwardMigrationPlan{
forward: forward,
oldPorts: oldPorts,
targetTunnelID: targetTunnelID,
targetPort: targetPort,
keptNodeIDs: keptNodeIDs,
removedNodeIDs: removedNodeIDs,
portAdjusted: previousPort > 0 && previousPort != targetPort,
}, nil
}
func (h *Handler) executeTunnelDeleteForwardMigrations(plans []tunnelForwardMigrationPlan) ([]string, error, batchFailureDetail) {
warnings := make([]string, 0)
completed := make([]tunnelForwardMigrationPlan, 0, len(plans))
for _, plan := range plans {
migrationWarnings, err := h.applyTunnelDeleteForwardMigration(plan)
if err != nil {
h.rollbackTunnelForwardMigrationPlans(completed)
return warnings, err, batchFailureDetail{ID: plan.forward.ID, Name: plan.forward.Name, Reason: normalizeBatchFailureReason(errString(err))}
}
warnings = append(warnings, migrationWarnings...)
completed = append(completed, plan)
}
return warnings, nil, batchFailureDetail{}
}
func (h *Handler) applyTunnelDeleteForwardMigration(plan tunnelForwardMigrationPlan) ([]string, error) {
if plan.forward == nil {
return nil, errors.New("转发不存在")
}
if err := h.repo.UpdateForwardTunnel(plan.forward.ID, plan.targetTunnelID, time.Now().UnixMilli()); err != nil {
return nil, err
}
if err := h.replaceForwardPorts(plan.forward.ID, plan.targetTunnelID, plan.targetPort, ""); err != nil {
h.rollbackForwardMutation(plan.forward, plan.oldPorts)
return nil, err
}
updatedForward, err := h.getForwardRecord(plan.forward.ID)
if err != nil {
h.rollbackForwardMutation(plan.forward, plan.oldPorts)
return nil, err
}
warnings := make([]string, 0)
if len(plan.keptNodeIDs) > 0 {
for _, nodeID := range plan.keptNodeIDs {
if delErr := h.deleteForwardServicesOnNodeBatch(plan.forward, nodeID); delErr != nil {
nodeLabel := fmt.Sprintf("%d", nodeID)
if n, nErr := h.getNodeRecord(nodeID); nErr == nil && n != nil && strings.TrimSpace(n.Name) != "" {
nodeLabel = strings.TrimSpace(n.Name)
}
warnings = append(warnings, fmt.Sprintf("节点 %s 清理旧转发监听失败: %v", nodeLabel, delErr))
}
}
time.Sleep(tunnelServiceBindRetryDelay)
}
syncWarnings, err := h.syncForwardServicesWithWarnings(updatedForward, "UpdateService", true)
if err != nil {
h.rollbackForwardMutation(plan.forward, plan.oldPorts)
return nil, err
}
warnings = append(warnings, syncWarnings...)
if len(plan.removedNodeIDs) > 0 {
for _, nodeID := range plan.removedNodeIDs {
if delErr := h.deleteForwardServicesOnNodeBatch(plan.forward, nodeID); delErr != nil {
nodeLabel := fmt.Sprintf("%d", nodeID)
if n, nErr := h.getNodeRecord(nodeID); nErr == nil && n != nil && strings.TrimSpace(n.Name) != "" {
nodeLabel = strings.TrimSpace(n.Name)
}
warnings = append(warnings, fmt.Sprintf("节点 %s 清理旧隧道残留服务失败: %v", nodeLabel, delErr))
}
}
}
return warnings, nil
}
func (h *Handler) rollbackTunnelForwardMigrationPlans(plans []tunnelForwardMigrationPlan) {
for i := len(plans) - 1; i >= 0; i-- {
plan := plans[i]
h.rollbackForwardMutation(plan.forward, plan.oldPorts)
}
}
func (h *Handler) deleteTunnelAndCleanup(tunnelID int64) error {
h.cleanupTunnelRuntime(tunnelID)
h.cleanupFederationRuntime(tunnelID)
if err := h.deleteTunnelByID(tunnelID); err != nil {
return err
}
return nil
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
@@ -0,0 +1,104 @@
package handler
import (
"path/filepath"
"testing"
"time"
"go-backend/internal/store/repo"
)
func TestValidateTunnelEntryPortConflictsForNewEntriesDoesNotBlockOnSQLiteTx(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 := &Handler{repo: r}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, port, created_time, status, tcp_listen_addr, udp_listen_addr, is_remote)
VALUES
('entry-old', 'secret-old', '10.0.0.1', '12000-12010', ?, 1, '[::]', '[::]', 0),
('entry-new', 'secret-new', '10.0.0.2', '12000-12010', ?, 1, '[::]', '[::]', 0)
`, now, now).Error; err != nil {
t.Fatalf("insert nodes: %v", err)
}
var oldEntryID, newEntryID int64
if err := r.DB().Raw(`SELECT id FROM node WHERE name = 'entry-old'`).Scan(&oldEntryID).Error; err != nil {
t.Fatalf("load old entry id: %v", err)
}
if err := r.DB().Raw(`SELECT id FROM node WHERE name = 'entry-new'`).Scan(&newEntryID).Error; err != nil {
t.Fatalf("load new entry id: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, inx, ip_preference)
VALUES('sqlite-tunnel', 1, 1, 'tls', 1, ?, ?, 1, 1, '')
`, now, now).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
var tunnelID int64
if err := r.DB().Raw(`SELECT id FROM tunnel WHERE name = 'sqlite-tunnel'`).Scan(&tunnelID).Error; err != nil {
t.Fatalf("load tunnel id: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, inx, protocol)
VALUES(?, '1', ?, 1, 'tls')
`, tunnelID, oldEntryID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, created_time, updated_time, status, inx)
VALUES(1, 'tester', 'forward-a', ?, '127.0.0.1:8080', 'fifo', ?, ?, 1, 1)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
var forwardID int64
if err := r.DB().Raw(`SELECT id FROM forward WHERE name = 'forward-a'`).Scan(&forwardID).Error; err != nil {
t.Fatalf("load forward id: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward_port(forward_id, node_id, port)
VALUES(?, ?, 12001)
`, forwardID, oldEntryID).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
tx := r.BeginTx()
if tx == nil {
t.Fatal("begin tx: nil transaction")
}
if tx.Error != nil {
t.Fatalf("begin tx: %v", tx.Error)
}
errCh := make(chan error, 1)
doneCh := make(chan struct{})
go func() {
defer close(doneCh)
errCh <- h.validateTunnelEntryPortConflictsForNewEntriesTx(tx, tunnelID, []int64{oldEntryID}, []int64{oldEntryID, newEntryID})
}()
select {
case err := <-errCh:
if err != nil {
_ = tx.Rollback().Error
t.Fatalf("unexpected validation error: %v", err)
}
case <-time.After(500 * time.Millisecond):
_ = tx.Rollback().Error
<-doneCh
t.Fatal("validation blocked while transaction was open on sqlite")
}
if err := tx.Rollback().Error; err != nil {
t.Fatalf("rollback tx: %v", err)
}
}
@@ -0,0 +1,111 @@
package handler
import (
"log"
"strings"
"time"
"go-backend/internal/store/model"
)
type tunnelTrafficDelta struct {
bytesIn int64
bytesOut int64
}
func unixMilliBucketMinute(nowMs int64) int64 {
if nowMs <= 0 {
return 0
}
const minuteMs = int64(time.Minute / time.Millisecond)
return nowMs - (nowMs % minuteMs)
}
func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowItem, nowMs int64) {
if h == nil || h.repo == nil {
return
}
if nodeID <= 0 || len(items) == 0 {
return
}
bucketTs := unixMilliBucketMinute(nowMs)
if bucketTs <= 0 {
return
}
forwardDeltas := make(map[int64]tunnelTrafficDelta)
for _, item := range items {
name := strings.TrimSpace(item.N)
if name == "" || name == "web_api" {
continue
}
forwardID, _, _, ok := parseFlowServiceIDs(name)
if !ok {
continue
}
if item.D == 0 && item.U == 0 {
continue
}
d := forwardDeltas[forwardID]
d.bytesIn += item.D
d.bytesOut += item.U
forwardDeltas[forwardID] = d
}
if len(forwardDeltas) == 0 {
return
}
forwardIDs := make([]int64, 0, len(forwardDeltas))
for id := range forwardDeltas {
forwardIDs = append(forwardIDs, id)
}
forwardTunnelMap, err := h.repo.MapForwardIDsToTunnelIDs(forwardIDs)
if err != nil {
log.Printf("monitoring write skipped op=tunnel_metric.map_forward_to_tunnel node_id=%d err=%v", nodeID, err)
return
}
if len(forwardTunnelMap) == 0 {
return
}
tunnelAgg := make(map[int64]tunnelTrafficDelta)
for forwardID, delta := range forwardDeltas {
tunnelID := forwardTunnelMap[forwardID]
if tunnelID <= 0 {
continue
}
a := tunnelAgg[tunnelID]
a.bytesIn += delta.bytesIn
a.bytesOut += delta.bytesOut
tunnelAgg[tunnelID] = a
}
if len(tunnelAgg) == 0 {
return
}
metrics := make([]*model.TunnelMetric, 0, len(tunnelAgg))
for tunnelID, delta := range tunnelAgg {
if delta.bytesIn == 0 && delta.bytesOut == 0 {
continue
}
metrics = append(metrics, &model.TunnelMetric{
TunnelID: tunnelID,
NodeID: nodeID,
Timestamp: bucketTs,
BytesIn: delta.bytesIn,
BytesOut: delta.bytesOut,
Connections: 0,
Errors: 0,
AvgLatencyMs: 0,
})
}
if len(metrics) == 0 {
return
}
if err := h.repo.UpsertTunnelMetricBuckets(metrics); err != nil {
log.Printf("monitoring write failed op=tunnel_metric.upsert_buckets node_id=%d bucket_ts=%d count=%d err=%v", nodeID, bucketTs, len(metrics), err)
}
}
@@ -0,0 +1,332 @@
package handler
import (
"context"
"log"
"sync"
"time"
"go-backend/internal/store/model"
)
const (
tunnelQualityProbeInterval = 10 * time.Second
tunnelQualityProbeTimeout = 8 * time.Second
tunnelQualityPingTimeoutMs = 5000
tunnelQualityRetention = 24 * time.Hour // keep 24h of history
tunnelQualityPruneInterval = 10 * time.Minute
)
// tunnelQualitySnapshot is the in-memory latest probe result for a tunnel.
type tunnelQualitySnapshot struct {
TunnelID int64 `json:"tunnelId"`
EntryToExitLatency float64 `json:"entryToExitLatency"`
ExitToBingLatency float64 `json:"exitToBingLatency"`
EntryToExitLoss float64 `json:"entryToExitLoss"`
ExitToBingLoss float64 `json:"exitToBingLoss"`
Success bool `json:"success"`
ErrorMessage string `json:"errorMessage,omitempty"`
Timestamp int64 `json:"timestamp"`
}
// tunnelQualityProber runs periodic TCP ping probes against all enabled tunnels.
// Design mirrors health.Checker: background goroutine with worker pool + scheduled cleanup.
type tunnelQualityProber struct {
handler *Handler
cache sync.Map // tunnelID (int64) → *tunnelQualitySnapshot
ctx context.Context
cancel context.CancelFunc
interval time.Duration
lastPrune int64
}
// newTunnelQualityProber creates a new prober (not yet running).
func newTunnelQualityProber(h *Handler) *tunnelQualityProber {
ctx, cancel := context.WithCancel(context.Background())
return &tunnelQualityProber{
handler: h,
ctx: ctx,
cancel: cancel,
interval: tunnelQualityProbeInterval,
}
}
// Start launches the background probe loop (call from jobs.go).
func (p *tunnelQualityProber) Start(ctx context.Context) {
// Use the provided context so we stop with other background jobs.
p.ctx, p.cancel = context.WithCancel(ctx)
p.loop()
}
// Stop halts the background probe loop.
func (p *tunnelQualityProber) Stop() {
p.cancel()
}
// GetAll returns all cached quality snapshots (latest per tunnel).
func (p *tunnelQualityProber) GetAll() []tunnelQualitySnapshot {
var items []tunnelQualitySnapshot
p.cache.Range(func(_, value interface{}) bool {
if snap, ok := value.(*tunnelQualitySnapshot); ok {
items = append(items, *snap)
}
return true
})
return items
}
func (p *tunnelQualityProber) loop() {
// Initial delay to let the system boot up
select {
case <-time.After(5 * time.Second):
case <-p.ctx.Done():
return
}
// Run once immediately
p.probeAll()
ticker := time.NewTicker(p.interval)
defer ticker.Stop()
for {
select {
case <-p.ctx.Done():
return
case <-ticker.C:
p.probeAll()
p.maybePrune()
}
}
}
// maybePrune deletes old quality rows periodically (mirrors PruneServiceMonitorResults).
func (p *tunnelQualityProber) maybePrune() {
now := time.Now().UnixMilli()
if p.lastPrune > 0 && now-p.lastPrune < int64(tunnelQualityPruneInterval/time.Millisecond) {
return
}
p.lastPrune = now
h := p.handler
if h == nil || h.repo == nil {
return
}
cutoff := now - int64(tunnelQualityRetention/time.Millisecond)
if err := h.repo.PruneTunnelQualityResults(cutoff); err != nil {
log.Printf("tunnel_quality_prober: prune err=%v", err)
}
}
func (p *tunnelQualityProber) probeAll() {
h := p.handler
if h == nil || h.repo == nil {
return
}
tunnelIDs, err := h.repo.ListEnabledTunnelIDs()
if err != nil {
log.Printf("tunnel_quality_prober: list enabled tunnels err=%v", err)
return
}
if len(tunnelIDs) == 0 {
return
}
// Probe tunnels concurrently with a worker limit
// (mirrors health.Checker worker pool pattern)
const maxWorkers = 4
sem := make(chan struct{}, maxWorkers)
var wg sync.WaitGroup
for _, tunnelID := range tunnelIDs {
select {
case <-p.ctx.Done():
return
default:
}
wg.Add(1)
sem <- struct{}{}
go func(tid int64) {
defer wg.Done()
defer func() { <-sem }()
p.probeTunnel(tid)
}(tunnelID)
}
wg.Wait()
}
func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
h := p.handler
if h == nil || h.repo == nil {
return
}
now := time.Now().UnixMilli()
snap := &tunnelQualitySnapshot{
TunnelID: tunnelID,
Timestamp: now,
}
// Get tunnel chain info
tunnel, err := h.getTunnelRecord(tunnelID)
if err != nil {
snap.ErrorMessage = "隧道不存在"
p.storeResult(snap)
return
}
chainRows, err := h.listChainNodesForTunnel(tunnelID)
if err != nil || len(chainRows) == 0 {
snap.ErrorMessage = "隧道配置不完整"
p.storeResult(snap)
return
}
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
inNodes, _, outNodes := splitChainNodeGroups(chainRows)
options := diagnosisExecOptions{
commandTimeout: tunnelQualityProbeTimeout,
pingTimeoutMS: tunnelQualityPingTimeoutMs,
timeoutMessage: "探测超时",
}
switch tunnel.Type {
case 1:
// Port forwarding: entry → Bing only
if len(inNodes) > 0 {
lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, "www.bing.com", 443, options)
if err == nil {
snap.ExitToBingLatency = lat
snap.ExitToBingLoss = loss
snap.Success = true
} else {
snap.ErrorMessage = err.Error()
}
}
case 2:
// Tunnel forwarding: entry → exit + exit → Bing
probeOK := true
if len(inNodes) > 0 && len(outNodes) > 0 {
// Entry → Exit
targetNode, nodeErr := h.getNodeRecord(outNodes[0].NodeID)
if nodeErr == nil && targetNode != nil {
fromNode, _ := h.getNodeRecord(inNodes[0].NodeID)
targetIP, targetPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, outNodes[0].Port, ipPreference, outNodes[0].ConnectIP)
if resolveErr == nil {
lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, targetIP, targetPort, options)
if err == nil {
snap.EntryToExitLatency = lat
snap.EntryToExitLoss = loss
} else {
snap.EntryToExitLatency = -1
snap.EntryToExitLoss = 100
probeOK = false
}
} else {
snap.ErrorMessage = resolveErr.Error()
probeOK = false
}
} else {
snap.ErrorMessage = "出口节点不可用"
probeOK = false
}
}
// Exit → Bing
if len(outNodes) > 0 {
lat, loss, err := p.tcpPingNode(outNodes[0].NodeID, "www.bing.com", 443, options)
if err == nil {
snap.ExitToBingLatency = lat
snap.ExitToBingLoss = loss
} else {
if snap.ErrorMessage == "" {
snap.ErrorMessage = err.Error()
}
probeOK = false
}
}
snap.Success = probeOK
default:
// Unknown type: entry → Bing
if len(inNodes) > 0 {
lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, "www.bing.com", 443, options)
if err == nil {
snap.ExitToBingLatency = lat
snap.ExitToBingLoss = loss
snap.Success = true
} else {
snap.ErrorMessage = err.Error()
}
}
}
p.storeResult(snap)
}
func (p *tunnelQualityProber) tcpPingNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (latency float64, loss float64, err error) {
h := p.handler
if h == nil {
return 0, 100, nil
}
node, nodeErr := h.getNodeRecord(nodeID)
if nodeErr != nil {
return 0, 100, nodeErr
}
var pingData map[string]interface{}
var pingErr error
if node != nil && node.IsRemote == 1 {
pingData, pingErr = h.tcpPingViaRemoteNode(node, ip, port, options)
} else {
pingData, pingErr = h.tcpPingViaNode(nodeID, ip, port, options)
}
if pingErr != nil {
return 0, 100, pingErr
}
avgTime := asFloat(pingData["averageTime"], 0)
packetLoss := asFloat(pingData["packetLoss"], 100)
return avgTime, packetLoss, nil
}
func (p *tunnelQualityProber) storeResult(snap *tunnelQualitySnapshot) {
if snap == nil {
return
}
// Update in-memory cache (latest per tunnel)
p.cache.Store(snap.TunnelID, snap)
// Persist to database (history)
h := p.handler
if h == nil || h.repo == nil {
return
}
successInt := 0
if snap.Success {
successInt = 1
}
q := &model.TunnelQuality{
TunnelID: snap.TunnelID,
EntryToExitLatency: snap.EntryToExitLatency,
ExitToBingLatency: snap.ExitToBingLatency,
EntryToExitLoss: snap.EntryToExitLoss,
ExitToBingLoss: snap.ExitToBingLoss,
Success: successInt,
ErrorMessage: snap.ErrorMessage,
Timestamp: snap.Timestamp,
}
if err := h.repo.InsertTunnelQuality(q); err != nil {
log.Printf("tunnel_quality_prober: insert db err=%v tunnel_id=%d", err, snap.TunnelID)
}
}
@@ -0,0 +1,139 @@
package handler
import (
"errors"
"net/http"
"time"
"go-backend/internal/http/response"
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
)
func isUserQuotaExceeded(view *model.UserQuotaView) bool {
if view == nil {
return false
}
if view.DailyLimitGB > 0 && view.DailyUsedBytes >= view.DailyLimitGB*bytesPerGB {
return true
}
if view.MonthlyLimitGB > 0 && view.MonthlyUsedBytes >= view.MonthlyLimitGB*bytesPerGB {
return true
}
return false
}
func (h *Handler) userQuotaBlockReason(userID int64, now int64) (string, error) {
if h == nil || h.repo == nil || userID <= 0 {
return "", nil
}
quota, err := h.repo.GetUserQuotaView(userID, time.UnixMilli(now))
if err != nil || quota == nil {
return "", err
}
if quota.DisabledByQuota == 1 || isUserQuotaExceeded(quota) {
return "该用户流量配额已超额,禁止开启转发", nil
}
return "", nil
}
func (h *Handler) enforceUserQuotaIfNeeded(userID int64, quota *model.UserQuotaView) {
if h == nil || h.repo == nil || userID <= 0 || quota == nil {
return
}
if quota.DisabledByQuota == 1 || !isUserQuotaExceeded(quota) {
return
}
forwards, err := h.listActiveForwardsByUser(userID)
if err != nil {
return
}
pausedIDs := make([]int64, 0, len(forwards))
now := time.Now().UnixMilli()
for i := range forwards {
forward := &forwards[i]
if forward.Status != 1 {
continue
}
if err := h.controlForwardServices(forward, "PauseService", false); err != nil {
continue
}
if err := h.repo.UpdateForwardStatus(forward.ID, 0, now); err != nil {
continue
}
pausedIDs = append(pausedIDs, forward.ID)
}
_ = h.repo.MarkUserQuotaDisabled(userID, pausedIDs, now)
}
func (h *Handler) applyUserQuotaRelease(release *repo.UserQuotaRelease, now int64) {
if h == nil || h.repo == nil || release == nil || release.UserID <= 0 || !release.UnblockUser {
return
}
for _, forwardID := range release.ForwardIDs {
forward, err := h.getForwardRecord(forwardID)
if err != nil || forward == nil {
continue
}
if err := h.ensureUserTunnelForwardAllowed(forward.UserID, forward.TunnelID, now); err != nil {
continue
}
if err := h.controlForwardServices(forward, "ResumeService", false); err != nil {
continue
}
_ = h.repo.UpdateForwardStatus(forwardID, 1, now)
}
}
func (h *Handler) resetUserQuotaWindows(now time.Time) {
if h == nil || h.repo == nil {
return
}
releases, err := h.repo.RollUserQuotaWindows(now)
if err != nil {
return
}
nowMs := now.UnixMilli()
for i := range releases {
h.applyUserQuotaRelease(&releases[i], nowMs)
}
}
func (h *Handler) userQuotaReset(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req struct {
UserID int64 `json:"userId"`
Scope string `json:"scope"`
}
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if req.UserID <= 0 {
response.WriteJSON(w, response.ErrDefault("用户ID不能为空"))
return
}
release, err := h.repo.ResetUserQuotaUsage(req.UserID, req.Scope, time.Now())
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
nowMs := time.Now().UnixMilli()
h.applyUserQuotaRelease(release, nowMs)
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) ensureUserForwardAllowedByQuota(userID int64, now int64) error {
reason, err := h.userQuotaBlockReason(userID, now)
if err != nil {
return err
}
if reason != "" {
return errors.New(reason)
}
return nil
}
@@ -101,6 +101,10 @@ func shouldSkip(path string) bool {
}
func requiresAdmin(path string) bool {
if strings.HasPrefix(path, "/api/v1/monitor/permission/") {
return true
}
if strings.HasPrefix(path, "/api/v1/group/") {
return true
}
+135
View File
@@ -0,0 +1,135 @@
package metrics
import (
"context"
"log"
"sync"
"time"
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
)
type SystemInfo struct {
Uptime uint64 `json:"uptime"`
BytesReceived uint64 `json:"bytes_received"`
BytesTransmitted uint64 `json:"bytes_transmitted"`
CPUUsage float64 `json:"cpu_usage"`
MemoryUsage float64 `json:"memory_usage"`
DiskUsage float64 `json:"disk_usage"`
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
TCPConns int64 `json:"tcp_conns"`
UDPConns int64 `json:"udp_conns"`
NetInSpeed int64 `json:"net_in_speed"`
NetOutSpeed int64 `json:"net_out_speed"`
}
type IngestionService struct {
repo *repo.Repository
nodeBuffer []*model.NodeMetric
nodeBufferMu sync.Mutex
flushInterval time.Duration
retentionDays int
}
func NewIngestionService(repo *repo.Repository) *IngestionService {
return &IngestionService{
repo: repo,
nodeBuffer: make([]*model.NodeMetric, 0, 500),
flushInterval: 30 * time.Second,
retentionDays: 7,
}
}
func (s *IngestionService) Start(ctx context.Context) {
flushTicker := time.NewTicker(s.flushInterval)
defer flushTicker.Stop()
pruneTicker := time.NewTicker(1 * time.Hour)
defer pruneTicker.Stop()
for {
select {
case <-ctx.Done():
s.flushNodeMetrics()
return
case <-flushTicker.C:
s.flushNodeMetrics()
case <-pruneTicker.C:
s.pruneMetrics()
}
}
}
func (s *IngestionService) RecordNodeMetric(nodeID int64, info SystemInfo) {
m := &model.NodeMetric{
NodeID: nodeID,
Timestamp: time.Now().UnixMilli(),
CPUUsage: info.CPUUsage,
MemUsage: info.MemoryUsage,
DiskUsage: info.DiskUsage,
NetInBytes: int64(info.BytesReceived),
NetOutBytes: int64(info.BytesTransmitted),
NetInSpeed: info.NetInSpeed,
NetOutSpeed: info.NetOutSpeed,
Load1: info.Load1,
Load5: info.Load5,
Load15: info.Load15,
TCPConns: info.TCPConns,
UDPConns: info.UDPConns,
Uptime: int64(info.Uptime),
}
s.nodeBufferMu.Lock()
s.nodeBuffer = append(s.nodeBuffer, m)
shouldFlush := len(s.nodeBuffer) >= 200
s.nodeBufferMu.Unlock()
if shouldFlush {
go s.flushNodeMetrics()
}
}
func (s *IngestionService) flushNodeMetrics() {
s.nodeBufferMu.Lock()
if len(s.nodeBuffer) == 0 {
s.nodeBufferMu.Unlock()
return
}
buffer := s.nodeBuffer
s.nodeBuffer = make([]*model.NodeMetric, 0, 500)
s.nodeBufferMu.Unlock()
if s.repo == nil {
return
}
if err := s.repo.InsertNodeMetricBatch(buffer); err != nil {
log.Printf("monitoring write failed op=node_metric.flush count=%d err=%v", len(buffer), err)
}
}
func (s *IngestionService) pruneMetrics() {
cutoff := time.Now().Add(-time.Duration(s.retentionDays) * 24 * time.Hour).UnixMilli()
if s.repo == nil {
return
}
if err := s.repo.PruneNodeMetrics(cutoff); err != nil {
log.Printf("monitoring prune failed op=node_metric cutoff=%d err=%v", cutoff, err)
}
if err := s.repo.PruneTunnelMetrics(cutoff); err != nil {
log.Printf("monitoring prune failed op=tunnel_metric cutoff=%d err=%v", cutoff, err)
}
if err := s.repo.PruneServiceMonitorResults(cutoff); err != nil {
log.Printf("monitoring prune failed op=service_monitor_result cutoff=%d err=%v", cutoff, err)
}
}
func (s *IngestionService) GetLatestMetric(nodeID int64) (*model.NodeMetric, error) {
return s.repo.GetLatestNodeMetric(nodeID)
}
func (s *IngestionService) GetMetrics(nodeID int64, startMs, endMs int64) ([]model.NodeMetric, error) {
return s.repo.GetNodeMetrics(nodeID, startMs, endMs)
}
@@ -0,0 +1,294 @@
package metrics
import (
"context"
"testing"
"time"
"go-backend/internal/store/repo"
)
func TestRecordNodeMetric(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
svc := NewIngestionService(r)
info := SystemInfo{
Uptime: 86400,
BytesReceived: 1024000,
BytesTransmitted: 2048000,
CPUUsage: 45.5,
MemoryUsage: 60.2,
DiskUsage: 30.1,
Load1: 1.5,
Load5: 1.2,
Load15: 0.9,
TCPConns: 100,
UDPConns: 50,
NetInSpeed: 51200,
NetOutSpeed: 102400,
}
svc.RecordNodeMetric(1, info)
svc.flushNodeMetrics()
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
if len(metrics) != 1 {
t.Fatalf("expected 1 metric, got %d", len(metrics))
}
m := metrics[0]
if m.CPUUsage != 45.5 {
t.Fatalf("expected CPUUsage 45.5, got %f", m.CPUUsage)
}
if m.MemUsage != 60.2 {
t.Fatalf("expected MemUsage 60.2, got %f", m.MemUsage)
}
if m.DiskUsage != 30.1 {
t.Fatalf("expected DiskUsage 30.1, got %f", m.DiskUsage)
}
if m.Load1 != 1.5 {
t.Fatalf("expected Load1 1.5, got %f", m.Load1)
}
if m.TCPConns != 100 {
t.Fatalf("expected TCPConns 100, got %d", m.TCPConns)
}
if m.UDPConns != 50 {
t.Fatalf("expected UDPConns 50, got %d", m.UDPConns)
}
}
func TestRecordNodeMetricAutoFlush(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
svc := NewIngestionService(r)
info := SystemInfo{
CPUUsage: 50.0,
MemoryUsage: 60.0,
DiskUsage: 30.0,
}
for i := 0; i < 250; i++ {
svc.RecordNodeMetric(1, info)
}
time.Sleep(100 * time.Millisecond)
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
if len(metrics) < 200 {
t.Fatalf("expected at least 200 metrics after auto-flush, got %d", len(metrics))
}
}
func TestIngestionServiceStart(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
svc := NewIngestionService(r)
svc.flushInterval = 100 * time.Millisecond
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
info := SystemInfo{
CPUUsage: 45.0,
MemoryUsage: 55.0,
DiskUsage: 35.0,
}
go svc.Start(ctx)
for i := 0; i < 10; i++ {
svc.RecordNodeMetric(1, info)
time.Sleep(50 * time.Millisecond)
}
<-ctx.Done()
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
if len(metrics) == 0 {
t.Fatalf("expected metrics after service run")
}
}
func TestGetLatestMetric(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
svc := NewIngestionService(r)
now := time.Now().UnixMilli()
info1 := SystemInfo{CPUUsage: 40.0, MemoryUsage: 50.0, DiskUsage: 30.0}
svc.RecordNodeMetric(1, info1)
time.Sleep(5 * time.Millisecond)
info2 := SystemInfo{CPUUsage: 60.0, MemoryUsage: 70.0, DiskUsage: 40.0}
svc.RecordNodeMetric(1, info2)
svc.flushNodeMetrics()
latest, err := svc.GetLatestMetric(1)
if err != nil {
t.Fatalf("get latest: %v", err)
}
if latest == nil {
t.Fatalf("expected latest metric")
}
if latest.CPUUsage != 60.0 {
t.Fatalf("expected latest CPUUsage 60.0, got %f", latest.CPUUsage)
}
_ = now
latestNone, err := svc.GetLatestMetric(999)
if err != nil {
t.Fatalf("get latest for non-existent: %v", err)
}
if latestNone != nil {
t.Fatalf("expected nil for non-existent node")
}
}
func TestGetMetricsWithTimeRange(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
svc := NewIngestionService(r)
now := time.Now().UnixMilli()
for i := 0; i < 5; i++ {
info := SystemInfo{
CPUUsage: float64(40 + i*5),
MemoryUsage: 50.0,
DiskUsage: 30.0,
}
svc.RecordNodeMetric(1, info)
time.Sleep(10 * time.Millisecond)
}
svc.flushNodeMetrics()
metrics, err := svc.GetMetrics(1, 0, now+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
if len(metrics) != 5 {
t.Fatalf("expected 5 metrics, got %d", len(metrics))
}
}
func TestPruneMetrics(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
svc := NewIngestionService(r)
svc.retentionDays = 1
info := SystemInfo{CPUUsage: 50.0, MemoryUsage: 60.0, DiskUsage: 30.0}
svc.RecordNodeMetric(1, info)
svc.flushNodeMetrics()
svc.pruneMetrics()
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
if len(metrics) != 1 {
t.Fatalf("expected 1 metric (not pruned), got %d", len(metrics))
}
}
func TestMultipleNodes(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
svc := NewIngestionService(r)
info := SystemInfo{
CPUUsage: 50.0,
MemoryUsage: 60.0,
DiskUsage: 30.0,
}
svc.RecordNodeMetric(1, info)
svc.RecordNodeMetric(2, info)
svc.RecordNodeMetric(3, info)
svc.flushNodeMetrics()
for nodeID := int64(1); nodeID <= 3; nodeID++ {
metrics, err := r.GetNodeMetrics(nodeID, 0, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics for node %d: %v", nodeID, err)
}
if len(metrics) != 1 {
t.Fatalf("expected 1 metric for node %d, got %d", nodeID, len(metrics))
}
}
}
func TestZeroValues(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
svc := NewIngestionService(r)
info := SystemInfo{}
svc.RecordNodeMetric(1, info)
svc.flushNodeMetrics()
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
if len(metrics) != 1 {
t.Fatalf("expected 1 metric, got %d", len(metrics))
}
m := metrics[0]
if m.CPUUsage != 0 || m.MemUsage != 0 || m.DiskUsage != 0 {
t.Fatalf("expected zero values, got CPU=%f Mem=%f Disk=%f", m.CPUUsage, m.MemUsage, m.DiskUsage)
}
}
+114
View File
@@ -0,0 +1,114 @@
package monitoring
import (
"strconv"
"strings"
)
type ServiceMonitorLimits struct {
CheckerScanIntervalSec int `json:"checkerScanIntervalSec"`
WorkerLimit int `json:"workerLimit"`
MinIntervalSec int `json:"minIntervalSec"`
DefaultIntervalSec int `json:"defaultIntervalSec"`
MinTimeoutSec int `json:"minTimeoutSec"`
DefaultTimeoutSec int `json:"defaultTimeoutSec"`
MaxTimeoutSec int `json:"maxTimeoutSec"`
}
const (
ConfigServiceMonitorCheckerScanIntervalSec = "service_monitor_checker_scan_interval_sec"
ConfigServiceMonitorWorkerLimit = "service_monitor_worker_limit"
ConfigServiceMonitorMinIntervalSec = "service_monitor_min_interval_sec"
ConfigServiceMonitorDefaultIntervalSec = "service_monitor_default_interval_sec"
ConfigServiceMonitorMinTimeoutSec = "service_monitor_min_timeout_sec"
ConfigServiceMonitorDefaultTimeoutSec = "service_monitor_default_timeout_sec"
ConfigServiceMonitorMaxTimeoutSec = "service_monitor_max_timeout_sec"
)
func DefaultServiceMonitorLimits() ServiceMonitorLimits {
return ServiceMonitorLimits{
CheckerScanIntervalSec: 30,
WorkerLimit: 5,
MinIntervalSec: 30,
DefaultIntervalSec: 60,
MinTimeoutSec: 1,
DefaultTimeoutSec: 5,
MaxTimeoutSec: 60,
}
}
// ServiceMonitorLimitsFromConfigMap parses limits from vite_config values.
// Missing/invalid values fall back to defaults.
func ServiceMonitorLimitsFromConfigMap(cfg map[string]string) ServiceMonitorLimits {
limits := DefaultServiceMonitorLimits()
if cfg == nil {
return limits
}
limits.CheckerScanIntervalSec = parseConfigInt(cfg, ConfigServiceMonitorCheckerScanIntervalSec, limits.CheckerScanIntervalSec)
limits.WorkerLimit = parseConfigInt(cfg, ConfigServiceMonitorWorkerLimit, limits.WorkerLimit)
limits.MinIntervalSec = parseConfigInt(cfg, ConfigServiceMonitorMinIntervalSec, limits.MinIntervalSec)
limits.DefaultIntervalSec = parseConfigInt(cfg, ConfigServiceMonitorDefaultIntervalSec, limits.DefaultIntervalSec)
limits.MinTimeoutSec = parseConfigInt(cfg, ConfigServiceMonitorMinTimeoutSec, limits.MinTimeoutSec)
limits.DefaultTimeoutSec = parseConfigInt(cfg, ConfigServiceMonitorDefaultTimeoutSec, limits.DefaultTimeoutSec)
limits.MaxTimeoutSec = parseConfigInt(cfg, ConfigServiceMonitorMaxTimeoutSec, limits.MaxTimeoutSec)
return normalizeServiceMonitorLimits(limits)
}
func normalizeServiceMonitorLimits(limits ServiceMonitorLimits) ServiceMonitorLimits {
if limits.CheckerScanIntervalSec <= 0 {
limits.CheckerScanIntervalSec = 30
}
if limits.WorkerLimit <= 0 {
limits.WorkerLimit = 5
}
if limits.WorkerLimit > 50 {
limits.WorkerLimit = 50
}
if limits.MinIntervalSec <= 0 {
limits.MinIntervalSec = limits.CheckerScanIntervalSec
}
if limits.MinIntervalSec < limits.CheckerScanIntervalSec {
limits.MinIntervalSec = limits.CheckerScanIntervalSec
}
if limits.DefaultIntervalSec <= 0 {
limits.DefaultIntervalSec = 60
}
if limits.DefaultIntervalSec < limits.MinIntervalSec {
limits.DefaultIntervalSec = limits.MinIntervalSec
}
if limits.MinTimeoutSec <= 0 {
limits.MinTimeoutSec = 1
}
if limits.DefaultTimeoutSec <= 0 {
limits.DefaultTimeoutSec = 5
}
if limits.DefaultTimeoutSec < limits.MinTimeoutSec {
limits.DefaultTimeoutSec = limits.MinTimeoutSec
}
if limits.MaxTimeoutSec <= 0 {
limits.MaxTimeoutSec = 60
}
if limits.MaxTimeoutSec < limits.DefaultTimeoutSec {
limits.MaxTimeoutSec = limits.DefaultTimeoutSec
}
return limits
}
func parseConfigInt(cfg map[string]string, key string, fallback int) int {
v := strings.TrimSpace(cfg[key])
if v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil {
return fallback
}
return n
}
+178 -39
View File
@@ -48,37 +48,43 @@ type Forward struct {
func (Forward) TableName() string { return "forward" }
type ForwardPort struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
ForwardID int64 `gorm:"column:forward_id;not null"`
NodeID int64 `gorm:"column:node_id;not null"`
Port int `gorm:"not null"`
ID int64 `gorm:"primaryKey;autoIncrement"`
ForwardID int64 `gorm:"column:forward_id;not null"`
NodeID int64 `gorm:"column:node_id;not null"`
Port int `gorm:"not null"`
InIP sql.NullString `gorm:"column:in_ip;type:text"`
}
func (ForwardPort) TableName() string { return "forward_port" }
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"`
ID int64 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"type:varchar(100);not null"`
Remark sql.NullString `gorm:"column:remark;type:text"`
ExpiryTime sql.NullInt64 `gorm:"column:expiry_time"`
RenewalCycle sql.NullString `gorm:"column:renewal_cycle;type:varchar(20)"`
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)"`
ExtraIPs sql.NullString `gorm:"column:extra_ips;type:text"`
Port string `gorm:"type:text;not null"`
InterfaceName sql.NullString `gorm:"column:interface_name;type:varchar(200)"`
Version sql.NullString `gorm:"type:varchar(100)"`
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"`
ExpiryReminderDismissed int `gorm:"column:expiry_reminder_dismissed;not null;default:0"`
}
func (Node) TableName() string { return "node" }
@@ -124,6 +130,23 @@ type Tunnel struct {
func (Tunnel) TableName() string { return "tunnel" }
type UserQuota struct {
UserID int64 `gorm:"column:user_id;primaryKey"`
DailyLimitGB int64 `gorm:"column:daily_limit_gb;not null;default:0"`
MonthlyLimitGB int64 `gorm:"column:monthly_limit_gb;not null;default:0"`
DailyUsedBytes int64 `gorm:"column:daily_used_bytes;not null;default:0"`
MonthlyUsedBytes int64 `gorm:"column:monthly_used_bytes;not null;default:0"`
DayKey int64 `gorm:"column:day_key;not null;default:0"`
MonthKey int64 `gorm:"column:month_key;not null;default:0"`
DisabledByQuota int `gorm:"column:disabled_by_quota;not null;default:0"`
DisabledAt int64 `gorm:"column:disabled_at;not null;default:0"`
PausedForwardIDs string `gorm:"column:paused_forward_ids;type:text;not null;default:''"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime int64 `gorm:"column:updated_time;not null"`
}
func (UserQuota) TableName() string { return "user_quota" }
type ChainTunnel struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
TunnelID int64 `gorm:"column:tunnel_id;not null"`
@@ -133,6 +156,7 @@ type ChainTunnel struct {
Strategy sql.NullString `gorm:"type:varchar(10)"`
Inx sql.NullInt64 `gorm:"column:inx"`
Protocol sql.NullString `gorm:"type:varchar(10)"`
ConnectIP sql.NullString `gorm:"column:connect_ip;type:varchar(45)"`
}
func (ChainTunnel) TableName() string { return "chain_tunnel" }
@@ -211,6 +235,16 @@ type GroupPermissionGrant struct {
func (GroupPermissionGrant) TableName() string { return "group_permission_grant" }
// MonitorPermission grants a non-admin user access to monitoring endpoints.
// One row per user_id.
type MonitorPermission struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
UserID int64 `gorm:"column:user_id;not null;uniqueIndex:idx_monitor_permission_user" json:"userId"`
CreatedTime int64 `gorm:"column:created_time;not null" json:"createdTime"`
}
func (MonitorPermission) TableName() string { return "monitor_permission" }
type ViteConfig struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"type:varchar(200);not null;uniqueIndex" json:"name"`
@@ -315,28 +349,36 @@ type BackupData struct {
}
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"`
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"`
DailyQuotaGB int64 `json:"dailyQuotaGB,omitempty"`
MonthlyQuotaGB int64 `json:"monthlyQuotaGB,omitempty"`
DisabledByQuota int `json:"disabledByQuota,omitempty"`
QuotaDisabledAt int64 `json:"quotaDisabledAt,omitempty"`
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"`
Remark string `json:"remark,omitempty"`
ExpiryTime int64 `json:"expiryTime,omitempty"`
RenewalCycle string `json:"renewalCycle,omitempty"`
Secret string `json:"secret"`
ServerIP string `json:"serverIp"`
ServerIPv4 string `json:"serverIpV4,omitempty"`
ServerIPv6 string `json:"serverIpV6,omitempty"`
ExtraIPs string `json:"extraIPs,omitempty"`
Port string `json:"port"`
InterfaceName string `json:"interfaceName,omitempty"`
Version string `json:"version,omitempty"`
@@ -506,10 +548,24 @@ type TunnelRecord struct {
TrafficRatio float64
}
type UserQuotaView struct {
UserID int64
DailyLimitGB int64
MonthlyLimitGB int64
DailyUsedBytes int64
MonthlyUsedBytes int64
DayKey int64
MonthKey int64
DisabledByQuota int
DisabledAt int64
PausedForwardIDs string
}
// ForwardPortRecord is a forward port mapping used by control plane.
type ForwardPortRecord struct {
NodeID int64
Port int
InIP string
}
// NodeRecord is a node view used by control plane.
@@ -519,6 +575,7 @@ type NodeRecord struct {
ServerIP string
ServerIPv4 string
ServerIPv6 string
ExtraIPs string
Status int
PortRange string
TCPListenAddr string
@@ -538,6 +595,7 @@ type ChainNodeRecord struct {
NodeName string
Protocol string
Strategy string
ConnectIP string
}
type UserTunnelLimiterInfo struct {
@@ -566,6 +624,7 @@ type UserTunnelDetail struct {
UserID int64
TunnelID int64
TunnelName string
Status int
TunnelFlow int
Flow int64
InFlow int64
@@ -592,3 +651,83 @@ type UserForwardDetail struct {
Status int
CreatedAt int64
}
type NodeMetric struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
NodeID int64 `gorm:"column:node_id;not null;index:idx_node_metric_node_time,priority:1" json:"nodeId"`
Timestamp int64 `gorm:"not null;index:idx_node_metric_node_time,priority:2;index:idx_node_metric_time" json:"timestamp"`
CPUUsage float64 `gorm:"column:cpu_usage" json:"cpuUsage"`
MemUsage float64 `gorm:"column:mem_usage" json:"memoryUsage"`
DiskUsage float64 `gorm:"column:disk_usage" json:"diskUsage"`
NetInBytes int64 `gorm:"column:net_in_bytes" json:"netInBytes"`
NetOutBytes int64 `gorm:"column:net_out_bytes" json:"netOutBytes"`
NetInSpeed int64 `gorm:"column:net_in_speed" json:"netInSpeed"`
NetOutSpeed int64 `gorm:"column:net_out_speed" json:"netOutSpeed"`
Load1 float64 `gorm:"column:load1" json:"load1"`
Load5 float64 `gorm:"column:load5" json:"load5"`
Load15 float64 `gorm:"column:load15" json:"load15"`
TCPConns int64 `gorm:"column:tcp_conns" json:"tcpConns"`
UDPConns int64 `gorm:"column:udp_conns" json:"udpConns"`
Uptime int64 `gorm:"column:uptime" json:"uptime"`
}
func (NodeMetric) TableName() string { return "node_metric" }
type TunnelMetric struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
TunnelID int64 `gorm:"column:tunnel_id;not null;index:idx_tunnel_metric_tunnel_time,priority:1" json:"tunnelId"`
NodeID int64 `gorm:"column:node_id;not null;index:idx_tunnel_metric_tunnel_time,priority:2" json:"nodeId"`
Timestamp int64 `gorm:"not null;index:idx_tunnel_metric_tunnel_time,priority:3;index:idx_tunnel_metric_time" json:"timestamp"`
BytesIn int64 `gorm:"column:bytes_in" json:"bytesIn"`
BytesOut int64 `gorm:"column:bytes_out" json:"bytesOut"`
Connections int64 `gorm:"column:connections" json:"connections"`
Errors int64 `gorm:"column:errors" json:"errors"`
AvgLatencyMs float64 `gorm:"column:avg_latency_ms" json:"avgLatencyMs"`
}
func (TunnelMetric) TableName() string { return "tunnel_metric" }
type ServiceMonitor struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"type:varchar(100);not null" json:"name"`
Type string `gorm:"type:varchar(20);not null" json:"type"`
Target string `gorm:"type:text;not null" json:"target"`
IntervalSec int `gorm:"column:interval_sec;not null;default:60" json:"intervalSec"`
TimeoutSec int `gorm:"column:timeout_sec;not null;default:5" json:"timeoutSec"`
NodeID int64 `gorm:"column:node_id;index" json:"nodeId"`
Enabled int `gorm:"not null;default:1" json:"enabled"`
CreatedTime int64 `gorm:"column:created_time;not null" json:"createdTime"`
UpdatedTime int64 `gorm:"column:updated_time;not null" json:"updatedTime"`
}
func (ServiceMonitor) TableName() string { return "service_monitor" }
type ServiceMonitorResult struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
MonitorID int64 `gorm:"column:monitor_id;not null;index:idx_monitor_result_monitor_time,priority:1" json:"monitorId"`
NodeID int64 `gorm:"column:node_id;not null;index" json:"nodeId"`
Timestamp int64 `gorm:"not null;index:idx_monitor_result_monitor_time,priority:2" json:"timestamp"`
Success int `gorm:"not null" json:"success"`
LatencyMs float64 `gorm:"column:latency_ms" json:"latencyMs"`
StatusCode int `gorm:"column:status_code" json:"statusCode"`
ErrorMessage string `gorm:"column:error_message;type:text" json:"errorMessage"`
}
func (ServiceMonitorResult) TableName() string { return "service_monitor_result" }
// TunnelQuality stores periodic probe results for a tunnel.
// Unlike the old upsert model, rows accumulate for history/charting.
// Old rows are pruned periodically (default: keep 24h).
type TunnelQuality struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
TunnelID int64 `gorm:"column:tunnel_id;not null;index:idx_tunnel_quality_tunnel_time,priority:1" json:"tunnelId"`
EntryToExitLatency float64 `gorm:"column:entry_to_exit_latency" json:"entryToExitLatency"`
ExitToBingLatency float64 `gorm:"column:exit_to_bing_latency" json:"exitToBingLatency"`
EntryToExitLoss float64 `gorm:"column:entry_to_exit_loss" json:"entryToExitLoss"`
ExitToBingLoss float64 `gorm:"column:exit_to_bing_loss" json:"exitToBingLoss"`
Success int `gorm:"not null;default:1" json:"success"`
ErrorMessage string `gorm:"column:error_message;type:text" json:"errorMessage,omitempty"`
Timestamp int64 `gorm:"not null;index:idx_tunnel_quality_tunnel_time,priority:2;index:idx_tunnel_quality_time" json:"timestamp"`
}
func (TunnelQuality) TableName() string { return "tunnel_quality" }
File diff suppressed because it is too large Load Diff
@@ -30,8 +30,15 @@ func (r *Repository) ListForwardsByTunnel(tunnelID int64) ([]model.ForwardRecord
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
return r.ListForwardsByTunnelTx(r.db, tunnelID)
}
func (r *Repository) ListForwardsByTunnelTx(tx *gorm.DB, tunnelID int64) ([]model.ForwardRecord, error) {
if tx == nil {
return nil, errors.New("database unavailable")
}
var forwards []model.Forward
err := r.db.Where("tunnel_id = ?", tunnelID).Order("id ASC").Find(&forwards).Error
err := tx.Where("tunnel_id = ?", tunnelID).Order("id ASC").Find(&forwards).Error
if err != nil {
return nil, err
}
@@ -57,6 +64,7 @@ func (r *Repository) ListForwardsByTunnel(tunnelID int64) ([]model.ForwardRecord
return rows, nil
}
func (r *Repository) ListActiveTunnelIDsByNode(nodeID int64) ([]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
@@ -95,18 +103,57 @@ func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecor
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
return r.ListForwardPortsTx(r.db, forwardID)
}
func (r *Repository) ListForwardPortsTx(tx *gorm.DB, forwardID int64) ([]model.ForwardPortRecord, error) {
if tx == nil {
return nil, errors.New("database unavailable")
}
var ports []model.ForwardPort
err := r.db.Where("forward_id = ?", forwardID).Order("id ASC").Find(&ports).Error
err := tx.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})
inIP := ""
if p.InIP.Valid {
inIP = p.InIP.String
}
rows = append(rows, model.ForwardPortRecord{NodeID: p.NodeID, Port: p.Port, InIP: inIP})
}
return rows, nil
}
func (r *Repository) HasOtherForwardOnNodePort(nodeID int64, port int, currentForwardID int64) (bool, error) {
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
}
return r.HasOtherForwardOnNodePortTx(r.db, nodeID, port, currentForwardID)
}
func (r *Repository) HasOtherForwardOnNodePortTx(tx *gorm.DB, nodeID int64, port int, currentForwardID int64) (bool, error) {
if tx == nil {
return false, errors.New("database unavailable")
}
if nodeID <= 0 || port <= 0 {
return false, nil
}
var count int64
err := tx.Model(&model.ForwardPort{}).
Where("node_id = ? AND port = ? AND forward_id <> ?", nodeID, port, currentForwardID).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *Repository) GetTunnelOutProtocol(tunnelID int64) (string, error) {
if r == nil || r.db == nil {
return "", errors.New("repository not initialized")
@@ -177,6 +224,9 @@ func nodeRecordFromModel(n *model.Node) *model.NodeRecord {
if n.ServerIPV6.Valid {
rec.ServerIPv6 = strings.TrimSpace(n.ServerIPV6.String)
}
if n.ExtraIPs.Valid {
rec.ExtraIPs = strings.TrimSpace(n.ExtraIPs.String)
}
if n.InterfaceName.Valid {
rec.InterfaceName = strings.TrimSpace(n.InterfaceName.String)
}
@@ -289,10 +339,11 @@ func (r *Repository) ListChainNodesForTunnel(tunnelID int64) ([]model.ChainNodeR
Name sql.NullString
Protocol sql.NullString
Strategy sql.NullString
ConnectIP sql.NullString
}
var rows []row
err := r.db.Model(&model.ChainTunnel{}).
Select("chain_tunnel.chain_type, chain_tunnel.inx, chain_tunnel.node_id, chain_tunnel.port, node.name, chain_tunnel.protocol, chain_tunnel.strategy").
Select("chain_tunnel.chain_type, chain_tunnel.inx, chain_tunnel.node_id, chain_tunnel.port, node.name, chain_tunnel.protocol, chain_tunnel.strategy, chain_tunnel.connect_ip").
Joins("LEFT JOIN node ON node.id = chain_tunnel.node_id").
Where("chain_tunnel.tunnel_id = ?", tunnelID).
Order("chain_tunnel.chain_type ASC, chain_tunnel.inx ASC, chain_tunnel.id ASC").
@@ -337,6 +388,9 @@ func (r *Repository) ListChainNodesForTunnel(tunnelID int64) ([]model.ChainNodeR
} else {
item.Strategy = row.Strategy.String
}
if row.ConnectIP.Valid {
item.ConnectIP = row.ConnectIP.String
}
result = append(result, item)
}
return result, nil
@@ -161,6 +161,64 @@ func (r *Repository) ForwardExists(forwardID int64) (bool, error) {
return count > 0, nil
}
// MapForwardIDsToTunnelIDs returns a mapping from forward.id to forward.tunnel_id.
// Missing forward IDs are omitted from the returned map.
func (r *Repository) MapForwardIDsToTunnelIDs(forwardIDs []int64) (map[int64]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
if len(forwardIDs) == 0 {
return map[int64]int64{}, nil
}
// Deduplicate and filter invalid IDs.
ids := make([]int64, 0, len(forwardIDs))
seen := make(map[int64]struct{}, len(forwardIDs))
for _, id := range forwardIDs {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
if len(ids) == 0 {
return map[int64]int64{}, nil
}
type row struct {
ID int64 `gorm:"column:id"`
TunnelID int64 `gorm:"column:tunnel_id"`
}
out := make(map[int64]int64, len(ids))
const chunkSize = 500
for start := 0; start < len(ids); start += chunkSize {
end := start + chunkSize
if end > len(ids) {
end = len(ids)
}
var rows []row
if err := r.db.Model(&model.Forward{}).
Select("id", "tunnel_id").
Where("id IN ?", ids[start:end]).
Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
if r.ID <= 0 || r.TunnelID <= 0 {
continue
}
out[r.ID] = r.TunnelID
}
}
return out, nil
}
func (r *Repository) SpeedLimitExists(id int64) (bool, error) {
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
@@ -3,13 +3,61 @@ package repo
import (
"database/sql"
"errors"
"strings"
"testing"
gsqlite "github.com/glebarez/sqlite"
"go-backend/internal/store/model"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestPrepareSQLiteLegacyColumnsAddsNodeMetadataColumns(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 node (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
secret VARCHAR(100) NOT NULL,
server_ip VARCHAR(100) NOT NULL,
port TEXT NOT NULL,
interface_name VARCHAR(200),
version VARCHAR(100),
http INTEGER NOT NULL DEFAULT 0,
tls INTEGER NOT NULL DEFAULT 0,
socks INTEGER NOT NULL DEFAULT 0,
created_time INTEGER NOT NULL,
updated_time INTEGER,
status INTEGER NOT NULL
)
`).Error; err != nil {
t.Fatalf("create legacy node table: %v", err)
}
if err := prepareSQLiteLegacyColumns(db); err != nil {
t.Fatalf("prepareSQLiteLegacyColumns: %v", err)
}
m := db.Migrator()
for _, field := range []string{"Remark", "ExpiryTime", "RenewalCycle"} {
if !m.HasColumn(&model.Node{}, field) {
t.Fatalf("expected node.%s column to exist", field)
}
}
}
func TestMigrateSchemaRunsPostgresIDRepairEvenAtCurrentVersion(t *testing.T) {
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
@@ -250,3 +298,115 @@ func TestMigrateSchemaClearsSpeedLimitTunnelBinding(t *testing.T) {
t.Fatalf("expected schema version %d, got %d", currentSchemaVersion, schemaVersion)
}
}
func TestMigrateSchemaRunsTrafficInt64MigrationForLegacySchema(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(?)`, 4).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 := migratePostgresTrafficInt64ColumnsFn
migratePostgresTrafficInt64ColumnsFn = func(db *gorm.DB) error {
called++
return nil
}
t.Cleanup(func() {
migratePostgresTrafficInt64ColumnsFn = originalMigrate
})
if err := migrateSchema(db); err != nil {
t.Fatalf("migrateSchema: %v", err)
}
if called != 1 {
t.Fatalf("expected traffic bigint migration to run once, got %d", called)
}
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)
}
}
func TestMigrateSchemaReturnsTrafficInt64MigrationError(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(?)`, 4).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("traffic bigint migration failed")
originalMigrate := migratePostgresTrafficInt64ColumnsFn
migratePostgresTrafficInt64ColumnsFn = func(db *gorm.DB) error {
return wantErr
}
t.Cleanup(func() {
migratePostgresTrafficInt64ColumnsFn = originalMigrate
})
err = migrateSchema(db)
if !errors.Is(err, wantErr) {
t.Fatalf("expected error %v, got %v", wantErr, err)
}
}
func TestAlterPostgresColumnToBigIntIfNeededValidatesNames(t *testing.T) {
if err := alterPostgresColumnToBigIntIfNeeded(nil, "peer_share", "max_bandwidth"); err == nil || !strings.Contains(err.Error(), "nil db") {
t.Fatalf("expected nil db error, got %v", err)
}
if err := alterPostgresColumnToBigIntIfNeeded(&gorm.DB{}, "", "max_bandwidth"); err == nil || !strings.Contains(err.Error(), "empty table or column name") {
t.Fatalf("expected empty name error, got %v", err)
}
if err := alterPostgresColumnToBigIntIfNeeded(&gorm.DB{}, "peer_share", ""); err == nil || !strings.Contains(err.Error(), "empty table or column name") {
t.Fatalf("expected empty name error, got %v", err)
}
}
@@ -0,0 +1,19 @@
package repo
import (
"errors"
"go-backend/internal/store/model"
)
func (r *Repository) ListMonitorNodes() ([]model.Node, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var nodes []model.Node
err := r.db.Select("id", "inx", "name", "status", "updated_time").
Where("is_remote = ?", 0).
Order("inx ASC, id ASC").
Find(&nodes).Error
return nodes, err
}
@@ -0,0 +1,54 @@
package repo
import (
"errors"
"go-backend/internal/store/model"
"gorm.io/gorm/clause"
)
func (r *Repository) InsertMonitorPermission(userID int64, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
if userID <= 0 {
return nil
}
row := model.MonitorPermission{UserID: userID, CreatedTime: now}
return r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error
}
func (r *Repository) DeleteMonitorPermission(userID int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
if userID <= 0 {
return nil
}
return r.db.Where("user_id = ?", userID).Delete(&model.MonitorPermission{}).Error
}
func (r *Repository) HasMonitorPermission(userID int64) (bool, error) {
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
}
if userID <= 0 {
return false, nil
}
var count int64
err := r.db.Model(&model.MonitorPermission{}).Where("user_id = ?", userID).Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *Repository) ListMonitorPermissions() ([]model.MonitorPermission, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var items []model.MonitorPermission
err := r.db.Order("id ASC").Find(&items).Error
return items, err
}
@@ -0,0 +1,18 @@
package repo
import (
"errors"
"go-backend/internal/store/model"
)
func (r *Repository) ListMonitorTunnels() ([]model.Tunnel, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var tunnels []model.Tunnel
err := r.db.Select("id", "inx", "name", "status", "updated_time").
Order("inx ASC, id ASC").
Find(&tunnels).Error
return tunnels, err
}
@@ -0,0 +1,134 @@
package repo
import (
"sync"
"testing"
"time"
"go-backend/internal/store/model"
)
func TestGetTunnelMetricsAggregatedSumsAcrossNodes(t *testing.T) {
r, err := Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
ts := time.Now().UnixMilli()
if err := r.InsertTunnelMetric(&model.TunnelMetric{
TunnelID: 1,
NodeID: 1,
Timestamp: ts,
BytesIn: 100,
BytesOut: 200,
}); err != nil {
t.Fatalf("insert tunnel metric n1: %v", err)
}
if err := r.InsertTunnelMetric(&model.TunnelMetric{
TunnelID: 1,
NodeID: 2,
Timestamp: ts,
BytesIn: 300,
BytesOut: 400,
}); err != nil {
t.Fatalf("insert tunnel metric n2: %v", err)
}
metrics, err := r.GetTunnelMetricsAggregated(1, ts-1000, ts+1000)
if err != nil {
t.Fatalf("get aggregated tunnel metrics: %v", err)
}
if len(metrics) != 1 {
t.Fatalf("expected 1 aggregated point, got %d", len(metrics))
}
if metrics[0].Timestamp != ts {
t.Fatalf("expected timestamp %d, got %d", ts, metrics[0].Timestamp)
}
if metrics[0].BytesIn != 400 {
t.Fatalf("expected bytesIn 400, got %d", metrics[0].BytesIn)
}
if metrics[0].BytesOut != 600 {
t.Fatalf("expected bytesOut 600, got %d", metrics[0].BytesOut)
}
}
func TestUpsertTunnelMetricBucketsAggregatesDuplicateKeysInBatch(t *testing.T) {
r, err := Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
ts := time.Now().UnixMilli()
items := []*model.TunnelMetric{
{TunnelID: 1, NodeID: 1, Timestamp: ts, BytesIn: 10, BytesOut: 20},
{TunnelID: 1, NodeID: 1, Timestamp: ts, BytesIn: 30, BytesOut: 40},
}
if err := r.UpsertTunnelMetricBuckets(items); err != nil {
t.Fatalf("upsert buckets: %v", err)
}
rows, err := r.GetTunnelMetrics(1, ts-1000, ts+1000)
if err != nil {
t.Fatalf("get tunnel metrics: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 stored row, got %d", len(rows))
}
if rows[0].BytesIn != 40 {
t.Fatalf("expected bytesIn 40, got %d", rows[0].BytesIn)
}
if rows[0].BytesOut != 60 {
t.Fatalf("expected bytesOut 60, got %d", rows[0].BytesOut)
}
}
func TestUpsertTunnelMetricBucketsIsSafeUnderConcurrency(t *testing.T) {
r, err := Open(":memory:")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
ts := time.Now().UnixMilli()
const workers = 20
const perWorkerIn = int64(5)
const perWorkerOut = int64(7)
var wg sync.WaitGroup
wg.Add(workers)
for i := 0; i < workers; i++ {
go func() {
defer wg.Done()
_ = r.UpsertTunnelMetricBuckets([]*model.TunnelMetric{{
TunnelID: 1,
NodeID: 1,
Timestamp: ts,
BytesIn: perWorkerIn,
BytesOut: perWorkerOut,
}})
}()
}
wg.Wait()
rows, err := r.GetTunnelMetrics(1, ts-1000, ts+1000)
if err != nil {
t.Fatalf("get tunnel metrics: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 stored row, got %d", len(rows))
}
wantIn := int64(workers) * perWorkerIn
wantOut := int64(workers) * perWorkerOut
if rows[0].BytesIn != wantIn {
t.Fatalf("expected bytesIn %d, got %d", wantIn, rows[0].BytesIn)
}
if rows[0].BytesOut != wantOut {
t.Fatalf("expected bytesOut %d, got %d", wantOut, rows[0].BytesOut)
}
}
@@ -3,6 +3,7 @@ package repo
import (
"database/sql"
"errors"
"fmt"
"sort"
"strconv"
"strings"
@@ -144,6 +145,9 @@ func (r *Repository) DeleteUserCascade(userID int64) error {
if err := tx.Where("user_id = ?", userID).Delete(&model.StatisticsFlow{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.UserQuota{}).Error; err != nil {
return err
}
return tx.Where("id = ?", userID).Delete(&model.User{}).Error
})
}
@@ -196,16 +200,20 @@ func (r *Repository) GetUserDefaultsForTunnel(userID int64) (flow int64, num int
return user.Flow, user.Num, user.ExpTime, user.FlowResetTime, nil
}
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig interface{}) error {
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version, remark, expiryTime, renewalCycle interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
node := model.Node{
Name: name,
Remark: nullStringFromInterface(remark),
ExpiryTime: nullInt64FromInterface(expiryTime),
RenewalCycle: nullStringFromInterface(renewalCycle),
Secret: secret,
ServerIP: serverIP,
ServerIPV4: nullStringFromInterface(serverIPV4),
ServerIPV6: nullStringFromInterface(serverIPV6),
ExtraIPs: nullStringFromInterface(extraIPs),
Port: stringFromInterface(port),
InterfaceName: nullStringFromInterface(interfaceName),
Version: nullStringFromInterface(version),
@@ -238,25 +246,30 @@ func (r *Repository) GetNodeStatusFields(nodeID int64) (status, httpFlag, tlsFla
return node.Status, node.HTTP, node.TLS, node.Socks, nil
}
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs, remark, expiryTime, renewalCycle interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
return r.db.Model(&model.Node{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"name": name,
"server_ip": serverIP,
"server_ip_v4": nullStringFromInterface(serverIPV4),
"server_ip_v6": nullStringFromInterface(serverIPV6),
"port": stringFromInterface(port),
"interface_name": nullStringFromInterface(interfaceName),
"http": httpFlag,
"tls": tlsFlag,
"socks": socksFlag,
"tcp_listen_addr": tcpAddr,
"udp_listen_addr": udpAddr,
"updated_time": sql.NullInt64{Int64: now, Valid: true},
"name": name,
"remark": nullStringFromInterface(remark),
"expiry_time": nullInt64FromInterface(expiryTime),
"renewal_cycle": nullStringFromInterface(renewalCycle),
"server_ip": serverIP,
"server_ip_v4": nullStringFromInterface(serverIPV4),
"server_ip_v6": nullStringFromInterface(serverIPV6),
"extra_ips": nullStringFromInterface(extraIPs),
"port": stringFromInterface(port),
"interface_name": nullStringFromInterface(interfaceName),
"http": httpFlag,
"tls": tlsFlag,
"socks": socksFlag,
"tcp_listen_addr": tcpAddr,
"udp_listen_addr": udpAddr,
"updated_time": sql.NullInt64{Int64: now, Valid: true},
"expiry_reminder_dismissed": 0,
}).Error
}
@@ -296,6 +309,15 @@ func (r *Repository) UpdateNodeOrder(nodeID int64, inx int, now int64) {
}).Error
}
func (r *Repository) UpdateNodeExpiryReminderDismissed(nodeID int64, dismissed int) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
return r.db.Model(&model.Node{}).
Where("id = ?", nodeID).
Update("expiry_reminder_dismissed", dismissed).Error
}
func (r *Repository) DeleteNodeCascade(nodeID int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
@@ -395,7 +417,7 @@ func (r *Repository) DeleteChainTunnelsByTunnelTx(tx *gorm.DB, tunnelID int64) e
return tx.Where("tunnel_id = ?", tunnelID).Delete(&model.ChainTunnel{}).Error
}
func (r *Repository) CreateChainTunnelTx(tx *gorm.DB, tunnelID int64, chainType string, nodeID int64, port sql.NullInt64, strategy string, inx int, protocol string) error {
func (r *Repository) CreateChainTunnelTx(tx *gorm.DB, tunnelID int64, chainType string, nodeID int64, port sql.NullInt64, strategy string, inx int, protocol string, connectIp string) error {
if tx == nil {
return errors.New("database unavailable")
}
@@ -407,6 +429,7 @@ func (r *Repository) CreateChainTunnelTx(tx *gorm.DB, tunnelID int64, chainType
Strategy: nullStringFromInterface(strategy),
Inx: nullInt64FromInterface(inx),
Protocol: nullStringFromInterface(protocol),
ConnectIP: sql.NullString{String: connectIp, Valid: connectIp != ""},
}
return tx.Create(&ct).Error
}
@@ -692,6 +715,7 @@ func (r *Repository) DeleteForwardCascade(forwardID int64) error {
func (r *Repository) ReplaceForwardPorts(forwardID int64, entries []struct {
NodeID int64
Port int
InIP string
}) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
@@ -705,12 +729,29 @@ func (r *Repository) ReplaceForwardPorts(forwardID int64, entries []struct {
}
rows := make([]model.ForwardPort, 0, len(entries))
for _, e := range entries {
rows = append(rows, model.ForwardPort{ForwardID: forwardID, NodeID: e.NodeID, Port: e.Port})
rows = append(rows, model.ForwardPort{
ForwardID: forwardID,
NodeID: e.NodeID,
Port: e.Port,
InIP: sql.NullString{String: e.InIP, Valid: e.InIP != ""},
})
}
return tx.Create(&rows).Error
})
}
func (r *Repository) UpdateForwardPortBindIP(forwardID, nodeID int64, port int, inIP string) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
if forwardID <= 0 || nodeID <= 0 || port <= 0 {
return nil
}
return r.db.Model(&model.ForwardPort{}).
Where("forward_id = ? AND node_id = ? AND port = ?", forwardID, nodeID, port).
Update("in_ip", sql.NullString{String: inIP, Valid: strings.TrimSpace(inIP) != ""}).Error
}
func (r *Repository) RollbackForwardFields(id, userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, status int, speedID interface{}, now int64) {
if r == nil || r.db == nil {
return
@@ -1168,7 +1209,7 @@ func (r *Repository) EnsureUserTunnelGrant(userID, tunnelID int64) (int64, bool,
return ut.ID, true, nil
}
func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, now int64, inx int, entryNodeIDs []int64, port int, speedID interface{}) (int64, error) {
func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, now int64, inx int, entryNodeIDs []int64, port int, inIp string, speedID interface{}) (int64, error) {
if r == nil || r.db == nil {
return 0, errors.New("repository not initialized")
}
@@ -1198,6 +1239,7 @@ func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnel
ForwardID: forwardID,
NodeID: nodeID,
Port: port,
InIP: sql.NullString{String: inIp, Valid: inIp != ""},
}
if err := tx.Create(&fp).Error; err != nil {
return err
@@ -1455,3 +1497,59 @@ func (r *Repository) ReplaceUserGroupsByUserID(userID int64, newGroupIDs []int64
}
return affectedGroupIDs, nil
}
func (r *Repository) AdvanceNodeRenewalCycles(now int64) (int, error) {
if r == nil || r.db == nil {
return 0, nil
}
var nodes []model.Node
if err := r.db.Where("renewal_cycle IS NOT NULL AND renewal_cycle != '' AND expiry_time IS NOT NULL").Find(&nodes).Error; err != nil {
return 0, fmt.Errorf("list nodes with renewal cycle: %w", err)
}
advanced := 0
for _, node := range nodes {
if !node.ExpiryTime.Valid || node.ExpiryTime.Int64 <= 0 {
continue
}
cycleMonths := 0
switch node.RenewalCycle.String {
case "month":
cycleMonths = 1
case "quarter":
cycleMonths = 3
case "year":
cycleMonths = 12
default:
continue
}
anchorTime := node.ExpiryTime.Int64
for anchorTime <= now {
nextAnchor := advanceByMonths(anchorTime, cycleMonths)
if nextAnchor <= anchorTime {
break
}
anchorTime = nextAnchor
}
if anchorTime == node.ExpiryTime.Int64 {
continue
}
if err := r.db.Model(&model.Node{}).Where("id = ?", node.ID).Update("expiry_time", anchorTime).Error; err != nil {
continue
}
advanced++
}
return advanced, nil
}
func advanceByMonths(timestamp int64, months int) int64 {
t := time.Unix(timestamp/1000, 0)
next := t.AddDate(0, months, 0)
return next.UnixMilli()
}
@@ -0,0 +1,98 @@
package repo
import (
"errors"
"go-backend/internal/store/model"
)
// InsertTunnelQuality appends a tunnel quality probe result.
// (Follows the same pattern as InsertServiceMonitorResult.)
func (r *Repository) InsertTunnelQuality(q *model.TunnelQuality) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
if q == nil || q.TunnelID <= 0 {
return nil
}
return r.db.Create(q).Error
}
// GetTunnelQualityHistory returns quality probe results for a tunnel
// within a time range, ordered by timestamp ascending.
// (Mirrors GetServiceMonitorResults pattern.)
func (r *Repository) GetTunnelQualityHistory(tunnelID int64, startMs, endMs int64) ([]model.TunnelQuality, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var results []model.TunnelQuality
err := r.db.Where("tunnel_id = ? AND timestamp >= ? AND timestamp <= ?", tunnelID, startMs, endMs).
Order("timestamp ASC").
Find(&results).Error
return results, err
}
// GetLatestTunnelQualities returns the newest quality result per tunnel_id.
// (Mirrors GetLatestServiceMonitorResults pattern.)
func (r *Repository) GetLatestTunnelQualities() ([]model.TunnelQuality, error) {
if r == nil || r.db == nil {
return nil, nil
}
var results []model.TunnelQuality
// Use window function (works on modern SQLite 3.25+ and PostgreSQL).
q := `
SELECT id, tunnel_id, entry_to_exit_latency, exit_to_bing_latency,
entry_to_exit_loss, exit_to_bing_loss, success, error_message, timestamp
FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY tunnel_id ORDER BY timestamp DESC, id DESC) AS rn
FROM tunnel_quality
) t
WHERE rn = 1
ORDER BY tunnel_id ASC
`
if err := r.db.Raw(q).Scan(&results).Error; err == nil {
return results, nil
}
// Fallback for older SQLite
results = nil
err := r.db.Order("timestamp DESC, id DESC").Limit(5000).Find(&results).Error
if err != nil {
return nil, err
}
seen := make(map[int64]struct{}, len(results))
out := make([]model.TunnelQuality, 0, len(results))
for _, row := range results {
if row.TunnelID <= 0 {
continue
}
if _, ok := seen[row.TunnelID]; ok {
continue
}
seen[row.TunnelID] = struct{}{}
out = append(out, row)
}
return out, nil
}
// PruneTunnelQualityResults deletes quality results older than the given timestamp.
// (Mirrors PruneServiceMonitorResults pattern.)
func (r *Repository) PruneTunnelQualityResults(olderThanMs int64) error {
if r == nil || r.db == nil {
return nil
}
return r.db.Where("timestamp < ?", olderThanMs).Delete(&model.TunnelQuality{}).Error
}
// ListEnabledTunnelIDs returns IDs of all tunnels with status=1.
func (r *Repository) ListEnabledTunnelIDs() ([]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("status = ?", 1).Pluck("id", &ids).Error
return ids, err
}
@@ -0,0 +1,378 @@
package repo
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"go-backend/internal/store/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const userQuotaBytesPerGB int64 = 1024 * 1024 * 1024
type UserQuotaRelease struct {
UserID int64
ForwardIDs []int64
UnblockUser bool
}
func userQuotaWindowKeys(now time.Time) (int64, int64) {
return int64(now.Year()*10000 + int(now.Month())*100 + now.Day()), int64(now.Year()*100 + int(now.Month()))
}
func cloneUserQuotaView(q model.UserQuota) *model.UserQuotaView {
return &model.UserQuotaView{
UserID: q.UserID,
DailyLimitGB: q.DailyLimitGB,
MonthlyLimitGB: q.MonthlyLimitGB,
DailyUsedBytes: q.DailyUsedBytes,
MonthlyUsedBytes: q.MonthlyUsedBytes,
DayKey: q.DayKey,
MonthKey: q.MonthKey,
DisabledByQuota: q.DisabledByQuota,
DisabledAt: q.DisabledAt,
PausedForwardIDs: q.PausedForwardIDs,
}
}
func normalizeUserQuotaView(view *model.UserQuotaView, now time.Time) *model.UserQuotaView {
if view == nil {
return nil
}
dayKey, monthKey := userQuotaWindowKeys(now)
out := *view
if out.DayKey != dayKey {
out.DayKey = dayKey
out.DailyUsedBytes = 0
}
if out.MonthKey != monthKey {
out.MonthKey = monthKey
out.MonthlyUsedBytes = 0
}
return &out
}
func userQuotaExceeded(view *model.UserQuotaView) bool {
if view == nil {
return false
}
if view.DailyLimitGB > 0 && view.DailyUsedBytes >= view.DailyLimitGB*userQuotaBytesPerGB {
return true
}
if view.MonthlyLimitGB > 0 && view.MonthlyUsedBytes >= view.MonthlyLimitGB*userQuotaBytesPerGB {
return true
}
return false
}
func parsePausedForwardIDs(raw string) []int64 {
parts := strings.Split(strings.TrimSpace(raw), ",")
out := make([]int64, 0, len(parts))
seen := make(map[int64]struct{}, len(parts))
for _, part := range parts {
id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
if err != nil || id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func joinPausedForwardIDs(ids []int64) string {
if len(ids) == 0 {
return ""
}
parts := make([]string, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
parts = append(parts, strconv.FormatInt(id, 10))
}
return strings.Join(parts, ",")
}
func (r *Repository) loadOrCreateUserQuotaTx(tx *gorm.DB, userID int64, now time.Time) (*model.UserQuota, error) {
if tx == nil {
return nil, errors.New("database unavailable")
}
dayKey, monthKey := userQuotaWindowKeys(now)
q := &model.UserQuota{}
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", userID).First(q).Error
if err == nil {
return q, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
nowMs := now.UnixMilli()
q = &model.UserQuota{
UserID: userID,
DayKey: dayKey,
MonthKey: monthKey,
CreatedTime: nowMs,
UpdatedTime: nowMs,
PausedForwardIDs: "",
}
if err := tx.Create(q).Error; err != nil {
return nil, err
}
return q, nil
}
func applyUserQuotaWindowRoll(q *model.UserQuota, now time.Time) bool {
if q == nil {
return false
}
changed := false
dayKey, monthKey := userQuotaWindowKeys(now)
if q.DayKey != dayKey {
q.DayKey = dayKey
q.DailyUsedBytes = 0
changed = true
}
if q.MonthKey != monthKey {
q.MonthKey = monthKey
q.MonthlyUsedBytes = 0
changed = true
}
return changed
}
func (r *Repository) SaveUserQuotaConfigTx(tx *gorm.DB, userID, dailyLimitGB, monthlyLimitGB int64, now int64) error {
if tx == nil {
return errors.New("database unavailable")
}
if userID <= 0 {
return errors.New("user id is required")
}
if dailyLimitGB < 0 || monthlyLimitGB < 0 {
return errors.New("quota limit cannot be negative")
}
current := time.UnixMilli(now)
q, err := r.loadOrCreateUserQuotaTx(tx, userID, current)
if err != nil {
return err
}
updates := map[string]interface{}{
"daily_limit_gb": dailyLimitGB,
"monthly_limit_gb": monthlyLimitGB,
"updated_time": now,
}
if q.DayKey == 0 || q.MonthKey == 0 {
dayKey, monthKey := userQuotaWindowKeys(current)
updates["day_key"] = dayKey
updates["month_key"] = monthKey
}
return tx.Model(&model.UserQuota{}).Where("user_id = ?", userID).Updates(updates).Error
}
func (r *Repository) ListUserQuotaViewsByUserIDs(userIDs []int64, now time.Time) (map[int64]*model.UserQuotaView, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
out := make(map[int64]*model.UserQuotaView)
if len(userIDs) == 0 {
return out, nil
}
var rows []model.UserQuota
if err := r.db.Where("user_id IN ?", userIDs).Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
out[row.UserID] = normalizeUserQuotaView(cloneUserQuotaView(row), now)
}
return out, nil
}
func (r *Repository) GetUserQuotaView(userID int64, now time.Time) (*model.UserQuotaView, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
if userID <= 0 {
return nil, nil
}
var row model.UserQuota
err := r.db.Where("user_id = ?", userID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return normalizeUserQuotaView(cloneUserQuotaView(row), now), nil
}
func (r *Repository) AddUserQuotaUsage(userID int64, usedBytes int64, now time.Time) (*model.UserQuotaView, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
if userID <= 0 {
return nil, nil
}
result := &model.UserQuotaView{}
err := r.db.Transaction(func(tx *gorm.DB) error {
q, err := r.loadOrCreateUserQuotaTx(tx, userID, now)
if err != nil {
return err
}
applyUserQuotaWindowRoll(q, now)
if usedBytes > 0 {
q.DailyUsedBytes += usedBytes
q.MonthlyUsedBytes += usedBytes
}
q.UpdatedTime = now.UnixMilli()
if err := tx.Model(&model.UserQuota{}).Where("user_id = ?", userID).Updates(map[string]interface{}{
"daily_used_bytes": q.DailyUsedBytes,
"monthly_used_bytes": q.MonthlyUsedBytes,
"day_key": q.DayKey,
"month_key": q.MonthKey,
"updated_time": q.UpdatedTime,
}).Error; err != nil {
return err
}
*result = *cloneUserQuotaView(*q)
return nil
})
if err != nil {
return nil, err
}
return normalizeUserQuotaView(result, now), nil
}
func (r *Repository) MarkUserQuotaDisabled(userID int64, pausedForwardIDs []int64, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
if userID <= 0 {
return errors.New("user id is required")
}
return r.db.Model(&model.UserQuota{}).Where("user_id = ?", userID).Updates(map[string]interface{}{
"disabled_by_quota": 1,
"disabled_at": now,
"paused_forward_ids": joinPausedForwardIDs(pausedForwardIDs),
"updated_time": now,
}).Error
}
func (r *Repository) ResetUserQuotaUsage(userID int64, scope string, now time.Time) (*UserQuotaRelease, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
if userID <= 0 {
return nil, errors.New("user id is required")
}
scope = strings.TrimSpace(strings.ToLower(scope))
if scope == "" {
scope = "all"
}
if scope != "daily" && scope != "monthly" && scope != "all" {
return nil, fmt.Errorf("unsupported quota reset scope: %s", scope)
}
var release *UserQuotaRelease
err := r.db.Transaction(func(tx *gorm.DB) error {
q, err := r.loadOrCreateUserQuotaTx(tx, userID, now)
if err != nil {
return err
}
applyUserQuotaWindowRoll(q, now)
switch scope {
case "daily":
q.DailyUsedBytes = 0
case "monthly":
q.MonthlyUsedBytes = 0
case "all":
q.DailyUsedBytes = 0
q.MonthlyUsedBytes = 0
}
q.UpdatedTime = now.UnixMilli()
release = &UserQuotaRelease{UserID: userID}
if q.DisabledByQuota == 1 && !userQuotaExceeded(cloneUserQuotaView(*q)) {
release.UnblockUser = true
release.ForwardIDs = parsePausedForwardIDs(q.PausedForwardIDs)
q.DisabledByQuota = 0
q.DisabledAt = 0
q.PausedForwardIDs = ""
}
return tx.Model(&model.UserQuota{}).Where("user_id = ?", userID).Updates(map[string]interface{}{
"daily_used_bytes": q.DailyUsedBytes,
"monthly_used_bytes": q.MonthlyUsedBytes,
"day_key": q.DayKey,
"month_key": q.MonthKey,
"disabled_by_quota": q.DisabledByQuota,
"disabled_at": q.DisabledAt,
"paused_forward_ids": q.PausedForwardIDs,
"updated_time": q.UpdatedTime,
}).Error
})
if err != nil {
return nil, err
}
return release, nil
}
func (r *Repository) RollUserQuotaWindows(now time.Time) ([]UserQuotaRelease, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var releases []UserQuotaRelease
err := r.db.Transaction(func(tx *gorm.DB) error {
var rows []model.UserQuota
if err := tx.Find(&rows).Error; err != nil {
return err
}
nowMs := now.UnixMilli()
for _, row := range rows {
q := row
changed := applyUserQuotaWindowRoll(&q, now)
release := UserQuotaRelease{UserID: q.UserID}
if q.DisabledByQuota == 1 && !userQuotaExceeded(cloneUserQuotaView(q)) {
release.UnblockUser = true
release.ForwardIDs = parsePausedForwardIDs(q.PausedForwardIDs)
q.DisabledByQuota = 0
q.DisabledAt = 0
q.PausedForwardIDs = ""
changed = true
}
if !changed {
continue
}
q.UpdatedTime = nowMs
if err := tx.Model(&model.UserQuota{}).Where("user_id = ?", q.UserID).Updates(map[string]interface{}{
"daily_used_bytes": q.DailyUsedBytes,
"monthly_used_bytes": q.MonthlyUsedBytes,
"day_key": q.DayKey,
"month_key": q.MonthKey,
"disabled_by_quota": q.DisabledByQuota,
"disabled_at": q.DisabledAt,
"paused_forward_ids": q.PausedForwardIDs,
"updated_time": q.UpdatedTime,
}).Error; err != nil {
return err
}
if release.UnblockUser {
releases = append(releases, release)
}
}
return nil
})
if err != nil {
return nil, err
}
return releases, nil
}
+135 -17
View File
@@ -39,6 +39,7 @@ type nodeSession struct {
nodeID int64
secret string
conn *connWrap
crypto *security.AESCrypto // 缓存的 AES 加密器,避免每条消息重建
}
type commandResponse struct {
@@ -72,6 +73,7 @@ type Server struct {
jwtSecret string
upgrader websocket.Upgrader
onNodeOnline func(nodeID int64)
onNodeMetric func(nodeID int64, info SystemInfo)
mu sync.RWMutex
admins map[*connWrap]struct{}
@@ -80,6 +82,22 @@ type Server struct {
pending map[string]pendingRequest
}
type SystemInfo struct {
Uptime uint64 `json:"uptime"`
BytesReceived uint64 `json:"bytes_received"`
BytesTransmitted uint64 `json:"bytes_transmitted"`
CPUUsage float64 `json:"cpu_usage"`
MemoryUsage float64 `json:"memory_usage"`
DiskUsage float64 `json:"disk_usage"`
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
TCPConns int64 `json:"tcp_conns"`
UDPConns int64 `json:"udp_conns"`
NetInSpeed int64 `json:"net_in_speed"`
NetOutSpeed int64 `json:"net_out_speed"`
}
func (s *Server) SetNodeOnlineHook(fn func(nodeID int64)) {
if s == nil {
return
@@ -89,6 +107,15 @@ func (s *Server) SetNodeOnlineHook(fn func(nodeID int64)) {
s.mu.Unlock()
}
func (s *Server) SetNodeMetricHook(fn func(nodeID int64, info SystemInfo)) {
if s == nil {
return
}
s.mu.Lock()
s.onNodeMetric = fn
s.mu.Unlock()
}
func NewServer(repo *repo.Repository, jwtSecret string) *Server {
return &Server{
repo: repo,
@@ -185,7 +212,12 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
_ = old.conn.conn.Close()
delete(s.byConn, old.conn.conn)
}
ns := &nodeSession{nodeID: nodeID, secret: secret, conn: cw}
// 初始化 AES 加密器并缓存(仅创建一次)
var nodeCrypto *security.AESCrypto
if strings.TrimSpace(secret) != "" {
nodeCrypto, _ = security.NewAESCrypto(secret)
}
ns := &nodeSession{nodeID: nodeID, secret: secret, conn: cw, crypto: nodeCrypto}
s.nodes[nodeID] = ns
s.byConn[conn] = ns
s.mu.Unlock()
@@ -225,18 +257,100 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
return
}
msg := decryptIfNeeded(payload, secret)
msg := decryptIfNeeded(payload, ns.crypto, secret)
s.tryResolvePending(nodeID, msg)
var parsed struct {
Type string `json:"type"`
}
if json.Unmarshal([]byte(msg), &parsed) == nil && parsed.Type == "UpgradeProgress" {
s.broadcastTyped(nodeID, "upgrade_progress", msg)
} else {
s.broadcastInfo(nodeID, msg)
if json.Unmarshal([]byte(msg), &parsed) == nil && parsed.Type != "" {
switch parsed.Type {
case "metric":
// Agent 新版指标消息:{type:"metric", data:{...}}
var envelope struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal([]byte(msg), &envelope); err == nil && len(envelope.Data) > 0 {
// 解析 SystemInfo 并调用 hook
var sysInfo SystemInfo
if json.Unmarshal(envelope.Data, &sysInfo) == nil {
s.mu.RLock()
onMetric := s.onNodeMetric
s.mu.RUnlock()
if onMetric != nil {
go onMetric(nodeID, sysInfo)
}
}
// 广播内层 data 给前端(保持平坦结构兼容性)
s.broadcastTyped(nodeID, "metric", string(envelope.Data))
}
continue
case "UpgradeProgress":
s.broadcastTyped(nodeID, "upgrade_progress", msg)
continue
default:
// Unknown typed messages still get broadcast so future
// agent message types are not silently lost.
s.broadcastInfo(nodeID, msg)
continue
}
}
// 兼容旧版 Agent:无 type 字段的系统信息消息
if looksLikeSystemInfoMessage(msg) {
var sysInfo SystemInfo
if err := json.Unmarshal([]byte(msg), &sysInfo); err == nil {
s.mu.RLock()
onMetric := s.onNodeMetric
s.mu.RUnlock()
if onMetric != nil {
go onMetric(nodeID, sysInfo)
}
s.broadcastTyped(nodeID, "metric", msg)
continue
}
}
s.broadcastInfo(nodeID, msg)
}
}
func looksLikeSystemInfoMessage(msg string) bool {
// Keep this as a cheap heuristic so that arbitrary JSON objects don't get
// misclassified as metrics (SystemInfo unmarshal would otherwise succeed with
// all-zero values).
if strings.TrimSpace(msg) == "" {
return false
}
if !strings.Contains(msg, "{") {
return false
}
keys := []string{
"\"uptime\"",
"\"cpu_usage\"",
"\"memory_usage\"",
"\"disk_usage\"",
"\"bytes_received\"",
"\"bytes_transmitted\"",
"\"net_in_speed\"",
"\"net_out_speed\"",
"\"tcp_conns\"",
"\"udp_conns\"",
"\"load1\"",
"\"load5\"",
"\"load15\"",
}
matched := 0
for _, k := range keys {
if strings.Contains(msg, k) {
matched++
if matched >= 3 {
return true
}
}
}
return false
}
func (s *Server) SendCommand(nodeID int64, cmdType string, data interface{}, timeout time.Duration) (CommandResult, error) {
@@ -285,13 +399,8 @@ func (s *Server) SendCommand(nodeID int64, cmdType string, data interface{}, tim
}
messageData := rawCmd
if strings.TrimSpace(ns.secret) != "" {
crypto, err := security.NewAESCrypto(ns.secret)
if err != nil {
cleanup()
return CommandResult{}, err
}
encrypted, err := crypto.Encrypt(rawCmd)
if ns.crypto != nil {
encrypted, err := ns.crypto.Encrypt(rawCmd)
if err != nil {
cleanup()
return CommandResult{}, err
@@ -341,6 +450,11 @@ func (s *Server) tryResolvePending(nodeID int64, message string) {
return
}
// 快速短路:指标消息永远不含 requestId,跳过完整 JSON 解析
if !strings.Contains(message, "\"requestId\"") {
return
}
var resp commandResponse
if err := json.Unmarshal([]byte(message), &resp); err != nil {
return
@@ -458,18 +572,22 @@ func (s *Server) broadcastToAdmins(message string) {
}
}
func decryptIfNeeded(payload []byte, secret string) string {
func decryptIfNeeded(payload []byte, crypto *security.AESCrypto, secret string) string {
text := string(payload)
var wrap encryptedMessage
if err := json.Unmarshal(payload, &wrap); err != nil || !wrap.Encrypted || strings.TrimSpace(wrap.Data) == "" {
return text
}
crypto, err := security.NewAESCrypto(secret)
if err != nil {
// 优先使用缓存的 crypto 实例
c := crypto
if c == nil && strings.TrimSpace(secret) != "" {
c, _ = security.NewAESCrypto(secret)
}
if c == nil {
return text
}
plain, err := crypto.Decrypt(wrap.Data)
plain, err := c.Decrypt(wrap.Data)
if err != nil {
return text
}
@@ -0,0 +1,202 @@
package contract_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
"go-backend/internal/store/repo"
)
func TestForwardBatchDeleteReturnsFailureReasonsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, _ := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
out := postBatchRequest(t, router, adminToken, "/api/v1/forward/batch-delete", `{"ids":[999]}`)
result := mustBatchResult(t, out)
assertBatchFailureReasonContains(t, result, "转发不存在")
}
func TestForwardBatchPauseReturnsFailureReasonsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, _ := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
out := postBatchRequest(t, router, adminToken, "/api/v1/forward/batch-pause", `{"ids":[999]}`)
result := mustBatchResult(t, out)
assertBatchFailureReasonContains(t, result, "转发不存在")
}
func TestForwardBatchResumeReturnsFailureReasonsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
forwardID := seedForwardForBatchAction(t, repo, batchForwardSeedOptions{
Now: now,
TunnelName: "resume-detail-tunnel",
ForwardName: "resume-detail-forward",
CreateUserTunnel: true,
UserTunnelStatus: 0,
})
out := postBatchRequest(t, router, adminToken, "/api/v1/forward/batch-resume", `{"ids":[`+jsonNumber(forwardID)+`]}`)
result := mustBatchResult(t, out)
assertBatchFailureNameAndReason(t, result, "resume-detail-forward", "该隧道已禁用")
}
func TestForwardBatchChangeTunnelReturnsFailureReasonsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
forwardID := seedForwardForBatchAction(t, repo, batchForwardSeedOptions{
Now: now,
TunnelName: "change-detail-tunnel",
ForwardName: "change-detail-forward",
})
tunnelID := mustQueryInt64(t, repo, `SELECT tunnel_id FROM forward WHERE id = ?`, forwardID)
payload := `{"forwardIds":[` + jsonNumber(forwardID) + `],"targetTunnelId":` + jsonNumber(tunnelID) + `}`
out := postBatchRequest(t, router, adminToken, "/api/v1/forward/batch-change-tunnel", payload)
result := mustBatchResult(t, out)
assertBatchFailureNameAndReason(t, result, "change-detail-forward", "规则已在目标隧道中")
}
func TestTunnelBatchDeleteReturnsFailureReasonsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, _ := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
out := postBatchRequest(t, router, adminToken, "/api/v1/tunnel/batch-delete", `{"ids":[999]}`)
result := mustBatchResult(t, out)
assertBatchFailureReasonContains(t, result, "隧道不存在")
}
type batchForwardSeedOptions struct {
Now int64
TunnelName string
ForwardName string
CreateUserTunnel bool
UserTunnelStatus int
}
func mustAdminToken(t *testing.T, secret string) string {
t.Helper()
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
return token
}
func postBatchRequest(t *testing.T, router http.Handler, token, path, payload string) response.R {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(payload))
req.Header.Set("Authorization", token)
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 API success envelope, got code=%d msg=%q", out.Code, out.Msg)
}
return out
}
func mustBatchResult(t *testing.T, out response.R) map[string]interface{} {
t.Helper()
result, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected map result, got %T", out.Data)
}
if int(result["failCount"].(float64)) != 1 {
t.Fatalf("expected failCount=1, got %v", result["failCount"])
}
return result
}
func assertBatchFailureReasonContains(t *testing.T, result map[string]interface{}, snippet string) {
t.Helper()
failures, ok := result["failures"].([]interface{})
if !ok || len(failures) != 1 {
t.Fatalf("expected exactly one failure detail, got %#v", result["failures"])
}
first, ok := failures[0].(map[string]interface{})
if !ok {
t.Fatalf("expected failure detail object, got %T", failures[0])
}
reason, _ := first["reason"].(string)
if !strings.Contains(reason, snippet) {
t.Fatalf("expected failure reason to contain %q, got %q", snippet, reason)
}
}
func assertBatchFailureNameAndReason(t *testing.T, result map[string]interface{}, expectedName, reasonSnippet string) {
t.Helper()
failures, ok := result["failures"].([]interface{})
if !ok || len(failures) != 1 {
t.Fatalf("expected exactly one failure detail, got %#v", result["failures"])
}
first, ok := failures[0].(map[string]interface{})
if !ok {
t.Fatalf("expected failure detail object, got %T", failures[0])
}
gotName, _ := first["name"].(string)
if strings.TrimSpace(gotName) != expectedName {
t.Fatalf("expected failure name %q, got %q", expectedName, gotName)
}
reason, _ := first["reason"].(string)
if !strings.Contains(reason, reasonSnippet) {
t.Fatalf("expected failure reason to contain %q, got %q", reasonSnippet, reason)
}
}
func seedForwardForBatchAction(t *testing.T, repo *repo.Repository, opts batchForwardSeedOptions) int64 {
t.Helper()
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_action_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, opts.Now, opts.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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, opts.TunnelName, 1.0, 1, "tls", 99999, opts.Now, opts.Now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, opts.TunnelName)
if opts.CreateUserTunnel {
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, ?)
`, tunnelID, opts.UserTunnelStatus).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
}
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_action_user', ?, ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, opts.ForwardName, tunnelID, opts.Now, opts.Now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
return mustLastInsertID(t, repo, opts.ForwardName)
}
@@ -0,0 +1,161 @@
package contract_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
func TestForwardBatchRedeployReturnsFailureReasonsContract(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, 'batch_redeploy_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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "batch-redeploy-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "batch-redeploy-tunnel")
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, 0)
`, 2, "batch_redeploy_user", "redeploy-forward", tunnelID, "1.1.1.1:443", "fifo", now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, repo, "redeploy-forward")
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/batch-redeploy", bytes.NewBufferString(`{"ids":[`+jsonNumber(forwardID)+`]}`))
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 API success envelope, got code=%d msg=%q", out.Code, out.Msg)
}
result, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected map result, got %T", out.Data)
}
if int(result["failCount"].(float64)) != 1 {
t.Fatalf("expected failCount=1, got %v", result["failCount"])
}
if int(result["successCount"].(float64)) != 0 {
t.Fatalf("expected successCount=0, got %v", result["successCount"])
}
failures, ok := result["failures"].([]interface{})
if !ok || len(failures) != 1 {
t.Fatalf("expected exactly one failure detail, got %#v", result["failures"])
}
first, ok := failures[0].(map[string]interface{})
if !ok {
t.Fatalf("expected failure detail object, got %T", failures[0])
}
if gotName := strings.TrimSpace(first["name"].(string)); gotName != "redeploy-forward" {
t.Fatalf("expected failure name redeploy-forward, got %q", gotName)
}
reason, _ := first["reason"].(string)
if !strings.Contains(reason, "转发入口端口不存在") {
t.Fatalf("expected forward failure reason to mention missing entry port, got %q", reason)
}
}
func TestTunnelBatchRedeployReturnsFailureReasonsContract(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 tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "broken-redeploy-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "broken-redeploy-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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "entry-only-node", "entry-only-secret", "10.0.0.20", "10.0.0.20", "", "20000-20010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
entryNodeID := mustLastInsertID(t, repo, "entry-only-node")
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).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy", bytes.NewBufferString(`{"ids":[`+jsonNumber(tunnelID)+`]}`))
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 API success envelope, got code=%d msg=%q", out.Code, out.Msg)
}
result, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected map result, got %T", out.Data)
}
if int(result["failCount"].(float64)) != 1 {
t.Fatalf("expected failCount=1, got %v", result["failCount"])
}
failures, ok := result["failures"].([]interface{})
if !ok || len(failures) != 1 {
t.Fatalf("expected exactly one failure detail, got %#v", result["failures"])
}
first, ok := failures[0].(map[string]interface{})
if !ok {
t.Fatalf("expected failure detail object, got %T", failures[0])
}
if gotName := strings.TrimSpace(first["name"].(string)); gotName != "broken-redeploy-tunnel" {
t.Fatalf("expected failure name broken-redeploy-tunnel, got %q", gotName)
}
reason, _ := first["reason"].(string)
if !strings.Contains(reason, "转发链目标不能为空") {
t.Fatalf("expected tunnel failure reason to mention missing target, got %q", reason)
}
}
@@ -1,6 +1,7 @@
package contract_test
import (
"bufio"
"bytes"
"encoding/json"
"net/http"
@@ -461,3 +462,167 @@ func TestDiagnosisUsesFederationRuntimeForRemoteNodes(t *testing.T) {
t.Fatalf("expected federation runtime diagnose endpoint to be called")
}
}
func TestTunnelDiagnosisUsesConfiguredConnectIPContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
insertNode := func(name, ip 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", ip, ip, "", "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-connectip", "10.80.0.10")
middleNodeID := insertNode("middle-connectip", "10.80.0.20")
exitNodeID := insertNode("exit-connectip", "10.80.0.30")
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "diagnose-connectip-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "diagnose-connectip-tunnel")
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, connect_ip)
VALUES(?, 2, ?, 30002, 'round', 1, 'tls', ?)
`, tunnelID, middleNodeID, "10.99.0.22").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, connect_ip)
VALUES(?, 3, ?, 30003, 'round', 1, 'tls', ?)
`, tunnelID, exitNodeID, "10.99.0.33").Error; err != nil {
t.Fatalf("insert exit chain: %v", err)
}
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
t.Run("normal diagnose should use configured connectIp", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/diagnose", bytes.NewBufferString(`{"tunnelId":`+strconv.FormatInt(tunnelID, 10)+`}`))
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 (%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"])
}
entryToMiddleOK := false
middleToExitOK := 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 && targetIP == "10.99.0.22" {
entryToMiddleOK = true
}
if from == 2 && to == 3 && targetIP == "10.99.0.33" {
middleToExitOK = true
}
}
if !entryToMiddleOK || !middleToExitOK {
t.Fatalf("expected connectIp targets 10.99.0.22/10.99.0.33, got entry=%v middle=%v", entryToMiddleOK, middleToExitOK)
}
})
t.Run("stream diagnose start items should use configured connectIp", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/diagnose/stream", bytes.NewBufferString(`{"tunnelId":`+strconv.FormatInt(tunnelID, 10)+`}`))
req.Header.Set("Authorization", adminToken)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", res.Code)
}
scanner := bufio.NewScanner(bytes.NewReader(res.Body.Bytes()))
startFound := false
entryToMiddleOK := false
middleToExitOK := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var event map[string]interface{}
if err := json.Unmarshal([]byte(line), &event); err != nil {
continue
}
if strings.TrimSpace(valueAsString(event["type"])) != "start" {
continue
}
startFound = true
data, ok := event["data"].(map[string]interface{})
if !ok {
break
}
items, ok := data["items"].([]interface{})
if !ok {
break
}
for _, raw := range items {
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 && targetIP == "10.99.0.22" {
entryToMiddleOK = true
}
if from == 2 && to == 3 && targetIP == "10.99.0.33" {
middleToExitOK = true
}
}
break
}
if err := scanner.Err(); err != nil {
t.Fatalf("scan stream body: %v", err)
}
if !startFound {
t.Fatalf("expected start event in stream response")
}
if !entryToMiddleOK || !middleToExitOK {
t.Fatalf("expected start items with connectIp targets 10.99.0.22/10.99.0.33, got entry=%v middle=%v", entryToMiddleOK, middleToExitOK)
}
})
}
@@ -0,0 +1,201 @@
package contract_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
const contractBytesPerGB int64 = 1024 * 1024 * 1024
func TestForwardResumeBlockedWhenUserFlowExceeded(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
userID := int64(2)
tunnelID := int64(1)
forwardID := int64(1)
flowGB := int64(120)
used := flowGB*contractBytesPerGB + 1
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(?, 'flow_user', 'pwd', 1, 2727251700000, ?, ?, 0, 1, 99999, ?, ?, 1)
`, userID, flowGB, used, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, 'flow_tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert tunnel: %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(10, ?, ?, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
`, userID, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := repo.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(?, ?, 'flow_user', 'flow_forward', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 0, 0)
`, forwardID, userID, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
token, err := auth.GenerateToken(userID, "flow_user", 1, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/resume", bytes.NewBufferString(`{"id":1}`))
req.Header.Set("Authorization", token)
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 non-zero code when flow exceeded")
}
if !strings.Contains(out.Msg, "流量") {
t.Fatalf("expected flow exceeded message, got %q", out.Msg)
}
status := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = ?`, forwardID)
if status != 0 {
t.Fatalf("expected forward status to remain 0, got %d", status)
}
}
func TestForwardResumeBlockedWhenUserTunnelFlowExceeded(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
userID := int64(2)
tunnelID := int64(1)
forwardID := int64(1)
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(?, 'ut_flow_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, userID, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, 'ut_flow_tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
utFlowGB := int64(120)
utUsed := utFlowGB * contractBytesPerGB
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, ?, ?, NULL, 99999, ?, ?, 0, 1, 2727251700000, 1)
`, userID, tunnelID, utFlowGB, utUsed).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := repo.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(?, ?, 'ut_flow_user', 'ut_flow_forward', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 0, 0)
`, forwardID, userID, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
token, err := auth.GenerateToken(userID, "ut_flow_user", 1, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/resume", bytes.NewBufferString(`{"id":1}`))
req.Header.Set("Authorization", token)
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 non-zero code when tunnel flow exceeded")
}
if !strings.Contains(out.Msg, "隧道") || !strings.Contains(out.Msg, "流量") {
t.Fatalf("expected tunnel flow exceeded message, got %q", out.Msg)
}
}
func TestForwardCreateBlockedWhenFlowExceeded(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
userID := int64(2)
tunnelID := int64(1)
flowGB := int64(120)
used := flowGB*contractBytesPerGB + 1
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(?, 'create_flow_user', 'pwd', 1, 2727251700000, ?, ?, 0, 1, 99999, ?, ?, 1)
`, userID, flowGB, used, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, 'create_flow_tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert tunnel: %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(10, ?, ?, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
`, userID, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
token, err := auth.GenerateToken(userID, "create_flow_user", 1, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
payload := `{"tunnelId":1,"name":"n","remoteAddr":"1.1.1.1:53"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewBufferString(payload))
req.Header.Set("Authorization", token)
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 non-zero code when flow exceeded")
}
if !strings.Contains(out.Msg, "流量") {
t.Fatalf("expected flow exceeded message, got %q", out.Msg)
}
}
@@ -7,6 +7,8 @@ import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
@@ -29,7 +31,7 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) {
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).Error; err != nil {
`, "contract-tunnel", 2.5, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "contract-tunnel")
@@ -116,6 +118,13 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) {
if got := int64(idFloat); got != userForwardID {
t.Fatalf("expected forward id %d, got %d", userForwardID, got)
}
ratioFloat, ok := item["tunnelTrafficRatio"].(float64)
if !ok {
t.Fatalf("expected tunnelTrafficRatio to be float64, got %T", item["tunnelTrafficRatio"])
}
if ratioFloat != 2.5 {
t.Fatalf("expected tunnelTrafficRatio 2.5, got %v", ratioFloat)
}
})
t.Run("forward diagnose returns structured payload", func(t *testing.T) {
@@ -480,6 +489,113 @@ func TestUserTunnelReassignmentKeepsStableID(t *testing.T) {
}
}
func TestUserTunnelSaveIgnoresDeletedSpeedLimitContract(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(101, 'user_tunnel_speed_user_a', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user a: %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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "user-tunnel-missing-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, "user-tunnel-missing-speed-tunnel")
if err := repo.DB().Exec(`
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
`, "user-tunnel-missing-speed-limit", 2048, now, 1).Error; err != nil {
t.Fatalf("insert speed limit: %v", err)
}
speedID := mustLastInsertID(t, repo, "user-tunnel-missing-speed-limit")
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(31, 101, ?, ?, 999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelID, speedID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := repo.DB().Exec(`DELETE FROM speed_limit WHERE id = ?`, speedID).Error; err != nil {
t.Fatalf("delete speed limit: %v", err)
}
t.Run("user tunnel update auto clears missing speed", func(t *testing.T) {
updatePayload := map[string]interface{}{
"id": 31,
"flow": 99999,
"num": 999,
"expTime": int64(2727251700000),
"flowResetTime": 1,
"status": 1,
"speedId": speedID,
}
updateBody, err := json.Marshal(updatePayload)
if err != nil {
t.Fatalf("marshal update payload: %v", err)
}
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/update", bytes.NewReader(updateBody))
updateReq.Header.Set("Authorization", adminToken)
updateReq.Header.Set("Content-Type", "application/json")
updateRes := httptest.NewRecorder()
router.ServeHTTP(updateRes, updateReq)
assertCode(t, updateRes, 0)
var updatedSpeed sql.NullInt64
if err := repo.DB().Raw(`SELECT speed_id FROM user_tunnel WHERE id = 31`).Row().Scan(&updatedSpeed); err != nil {
t.Fatalf("query updated user_tunnel speed_id: %v", err)
}
if updatedSpeed.Valid {
t.Fatalf("expected updated user_tunnel speed_id to be NULL, got %d", updatedSpeed.Int64)
}
})
t.Run("user tunnel batch assign auto clears missing speed", func(t *testing.T) {
if err := repo.DB().Exec(`UPDATE user_tunnel SET speed_id = ? WHERE id = 31`, speedID).Error; err != nil {
t.Fatalf("prepare user_tunnel speed_id for batch assign: %v", err)
}
assignPayload := map[string]interface{}{
"userId": 101,
"tunnels": []map[string]interface{}{{
"tunnelId": tunnelID,
"speedId": speedID,
}},
}
assignBody, err := json.Marshal(assignPayload)
if err != nil {
t.Fatalf("marshal assign payload: %v", err)
}
assignReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/batch-assign", bytes.NewReader(assignBody))
assignReq.Header.Set("Authorization", adminToken)
assignReq.Header.Set("Content-Type", "application/json")
assignRes := httptest.NewRecorder()
router.ServeHTTP(assignRes, assignReq)
assertCode(t, assignRes, 0)
var assignedSpeed sql.NullInt64
if err := repo.DB().Raw(`SELECT speed_id FROM user_tunnel WHERE id = 31`).Row().Scan(&assignedSpeed); err != nil {
t.Fatalf("query assigned user_tunnel speed_id: %v", err)
}
if assignedSpeed.Valid {
t.Fatalf("expected assigned user_tunnel speed_id to be NULL, got %d", assignedSpeed.Int64)
}
})
}
func TestForwardSpeedIDWriteAndClearContracts(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
@@ -618,6 +734,105 @@ func TestForwardSpeedIDWriteAndClearContracts(t *testing.T) {
}
}
func TestForwardUpdateIgnoresDeletedSpeedLimitContract(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-update-missing-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-update-missing-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-update-missing-speed-node", "forward-update-missing-speed-secret", "10.32.0.1", "10.32.0.1", "", "42000-42010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, repo, "forward-update-missing-speed-node")
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 42001, '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-update-missing-speed-limit", 2048, now, 1).Error; err != nil {
t.Fatalf("insert speed limit: %v", err)
}
speedID := mustLastInsertID(t, repo, "forward-update-missing-speed-limit")
server := httptest.NewServer(router)
defer server.Close()
stopNode := startMockNodeSession(t, server.URL, "forward-update-missing-speed-secret")
defer stopNode()
createPayload := map[string]interface{}{
"name": "forward-update-missing-speed-target",
"tunnelId": tunnelID,
"remoteAddr": "1.1.1.1:443",
"strategy": "fifo",
"speedId": speedID,
}
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-update-missing-speed-target")
if err := repo.DB().Exec(`DELETE FROM speed_limit WHERE id = ?`, speedID).Error; err != nil {
t.Fatalf("delete speed limit: %v", err)
}
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "forward-update-missing-speed-target-updated",
"tunnelId": tunnelID,
"remoteAddr": "1.1.1.1:443",
"strategy": "fifo",
"speedId": speedID,
}
updateBody, err := json.Marshal(updatePayload)
if err != nil {
t.Fatalf("marshal update payload: %v", err)
}
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
updateReq.Header.Set("Authorization", adminToken)
updateReq.Header.Set("Content-Type", "application/json")
updateRes := httptest.NewRecorder()
router.ServeHTTP(updateRes, updateReq)
assertCode(t, updateRes, 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 {
t.Fatalf("expected updated speed_id to be NULL after missing speed limit, got %d", updatedSpeed.Int64)
}
}
func TestForwardCreateThenPauseResumeContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
@@ -708,6 +923,462 @@ func TestForwardCreateThenPauseResumeContract(t *testing.T) {
}
}
func TestForwardUpdateRecoversFromAddressInUseContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := 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 := 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(202, 'forward_bind_retry_user', 'pwd', 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-bind-retry-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-bind-retry-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-bind-retry-node", "forward-bind-retry-secret", "10.42.0.1", "10.42.0.1", "", "44000-44010", "", "v1", 1, 1, 1, now, now, 1, "10.42.0.9", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, repo, "forward-bind-retry-node")
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 44001, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %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(41, 202, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
createPayload := map[string]interface{}{
"name": "forward-bind-retry-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)
}
var mu sync.Mutex
counts := map[string]int{}
var addServiceAddrs []string
triggerConflict := false
stopNode := startMockNodeSessionWithCommandRecorder(t, server.URL, "forward-bind-retry-secret", func(cmdType string, data json.RawMessage) (bool, string) {
key := strings.ToLower(strings.TrimSpace(cmdType))
mu.Lock()
counts[key]++
attempt := counts[key]
if strings.EqualFold(strings.TrimSpace(cmdType), "AddService") || strings.EqualFold(strings.TrimSpace(cmdType), "UpdateService") {
var services []map[string]interface{}
if err := json.Unmarshal(data, &services); err == nil {
for _, svc := range services {
if addr, _ := svc["addr"].(string); strings.TrimSpace(addr) != "" {
addServiceAddrs = append(addServiceAddrs, addr)
}
}
}
}
shouldFail := false
if triggerConflict {
if strings.EqualFold(strings.TrimSpace(cmdType), "UpdateService") && attempt == 1 {
shouldFail = true
}
if strings.EqualFold(strings.TrimSpace(cmdType), "AddService") && attempt == 1 {
shouldFail = true
}
}
mu.Unlock()
if shouldFail {
return true, "create service 57_7_7_tcp failed: listen tcp4 0.0.0.0:46222: bind: address alreadyin use"
}
return false, ""
})
defer stopNode()
waitNodeStatus(t, repo, nodeID, 1)
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)
mu.Lock()
counts = map[string]int{}
addServiceAddrs = nil
triggerConflict = true
mu.Unlock()
forwardID := mustLastInsertID(t, repo, "forward-bind-retry-target")
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "forward-bind-retry-target-updated",
"tunnelId": tunnelID,
"remoteAddr": "9.9.9.9:8443",
"strategy": "fifo",
}
updateBody, err := json.Marshal(updatePayload)
if err != nil {
t.Fatalf("marshal update payload: %v", err)
}
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
updateReq.Header.Set("Authorization", adminToken)
updateReq.Header.Set("Content-Type", "application/json")
updateRes := httptest.NewRecorder()
router.ServeHTTP(updateRes, updateReq)
assertCode(t, updateRes, 0)
mu.Lock()
defer mu.Unlock()
boundPort := mustQueryInt(t, repo, `SELECT port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID)
if counts["updateservice"] != 1 {
t.Fatalf("expected one UpdateService attempt, got %d (%v)", counts["updateservice"], counts)
}
if counts["deleteservice"] == 0 {
t.Fatalf("expected DeleteService cleanup after address-in-use (%v)", counts)
}
if counts["addservice"] < 2 {
t.Fatalf("expected AddService retry path to run at least twice total, got %d (%v)", counts["addservice"], counts)
}
foundBindAddr := false
for _, addr := range addServiceAddrs {
if addr == "10.42.0.9:"+strconv.Itoa(boundPort) {
foundBindAddr = true
break
}
}
if !foundBindAddr {
t.Fatalf("expected forward runtime to keep node listen addr 10.42.0.9:%d, got %v", boundPort, addServiceAddrs)
}
storedRemoteAddr := mustQueryString(t, repo, `SELECT remote_addr FROM forward WHERE id = ?`, forwardID)
if storedRemoteAddr != "9.9.9.9:8443" {
t.Fatalf("expected remote_addr update to persist, got %q", storedRemoteAddr)
}
}
func jsonNumber(v int64) string {
return strconv.FormatInt(v, 10)
}
func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
secret := "contract-jwt-secret-perm"
router, repo := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
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(2, 'normal_user_perm', '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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "perm-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "perm-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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "perm-node", "perm-secret", "10.0.0.20", "10.0.0.20", "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
entryNodeID := mustLastInsertID(t, repo, "perm-node")
if err := repo.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 chain_tunnel: %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, 10, 99999, 0, 0, 1, 2727251700000, 1)
`, 2, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
VALUES(?, ?, ?, ?, ?, ?, 1)
`, "perm-speed-limit", 2048, tunnelID, "perm-tunnel", now, now).Error; err != nil {
t.Fatalf("insert speed limit: %v", err)
}
speedID := mustLastInsertID(t, repo, "perm-speed-limit")
userToken, err := auth.GenerateToken(2, "normal_user_perm", 1, secret)
if err != nil {
t.Fatalf("generate user token: %v", err)
}
stopNode := startMockNodeSession(t, server.URL, "perm-secret")
defer stopNode()
t.Run("non-admin cannot set speedId on create", func(t *testing.T) {
createPayload := map[string]interface{}{
"name": "perm-forward-speed",
"tunnelId": tunnelID,
"remoteAddr": "1.2.3.4:443",
"strategy": "fifo",
"speedId": speedID,
}
createBody, err := json.Marshal(createPayload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCodeMsg(t, res, -1, "普通用户无法设置限速规则")
})
t.Run("non-admin cannot set inPort out of range on create", func(t *testing.T) {
createPayload := map[string]interface{}{
"name": "perm-forward-port-out",
"tunnelId": tunnelID,
"remoteAddr": "1.2.3.4:443",
"strategy": "fifo",
"inPort": 12345,
}
createBody, err := json.Marshal(createPayload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
req.Header.Set("Authorization", userToken)
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.Errorf("expected port out of range error, got code=%d msg=%s", out.Code, out.Msg)
}
})
t.Run("non-admin can set inPort within range on create", func(t *testing.T) {
createPayload := map[string]interface{}{
"name": "perm-forward-port-in",
"tunnelId": tunnelID,
"remoteAddr": "1.2.3.4:443",
"strategy": "fifo",
"inPort": 30005,
}
createBody, err := json.Marshal(createPayload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
})
t.Run("non-admin can create without speedId and inPort", func(t *testing.T) {
createPayload := map[string]interface{}{
"name": "perm-forward-ok",
"tunnelId": tunnelID,
"remoteAddr": "1.2.3.4:443",
"strategy": "fifo",
}
createBody, err := json.Marshal(createPayload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
})
forwardID := mustLastInsertID(t, repo, "perm-forward-ok")
t.Run("non-admin cannot update speedId", func(t *testing.T) {
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "perm-forward-updated",
"tunnelId": tunnelID,
"remoteAddr": "5.6.7.8:443",
"speedId": speedID,
}
updateBody, err := json.Marshal(updatePayload)
if err != nil {
t.Fatalf("marshal update payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCodeMsg(t, res, -1, "普通用户无法修改限速规则")
})
t.Run("non-admin cannot update inPort out of range", func(t *testing.T) {
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "perm-forward-updated2",
"tunnelId": tunnelID,
"remoteAddr": "5.6.7.8:443",
"inPort": 54321,
}
updateBody, err := json.Marshal(updatePayload)
if err != nil {
t.Fatalf("marshal update payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
req.Header.Set("Authorization", userToken)
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.Errorf("expected port out of range error, got code=%d msg=%s", out.Code, out.Msg)
}
})
t.Run("non-admin can update inPort within range", func(t *testing.T) {
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "perm-forward-updated3",
"tunnelId": tunnelID,
"remoteAddr": "5.6.7.8:443",
"inPort": 30006,
}
updateBody, err := json.Marshal(updatePayload)
if err != nil {
t.Fatalf("marshal update payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
})
t.Run("non-admin can update without speedId and inPort", func(t *testing.T) {
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "perm-forward-updated-ok",
"tunnelId": tunnelID,
"remoteAddr": "9.10.11.12:443",
}
updateBody, err := json.Marshal(updatePayload)
if err != nil {
t.Fatalf("marshal update payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
})
t.Run("non-admin can update when request keeps existing speedId", func(t *testing.T) {
if err := repo.DB().Exec(`UPDATE forward SET speed_id = ? WHERE id = ?`, speedID, forwardID).Error; err != nil {
t.Fatalf("assign forward speed limit: %v", err)
}
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "perm-forward-keep-speed",
"tunnelId": tunnelID,
"remoteAddr": "9.10.11.12:443",
"speedId": speedID,
}
updateBody, err := json.Marshal(updatePayload)
if err != nil {
t.Fatalf("marshal update payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
})
t.Run("non-admin can create with speedId null and inPort 0", func(t *testing.T) {
createPayload := map[string]interface{}{
"name": "perm-forward-null-values",
"tunnelId": tunnelID,
"remoteAddr": "1.2.3.4:443",
"strategy": "fifo",
"speedId": nil,
"inPort": 0,
}
createBody, err := json.Marshal(createPayload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
})
t.Run("non-admin can update with speedId null", func(t *testing.T) {
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "perm-forward-null-speed",
"tunnelId": tunnelID,
"remoteAddr": "9.10.11.12:443",
"speedId": nil,
}
updateBody, err := json.Marshal(updatePayload)
if err != nil {
t.Fatalf("marshal update payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
})
}
@@ -0,0 +1,193 @@
package contract_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
func TestIssue313_EntryPortCrossTunnelConflictContract(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)
}
insertNode := func(name, ip, 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", ip, ip, "", 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)
}
entryB1 := insertNode("issue313-entry-b1", "10.100.0.2", "2000-2010")
entryB2 := insertNode("issue313-entry-b2", "10.100.0.3", "2000-2010")
chainA := insertNode("issue313-chain-a", "10.100.0.4", "3000-3010")
chainB := insertNode("issue313-chain-b", "10.100.0.5", "3000-3010")
exitA := insertNode("issue313-exit-a", "10.100.0.6", "4000-4010")
exitB := insertNode("issue313-exit-b", "10.100.0.7", "4000-4010")
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "issue313-tunnel-a", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel a: %v", err)
}
tunnelAID := mustLastInsertID(t, repo, "issue313-tunnel-a")
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 2000, 'round', 1, 'tls')
`, tunnelAID, entryB2).Error; err != nil {
t.Fatalf("insert chain_tunnel entry a: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 2, ?, 3000, 'round', 1, 'tls')
`, tunnelAID, chainA).Error; err != nil {
t.Fatalf("insert chain_tunnel chain a: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 3, ?, 4000, 'round', 1, 'tls')
`, tunnelAID, exitA).Error; err != nil {
t.Fatalf("insert chain_tunnel exit a: %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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "issue313-tunnel-b", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel b: %v", err)
}
tunnelBID := mustLastInsertID(t, repo, "issue313-tunnel-b")
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 2000, 'round', 1, 'tls')
`, tunnelBID, entryB1).Error; err != nil {
t.Fatalf("insert chain_tunnel entry b1: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 2, ?, 3000, 'round', 1, 'tls')
`, tunnelBID, chainB).Error; err != nil {
t.Fatalf("insert chain_tunnel chain b: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 3, ?, 4000, 'round', 1, 'tls')
`, tunnelBID, exitB).Error; err != nil {
t.Fatalf("insert chain_tunnel exit 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(3131, 1, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelAID).Error; err != nil {
t.Fatalf("insert user_tunnel for tunnel a: %v", err)
}
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(1, 'admin_user', 'issue313-forward-a', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, tunnelAID, now, now).Error; err != nil {
t.Fatalf("insert forward a: %v", err)
}
forwardAID := mustLastInsertID(t, repo, "issue313-forward-a")
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardAID, entryB2, 2000).Error; err != nil {
t.Fatalf("insert forward_port a: %v", err)
}
// Simulate legacy dirty data: tunnel A already occupies port 2000 on entryB2.
// When tunnel B adds entryB2, the inherited forward port should conflict cross-tunnel.
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardAID, entryB2, 2000).Error; err != nil {
t.Fatalf("insert forward_port a on entryB2: %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(3132, 1, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelBID).Error; err != nil {
t.Fatalf("insert user_tunnel for tunnel b: %v", err)
}
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(1, 'admin_user', 'issue313-forward-b', ?, '2.2.2.2:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, tunnelBID, now, now).Error; err != nil {
t.Fatalf("insert forward b: %v", err)
}
forwardBID := mustLastInsertID(t, repo, "issue313-forward-b")
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardBID, entryB1, 2000).Error; err != nil {
t.Fatalf("insert forward_port b: %v", err)
}
payload := map[string]interface{}{
"id": tunnelBID,
"name": "issue313-tunnel-b",
"type": 2,
"flow": 99999,
"trafficRatio": 1.0,
"status": 1,
"inNodeId": []map[string]interface{}{
{"nodeId": entryB1, "protocol": "tls", "strategy": "round"},
{"nodeId": entryB2, "protocol": "tls", "strategy": "round"},
},
"chainNodes": []interface{}{
[]map[string]interface{}{{"nodeId": chainB, "protocol": "tls", "strategy": "round"}},
},
"outNodeId": []map[string]interface{}{
{"nodeId": exitB, "protocol": "tls", "strategy": "round"},
},
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", 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 update failure due to cross-tunnel port conflict, got success with code 0")
}
msgBytes := []byte(out.Msg)
if !bytes.Contains(msgBytes, []byte("端口")) && !bytes.Contains(msgBytes, []byte("占用")) {
t.Fatalf("expected port conflict error message, got %q", out.Msg)
}
countB2 := mustQueryInt(t, repo, `SELECT COUNT(1) FROM forward_port WHERE forward_id = ? AND node_id = ?`, forwardBID, entryB2)
if countB2 > 0 {
t.Fatalf("expected no forward_port record for entryB2, but found %d", countB2)
}
chainCountB2 := mustQueryInt(t, repo, `SELECT COUNT(1) FROM chain_tunnel WHERE tunnel_id = ? AND node_id = ?`, tunnelBID, entryB2)
if chainCountB2 > 0 {
t.Fatalf("expected no chain_tunnel record for entryB2, but found %d", chainCountB2)
}
}
@@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"sort"
"strings"
"sync"
"testing"
@@ -538,6 +539,549 @@ func TestBatchAssignInsertRollbackWhenLimiterDispatchFailsContract(t *testing.T)
}
}
func TestTunnelUpdateRecoversFromAddressInUseContract(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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "tunnel-bind-retry", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "tunnel-bind-retry")
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "tunnel-bind-entry", "tunnel-bind-entry-secret", "10.41.0.1", "10.41.0.1", "", "43000-43010", "", "v1", 1, 1, 1, now, now, 1, "10.41.0.1", "[::]", 0).Error; err != nil {
t.Fatalf("insert entry node: %v", err)
}
entryNodeID := mustLastInsertID(t, r, "tunnel-bind-entry")
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "tunnel-bind-exit", "tunnel-bind-exit-secret", "10.41.0.2", "10.41.0.2", "", "43100-43110", "eth0", "v1", 1, 1, 1, now, now, 1, "10.41.0.9", "[::]", 0).Error; err != nil {
t.Fatalf("insert exit node: %v", err)
}
exitNodeID := mustLastInsertID(t, r, "tunnel-bind-exit")
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 43001, 'round', 1, 'tls')
`, tunnelID, entryNodeID).Error; err != nil {
t.Fatalf("insert entry chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol, connect_ip)
VALUES(?, 3, ?, 43101, 'round', 1, 'tls', ?)
`, tunnelID, exitNodeID, "10.41.0.99").Error; err != nil {
t.Fatalf("insert exit chain_tunnel: %v", err)
}
var commandMu sync.Mutex
commandCounts := map[string]int{}
var addServiceAddrs []string
stopEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "tunnel-bind-entry-secret", func(cmdType string, data json.RawMessage) (bool, string) {
commandMu.Lock()
defer commandMu.Unlock()
commandCounts["entry:"+strings.ToLower(strings.TrimSpace(cmdType))]++
return false, ""
})
defer stopEntry()
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "tunnel-bind-exit-secret", func(cmdType string, data json.RawMessage) (bool, string) {
key := "exit:" + strings.ToLower(strings.TrimSpace(cmdType))
commandMu.Lock()
commandCounts[key]++
attempt := commandCounts[key]
if strings.EqualFold(strings.TrimSpace(cmdType), "AddService") {
var services []map[string]interface{}
if err := json.Unmarshal(data, &services); err == nil {
for _, svc := range services {
if addr, _ := svc["addr"].(string); strings.TrimSpace(addr) != "" {
addServiceAddrs = append(addServiceAddrs, addr)
}
}
}
}
commandMu.Unlock()
if strings.EqualFold(strings.TrimSpace(cmdType), "AddService") && attempt == 1 {
return true, "listen tcp 10.41.0.99:43101: bind: address already in use"
}
return false, ""
})
defer stopExit()
waitNodeStatus(t, r, entryNodeID, 1)
waitNodeStatus(t, r, exitNodeID, 1)
payload := map[string]interface{}{
"id": tunnelID,
"name": "tunnel-bind-retry",
"type": 2,
"flow": 99999,
"trafficRatio": 1.0,
"status": 1,
"inNodeId": []map[string]interface{}{
{"nodeId": entryNodeID, "protocol": "tls", "strategy": "round"},
},
"chainNodes": []interface{}{},
"outNodeId": []map[string]interface{}{
{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 43101, "connectIp": "10.41.0.99"},
},
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
commandMu.Lock()
defer commandMu.Unlock()
if commandCounts["exit:addservice"] != 2 {
t.Fatalf("expected exit AddService twice, got %d (%v)", commandCounts["exit:addservice"], sortedCommandCounts(commandCounts))
}
if commandCounts["exit:deleteservice"] == 0 {
t.Fatalf("expected exit DeleteService retry cleanup to run (%v)", sortedCommandCounts(commandCounts))
}
if len(addServiceAddrs) < 2 {
t.Fatalf("expected recorded AddService addresses, got %v", addServiceAddrs)
}
for _, addr := range addServiceAddrs {
if addr != "10.41.0.99:43101" {
t.Fatalf("expected connectIp to stay preferred in AddService addr, got %q", addr)
}
}
}
func TestTunnelUpdateChangesEntryNodeButLeavesOldForwardRuntimeContract(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, 'issue281_user', 'pwd', 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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "issue281-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "issue281-tunnel")
insertNode := func(name, secretValue, ip, portRange string, inx int) 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, secretValue, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx).Error; err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
return mustLastInsertID(t, r, name)
}
oldEntryNodeID := insertNode("issue281-old-entry", "issue281-old-entry-secret", "10.51.0.1", "51000-51010", 0)
newEntryNodeID := insertNode("issue281-new-entry", "issue281-new-entry-secret", "10.51.0.2", "51000-51010", 1)
exitNodeID := insertNode("issue281-exit", "issue281-exit-secret", "10.51.0.3", "53000-53010", 2)
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 51001, 'round', 1, 'tls')
`, tunnelID, oldEntryNodeID).Error; err != nil {
t.Fatalf("insert old entry chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 3, ?, 53001, 'round', 1, 'tls')
`, tunnelID, exitNodeID).Error; err != nil {
t.Fatalf("insert exit chain_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(281, 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, 'issue281_user', 'issue281-forward', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, "issue281-forward")
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, oldEntryNodeID, 51001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
forwardBase := fmt.Sprintf("%d_%d_%d", forwardID, 2, 281)
var commandMu sync.Mutex
oldEntryDeleteNames := make([]string, 0)
newEntryUpdateNames := make([]string, 0)
recordForwardServiceNames := func(data json.RawMessage, list *[]string) {
var serviceList []map[string]interface{}
if err := json.Unmarshal(data, &serviceList); err == nil {
for _, service := range serviceList {
name, _ := service["name"].(string)
if strings.HasPrefix(strings.TrimSpace(name), forwardBase) {
*list = append(*list, name)
}
}
return
}
var payload map[string]interface{}
if err := json.Unmarshal(data, &payload); err != nil {
return
}
if rawServices, ok := payload["services"].([]interface{}); ok {
for _, raw := range rawServices {
name, _ := raw.(string)
if strings.HasPrefix(strings.TrimSpace(name), forwardBase) {
*list = append(*list, name)
}
}
return
}
}
stopOldEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "issue281-old-entry-secret", func(cmdType string, data json.RawMessage) (bool, string) {
commandMu.Lock()
defer commandMu.Unlock()
if strings.EqualFold(strings.TrimSpace(cmdType), "DeleteService") {
recordForwardServiceNames(data, &oldEntryDeleteNames)
}
return false, ""
})
defer stopOldEntry()
stopNewEntry := startMockNodeSessionWithCommandRecorder(t, server.URL, "issue281-new-entry-secret", func(cmdType string, data json.RawMessage) (bool, string) {
commandMu.Lock()
defer commandMu.Unlock()
if strings.EqualFold(strings.TrimSpace(cmdType), "UpdateService") || strings.EqualFold(strings.TrimSpace(cmdType), "AddService") {
recordForwardServiceNames(data, &newEntryUpdateNames)
}
return false, ""
})
defer stopNewEntry()
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "issue281-exit-secret", func(cmdType string, data json.RawMessage) (bool, string) {
return false, ""
})
defer stopExit()
waitNodeStatus(t, r, oldEntryNodeID, 1)
waitNodeStatus(t, r, newEntryNodeID, 1)
waitNodeStatus(t, r, exitNodeID, 1)
payload := map[string]interface{}{
"id": tunnelID,
"name": "issue281-tunnel",
"type": 2,
"flow": 99999,
"trafficRatio": 1.0,
"status": 1,
"inNodeId": []map[string]interface{}{
{"nodeId": newEntryNodeID, "protocol": "tls", "strategy": "round"},
},
"chainNodes": []interface{}{},
"outNodeId": []map[string]interface{}{
{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 53001},
},
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
nodeAfter, portAfter := mustQueryInt64Int(t, r, `SELECT node_id, port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID)
if nodeAfter != newEntryNodeID || portAfter != 51001 {
t.Fatalf("expected forward_port rebound to node=%d port=51001, got node=%d port=%d", newEntryNodeID, nodeAfter, portAfter)
}
commandMu.Lock()
defer commandMu.Unlock()
if len(newEntryUpdateNames) == 0 {
t.Fatalf("expected new entry node to receive forward runtime sync for %s", forwardBase)
}
if len(oldEntryDeleteNames) == 0 {
t.Fatalf("expected old entry node to receive forward DeleteService cleanup for %s, got none", forwardBase)
}
}
func TestTunnelUpdateEntryTransitionsCleanupForwardRuntimeContract(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, 'issue281_transition_user', 'pwd', 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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "issue281-transition-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "issue281-transition-tunnel")
insertNode := func(name, secretValue, ip, portRange string, inx int) 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, secretValue, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx).Error; err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
return mustLastInsertID(t, r, name)
}
entryA := insertNode("issue281-transition-entry-a", "issue281-transition-entry-a-secret", "10.52.0.1", "54000-54010", 0)
entryB := insertNode("issue281-transition-entry-b", "issue281-transition-entry-b-secret", "10.52.0.2", "54000-54010", 1)
entryC := insertNode("issue281-transition-entry-c", "issue281-transition-entry-c-secret", "10.52.0.3", "54000-54010", 2)
exitNodeID := insertNode("issue281-transition-exit", "issue281-transition-exit-secret", "10.52.0.4", "57000-57010", 3)
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 54001, 'round', 1, 'tls')
`, tunnelID, entryA).Error; err != nil {
t.Fatalf("insert initial entry chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 3, ?, 57001, 'round', 1, 'tls')
`, tunnelID, exitNodeID).Error; err != nil {
t.Fatalf("insert exit chain_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(282, 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, 'issue281_transition_user', 'issue281-transition-forward', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, "issue281-transition-forward")
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, entryA, 54001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
forwardBase := fmt.Sprintf("%d_%d_%d", forwardID, 2, 282)
recorder := newForwardRuntimeCommandRecorder(forwardBase)
stopEntryA := startMockNodeSessionWithCommandRecorder(t, server.URL, "issue281-transition-entry-a-secret", recorder.handler("entry-a"))
defer stopEntryA()
stopEntryB := startMockNodeSessionWithCommandRecorder(t, server.URL, "issue281-transition-entry-b-secret", recorder.handler("entry-b"))
defer stopEntryB()
stopEntryC := startMockNodeSessionWithCommandRecorder(t, server.URL, "issue281-transition-entry-c-secret", recorder.handler("entry-c"))
defer stopEntryC()
stopExit := startMockNodeSessionWithCommandRecorder(t, server.URL, "issue281-transition-exit-secret", recorder.handler("exit"))
defer stopExit()
waitNodeStatus(t, r, entryA, 1)
waitNodeStatus(t, r, entryB, 1)
waitNodeStatus(t, r, entryC, 1)
waitNodeStatus(t, r, exitNodeID, 1)
updateTunnelEntries := func(entries []map[string]interface{}) {
payload := map[string]interface{}{
"id": tunnelID,
"name": "issue281-transition-tunnel",
"type": 2,
"flow": 99999,
"trafficRatio": 1.0,
"status": 1,
"inNodeId": entries,
"chainNodes": []interface{}{},
"outNodeId": []map[string]interface{}{
{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 57001},
},
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
}
updateTunnelEntries([]map[string]interface{}{
{"nodeId": entryA, "protocol": "tls", "strategy": "round"},
{"nodeId": entryB, "protocol": "tls", "strategy": "round"},
})
afterMulti := mustQueryNodePorts(t, r, `SELECT node_id, port FROM forward_port WHERE forward_id = ? ORDER BY id ASC`, forwardID)
if len(afterMulti) != 2 || afterMulti[entryA] != 54001 || afterMulti[entryB] != 54001 {
t.Fatalf("expected forward_port on entryA+entryB with port 54001, got %v", afterMulti)
}
if recorder.syncCount("entry-b") == 0 {
t.Fatalf("expected entry-b to receive forward runtime sync for %s", forwardBase)
}
if recorder.deleteCount("entry-a") != 0 {
t.Fatalf("expected no cleanup on retained entry-a during single->multi transition, got %v", recorder.deleteNames("entry-a"))
}
updateTunnelEntries([]map[string]interface{}{
{"nodeId": entryC, "protocol": "tls", "strategy": "round"},
})
afterSingle := mustQueryNodePorts(t, r, `SELECT node_id, port FROM forward_port WHERE forward_id = ? ORDER BY id ASC`, forwardID)
if len(afterSingle) != 1 || afterSingle[entryC] != 54001 {
t.Fatalf("expected forward_port on entryC with port 54001, got %v", afterSingle)
}
if recorder.deleteCount("entry-a") == 0 {
t.Fatalf("expected cleanup on removed entry-a during multi->single transition, got %v", recorder.deleteNames("entry-a"))
}
if recorder.deleteCount("entry-b") == 0 {
t.Fatalf("expected cleanup on removed entry-b during multi->single transition, got %v", recorder.deleteNames("entry-b"))
}
if recorder.syncCount("entry-c") == 0 {
t.Fatalf("expected entry-c to receive forward runtime sync for %s", forwardBase)
}
}
type forwardRuntimeCommandRecorder struct {
prefix string
mu sync.Mutex
deletes map[string][]string
syncNames map[string][]string
}
func newForwardRuntimeCommandRecorder(prefix string) *forwardRuntimeCommandRecorder {
return &forwardRuntimeCommandRecorder{
prefix: strings.TrimSpace(prefix),
deletes: make(map[string][]string),
syncNames: make(map[string][]string),
}
}
func (r *forwardRuntimeCommandRecorder) handler(node string) func(string, json.RawMessage) (bool, string) {
return func(cmdType string, data json.RawMessage) (bool, string) {
names := collectForwardServiceNames(data, r.prefix)
if len(names) == 0 {
return false, ""
}
r.mu.Lock()
defer r.mu.Unlock()
if strings.EqualFold(strings.TrimSpace(cmdType), "DeleteService") {
r.deletes[node] = append(r.deletes[node], names...)
}
if strings.EqualFold(strings.TrimSpace(cmdType), "UpdateService") || strings.EqualFold(strings.TrimSpace(cmdType), "AddService") {
r.syncNames[node] = append(r.syncNames[node], names...)
}
return false, ""
}
}
func (r *forwardRuntimeCommandRecorder) deleteCount(node string) int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.deletes[node])
}
func (r *forwardRuntimeCommandRecorder) syncCount(node string) int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.syncNames[node])
}
func (r *forwardRuntimeCommandRecorder) deleteNames(node string) []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.deletes[node]...)
}
func collectForwardServiceNames(data json.RawMessage, prefix string) []string {
prefix = strings.TrimSpace(prefix)
if prefix == "" {
return nil
}
names := make([]string, 0)
var serviceList []map[string]interface{}
if err := json.Unmarshal(data, &serviceList); err == nil {
for _, service := range serviceList {
name, _ := service["name"].(string)
if strings.HasPrefix(strings.TrimSpace(name), prefix) {
names = append(names, name)
}
}
return names
}
var payload map[string]interface{}
if err := json.Unmarshal(data, &payload); err != nil {
return nil
}
if rawServices, ok := payload["services"].([]interface{}); ok {
for _, raw := range rawServices {
name, _ := raw.(string)
if strings.HasPrefix(strings.TrimSpace(name), prefix) {
names = append(names, name)
}
}
}
return names
}
func startMockNodeSessionWithCommandFailures(t *testing.T, baseURL string, nodeSecret string, failCommands map[string]string) func() {
t.Helper()
@@ -633,3 +1177,112 @@ func startMockNodeSessionWithCommandFailures(t *testing.T, baseURL string, nodeS
})
}
}
func startMockNodeSessionWithCommandRecorder(t *testing.T, baseURL string, nodeSecret string, onCommand func(cmdType string, data json.RawMessage) (bool, 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"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(plain, &cmd); err != nil {
continue
}
if strings.TrimSpace(cmd.RequestID) == "" {
continue
}
shouldFail := false
failMsg := ""
if onCommand != nil {
shouldFail, failMsg = onCommand(strings.TrimSpace(cmd.Type), cmd.Data)
}
respType := fmt.Sprintf("%sResponse", cmd.Type)
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()
})
}
}
func sortedCommandCounts(counts map[string]int) []string {
items := make([]string, 0, len(counts))
for key, value := range counts {
items = append(items, fmt.Sprintf("%s=%d", key, value))
}
sort.Strings(items)
return items
}
@@ -684,15 +684,107 @@ func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {
columns := readTableColumns(t, r.DB(), "node")
for _, required := range []string{"server_ip_v4", "server_ip_v6", "inx"} {
for _, required := range []string{"server_ip_v4", "server_ip_v6", "inx", "extra_ips"} {
if !columns[required] {
t.Fatalf("expected node column %q to exist after migration", required)
}
}
tunnelColumns := readTableColumns(t, r.DB(), "tunnel")
if !tunnelColumns["inx"] {
t.Fatalf("expected tunnel column %q to exist after migration", "inx")
for _, required := range []string{"inx", "ip_preference"} {
if !tunnelColumns[required] {
t.Fatalf("expected tunnel column %q to exist after migration", required)
}
}
}
func TestOpenMigratesVeryLegacyNodeAndTunnelColumns(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "legacy-1.x.db")
legacyDB, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("open legacy sqlite: %v", err)
}
t.Cleanup(func() {
_ = legacyDB.Close()
})
if _, err := legacyDB.Exec(`
CREATE TABLE IF NOT EXISTS node (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
secret VARCHAR(100) NOT NULL,
server_ip VARCHAR(100) NOT NULL,
port TEXT NOT NULL,
interface_name VARCHAR(200),
version VARCHAR(100),
http INTEGER NOT NULL DEFAULT 0,
tls INTEGER NOT NULL DEFAULT 0,
socks INTEGER NOT NULL DEFAULT 0,
created_time INTEGER NOT NULL,
updated_time INTEGER,
status INTEGER NOT NULL
)
`); err != nil {
t.Fatalf("create very legacy node table: %v", err)
}
if _, err := legacyDB.Exec(`
CREATE TABLE IF NOT EXISTS tunnel (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
traffic_ratio REAL NOT NULL DEFAULT 1.0,
type INTEGER NOT NULL,
protocol VARCHAR(10) NOT NULL DEFAULT 'tls',
flow INTEGER NOT NULL,
created_time INTEGER NOT NULL,
updated_time INTEGER NOT NULL,
status INTEGER NOT NULL,
in_ip TEXT
)
`); err != nil {
t.Fatalf("create very legacy tunnel table: %v", err)
}
now := time.Now().UnixMilli()
if _, err := legacyDB.Exec(`
INSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "legacy-node", "legacy-secret", "10.10.0.1", "10000-10010", "eth0", "v-old", 1, 1, 1, now, now, 1); err != nil {
t.Fatalf("seed legacy node row: %v", err)
}
r, err := repo.Open(dbPath)
if err != nil {
t.Fatalf("open migrated sqlite: %v", err)
}
t.Cleanup(func() {
_ = r.Close()
})
columns := readTableColumns(t, r.DB(), "node")
for _, required := range []string{
"server_ip_v4",
"server_ip_v6",
"extra_ips",
"tcp_listen_addr",
"udp_listen_addr",
"inx",
"is_remote",
"remote_url",
"remote_token",
"remote_config",
} {
if !columns[required] {
t.Fatalf("expected node column %q to exist after migration", required)
}
}
tunnelColumns := readTableColumns(t, r.DB(), "tunnel")
for _, required := range []string{"inx", "ip_preference"} {
if !tunnelColumns[required] {
t.Fatalf("expected tunnel column %q to exist after migration", required)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,235 @@
package contract_test
import (
"testing"
"time"
"go-backend/internal/http/response"
storeRepo "go-backend/internal/store/repo"
)
func TestTunnelDeletePreviewIncludesDependentRulesContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
sourceTunnelID, sourceNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "preview-source-tunnel", "preview-source-node", "21000-21010")
seedTunnelDeleteForward(t, repo, now, sourceTunnelID, sourceNodeID, "preview-forward", 21001)
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/delete-preview", map[string]interface{}{"id": sourceTunnelID})
if out.Code != 0 {
t.Fatalf("expected success, got code=%d msg=%q", out.Code, out.Msg)
}
data, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected preview data object, got %T", out.Data)
}
if contractValueAsInt64(data["tunnelId"]) != sourceTunnelID {
t.Fatalf("unexpected tunnelId: %#v", data["tunnelId"])
}
if contractValueAsInt64(data["forwardCount"]) != 1 {
t.Fatalf("expected forwardCount=1, got %#v", data["forwardCount"])
}
samples, ok := data["sampleForwards"].([]interface{})
if !ok || len(samples) != 1 {
t.Fatalf("expected one sample forward, got %#v", data["sampleForwards"])
}
first, ok := samples[0].(map[string]interface{})
if !ok {
t.Fatalf("expected sample object, got %T", samples[0])
}
if first["name"] != "preview-forward" {
t.Fatalf("unexpected sample name: %#v", first["name"])
}
if contractValueAsInt64(first["inPort"]) != 21001 {
t.Fatalf("unexpected sample inPort: %#v", first["inPort"])
}
}
func TestTunnelDeleteWithForwardsDeleteActionRemovesTunnelAndRulesContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
sourceTunnelID, sourceNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "delete-source-tunnel", "delete-source-node", "22000-22010")
forwardID := seedTunnelDeleteForward(t, repo, now, sourceTunnelID, sourceNodeID, "delete-forward", 22001)
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/delete-with-forwards", map[string]interface{}{
"id": sourceTunnelID,
"action": "delete_forwards",
})
if out.Code != 0 {
t.Fatalf("expected success, got code=%d msg=%q", out.Code, out.Msg)
}
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelID); count != 0 {
t.Fatalf("expected tunnel deleted, got count=%d", count)
}
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM forward WHERE id = ?`, forwardID); count != 0 {
t.Fatalf("expected forward deleted, got count=%d", count)
}
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM forward_port WHERE forward_id = ?`, forwardID); count != 0 {
t.Fatalf("expected forward ports deleted, got count=%d", count)
}
}
func TestTunnelDeleteWithForwardsReplaceReturnsFailureDetailsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
sourceTunnelID, sourceNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "replace-source-tunnel", "replace-source-node", "23000-23010")
forwardID := seedTunnelDeleteForward(t, repo, now, sourceTunnelID, sourceNodeID, "replace-forward", 23001)
targetTunnelID, targetNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "replace-target-tunnel", "replace-target-node", "23000-23010")
seedTunnelDeleteForward(t, repo, now, targetTunnelID, targetNodeID, "occupied-forward", 23001)
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/delete-with-forwards", map[string]interface{}{
"id": sourceTunnelID,
"action": "replace",
"targetTunnelId": targetTunnelID,
})
if out.Code != -2 {
t.Fatalf("expected failure code -2, got code=%d msg=%q", out.Code, out.Msg)
}
result := mustTunnelDeleteFailureResult(t, out)
if contractValueAsInt64(result["failCount"]) != 1 {
t.Fatalf("expected failCount=1, got %#v", result["failCount"])
}
assertBatchFailureNameAndReason(t, result, "replace-forward", "节点 replace-target-node 端口 23001 已被其他转发占用")
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelID); count != 1 {
t.Fatalf("expected source tunnel kept, got count=%d", count)
}
if tunnelAfter := mustQueryInt64(t, repo, `SELECT tunnel_id FROM forward WHERE id = ?`, forwardID); tunnelAfter != sourceTunnelID {
t.Fatalf("expected forward tunnel unchanged, got %d", tunnelAfter)
}
}
func TestTunnelBatchDeletePreviewIncludesTotalsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
tunnelA, nodeA := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-preview-a", "batch-preview-node-a", "24000-24010")
tunnelB, _ := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-preview-b", "batch-preview-node-b", "24100-24110")
seedTunnelDeleteForward(t, repo, now, tunnelA, nodeA, "batch-preview-forward", 24001)
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/batch-delete-preview", map[string]interface{}{
"ids": []int64{tunnelA, tunnelB},
})
if out.Code != 0 {
t.Fatalf("expected success, got code=%d msg=%q", out.Code, out.Msg)
}
data, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected preview object, got %T", out.Data)
}
if contractValueAsInt64(data["tunnelCount"]) != 2 {
t.Fatalf("expected tunnelCount=2, got %#v", data["tunnelCount"])
}
if contractValueAsInt64(data["totalForwardCount"]) != 1 {
t.Fatalf("expected totalForwardCount=1, got %#v", data["totalForwardCount"])
}
}
func TestTunnelBatchDeleteWithForwardsReturnsTunnelLevelFailuresContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
sourceTunnelA, _ := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-replace-source-a", "batch-replace-source-node-a", "25000-25010")
sourceTunnelB, sourceNodeB := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-replace-source-b", "batch-replace-source-node-b", "25100-25110")
targetTunnelID, targetNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-replace-target", "batch-replace-target-node", "25000-25010")
seedTunnelDeleteForward(t, repo, now, sourceTunnelB, sourceNodeB, "batch-replace-forward-b", 25002)
seedTunnelDeleteForward(t, repo, now, targetTunnelID, targetNodeID, "batch-replace-occupied", 25002)
out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/batch-delete-with-forwards", map[string]interface{}{
"ids": []int64{sourceTunnelA, sourceTunnelB},
"action": "replace",
"targetTunnelId": targetTunnelID,
})
if out.Code != 0 {
t.Fatalf("expected success envelope, got code=%d msg=%q", out.Code, out.Msg)
}
result := mustTunnelDeleteFailureResult(t, out)
if contractValueAsInt64(result["successCount"]) != 1 {
t.Fatalf("expected successCount=1, got %#v", result["successCount"])
}
if contractValueAsInt64(result["failCount"]) != 1 {
t.Fatalf("expected failCount=1, got %#v", result["failCount"])
}
assertBatchFailureNameAndReason(t, result, "batch-replace-source-b", "batch-replace-forward-b: 节点 batch-replace-target-node 端口 25002 已被其他转发占用")
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelA); count != 0 {
t.Fatalf("expected source tunnel A deleted, got count=%d", count)
}
if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelB); count != 1 {
t.Fatalf("expected source tunnel B kept, got count=%d", count)
}
}
func seedTunnelDeleteTunnelWithNode(t *testing.T, repo *storeRepo.Repository, now int64, tunnelName, nodeName, portRange string) (int64, int64) {
t.Helper()
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, status, created_time, updated_time, in_ip, inx, ip_preference)
VALUES(?, 1.0, 1, 'tls', 1, 1, ?, ?, NULL, 0, '')
`, tunnelName, now, now).Error; err != nil {
t.Fatalf("insert tunnel %s: %v", tunnelName, err)
}
tunnelID := mustLastInsertID(t, repo, tunnelName)
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(?, ?, '10.0.0.1', '10.0.0.1', '', ?, '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
`, nodeName, nodeName+"-secret", portRange, now, now).Error; err != nil {
t.Fatalf("insert node %s: %v", nodeName, err)
}
nodeID := mustLastInsertID(t, repo, nodeName)
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 0, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel for %s: %v", tunnelName, err)
}
return tunnelID, nodeID
}
func seedTunnelDeleteForward(t *testing.T, repo *storeRepo.Repository, now int64, tunnelID, nodeID int64, forwardName string, port int) int64 {
t.Helper()
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, 'contract-user', ?, ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, forwardName, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert forward %s: %v", forwardName, err)
}
forwardID := mustLastInsertID(t, repo, forwardName)
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port).Error; err != nil {
t.Fatalf("insert forward_port for %s: %v", forwardName, err)
}
return forwardID
}
func mustTunnelDeleteFailureResult(t *testing.T, out response.R) map[string]interface{} {
t.Helper()
result, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected result object, got %T", out.Data)
}
return result
}
@@ -0,0 +1,97 @@
package contract_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"go-backend/internal/store/model"
)
func TestFlowUploadInsertsTunnelMetrics(t *testing.T) {
secret := "monitoring-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
node := &model.Node{
Name: "node-1",
Secret: "node-secret",
ServerIP: "127.0.0.1",
Port: "10000-10010",
TCPListenAddr: "[::]",
UDPListenAddr: "[::]",
CreatedTime: now,
Status: 1,
}
if err := repo.DB().Create(node).Error; err != nil {
t.Fatalf("seed node: %v", err)
}
tunnel := &model.Tunnel{
Name: "tunnel-1",
TrafficRatio: 1.0,
Type: 1,
Protocol: "tls",
Flow: 1,
CreatedTime: now,
UpdatedTime: now,
Status: 1,
}
if err := repo.DB().Create(tunnel).Error; err != nil {
t.Fatalf("seed tunnel: %v", err)
}
forward := &model.Forward{
UserID: 123,
UserName: "user-123",
Name: "forward-1",
TunnelID: tunnel.ID,
RemoteAddr: "1.1.1.1:80",
CreatedTime: now,
UpdatedTime: now,
Status: 1,
}
if err := repo.DB().Create(forward).Error; err != nil {
t.Fatalf("seed forward: %v", err)
}
serviceName := jsonNumber(forward.ID) + "_123_0"
body, _ := json.Marshal([]map[string]interface{}{{
"n": serviceName,
"u": 200,
"d": 100,
}})
req := httptest.NewRequest(http.MethodPost, "/flow/upload?secret="+node.Secret, bytes.NewReader(body))
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", res.Code)
}
metrics, err := repo.GetTunnelMetrics(tunnel.ID, 0, now+60_000)
if err != nil {
t.Fatalf("get tunnel metrics: %v", err)
}
if len(metrics) != 1 {
t.Fatalf("expected 1 tunnel metric row, got %d", len(metrics))
}
if metrics[0].TunnelID != tunnel.ID {
t.Fatalf("expected tunnelId %d, got %d", tunnel.ID, metrics[0].TunnelID)
}
if metrics[0].NodeID != node.ID {
t.Fatalf("expected nodeId %d, got %d", node.ID, metrics[0].NodeID)
}
if metrics[0].BytesIn != 100 {
t.Fatalf("expected bytesIn 100, got %d", metrics[0].BytesIn)
}
if metrics[0].BytesOut != 200 {
t.Fatalf("expected bytesOut 200, got %d", metrics[0].BytesOut)
}
}
@@ -0,0 +1,181 @@
package contract_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
func TestForwardCreateBlockedWhenUserQuotaExceeded(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now()
nowMs := now.UnixMilli()
dayKey := int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
monthKey := int64(now.Year()*100 + int(now.Month()))
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, 'quota_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(1, 'quota_tunnel', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert tunnel: %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(10, 2, 1, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
`).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_quota(user_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time)
VALUES(2, 10, 0, ?, ?, ?, ?, 1, ?, '', ?, ?)
`, 11*contractBytesPerGB, 11*contractBytesPerGB, dayKey, monthKey, nowMs, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert user_quota: %v", err)
}
token, err := auth.GenerateToken(2, "quota_user", 1, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewBufferString(`{"tunnelId":1,"name":"quota-forward","remoteAddr":"1.1.1.1:53"}`))
req.Header.Set("Authorization", token)
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 non-zero code when user quota exceeded")
}
if !strings.Contains(out.Msg, "配额") {
t.Fatalf("expected quota error, got %q", out.Msg)
}
}
func TestForwardResumeBlockedWhenUserQuotaExceeded(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now()
nowMs := now.UnixMilli()
dayKey := int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
monthKey := int64(now.Year()*100 + int(now.Month()))
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, 'quota_resume_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(1, 'quota_resume_tunnel', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert tunnel: %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(10, 2, 1, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
`).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := repo.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(1, 2, 'quota_resume_user', 'quota_resume_forward', 1, '1.1.1.1:53', 'fifo', 0, 0, ?, ?, 0, 0)
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_quota(user_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time)
VALUES(2, 10, 0, ?, ?, ?, ?, 1, ?, '1', ?, ?)
`, 11*contractBytesPerGB, 11*contractBytesPerGB, dayKey, monthKey, nowMs, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert user_quota: %v", err)
}
token, err := auth.GenerateToken(2, "quota_resume_user", 1, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/resume", bytes.NewBufferString(`{"id":1}`))
req.Header.Set("Authorization", token)
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 non-zero code when user quota exceeded")
}
if !strings.Contains(out.Msg, "配额") {
t.Fatalf("expected quota error, got %q", out.Msg)
}
status := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = 1`)
if status != 0 {
t.Fatalf("expected forward to remain paused, got %d", status)
}
}
func TestUserQuotaResetClearsDisableFlag(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now()
nowMs := now.UnixMilli()
dayKey := int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
monthKey := int64(now.Year()*100 + int(now.Month()))
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, 'quota_reset_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_quota(user_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time)
VALUES(2, 10, 0, ?, ?, ?, ?, 1, ?, '', ?, ?)
`, 11*contractBytesPerGB, 11*contractBytesPerGB, dayKey, monthKey, nowMs, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert user_quota: %v", err)
}
token, err := auth.GenerateToken(1, "admin", 0, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/quota/reset", bytes.NewBufferString(`{"userId":2,"scope":"all"}`))
req.Header.Set("Authorization", token)
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 reset success, got code=%d msg=%q", out.Code, out.Msg)
}
quotaDisabled := mustQueryInt(t, repo, `SELECT disabled_by_quota FROM user_quota WHERE user_id = 2`)
if quotaDisabled != 0 {
t.Fatalf("expected quota disable flag cleared, got %d", quotaDisabled)
}
}
@@ -0,0 +1,105 @@
package contract_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
func TestUserTunnelListReturnsStoredStatusContract(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(201, 'user_tunnel_status_user', 'pwd', 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(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(301, 'user-tunnel-status-enabled', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
`, now, now).Error; err != nil {
t.Fatalf("insert tunnel enabled: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(302, 'user-tunnel-status-disabled', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert tunnel disabled: %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(401, 201, 301, NULL, 10, 500, 0, 0, 1, 2727251700000, 1)
`).Error; err != nil {
t.Fatalf("insert enabled user_tunnel: %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(402, 201, 302, NULL, 10, 500, 0, 0, 1, 2727251700000, 0)
`).Error; err != nil {
t.Fatalf("insert disabled user_tunnel: %v", err)
}
body := bytes.NewBufferString(`{"userId":201}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/list", 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 code 0, got %d (%s)", out.Code, out.Msg)
}
items, ok := out.Data.([]interface{})
if !ok {
t.Fatalf("expected array data, got %T", out.Data)
}
if len(items) != 2 {
t.Fatalf("expected 2 items, got %d", len(items))
}
statusByTunnelID := make(map[int64]int, len(items))
for _, item := range items {
obj, ok := item.(map[string]interface{})
if !ok {
t.Fatalf("expected object item, got %T", item)
}
tunnelID, ok := obj["tunnelId"].(float64)
if !ok {
t.Fatalf("expected tunnelId to be float64, got %T", obj["tunnelId"])
}
status, ok := obj["status"].(float64)
if !ok {
t.Fatalf("expected status to be float64, got %T", obj["status"])
}
statusByTunnelID[int64(tunnelID)] = int(status)
}
if statusByTunnelID[301] != 1 {
t.Fatalf("expected enabled tunnel status 1, got %d", statusByTunnelID[301])
}
if statusByTunnelID[302] != 0 {
t.Fatalf("expected disabled tunnel status 0, got %d", statusByTunnelID[302])
}
}
+4 -1
View File
@@ -1,6 +1,9 @@
# GO-GOST SERVICE KNOWLEDGE BASE
**Generated:** Thu Feb 26 2026
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Forwarding agent built on GOST v3 with a local fork of `github.com/go-gost/x` under `x/`.
+2 -2
View File
@@ -109,12 +109,12 @@ func main() {
// 加载配置文件
config, err := LoadConfig("config.json")
if err != nil {
fmt.Println("❌ 配置加载失败: %v\n", err)
fmt.Printf("❌ 配置加载失败: %v\n", err)
fmt.Println("请确保当前目录存在 config.json 文件")
os.Exit(1)
}
fmt.Println("✅ 配置加载成功 - addr: %s", config.Addr)
fmt.Printf("✅ 配置加载成功 - addr: %s\n", config.Addr)
log := xlogger.NewLogger()
logger.SetDefault(log)
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## 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. 30+ top-level packages - framework-style layout.
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X API KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Gin-based management API for reading/writing config and controlling services at runtime.
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X CONFIG KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Config model + parsing/loading pipeline for the `go-gost/x` runtime. This is the bridge between `gost.json`/`gost.yaml` and in-memory registries/services.
+4 -1
View File
@@ -1,6 +1,9 @@
# GOST CONNECTOR KNOWLEDGE BASE
**Generated:** Fri Feb 13 2026
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Connection initiators (clients) for various protocols in GOST forwarding.
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X DIALERS KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Outbound dialers (client-side connection establishment) used by connectors/handlers.
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X HANDLERS KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Protocol handlers (server-side request handling) used by services defined in the GOST config.
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X LISTENERS KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Inbound listeners (transport-level accept loops) used by services defined in the GOST config.
+4 -1
View File
@@ -1,6 +1,9 @@
# GO-GOST REGISTRY KNOWLEDGE BASE
**Generated:** Wed Feb 04 2026
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Central registration point for all pluggable GOST components (handlers, listeners, dialers, etc.).
+3 -6
View File
@@ -7,7 +7,6 @@ import (
"errors"
"fmt"
"io"
"log"
"net"
"os"
"os/exec"
@@ -63,11 +62,9 @@ func SetProtocolBlock(httpOn int, tlsOn int, socksOn int) {
type Option func(opts *options)
func init() {
_, err := LoadConfig("config.json")
fmt.Println("config.json loaded")
if err != nil {
log.Fatal(err)
}
// NOTE: This package can be imported by tests/tools that don't have a local
// config.json. Missing config should not crash the process.
_, _ = LoadConfig("config.json")
needWrap = isTls+isSocks+isHttp > 0
}
+203 -77
View File
@@ -6,7 +6,9 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/go-gost/core/observer/stats"
@@ -18,6 +20,15 @@ import (
var httpReportURL string
var configReportURL string
var httpAESCrypto *crypto.AESCrypto // 新增:HTTP上报加密器
var reportURLPreferenceMutex sync.RWMutex
var preferredUploadURL string
var preferredConfigURL string
var reportDo = func(ctx context.Context, req *http.Request, timeout time.Duration) (*http.Response, error) {
client := &http.Client{
Timeout: timeout,
}
return client.Do(req.WithContext(ctx))
}
// TrafficReportItem 流量报告项(压缩格式)
type TrafficReportItem struct {
@@ -27,8 +38,17 @@ type TrafficReportItem struct {
}
func SetHTTPReportURL(addr string, secret string) {
httpReportURL = "http://" + addr + "/flow/upload?secret=" + secret
configReportURL = "http://" + addr + "/flow/config?secret=" + secret
uploadURLs, configURLs := buildReportURLCandidates(addr, secret)
if len(uploadURLs) > 0 {
httpReportURL = strings.Join(uploadURLs, ",")
}
if len(configURLs) > 0 {
configReportURL = strings.Join(configURLs, ",")
}
reportURLPreferenceMutex.Lock()
preferredUploadURL = ""
preferredConfigURL = ""
reportURLPreferenceMutex.Unlock()
// 创建 AES 加密器
var err error
@@ -41,8 +61,173 @@ func SetHTTPReportURL(addr string, secret string) {
}
}
func buildReportURLCandidates(addr string, secret string) (upload []string, config []string) {
normalizedAddr, explicitScheme := normalizeReportAddress(addr)
if normalizedAddr == "" {
normalizedAddr = strings.TrimSpace(addr)
}
schemes := []string{"https", "http"}
if mappedScheme := mapToHTTPScheme(explicitScheme); mappedScheme == "http" {
schemes = []string{"http", "https"}
}
upload = []string{
schemes[0] + "://" + normalizedAddr + "/flow/upload?secret=" + secret,
schemes[1] + "://" + normalizedAddr + "/flow/upload?secret=" + secret,
}
config = []string{
schemes[0] + "://" + normalizedAddr + "/flow/config?secret=" + secret,
schemes[1] + "://" + normalizedAddr + "/flow/config?secret=" + secret,
}
return upload, config
}
func normalizeReportAddress(addr string) (string, string) {
raw := strings.TrimSpace(addr)
if raw == "" {
return "", ""
}
scheme := ""
if idx := strings.Index(raw, "://"); idx > 0 {
scheme = strings.ToLower(strings.TrimSpace(raw[:idx]))
if parsed, err := url.Parse(raw); err == nil {
if host := strings.TrimSpace(parsed.Host); host != "" {
return host, scheme
}
}
raw = raw[idx+3:]
}
if idx := strings.IndexAny(raw, "/?#"); idx >= 0 {
raw = raw[:idx]
}
return strings.TrimSpace(raw), scheme
}
func mapToHTTPScheme(scheme string) string {
switch strings.ToLower(strings.TrimSpace(scheme)) {
case "https", "wss":
return "https"
case "http", "ws":
return "http"
default:
return ""
}
}
func loadPreferredURL(preferred *string) string {
if preferred == nil {
return ""
}
reportURLPreferenceMutex.RLock()
defer reportURLPreferenceMutex.RUnlock()
return *preferred
}
func storePreferredURL(preferred *string, value string) {
if preferred == nil {
return
}
reportURLPreferenceMutex.Lock()
defer reportURLPreferenceMutex.Unlock()
*preferred = value
}
func prioritizeURLs(urls []string, preferred string) []string {
ordered := append([]string(nil), urls...)
if preferred == "" || len(ordered) < 2 {
return ordered
}
for i, targetURL := range ordered {
if targetURL == preferred {
if i > 0 {
ordered[0], ordered[i] = ordered[i], ordered[0]
}
break
}
}
return ordered
}
func postJSONWithFallback(ctx context.Context, urls []string, requestBody []byte, userAgent string, timeout time.Duration, preferred *string) (bool, error) {
if len(urls) == 0 {
return false, fmt.Errorf("上报URL未设置")
}
orderedURLs := prioritizeURLs(urls, loadPreferredURL(preferred))
var errs []string
for i, targetURL := range orderedURLs {
req, err := http.NewRequest("POST", targetURL, bytes.NewBuffer(requestBody))
if err != nil {
errs = append(errs, fmt.Sprintf("%s => 创建请求失败: %v", targetURL, err))
if i < len(orderedURLs)-1 {
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => 创建请求失败: %v\n", targetURL, err)
}
continue
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", userAgent)
resp, err := reportDo(ctx, req, timeout)
if err != nil {
errs = append(errs, fmt.Sprintf("%s => 请求失败: %v", targetURL, err))
if i < len(orderedURLs)-1 {
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => 请求失败: %v\n", targetURL, err)
}
continue
}
var responseBytes bytes.Buffer
_, readErr := responseBytes.ReadFrom(resp.Body)
resp.Body.Close()
if readErr != nil {
errs = append(errs, fmt.Sprintf("%s => 读取响应失败: %v", targetURL, readErr))
if i < len(orderedURLs)-1 {
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => 读取响应失败: %v\n", targetURL, readErr)
}
continue
}
if resp.StatusCode != http.StatusOK {
errs = append(errs, fmt.Sprintf("%s => HTTP响应错误: %d %s", targetURL, resp.StatusCode, resp.Status))
if i < len(orderedURLs)-1 {
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => HTTP响应错误: %d %s\n", targetURL, resp.StatusCode, resp.Status)
}
continue
}
responseText := strings.TrimSpace(responseBytes.String())
if responseText == "ok" {
if i > 0 {
fmt.Printf("↪️ HTTP上报已自动回退到: %s\n", targetURL)
}
storePreferredURL(preferred, targetURL)
return true, nil
}
errs = append(errs, fmt.Sprintf("%s => 服务器响应: %s (期望: ok)", targetURL, responseText))
if i < len(orderedURLs)-1 {
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => 服务器响应: %s (期望: ok)\n", targetURL, responseText)
}
}
return false, fmt.Errorf("发送HTTP请求失败: %s", strings.Join(errs, " | "))
}
// sendBatchTrafficReport 批量发送多个服务的流量报告到HTTP接口
func sendBatchTrafficReport(ctx context.Context, reportItems []TrafficReportItem) (bool, error) {
if httpReportURL == "" {
return false, fmt.Errorf("流量上报URL未设置")
}
jsonData, err := json.Marshal(reportItems)
if err != nil {
return false, fmt.Errorf("序列化报告数据失败: %v", err)
@@ -73,46 +258,16 @@ func sendBatchTrafficReport(ctx context.Context, reportItems []TrafficReportItem
requestBody = jsonData
}
req, err := http.NewRequestWithContext(ctx, "POST", httpReportURL, bytes.NewBuffer(requestBody))
if err != nil {
return false, fmt.Errorf("创建HTTP请求失败: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "GOST-Traffic-Reporter/1.0")
client := &http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return false, fmt.Errorf("发送HTTP请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("HTTP响应错误: %d %s", resp.StatusCode, resp.Status)
}
// 读取响应内容
var responseBytes bytes.Buffer
_, err = responseBytes.ReadFrom(resp.Body)
if err != nil {
return false, fmt.Errorf("读取响应内容失败: %v", err)
}
responseText := strings.TrimSpace(responseBytes.String())
// 检查响应是否为"ok"
if responseText == "ok" {
return true, nil
} else {
return false, fmt.Errorf("服务器响应: %s (期望: ok)", responseText)
}
return postJSONWithFallback(
ctx,
strings.Split(httpReportURL, ","),
requestBody,
"GOST-Traffic-Reporter/1.0",
5*time.Second,
&preferredUploadURL,
)
}
// sendConfigReport 发送配置报告到HTTP接口
func sendConfigReport(ctx context.Context) (bool, error) {
if configReportURL == "" {
@@ -150,43 +305,14 @@ func sendConfigReport(ctx context.Context) (bool, error) {
requestBody = configData
}
req, err := http.NewRequestWithContext(ctx, "POST", configReportURL, bytes.NewBuffer(requestBody))
if err != nil {
return false, fmt.Errorf("创建HTTP请求失败: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Config-Reporter/1.0")
client := &http.Client{
Timeout: 10 * time.Second, // 配置上报可以稍长一些
}
resp, err := client.Do(req)
if err != nil {
return false, fmt.Errorf("发送HTTP请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("HTTP响应错误: %d %s", resp.StatusCode, resp.Status)
}
// 读取响应内容
var responseBytes bytes.Buffer
_, err = responseBytes.ReadFrom(resp.Body)
if err != nil {
return false, fmt.Errorf("读取响应内容失败: %v", err)
}
responseText := strings.TrimSpace(responseBytes.String())
// 检查响应是否为"ok"
if responseText == "ok" {
return true, nil
} else {
return false, fmt.Errorf("服务器响应: %s (期望: ok)", responseText)
}
return postJSONWithFallback(
ctx,
strings.Split(configReportURL, ","),
requestBody,
"Config-Reporter/1.0",
10*time.Second,
&preferredConfigURL,
)
}
// StartConfigReporter 启动配置定时上报器(每10分钟上报一次)
+150
View File
@@ -0,0 +1,150 @@
package service
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"time"
)
func TestBuildReportURLCandidatesSecureFirst(t *testing.T) {
upload, config := buildReportURLCandidates("panel.example.com:443", "abc")
if len(upload) != 2 {
t.Fatalf("expected 2 upload candidates, got %d", len(upload))
}
if len(config) != 2 {
t.Fatalf("expected 2 config candidates, got %d", len(config))
}
if upload[0] != "https://panel.example.com:443/flow/upload?secret=abc" {
t.Fatalf("unexpected upload[0]: %s", upload[0])
}
if upload[1] != "http://panel.example.com:443/flow/upload?secret=abc" {
t.Fatalf("unexpected upload[1]: %s", upload[1])
}
if config[0] != "https://panel.example.com:443/flow/config?secret=abc" {
t.Fatalf("unexpected config[0]: %s", config[0])
}
if config[1] != "http://panel.example.com:443/flow/config?secret=abc" {
t.Fatalf("unexpected config[1]: %s", config[1])
}
}
func TestBuildReportURLCandidatesNormalizeSchemeAddr(t *testing.T) {
upload, config := buildReportURLCandidates("https://panel.example.com:8443/path", "abc")
if upload[0] != "https://panel.example.com:8443/flow/upload?secret=abc" {
t.Fatalf("unexpected upload[0]: %s", upload[0])
}
if upload[1] != "http://panel.example.com:8443/flow/upload?secret=abc" {
t.Fatalf("unexpected upload[1]: %s", upload[1])
}
if config[0] != "https://panel.example.com:8443/flow/config?secret=abc" {
t.Fatalf("unexpected config[0]: %s", config[0])
}
if config[1] != "http://panel.example.com:8443/flow/config?secret=abc" {
t.Fatalf("unexpected config[1]: %s", config[1])
}
}
func TestPostJSONWithFallbackUsesHTTPAfterHTTPSFailure(t *testing.T) {
orig := reportDo
defer func() { reportDo = orig }()
var calls []string
reportDo = func(_ context.Context, req *http.Request, _ time.Duration) (*http.Response, error) {
calls = append(calls, req.URL.String())
if strings.HasPrefix(req.URL.String(), "https://") {
return nil, errors.New("tls handshake failed")
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("ok")),
}, nil
}
ok, err := postJSONWithFallback(
context.Background(),
[]string{
"https://panel.example.com:443/flow/upload?secret=abc",
"http://panel.example.com:443/flow/upload?secret=abc",
},
[]byte(`[]`),
"GOST-Traffic-Reporter/1.0",
5*time.Second,
nil,
)
if !ok || err != nil {
t.Fatalf("expected fallback success, ok=%v err=%v", ok, err)
}
if len(calls) != 2 {
t.Fatalf("expected 2 calls, got %d", len(calls))
}
if !strings.HasPrefix(calls[0], "https://") || !strings.HasPrefix(calls[1], "http://") {
t.Fatalf("unexpected call order: %#v", calls)
}
}
func TestPostJSONWithFallbackRemembersDetectedURL(t *testing.T) {
orig := reportDo
defer func() { reportDo = orig }()
targets := []string{
"https://panel.example.com:443/flow/upload?secret=abc",
"http://panel.example.com:443/flow/upload?secret=abc",
}
var preferred string
var calls []string
reportDo = func(_ context.Context, req *http.Request, _ time.Duration) (*http.Response, error) {
calls = append(calls, req.URL.String())
if strings.HasPrefix(req.URL.String(), "https://") {
return nil, errors.New("tls handshake failed")
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("ok")),
}, nil
}
ok, err := postJSONWithFallback(
context.Background(),
targets,
[]byte(`[]`),
"GOST-Traffic-Reporter/1.0",
5*time.Second,
&preferred,
)
if !ok || err != nil {
t.Fatalf("expected first call success, ok=%v err=%v", ok, err)
}
if preferred != targets[1] {
t.Fatalf("expected preferred url to be remembered as %s, got %s", targets[1], preferred)
}
if len(calls) != 2 {
t.Fatalf("expected 2 calls on first attempt, got %d", len(calls))
}
calls = nil
ok, err = postJSONWithFallback(
context.Background(),
targets,
[]byte(`[]`),
"GOST-Traffic-Reporter/1.0",
5*time.Second,
&preferred,
)
if !ok || err != nil {
t.Fatalf("expected second call success, ok=%v err=%v", ok, err)
}
if len(calls) != 1 {
t.Fatalf("expected second call to use remembered url once, got %d calls", len(calls))
}
if !strings.HasPrefix(calls[0], "http://") {
t.Fatalf("expected remembered http url first, got %s", calls[0])
}
}
+4 -1
View File
@@ -1,6 +1,9 @@
# GOST SOCKET KNOWLEDGE BASE
**Generated:** Sun Feb 15 2026
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
WebSocket reporter and socket utilities for panel integration.
+637 -126
View File
@@ -9,6 +9,7 @@ import (
"encoding/json"
"fmt"
"io"
"math/rand"
"net"
"net/http"
"net/url"
@@ -17,7 +18,7 @@ import (
"runtime"
"strconv"
"strings"
"sync" // 新增:用于管理连接状态的互斥锁
"sync"
"time"
"github.com/go-gost/x/config"
@@ -25,34 +26,67 @@ import (
"github.com/go-gost/x/service"
"github.com/gorilla/websocket"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/host"
"github.com/shirou/gopsutil/v3/load"
"github.com/shirou/gopsutil/v3/mem"
psnet "github.com/shirou/gopsutil/v3/net"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
)
// SystemInfo 系统信息结构体
type SystemInfo struct {
Uptime uint64 `json:"uptime"` // 开机时间 (秒)
BytesReceived uint64 `json:"bytes_received"` // 接收字节数
BytesTransmitted uint64 `json:"bytes_transmitted"` // 发送字节数
CPUUsage float64 `json:"cpu_usage"` // CPU使用率(百分比)
MemoryUsage float64 `json:"memory_usage"` // 内存使用率(百分比)
Uptime uint64 `json:"uptime"`
BytesReceived uint64 `json:"bytes_received"`
BytesTransmitted uint64 `json:"bytes_transmitted"`
CPUUsage float64 `json:"cpu_usage"`
MemoryUsage float64 `json:"memory_usage"`
DiskUsage float64 `json:"disk_usage"`
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
TCPConns int64 `json:"tcp_conns"`
UDPConns int64 `json:"udp_conns"`
NetInSpeed int64 `json:"net_in_speed"`
NetOutSpeed int64 `json:"net_out_speed"`
}
// NetworkStats 网络统计信息
type NetworkStats struct {
BytesReceived uint64 `json:"bytes_received"` // 接收字节数
BytesTransmitted uint64 `json:"bytes_transmitted"` // 发送字节数
BytesReceived uint64 `json:"bytes_received"`
BytesTransmitted uint64 `json:"bytes_transmitted"`
BytesRecvDelta uint64 `json:"bytes_recv_delta"`
BytesSentDelta uint64 `json:"bytes_sent_delta"`
}
// CPUInfo CPU信息
type CPUInfo struct {
Usage float64 `json:"usage"` // CPU使用率(百分比)
Usage float64 `json:"usage"`
}
// MemoryInfo 内存信息
type MemoryInfo struct {
Usage float64 `json:"usage"` // 内存使用率(百分比)
Usage float64 `json:"usage"`
}
// DiskInfo 磁盘信息
type DiskInfo struct {
Usage float64 `json:"usage"`
}
// LoadInfo 负载信息
type LoadInfo struct {
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
}
// ConnectionInfo 连接信息
type ConnectionInfo struct {
TCPConns int64 `json:"tcp_conns"`
UDPConns int64 `json:"udp_conns"`
}
// CommandMessage 命令消息结构体
@@ -91,26 +125,53 @@ type TcpPingResponse struct {
RequestId string `json:"requestId,omitempty"`
}
// ServiceMonitorCheckRequest service monitor check request.
type ServiceMonitorCheckRequest struct {
MonitorID int64 `json:"monitorId"`
Type string `json:"type"` // tcp|icmp
Target string `json:"target"`
TimeoutSec int `json:"timeoutSec"`
}
// ServiceMonitorCheckResult node-executed check output.
// CommandResponse.Success indicates command execution status.
// Actual check success is represented by this struct.
type ServiceMonitorCheckResult struct {
MonitorID int64 `json:"monitorId"`
Success bool `json:"success"`
LatencyMs float64 `json:"latencyMs"`
StatusCode int `json:"statusCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
}
const (
reporterReadWait = 60 * time.Second
reporterWriteWait = 5 * time.Second
wsPingInterval = 20 * time.Second // 独立 WebSocket ping 间隔
initialBackoff = 2 * time.Second // 重连初始退避
maxBackoff = 2 * time.Minute // 重连最大退避
)
type WebSocketReporter struct {
url string
addr string // 保存服务器地址
secret string // 保存密钥
version string // 保存版本号
conn *websocket.Conn
reconnectTime time.Duration
pingInterval time.Duration
configInterval time.Duration
ctx context.Context
cancel context.CancelFunc
connected bool
connecting bool // 新增:正在连接状态
connMutex sync.Mutex // 新增:连接状态锁
aesCrypto *crypto.AESCrypto // 新增:AES加密器
url string
addr string // 保存服务器地址
secret string // 保存密钥
version string // 保存版本号
preferredWSScheme string
conn *websocket.Conn
curBackoff time.Duration // 当前重连退避间隔
pingInterval time.Duration
configInterval time.Duration
ctx context.Context
cancel context.CancelFunc
connected bool
connecting bool // 正在连接状态
connMutex sync.Mutex // 连接状态锁
aesCrypto *crypto.AESCrypto // AES加密器
}
var wsDial = func(dialer *websocket.Dialer, rawURL string) (*websocket.Conn, *http.Response, error) {
return dialer.Dial(rawURL, nil)
}
// NewWebSocketReporter 创建一个新的WebSocket报告器
@@ -128,8 +189,8 @@ func NewWebSocketReporter(serverURL string, secret string) *WebSocketReporter {
return &WebSocketReporter{
url: serverURL,
reconnectTime: 5 * time.Second, // 重连间隔
pingInterval: 2 * time.Second, // 发送间隔改为2秒
curBackoff: initialBackoff, // 当前退避间隔
pingInterval: 5 * time.Second, // 指标上报间隔
configInterval: 10 * time.Minute, // 配置上报间隔
ctx: ctx,
cancel: cancel,
@@ -147,10 +208,17 @@ func (w *WebSocketReporter) Start() {
// Stop 停止WebSocket报告器
func (w *WebSocketReporter) Stop() {
w.cancel()
w.connMutex.Lock()
if w.conn != nil {
w.conn.Close()
}
w.connMutex.Unlock()
}
// backoffWithJitter 返回带随机抖动的退避时间(±25%)
func backoffWithJitter(base time.Duration) time.Duration {
jitter := time.Duration(float64(base) * (0.75 + rand.Float64()*0.5))
return jitter
}
// run 主运行循环
@@ -167,23 +235,32 @@ func (w *WebSocketReporter) run() {
if needConnect {
if err := w.connect(); err != nil {
fmt.Printf("❌ WebSocket连接失败: %v,%v后重试\n", err, w.reconnectTime)
wait := backoffWithJitter(w.curBackoff)
fmt.Printf("❌ WebSocket连接失败: %v,%v后重试\n", err, wait)
// 指数退避:翻倍当前退避间隔,上限 maxBackoff
w.curBackoff *= 2
if w.curBackoff > maxBackoff {
w.curBackoff = maxBackoff
}
select {
case <-time.After(w.reconnectTime):
case <-time.After(wait):
continue
case <-w.ctx.Done():
return
}
}
// 连接成功:重置退避
w.curBackoff = initialBackoff
}
// 连接成功,开始发送消息
if w.connected {
w.handleConnection()
} else {
wait := backoffWithJitter(w.curBackoff)
// 如果连接失败,等待重试
select {
case <-time.After(w.reconnectTime):
case <-time.After(wait):
continue
case <-w.ctx.Done():
return
@@ -223,21 +300,14 @@ func (w *WebSocketReporter) connect() error {
json.Unmarshal(b, &cfg)
}
// 使用最新的配置重新构建 URL
currentURL := "ws://" + w.addr + "/system-info?type=1&secret=" + w.secret + "&version=" + w.version +
"&http=" + strconv.Itoa(cfg.Http) + "&tls=" + strconv.Itoa(cfg.Tls) + "&socks=" + strconv.Itoa(cfg.Socks)
u, err := url.Parse(currentURL)
if err != nil {
return fmt.Errorf("解析URL失败: %v", err)
}
candidates := buildWebSocketCandidates(w.addr, w.secret, w.version, cfg.Http, cfg.Tls, cfg.Socks, w.preferredWSScheme)
dialer := websocket.DefaultDialer
dialer.HandshakeTimeout = 10 * time.Second
conn, _, err := dialer.Dial(u.String(), nil)
conn, usedURL, err := dialWebSocketWithFallback(dialer, candidates)
if err != nil {
return fmt.Errorf("连接WebSocket失败: %v", err)
return err
}
// 如果在连接过程中已经有连接了,关闭新连接
@@ -248,6 +318,9 @@ func (w *WebSocketReporter) connect() error {
w.conn = conn
w.connected = true
if scheme := detectWebSocketScheme(usedURL); scheme != "" {
w.preferredWSScheme = scheme
}
_ = conn.SetReadDeadline(time.Now().Add(reporterReadWait))
conn.SetPingHandler(func(appData string) error {
_ = conn.SetReadDeadline(time.Now().Add(reporterReadWait))
@@ -265,10 +338,145 @@ func (w *WebSocketReporter) connect() error {
return nil
})
fmt.Printf("✅ WebSocket连接建立成功 (http=%d, tls=%d, socks=%d)\n", cfg.Http, cfg.Tls, cfg.Socks)
fmt.Printf("✅ WebSocket连接建立成功 (%s, http=%d, tls=%d, socks=%d)\n", sanitizeWebSocketURL(usedURL), cfg.Http, cfg.Tls, cfg.Socks)
return nil
}
func buildWebSocketCandidates(addr string, secret string, version string, http int, tls int, socks int, preferredScheme string) []string {
normalizedAddr, explicitScheme := normalizeReporterAddress(addr)
if normalizedAddr == "" {
normalizedAddr = strings.TrimSpace(addr)
}
query := "/system-info?type=1&secret=" + secret + "&version=" + version +
"&http=" + strconv.Itoa(http) + "&tls=" + strconv.Itoa(tls) + "&socks=" + strconv.Itoa(socks)
schemes := []string{"wss", "ws"}
if mappedScheme := mapToWebSocketScheme(explicitScheme); mappedScheme != "" {
if mappedScheme == "ws" {
schemes = []string{"ws", "wss"}
}
} else if preferredScheme == "ws" {
schemes = []string{"ws", "wss"}
}
return []string{
schemes[0] + "://" + normalizedAddr + query,
schemes[1] + "://" + normalizedAddr + query,
}
}
func normalizeReporterAddress(addr string) (string, string) {
raw := strings.TrimSpace(addr)
if raw == "" {
return "", ""
}
scheme := ""
if idx := strings.Index(raw, "://"); idx > 0 {
scheme = strings.ToLower(strings.TrimSpace(raw[:idx]))
if parsed, err := url.Parse(raw); err == nil {
if host := strings.TrimSpace(parsed.Host); host != "" {
return host, scheme
}
}
raw = raw[idx+3:]
}
if idx := strings.IndexAny(raw, "/?#"); idx >= 0 {
raw = raw[:idx]
}
return strings.TrimSpace(raw), scheme
}
func mapToWebSocketScheme(scheme string) string {
switch strings.ToLower(strings.TrimSpace(scheme)) {
case "wss", "https":
return "wss"
case "ws", "http":
return "ws"
default:
return ""
}
}
func detectWebSocketScheme(rawURL string) string {
if strings.HasPrefix(rawURL, "wss://") {
return "wss"
}
if strings.HasPrefix(rawURL, "ws://") {
return "ws"
}
return ""
}
func dialWebSocketWithFallback(dialer *websocket.Dialer, candidates []string) (*websocket.Conn, string, error) {
if len(candidates) == 0 {
return nil, "", fmt.Errorf("WebSocket候选地址为空")
}
var errs []string
for i, targetURL := range candidates {
conn, resp, err := wsDial(dialer, targetURL)
if err == nil {
if i > 0 {
fmt.Printf("↪️ WebSocket已自动回退成功: %s\n", sanitizeWebSocketURL(targetURL))
}
return conn, targetURL, nil
}
errMsg := formatWebSocketDialError(err, resp)
errs = append(errs, fmt.Sprintf("%s => %s", sanitizeWebSocketURL(targetURL), errMsg))
if i < len(candidates)-1 {
fmt.Printf(
"⚠️ WebSocket连接失败,准备从 %s 回退到 %s: %s\n",
strings.ToUpper(detectWebSocketScheme(targetURL)),
strings.ToUpper(detectWebSocketScheme(candidates[i+1])),
errMsg,
)
}
}
return nil, "", fmt.Errorf("连接WebSocket失败(已尝试%d种协议): %s", len(candidates), strings.Join(errs, " | "))
}
func sanitizeWebSocketURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
q := u.Query()
if q.Get("secret") != "" {
q.Set("secret", "***")
u.RawQuery = q.Encode()
}
return u.String()
}
func formatWebSocketDialError(err error, resp *http.Response) string {
if err == nil {
return ""
}
if resp == nil {
return err.Error()
}
msg := fmt.Sprintf("%s (HTTP %s)", err, resp.Status)
if resp.Body == nil {
return msg
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256))
if readErr != nil {
return msg
}
bodyText := strings.TrimSpace(string(body))
if bodyText == "" {
return msg
}
return fmt.Sprintf("%s, body=%q", msg, bodyText)
}
// handleConnection 处理WebSocket连接
func (w *WebSocketReporter) handleConnection() {
defer func() {
@@ -285,15 +493,34 @@ func (w *WebSocketReporter) handleConnection() {
// 启动消息接收goroutine
go w.receiveMessages()
// 主发送循环
ticker := time.NewTicker(w.pingInterval)
defer ticker.Stop()
// 指标上报 ticker
metricTicker := time.NewTicker(w.pingInterval)
defer metricTicker.Stop()
// 独立 WebSocket keepalive ping ticker
pingTicker := time.NewTicker(wsPingInterval)
defer pingTicker.Stop()
for {
select {
case <-w.ctx.Done():
return
case <-ticker.C:
case <-pingTicker.C:
// 发送 WebSocket ping 保活,独立于指标上报
w.connMutex.Lock()
conn := w.conn
isConnected := w.connected
w.connMutex.Unlock()
if !isConnected || conn == nil {
return
}
if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(reporterWriteWait)); err != nil {
fmt.Printf("❌ 发送WebSocket ping失败: %v,准备重连\n", err)
return
}
case <-metricTicker.C:
// 检查连接状态
w.connMutex.Lock()
isConnected := w.connected
@@ -313,11 +540,35 @@ func (w *WebSocketReporter) handleConnection() {
}
}
var lastNetBytesReceived uint64
var lastNetBytesTransmitted uint64
var lastNetTime int64
var connInfoCached ConnectionInfo
var connInfoCachedAt int64
var connInfoCachedMu sync.Mutex
// collectSystemInfo 收集系统信息
func (w *WebSocketReporter) collectSystemInfo() SystemInfo {
networkStats := getNetworkStats()
cpuInfo := getCPUInfo()
memoryInfo := getMemoryInfo()
diskInfo := getDiskInfo()
loadInfo := getLoadInfo()
connInfo := getConnectionInfo()
now := time.Now().UnixMilli()
var netInSpeed, netOutSpeed int64
if lastNetTime > 0 {
deltaMs := now - lastNetTime
if deltaMs > 0 {
netInSpeed = int64(float64(networkStats.BytesRecvDelta) * 1000 / float64(deltaMs))
netOutSpeed = int64(float64(networkStats.BytesSentDelta) * 1000 / float64(deltaMs))
}
}
lastNetBytesReceived = networkStats.BytesReceived
lastNetBytesTransmitted = networkStats.BytesTransmitted
lastNetTime = now
return SystemInfo{
Uptime: getUptime(),
@@ -325,9 +576,48 @@ func (w *WebSocketReporter) collectSystemInfo() SystemInfo {
BytesTransmitted: networkStats.BytesTransmitted,
CPUUsage: cpuInfo.Usage,
MemoryUsage: memoryInfo.Usage,
DiskUsage: diskInfo.Usage,
Load1: loadInfo.Load1,
Load5: loadInfo.Load5,
Load15: loadInfo.Load15,
TCPConns: connInfo.TCPConns,
UDPConns: connInfo.UDPConns,
NetInSpeed: netInSpeed,
NetOutSpeed: netOutSpeed,
}
}
// encryptPayload 加密 JSON 数据,返回加密后的消息字节(若加密失败则回退到原始数据)
func (w *WebSocketReporter) encryptPayload(jsonData []byte) []byte {
if w.aesCrypto == nil {
return jsonData
}
encryptedData, err := w.aesCrypto.Encrypt(jsonData)
if err != nil {
fmt.Printf("⚠️ 加密失败,发送原始数据: %v\n", err)
return jsonData
}
encryptedMessage := map[string]interface{}{
"encrypted": true,
"data": encryptedData,
"timestamp": time.Now().Unix(),
}
messageData, err := json.Marshal(encryptedMessage)
if err != nil {
fmt.Printf("⚠️ 序列化加密消息失败,发送原始数据: %v\n", err)
return jsonData
}
return messageData
}
// metricEnvelope wraps SystemInfo with a type field for fast identification on the panel side.
type metricEnvelope struct {
Type string `json:"type"`
Data SystemInfo `json:"data"`
}
// sendSystemInfo 发送系统信息
func (w *WebSocketReporter) sendSystemInfo(sysInfo SystemInfo) error {
w.connMutex.Lock()
@@ -337,42 +627,19 @@ func (w *WebSocketReporter) sendSystemInfo(sysInfo SystemInfo) error {
return fmt.Errorf("连接未建立")
}
// 转换为JSON
jsonData, err := json.Marshal(sysInfo)
// 使用 type:"metric" 信封包装,Panel 可通过 type 字段直接识别指标消息
envelope := metricEnvelope{Type: "metric", Data: sysInfo}
jsonData, err := json.Marshal(envelope)
if err != nil {
return fmt.Errorf("序列化系统信息失败: %v", err)
}
var messageData []byte
messageData := w.encryptPayload(jsonData)
// 如果有加密器,则加密数据
if w.aesCrypto != nil {
encryptedData, err := w.aesCrypto.Encrypt(jsonData)
if err != nil {
fmt.Printf("⚠️ 加密失败,发送原始数据: %v\n", err)
messageData = jsonData
} else {
// 创建加密消息包装器
encryptedMessage := map[string]interface{}{
"encrypted": true,
"data": encryptedData,
"timestamp": time.Now().Unix(),
}
messageData, err = json.Marshal(encryptedMessage)
if err != nil {
fmt.Printf("⚠️ 序列化加密消息失败,发送原始数据: %v\n", err)
messageData = jsonData
}
}
} else {
messageData = jsonData
}
// 设置写入超时
w.conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
if err := w.conn.WriteMessage(websocket.TextMessage, messageData); err != nil {
w.connected = false // 标记连接已断开
w.connected = false
return fmt.Errorf("写入消息失败: %v", err)
}
@@ -381,23 +648,19 @@ func (w *WebSocketReporter) sendSystemInfo(sysInfo SystemInfo) error {
// receiveMessages 接收服务端发送的消息
func (w *WebSocketReporter) receiveMessages() {
// 获取连接引用一次即可,连接生命周期由 handleConnection 管理
w.connMutex.Lock()
conn := w.conn
w.connMutex.Unlock()
if conn == nil {
return
}
for {
select {
case <-w.ctx.Done():
return
default:
w.connMutex.Lock()
conn := w.conn
connected := w.connected
w.connMutex.Unlock()
if conn == nil || !connected {
return
}
// 设置读取超时
conn.SetReadDeadline(time.Now().Add(reporterReadWait))
messageType, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
@@ -485,12 +748,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
}
if cmdMsg.Type != "call" {
// 其他状态变更命令保持同步,确保顺序执行
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
go w.routeCommand(cmdMsg)
} else {
w.routeCommand(cmdMsg)
}
// 所有命令统一异步执行,避免阻塞消息接收循环
go w.routeCommand(cmdMsg)
}
} else {
// 处理普通消息
@@ -501,12 +760,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
return
}
if cmdMsg.Type != "call" {
// 其他状态变更命令保持同步,确保顺序执行
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
go w.routeCommand(cmdMsg)
} else {
w.routeCommand(cmdMsg)
}
// 所有命令统一异步执行,避免阻塞消息接收循环
go w.routeCommand(cmdMsg)
}
}
@@ -590,6 +845,13 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
response.Data = tcpPingResult
// needSaveConfig = false (默认值)
// Service monitor check (read-only)
case "ServiceMonitorCheck":
var checkResult ServiceMonitorCheckResult
checkResult, err = w.handleServiceMonitorCheck(cmd.Data)
response.Type = "ServiceMonitorCheckResponse"
response.Data = checkResult
// Protocol blocking switches
case "SetProtocol":
err = w.handleSetProtocol(cmd.Data)
@@ -1173,30 +1435,7 @@ func (w *WebSocketReporter) sendResponse(response CommandResponse) {
return
}
var messageData []byte
// 如果有加密器,则加密数据
if w.aesCrypto != nil {
encryptedData, err := w.aesCrypto.Encrypt(jsonData)
if err != nil {
fmt.Printf("⚠️ 加密响应失败,发送原始数据: %v\n", err)
messageData = jsonData
} else {
// 创建加密消息包装器
encryptedMessage := map[string]interface{}{
"encrypted": true,
"data": encryptedData,
"timestamp": time.Now().Unix(),
}
messageData, err = json.Marshal(encryptedMessage)
if err != nil {
fmt.Printf("⚠️ 序列化加密响应失败,发送原始数据: %v\n", err)
messageData = jsonData
}
}
} else {
messageData = jsonData
}
messageData := w.encryptPayload(jsonData)
// 检查消息大小,如果超过10MB则记录警告
if len(messageData) > 10*1024*1024 {
@@ -1245,17 +1484,21 @@ func getNetworkStats() NetworkStats {
return stats
}
// 汇总所有非回环接口的流量
for _, io := range ioCounters {
// 跳过回环接口
if io.Name == "lo" || strings.HasPrefix(io.Name, "lo") {
continue
}
stats.BytesReceived += io.BytesRecv
stats.BytesTransmitted += io.BytesSent
}
if lastNetBytesReceived > 0 && stats.BytesReceived >= lastNetBytesReceived {
stats.BytesRecvDelta = stats.BytesReceived - lastNetBytesReceived
}
if lastNetBytesTransmitted > 0 && stats.BytesTransmitted >= lastNetBytesTransmitted {
stats.BytesSentDelta = stats.BytesTransmitted - lastNetBytesTransmitted
}
return stats
}
@@ -1263,8 +1506,8 @@ func getNetworkStats() NetworkStats {
func getCPUInfo() CPUInfo {
var cpuInfo CPUInfo
// 获取CPU使用率
percentages, err := cpu.Percent(time.Second, false)
// 获取CPU使用率 (non-blocking)
percentages, err := cpu.Percent(0, false)
if err == nil && len(percentages) > 0 {
cpuInfo.Usage = percentages[0]
}
@@ -1286,11 +1529,75 @@ func getMemoryInfo() MemoryInfo {
return memInfo
}
// getDiskInfo 获取磁盘信息
func getDiskInfo() DiskInfo {
var diskInfo DiskInfo
usage, err := disk.Usage("/")
if err != nil {
return diskInfo
}
diskInfo.Usage = usage.UsedPercent
return diskInfo
}
// getLoadInfo 获取负载信息
func getLoadInfo() LoadInfo {
var loadInfo LoadInfo
avg, err := load.Avg()
if err != nil {
return loadInfo
}
loadInfo.Load1 = avg.Load1
loadInfo.Load5 = avg.Load5
loadInfo.Load15 = avg.Load15
return loadInfo
}
// getConnectionInfo 获取连接信息
func getConnectionInfo() ConnectionInfo {
now := time.Now().UnixMilli()
const refreshEveryMs = int64((15 * time.Second) / time.Millisecond)
connInfoCachedMu.Lock()
if connInfoCachedAt > 0 && now-connInfoCachedAt < refreshEveryMs {
v := connInfoCached
connInfoCachedMu.Unlock()
return v
}
connInfoCachedMu.Unlock()
var connInfo ConnectionInfo
connStats, err := psnet.Connections("tcp")
if err == nil {
connInfo.TCPConns = int64(len(connStats))
}
udpStats, err := psnet.Connections("udp")
if err == nil {
connInfo.UDPConns = int64(len(udpStats))
}
connInfoCachedMu.Lock()
connInfoCached = connInfo
connInfoCachedAt = now
connInfoCachedMu.Unlock()
return connInfo
}
// StartWebSocketReporterWithConfig 使用配置字段启动WebSocket报告器
func StartWebSocketReporterWithConfig(addr string, secret string, http int, tls int, socks int, version string) *WebSocketReporter {
// 构建初始 WebSocket URL
fullURL := "ws://" + addr + "/system-info?type=1&secret=" + secret + "&version=" + version + "&http=" + strconv.Itoa(http) + "&tls=" + strconv.Itoa(tls) + "&socks=" + strconv.Itoa(socks)
candidates := buildWebSocketCandidates(addr, secret, version, http, tls, socks, "")
fullURL := candidates[0]
fmt.Printf("🔗 WebSocket连接URL: %s\n", fullURL)
@@ -1366,6 +1673,210 @@ func (w *WebSocketReporter) handleTcpPing(data interface{}) (TcpPingResponse, er
return response, nil
}
// handleServiceMonitorCheck executes a service monitor check on this node.
// It always returns a result (command execution is considered successful even if the check fails).
func (w *WebSocketReporter) handleServiceMonitorCheck(data interface{}) (ServiceMonitorCheckResult, error) {
jsonData, err := json.Marshal(data)
if err != nil {
return ServiceMonitorCheckResult{}, fmt.Errorf("序列化检查数据失败: %v", err)
}
var req ServiceMonitorCheckRequest
if err := json.Unmarshal(jsonData, &req); err != nil {
return ServiceMonitorCheckResult{}, fmt.Errorf("解析检查请求失败: %v", err)
}
checkType := strings.ToLower(strings.TrimSpace(req.Type))
target := strings.TrimSpace(req.Target)
res := ServiceMonitorCheckResult{MonitorID: req.MonitorID}
if checkType != "tcp" && checkType != "icmp" {
res.Success = false
res.ErrorMessage = "不支持的检查类型"
return res, nil
}
if target == "" {
res.Success = false
res.ErrorMessage = "检查目标为空"
return res, nil
}
timeoutSec := req.TimeoutSec
if timeoutSec <= 0 {
timeoutSec = 5
}
timeout := time.Duration(timeoutSec) * time.Second
start := time.Now()
switch checkType {
case "tcp":
// Validate and normalize host:port.
_, _, splitErr := net.SplitHostPort(target)
if splitErr != nil {
res.Success = false
res.ErrorMessage = "无效的TCP目标"
res.LatencyMs = float64(time.Since(start).Milliseconds())
return res, nil
}
conn, dialErr := net.DialTimeout("tcp", target, timeout)
res.LatencyMs = float64(time.Since(start).Milliseconds())
if dialErr != nil {
res.Success = false
res.ErrorMessage = dialErr.Error()
return res, nil
}
_ = conn.Close()
res.Success = true
return res, nil
case "icmp":
rtt, pingErr := icmpPing(target, timeout)
res.LatencyMs = float64(rtt.Milliseconds())
if pingErr != nil {
res.Success = false
res.ErrorMessage = pingErr.Error()
return res, nil
}
res.Success = true
return res, nil
}
res.Success = false
res.ErrorMessage = "未知错误"
res.LatencyMs = float64(time.Since(start).Milliseconds())
return res, nil
}
func icmpPing(target string, timeout time.Duration) (time.Duration, error) {
start := time.Now()
target = strings.TrimSpace(target)
if target == "" {
return time.Since(start), fmt.Errorf("无效的ICMP目标")
}
// Avoid accepting URL-like targets.
if strings.Contains(target, "://") {
return time.Since(start), fmt.Errorf("无效的ICMP目标")
}
if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") {
target = strings.TrimSuffix(strings.TrimPrefix(target, "["), "]")
}
ipAddr, err := net.ResolveIPAddr("ip", target)
if err != nil || ipAddr == nil || ipAddr.IP == nil {
if err == nil {
err = fmt.Errorf("unknown address")
}
return time.Since(start), fmt.Errorf("解析目标失败: %v", err)
}
isV4 := ipAddr.IP.To4() != nil
listenAddr := "0.0.0.0"
proto := 1
var echoType icmp.Type = ipv4.ICMPTypeEcho
var echoReplyType icmp.Type = ipv4.ICMPTypeEchoReply
networks := []string{"udp4", "ip4:icmp"}
if !isV4 {
listenAddr = "::"
proto = 58
echoType = ipv6.ICMPTypeEchoRequest
echoReplyType = ipv6.ICMPTypeEchoReply
networks = []string{"udp6", "ip6:ipv6-icmp"}
}
var conn *icmp.PacketConn
selectedNetwork := ""
var lastErr error
for _, nw := range networks {
c, err := icmp.ListenPacket(nw, listenAddr)
if err == nil {
conn = c
selectedNetwork = nw
break
}
lastErr = err
}
if conn == nil {
if lastErr != nil {
return time.Since(start), fmt.Errorf("创建ICMP连接失败: %v", lastErr)
}
return time.Since(start), fmt.Errorf("创建ICMP连接失败")
}
defer conn.Close()
id := os.Getpid() & 0xffff
seq := 1
wm := icmp.Message{
Type: echoType,
Code: 0,
Body: &icmp.Echo{
ID: id,
Seq: seq,
Data: []byte("FLVX-PING"),
},
}
wb, err := wm.Marshal(nil)
if err != nil {
return time.Since(start), err
}
_ = conn.SetDeadline(time.Now().Add(timeout))
var dst net.Addr
if strings.HasPrefix(selectedNetwork, "udp") {
dst = &net.UDPAddr{IP: ipAddr.IP, Zone: ipAddr.Zone}
} else {
dst = &net.IPAddr{IP: ipAddr.IP, Zone: ipAddr.Zone}
}
if _, err := conn.WriteTo(wb, dst); err != nil {
return time.Since(start), err
}
addrIP := func(a net.Addr) net.IP {
switch v := a.(type) {
case *net.IPAddr:
return v.IP
case *net.UDPAddr:
return v.IP
default:
return nil
}
}
rb := make([]byte, 1500)
for {
n, peer, err := conn.ReadFrom(rb)
if err != nil {
return time.Since(start), err
}
if p := addrIP(peer); p != nil && !p.Equal(ipAddr.IP) {
continue
}
rm, err := icmp.ParseMessage(proto, rb[:n])
if err != nil {
continue
}
if rm.Type != echoReplyType {
continue
}
echo, ok := rm.Body.(*icmp.Echo)
if !ok {
continue
}
if echo.Seq != seq {
continue
}
// For non-privileged endpoints, the kernel may choose the ID.
if !strings.HasPrefix(selectedNetwork, "udp") && echo.ID != id {
continue
}
return time.Since(start), nil
}
}
// tcpPingHost 执行TCP连接测试,返回平均连接时间和失败率
func tcpPingHost(ip string, port int, count int, timeoutMs int) (float64, float64, error) {
var totalTime float64
+127
View File
@@ -0,0 +1,127 @@
package socket
import (
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/gorilla/websocket"
)
func TestBuildWebSocketCandidatesSecureFirst(t *testing.T) {
candidates := buildWebSocketCandidates("panel.example.com:443", "abc", "2.0.2", 1, 0, 1, "")
if len(candidates) != 2 {
t.Fatalf("expected 2 candidates, got %d", len(candidates))
}
if !strings.HasPrefix(candidates[0], "wss://") {
t.Fatalf("expected first candidate to start with wss://, got %s", candidates[0])
}
if !strings.HasPrefix(candidates[1], "ws://") {
t.Fatalf("expected second candidate to start with ws://, got %s", candidates[1])
}
}
func TestBuildWebSocketCandidatesUsesPreferredScheme(t *testing.T) {
candidates := buildWebSocketCandidates("panel.example.com:443", "abc", "2.0.2", 1, 0, 1, "ws")
if len(candidates) != 2 {
t.Fatalf("expected 2 candidates, got %d", len(candidates))
}
if !strings.HasPrefix(candidates[0], "ws://") {
t.Fatalf("expected preferred ws:// candidate first, got %s", candidates[0])
}
if !strings.HasPrefix(candidates[1], "wss://") {
t.Fatalf("expected fallback wss:// candidate second, got %s", candidates[1])
}
}
func TestBuildWebSocketCandidatesNormalizesSchemePrefixedAddr(t *testing.T) {
candidates := buildWebSocketCandidates("https://panel.example.com:443/path?q=1", "abc", "2.0.2", 0, 0, 0, "")
if len(candidates) != 2 {
t.Fatalf("expected 2 candidates, got %d", len(candidates))
}
if !strings.HasPrefix(candidates[0], "wss://panel.example.com:443/") {
t.Fatalf("expected normalized wss candidate, got %s", candidates[0])
}
if !strings.HasPrefix(candidates[1], "ws://panel.example.com:443/") {
t.Fatalf("expected normalized ws fallback candidate, got %s", candidates[1])
}
}
func TestDialWebSocketWithFallbackTriesWSAfterWSSFailure(t *testing.T) {
orig := wsDial
defer func() { wsDial = orig }()
var attempts []string
wsDial = func(_ *websocket.Dialer, rawURL string) (*websocket.Conn, *http.Response, error) {
attempts = append(attempts, rawURL)
if strings.HasPrefix(rawURL, "wss://") {
return nil, nil, errors.New("tls failed")
}
return &websocket.Conn{}, nil, nil
}
_, usedURL, err := dialWebSocketWithFallback(
&websocket.Dialer{},
[]string{
"wss://panel.example.com/system-info?type=1&secret=abc",
"ws://panel.example.com/system-info?type=1&secret=abc",
},
)
if err != nil {
t.Fatalf("expected fallback success, got err=%v", err)
}
if !strings.HasPrefix(usedURL, "ws://") {
t.Fatalf("expected fallback ws:// url, got %s", usedURL)
}
if len(attempts) != 2 {
t.Fatalf("expected 2 attempts, got %d", len(attempts))
}
if !strings.HasPrefix(attempts[0], "wss://") || !strings.HasPrefix(attempts[1], "ws://") {
t.Fatalf("unexpected attempt order: %#v", attempts)
}
}
func TestDetectWebSocketScheme(t *testing.T) {
if detectWebSocketScheme("wss://panel.example.com/system-info") != "wss" {
t.Fatalf("expected wss detection")
}
if detectWebSocketScheme("ws://panel.example.com/system-info") != "ws" {
t.Fatalf("expected ws detection")
}
if detectWebSocketScheme("http://panel.example.com/system-info") != "" {
t.Fatalf("expected empty detection for non-websocket scheme")
}
}
func TestSanitizeWebSocketURL(t *testing.T) {
raw := "wss://panel.example.com/system-info?type=1&secret=abc&version=2.0.2"
sanitized := sanitizeWebSocketURL(raw)
if strings.Contains(sanitized, "secret=abc") {
t.Fatalf("expected secret to be masked, got %s", sanitized)
}
if !strings.Contains(sanitized, "secret=%2A%2A%2A") {
t.Fatalf("expected masked secret in url, got %s", sanitized)
}
}
func TestFormatWebSocketDialErrorIncludesHTTPStatus(t *testing.T) {
err := errors.New("websocket: bad handshake")
resp := &http.Response{
Status: "403 Forbidden",
Body: io.NopCloser(strings.NewReader("forbidden")),
}
msg := formatWebSocketDialError(err, resp)
if !strings.Contains(msg, "HTTP 403 Forbidden") {
t.Fatalf("expected status in message, got %s", msg)
}
if !strings.Contains(msg, "forbidden") {
t.Fatalf("expected response body in message, got %s", msg)
}
}
@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-02-17
@@ -1,29 +0,0 @@
## 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.
@@ -1,28 +0,0 @@
## 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.
@@ -1,29 +0,0 @@
## 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.
@@ -1,22 +0,0 @@
## 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.
@@ -1,22 +0,0 @@
## 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.
@@ -1,22 +0,0 @@
## 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.
@@ -1,29 +0,0 @@
## 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.
@@ -1,30 +0,0 @@
## 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
@@ -1,20 +0,0 @@
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
@@ -1,52 +0,0 @@
# 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 .`
+15
View File
@@ -0,0 +1,15 @@
# 001 Fix 211 ConnectIP Full Chain
## Checklist
- [x] Analyze connectIp/inIp full chain across diagnosis/runtime/redeploy paths.
- [x] Fix diagnosis target resolution to honor selected `connectIp` for chain hops.
- [x] Fix tunnel state reconstruction to preserve `connectIp` on chain/out nodes.
- [x] Add contract regression tests for normal + stream diagnosis target IP behavior.
- [x] Add handler regression test for redeploy state reconstruction preserving `connectIp`.
- [x] Run backend handler and contract test suites.
## Notes
- Diagnosis now uses `chain_tunnel.connect_ip` for both stream start preview and runtime probing.
- Redeploy/batch-redeploy no longer drops `connectIp` during `reconstructTunnelState`.
@@ -0,0 +1,7 @@
- [x] Review current forward import flow and confirm ny import uses tunnel selection
- [x] Define ny compatibility update with tunnel-first behavior and auto port assignment fallback
- [x] Update ny parser to accept alias fields and optional `listen_port`
- [x] Keep import execution bound to selected tunnel and remove entry-selection dependency from ux copy
- [x] Update ny import help text to document optional port auto assignment
- [x] Add parser tests for alias-field compatibility and missing-port auto assignment
- [x] Validate updated import parser tests locally
@@ -0,0 +1,11 @@
# 003 Forward Edit Bind IP Preserve
## Checklist
- [x] Confirm forward edit flow and identify why untouched listen IP gets overwritten.
- [x] Update frontend forward edit submit logic to only send `inIp` when user explicitly changes listen IP.
- [x] On tunnel switch in edit form, reset listen IP to default unless user reselects.
- [x] Update backend forward update logic to preserve existing `forward_port.in_ip` when request omits `inIp` and tunnel is unchanged.
- [x] Keep backend behavior explicit: if `inIp` is sent (including empty), apply requested value; if tunnel changed with no `inIp`, use default bind.
- [x] Add regression tests for preserved bind-IP reconstruction helper behavior.
- [x] Run focused frontend/backend checks for touched files.
@@ -0,0 +1,11 @@
# 004 Forward Explicit Bind Self-Occupy Release
## Checklist
- [x] Confirm current forward edit/save failure path and lock strategy: explicit bind always stays explicit.
- [x] Add repository query to detect whether a node+port is occupied by other forwards (excluding current forward).
- [x] Enhance forward service sync to treat address-in-use as a recoverable case when only self occupies the port.
- [x] On self-occupy conflict, proactively delete current forward services on target node and retry AddService.
- [x] Keep hard failure when the same node+port is occupied by other forwards.
- [x] Add focused unit tests for new error classification helpers.
- [x] Run focused backend tests for touched handler/repo packages.
@@ -0,0 +1,11 @@
# 005 Forward Invalid BindIP Fallback Default
## Checklist
- [x] Split forward service bind failures into address-in-use and cannot-assign classes.
- [x] Keep self-occupy release/rebind only for address-in-use conflicts.
- [x] Add fallback path for cannot-assign: switch to default listener bind and retry service creation.
- [x] Persist fallback result to DB by clearing `forward_port.in_ip` for affected node+port.
- [x] Return non-blocking warning in forward update response when fallback occurs.
- [x] Show warning toast in forward edit UI while still treating operation as success.
- [x] Run focused backend tests for touched handler/repo packages.
@@ -0,0 +1,8 @@
# 006 Forward Save Missing Speed Limit Auto Clear
## Checklist
- [x] Locate forward create/update speed limit validation path that blocks save when speed rule is deleted.
- [x] Change forward save behavior to auto-clear missing `speedId` instead of returning "限速规则不存在".
- [x] Add contract test coverage for editing a forward after its referenced speed limit is deleted.
- [x] Run focused contract tests for forward save behavior.
@@ -0,0 +1,8 @@
# 007 User Tunnel Save Missing Speed Limit Auto Clear
## Checklist
- [x] Locate user tunnel speed limit validation paths for assign/update flows.
- [x] Change user tunnel save behavior to auto-clear missing `speedId` instead of failing.
- [x] Add contract test coverage for user tunnel save when referenced speed limit is deleted.
- [x] Run focused contract tests for user tunnel save behavior.
@@ -0,0 +1,8 @@
# 008 Frontend Missing Speed Limit Consistency
## Checklist
- [x] Review forward and user tunnel submit flows for missing speed limit behavior.
- [x] Make frontend normalize deleted `speedId` to `null` before submit in both pages.
- [x] Add consistent non-blocking warning toast when deleted speed rule is auto-cleared.
- [x] Verify touched frontend files pass lint checks.
@@ -0,0 +1,112 @@
# 009: 普通用户转发权限限制
## 背景
当前系统允许普通用户在创建和编辑转发时设置:
1. **限速规则** (`speedId`) - 应仅限管理员设置
2. **自定义入口端口** (`inPort`) - 应仅限管理员设置
普通用户应只能使用系统自动分配的端口和默认不限速设置。
## 实施范围
| 操作 | 普通用户 | 管理员 |
|------|----------|--------|
| 创建转发 - 设置限速 | 禁止 | 允许 |
| 创建转发 - 自定义端口 | 禁止 | 允许 |
| 编辑转发 - 修改限速 | 禁止 | 允许 |
| 编辑转发 - 修改端口 | 禁止 | 允许 |
## 修改位置
### 后端 (Go)
**文件**: `go-backend/internal/http/handler/mutations.go`
#### 1. `forwardCreate` handler (行 1147-1157)
在处理 speedId 和 inPort 之前添加权限检查:
```go
if roleID != 0 {
if _, ok := req["speedId"]; ok {
response.WriteJSON(w, response.Err(-1, "普通用户无法设置限速规则"))
return
}
if _, ok := req["inPort"]; ok {
response.WriteJSON(w, response.Err(-1, "普通用户无法设置自定义端口"))
return
}
}
```
#### 2. `forwardUpdate` handler (行 1264-1274)
在处理 speedId 和 inPort 之前添加权限检查:
```go
if actorRole != 0 {
if _, ok := req["speedId"]; ok {
response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则"))
return
}
if _, ok := req["inPort"]; ok {
response.WriteJSON(w, response.Err(-1, "普通用户无法修改自定义端口"))
return
}
}
```
### 前端 (React/TypeScript)
**文件**: `vite-frontend/src/pages/forward.tsx`
已有变量 `isAdmin` (行 610: `const isAdmin = tokenRoleId === 0;`)
#### 1. 隐藏限速规则选择器 (行 4252-4282)
用条件渲染包裹:
```tsx
{isAdmin && (
<Select
label="限速规则"
// ... 现有属性
>
{/* ... */}
</Select>
)}
```
#### 2. 隐藏入口端口输入框 (行 4311-4328)
用条件渲染包裹:
```tsx
{isAdmin && (
<Input
description="指定入口端口,留空则从节点可用端口中自动分配"
// ... 现有属性
/>
)}
```
## 任务清单
- [x] 后端: `forwardCreate` 添加权限检查
- [x] 后端: `forwardUpdate` 添加权限检查
- [x] 前端: 隐藏限速规则选择器 (仅管理员可见)
- [x] 前端: 隐藏入口端口输入框 (仅管理员可见)
- [x] 后端: 添加契约测试验证权限限制
- [x] 运行测试验证
## 测试验证
1. ✅ 契约测试已添加 `TestNonAdminCannotSetSpeedIdOrPort`
2. ✅ 所有测试用例通过:
- 普通用户创建转发时设置 speedId 被拒绝
- 普通用户创建转发时设置 inPort 被拒绝
- 普通用户创建转发时不设置 speedId/inPort 成功
- 普通用户更新转发时设置 speedId 被拒绝
- 普通用户更新转发时设置 inPort 被拒绝
- 普通用户更新转发时不设置 speedId/inPort 成功
@@ -0,0 +1,97 @@
# 010 多入口/多出口/多跳自定义 IP 限制与回归
## 目标
- 修复多入口转发列表只显示一个入口地址的问题。
- 在 UI 和后端同时限制以下场景的自定义 IP:
- 多入口转发禁止自定义监听 IP(`inIp`)。
- 多出口隧道禁止自定义连接 IP(`connectIp`)。
- 转发链单跳多节点禁止自定义连接 IP(`connectIp`)。
## 范围说明(基于当前实际)
- 不改“隧道页面入口 IP 文本域”的行为(按确认:该字段是展示用途,不作为本次约束点)。
- 本次仅覆盖已落地代码与可复现验证项。
## Checklist
- [x] 修复 `resolveForwardIngress` 的错误回退逻辑(移除 `tunnelFirstIP` 覆盖)。
- [x] 前端转发页:多入口隧道禁用“监听IP”选择并显示提示。
- [x] 前端隧道页:多出口禁用“连接IP”选择并显示提示。
- [x] 前端隧道页:转发链单跳多节点禁用“连接IP”选择并显示提示。
- [x] 后端隧道创建/编辑增加 `connectIp` 约束校验(多出口、多节点跳)。
- [x] 后端转发创建/编辑增加 `inIp` 约束校验(多入口)。
- [x] 后端构建验证通过。
- [x] 前端构建验证通过。
- [x] 相关定向合约测试通过(forward/tunnel)。
- [x] 全量 contract 测试执行并记录结果(存在与本次改动无关的既有失败)。
- [ ] 数据迁移脚本(可选):将历史多入口/多出口/多节点的自定义 IP 清理为默认值。
## 实施记录
### 代码变更
- `go-backend/internal/store/repo/repository.go`
- 在 `resolveForwardIngress` 中移除 `tunnelFirstIP` 逻辑。
- `in_ip` 为空时回退到每个入口节点自身 `server_ip`,避免多入口被合并为单入口展示。
- `vite-frontend/src/pages/forward.tsx`
- 新增 `isCurrentTunnelMultiEntrance` 判断。
- 多入口时禁用“监听IP”Select,并展示“多入口隧道使用节点默认IP”。
- `vite-frontend/src/pages/tunnel.tsx`
- 转发链区域新增 `isMultiNodeGroup`,单跳多节点时禁用连接 IP 选择。
- 出口区域新增 `isMultiExit`,多出口时禁用连接 IP 选择。
- `go-backend/internal/http/handler/mutations.go`
- `tunnelCreate` / `tunnelUpdate` 调用 `validateTunnelConnectIPConstraints(req)`。
- 新增 `validateTunnelConnectIPConstraints`:
- 多出口+自定义 `connectIp` 拒绝。
- 转发链单跳多节点+自定义 `connectIp` 拒绝。
- `forwardCreate` / `forwardUpdate`:多入口+自定义 `inIp` 拒绝。
## 验证记录
### 1) 后端构建
```bash
cd go-backend
go build ./internal/http/handler/...
```
结果:通过。
### 2) 前端构建
```bash
cd vite-frontend
npm run build
```
结果:通过。
### 3) 后端包测试
```bash
cd go-backend
go test ./internal/store/repo/...
go test ./internal/http/handler/...
```
结果:通过。
### 4) 定向合约测试(forward/tunnel)
```bash
cd go-backend
go test ./tests/contract/... -run "TestForward.*|TestTunnel.*"
```
结果:通过。
### 5) 全量合约测试(记录)
```bash
cd go-backend
go test ./tests/contract/...
```
结果:所有测试通过。
### 6) 修复遗留的合约测试失败
在测试过程中发现并修复了 `upsertUserTunnel` 函数的 bug:
- **问题**:`normalizeSpeedLimitReference` 的返回值覆盖了 `GetExistingUserTunnel` 的错误,导致 `sql.ErrNoRows` 判断失效。
- **修复**:将 `GetExistingUserTunnel` 的错误保存到 `lookupErr` 变量,避免被后续调用覆盖。
- **影响范围**:仅影响 `userTunnelBatchAssign` 路径,不影响其他功能。
- **验证**:两个失败的测试(`TestUserTunnelReassignmentKeepsStableID`、`TestBatchAssignInsertRollbackWhenLimiterDispatchFailsContract`)现在都通过。
## 完成状态
- 本计划按当前实际范围已完成。
- 所有合约测试通过(14/14)。
- 任务 10(数据迁移)已纳入计划,当前为可选项,默认不执行。
@@ -0,0 +1,28 @@
# 011 转发服务名升级兼容与节点滚动升级
## 目标
- 修复旧版本升级后编辑转发/隧道出现 `service not found`(service不存在)的问题。
- 在后端加入兼容自愈逻辑,允许旧命名与新命名共存过渡。
- 给出低风险节点升级顺序,避免一次性全量切换带来的中断。
## Checklist
- [x] 定位回归路径:服务名从 `forward_user_0` 迁移到真实 `user_tunnel_id` 后,与旧运行态不一致导致控制失败。
- [x] 在 `UpdateService` 的兼容路径加入旧服务清理后重建逻辑。
- [x] 在 `Pause/Resume` 控制路径加入首次 not found 后自愈重试逻辑。
- [x] 增加回归测试覆盖兼容行为。
- [x] 执行 `go-backend` 相关测试并记录结果。
- [x] 输出运维侧“后端先行 + agent 灰度升级 + 批量重部署”操作步骤。
## 变更说明(实施中)
- 后端控制面将在检测到升级期的服务名不一致时进行自动自愈,降低人工干预和手工重建成本。
## 测试记录
- 命令:`cd go-backend && go test ./internal/http/handler/...`
- 结果:通过。
## 运维升级顺序(推荐)
1. 先发布本次后端兼容补丁(无需等待所有 agent 同步升级)。
2. 按 10%-20% 灰度分批升级 agent(低风险节点 -> 非高峰节点 -> 全量)。
3. 每批升级后执行一次“转发批量重部署”,将运行态统一到新服务命名。
4. 观察日志中 `service .* not found` 是否清零,再推进下一批。
5. 全量稳定后保留兼容逻辑至少一个小版本周期,再评估收敛。
@@ -0,0 +1,158 @@
# Plan 012: 允许用户自定义转发入口端口(限制在节点端口范围内)
**Issue**: #268
**状态**: 已完成
## 背景
当前版本限制了普通用户自定义转发入口端口 (inPort) 的能力,导致:
- 用户迁移数据后无法保留原有端口配置
- 无法编辑转发配置
- 需要重建所有转发,操作繁琐
## 实现方案
允许用户和管理员自定义转发入口端口,但强制在节点端口设置的范围内。
### 默认行为
- 不填写端口 → 随机分配(在端口范围内)
- 填写端口 → 使用指定端口(需在范围内且不冲突)
---
## 任务清单
### 1. 后端修改
- [x] **1.1 移除非管理员 inPort 权限限制**
- 文件: `go-backend/internal/http/handler/mutations.go`
- 位置: `forwardCreate` 函数 (约 L1156-1167)
- 位置: `forwardUpdate` 函数 (约 L1279-1291)
- 操作: 删除 `roleID != 0` 时阻止 inPort 设置的逻辑
- 状态: 代码中已无 inPort 权限限制
- [x] **1.2 添加本地节点端口范围验证函数**
- 文件: `go-backend/internal/http/handler/mutations.go`
- 新增函数: `validateLocalNodePort(node *nodeRecord, port int) error`
- 逻辑: 使用 `parsePortRangeSpec` 解析端口范围,验证 port 是否在范围内
- 状态: 函数已存在于 L3517-3533
- [x] **1.3 修改 forwardCreate 端口验证**
- 文件: `go-backend/internal/http/handler/mutations.go`
- 位置: `forwardCreate` 中 entry nodes 遍历处 (约 L1188-1197)
- 操作:
- 对远程节点使用现有 `validateRemoteNodePort`
- 对本地节点使用新的 `validateLocalNodePort`
- 若用户指定的端口超出节点范围,返回错误提示
- 状态: 已实现
- [x] **1.4 修改 forwardUpdate 端口验证**
- 文件: `go-backend/internal/http/handler/mutations.go`
- 位置: `forwardUpdate` 中 entry nodes 遍历处 (约 L1326-1335)
- 操作: 同 1.3,添加本地节点端口范围验证
- 状态: 已实现
- [x] **1.5 `ListUserAccessibleTunnels` 添加端口范围信息**
- 文件: `go-backend/internal/store/repo/repository.go`
- 位置: L751-775
- 操作:
- 查询隧道关联的入口节点 (通过 `chain_tunnel` 表 `chain_type=1`)
- 获取入口节点的端口范围 (`node.port` 字段)
- 使用 `parsePortRangeSpec` 解析并计算 min/max
- 在返回的 map 中添加 `portRangeMin` 和 `portRangeMax` 字段
- 状态: 已实现
- [x] **1.6 `ListEnabledTunnelSummaries` 添加端口范围信息**
- 文件: `go-backend/internal/store/repo/repository.go`
- 位置: L777-796
- 操作: 同 1.5,为管理员视图也提供端口范围信息
- 状态: 已实现
### 2. 前端修改
- [x] **2.1 为所有用户显示 inPort 输入框**
- 文件: `vite-frontend/src/pages/forward.tsx`
- 位置: 约 L4350-4369
- 操作: 移除 `{isAdmin && (` 条件包装,改为所有用户可见
- 状态: 已实现
- [x] **2.2 提交时包含 inPort(非仅管理员)**
- 文件: `vite-frontend/src/pages/forward.tsx`
- 位置: `handleSave` 函数 (约 L1435, L1447)
- 操作: 移除 `...(isAdmin ? { inPort: form.inPort } : {})` 条件,直接包含 inPort
- 状态: 已实现
- [x] **2.3 更新 Tunnel 接口添加 portRangeMin/Max**
- 文件: `vite-frontend/src/pages/forward.tsx`
- 位置: L123-131
- 操作: 添加 `portRangeMin?: number; portRangeMax?: number;`
- 状态: 已实现
- [x] **2.4 inPort 输入框显示端口范围提示**
- 文件: `vite-frontend/src/pages/forward.tsx`
- 位置: L4350-4369
- 操作:
- 基于 `form.tunnelId` 获取当前隧道的端口范围
- 在 Input 的 `description` 中显示提示,如: `"指定入口端口,留空自动分配 (允许范围: 10000-20000)"`
- 状态: 已实现
- [x] **2.5 前端端口范围验证**
- 文件: `vite-frontend/src/pages/forward.tsx`
- 位置: 验证函数 (L1271-1279)
- 操作: 前端也做范围预检查,超出范围时显示错误
- 状态: 已实现并修复语法错误
### 3. 测试修改
- [x] **3.1 更新权限测试**
- 文件: `go-backend/tests/contract/forward_contract_test.go`
- 位置: L1001-1119
- 操作:
- 修改 "non-admin cannot set inPort" 测试为允许设置
- 新增 "non-admin inPort within range" 测试(通过)
- 新增 "non-admin inPort out of range" 测试(失败)
- 状态: 已更新
- [x] **3.2 新增端口范围验证测试**
- 文件: `go-backend/tests/contract/forward_contract_test.go`
- 操作:
- 测试本地节点端口范围验证
- 测试远程节点端口范围验证(已有 `validateRemoteNodePort` 相关测试可参考)
- 状态: 已添加
---
## 关键代码位置
| 功能 | 文件 | 行号 |
|------|------|------|
| 前端 inPort 输入框 | `vite-frontend/src/pages/forward.tsx` | L4350-4369 |
| 前端提交条件 | `vite-frontend/src/pages/forward.tsx` | L1435, L1447 |
| 后端创建权限检查 | `go-backend/internal/http/handler/mutations.go` | L1156-1167 |
| 后端更新权限检查 | `go-backend/internal/http/handler/mutations.go` | L1279-1291 |
| 远程节点端口验证 | `go-backend/internal/http/handler/federation.go` | L562-574 |
| 本地节点端口验证 | `go-backend/internal/http/handler/mutations.go` | L3517-3533 |
| 端口范围解析 | `go-backend/internal/store/repo/repository_mutations.go` | L1370-1412 |
| 用户隧道列表 | `go-backend/internal/store/repo/repository.go` | L751-775 |
| 管理员隧道列表 | `go-backend/internal/store/repo/repository.go` | L777-796 |
| 合约测试 | `go-backend/tests/contract/forward_contract_test.go` | L1001-1119 |
---
## 验收标准
1. ✅ 普通用户可以在创建转发时指定 inPort
2. ✅ 普通用户可以在编辑转发时修改 inPort
3. ✅ 指定的端口必须在节点端口范围内,否则返回错误
4. ✅ 留空 inPort 时行为不变(自动分配)
5. ✅ 前端显示端口范围提示
6. ✅ 所有合约测试通过
---
## 实施总结
该计划的大部分代码已在之前的开发中实现。本次实施主要完成了以下工作:
1. **修复前端验证代码语法错误** - `forward.tsx` 中 `validateForm` 函数的端口范围验证代码存在语法错误,已修复
2. **更新测试用例** - 将原本期望权限拒绝的测试改为端口范围验证测试,并修正了测试中使用的端口号

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