Compare commits

...

28 Commits

Author SHA1 Message Date
sagit 4bdfa50b0c docs: update AGENTS.md files with current project state (#213)
- Update root AGENTS.md to commit 21008cc / tag 2.1.5-rc15
- Add CI workflows info (ci-build.yml, docker-build.yml, deploy-docs.yml)
- Add Repository Layer and Contract Tests to WHERE TO LOOK
- Add websocket_reporter to CODE MAP
- Add Go version conventions (1.24/1.23/1.22)
- Add PostgreSQL migration support note
- Update go-backend AGENTS.md with PostgreSQL support and contract tests
- Update go-gost AGENTS.md with CI build conventions
- Update vite-frontend AGENTS.md with component counts
- Update go-gost/x AGENTS.md with file counts and registry reference
- Update handler AGENTS.md with LOC estimates
2026-02-26 09:52:11 +08:00
sagit 21008ccb43 fix: resolve federation forward card showing zero flow (#212)
## Summary

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-25 16:59:39 +08:00
sagit 7ba90e8696 fix(backend): resolve federation forward traffic stats and listener disappearance (#208)
* fix(backend): add repository methods for federation forward runtime management

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

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

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

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

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

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

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

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

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

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

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

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

Update assertion for entry share runtime binding behavior after fix

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

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

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-25 14:10:24 +08:00
sagit 0eed74fe10 fix: stabilize federation forward runtime cleanup and frontend version UX (#207)
## Summary
- fix federation forward runtime cleanup so valid forward listeners are
not treated as orphan during unbound binding windows
- add backend regression coverage for federation forward/runtime cleanup
and expiry handling paths
- wire frontend version update footer/flows and related UI updates
across admin pages

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

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

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

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

## Root Cause

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

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

## Test Plan

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

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

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

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

Fixes login issue on new panel setups with PostgreSQL.
2026-02-22 22:54:51 +08:00
sagit bb505d461d fix(frontend): enable modal scroll on panel sharing page (#193)
Add scrollBehavior="inside" to Create Share and Edit Share modals
to allow content scrolling on mobile devices when form fields
exceed viewport height.
2026-02-22 21:53:29 +08:00
45 changed files with 3710 additions and 565 deletions
+29 -11
View File
@@ -1,12 +1,12 @@
# PROJECT KNOWLEDGE BASE
**Generated:** Thu Feb 19 2026
**Commit:** 137c34e
**Generated:** Thu Feb 26 2026
**Commit:** 21008cc
**Branch:** main
**Tag:** 2.1.4-rc2
**Tag:** 2.1.5-rc15
## OVERVIEW
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite/PostgreSQL) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
## STRUCTURE
```
@@ -14,12 +14,14 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
├── go-gost/ # Go forwarding agent (forked gost + local x/)
│ └── x/ # Local fork of github.com/go-gost/x (replace => ./x)
├── go-backend/ # Go Admin API (GORM + SQLite/PostgreSQL, net/http)
│ └── tests/contract/ # Integration/contract tests
├── vite-frontend/ # React/Vite dashboard (shadcn bridge + Tailwind v4)
│ └── src/shadcn-bridge/heroui/ # HeroUI-compatible facade
├── docker-compose-v4.yml # Panel deploy (IPv4-only bridge)
├── docker-compose-v6.yml # Panel deploy (IPv6-enabled bridge)
├── panel_install.sh # Panel installer/upgrader (downloads compose)
├── install.sh # Node installer/upgrader (downloads gost binary)
└── .github/workflows/ # CI: build/push images + release artifacts
└── .github/workflows/ # CI: build/test + Docker push + release artifacts
```
## WHERE TO LOOK
@@ -29,12 +31,15 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
| **Deploy (IPv6)** | `docker-compose-v6.yml` | Same as v4 + IPv6-enabled bridge |
| **Panel install** | `panel_install.sh` | Picks v4/v6, generates `JWT_SECRET`, downloads compose |
| **Node install** | `install.sh` | Installs `/etc/flux_agent/flux_agent` + writes `config.json`/`gost.json` + systemd `flux_agent.service` |
| **Admin API** | `go-backend/` | Go Admin API (SQLite) |
| **Admin API** | `go-backend/` | Go Admin API (SQLite/PostgreSQL) |
| **Web UI** | `vite-frontend/` | React/Vite dashboard (shadcn bridge + Tailwind v4) |
| **UI Compatibility** | `vite-frontend/src/shadcn-bridge/heroui/` | HeroUI-compatible API wrappers backed by shadcn/radix |
| **Theme Tokens** | `vite-frontend/src/styles/tailwind-theme.pcss` | Tailwind v4 `@theme inline` semantic color mapping |
| **Go Agent** | `go-gost/` | Forwarding agent (forked gost + local x/) |
| **Go Core** | `go-gost/x/` | Handlers/listeners/dialers + management API |
| **Repository Layer** | `go-backend/internal/store/repo/` | GORM data access (repository.go 83k LOC) |
| **Contract Tests** | `go-backend/tests/contract/` | Integration tests for auth, federation, tunnels |
| **CI Workflows** | `.github/workflows/` | ci-build.yml, docker-build.yml, deploy-docs.yml |
## CODE MAP
| Symbol | Type | Location | Role |
@@ -43,7 +48,9 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
| `main` | Func | `go-backend/cmd/paneld/main.go` | Backend Entry |
| `App` | Component | `vite-frontend/src/App.tsx` | Frontend Entry |
| `main` | Func | `go-gost/main.go` | Agent Entry |
| `Repository` | Struct | `go-backend/internal/store/repo/repository.go` | Data Access Layer |
| `Handler` | Struct | `go-backend/internal/http/handler/handler.go` | HTTP Handlers |
| `websocket_reporter` | Func | `go-gost/x/socket/websocket_reporter.go` | Panel Telemetry |
## CONVENTIONS
- **Auth**: `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `go-backend/`.
@@ -52,6 +59,7 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
- **API Envelope**: All REST responses follow `{code, msg, data, ts}` structure (code 0 = success).
- **Frontend UI Layer**: Import UI primitives from `src/shadcn-bridge/heroui/*` (legacy-compatible facade), not direct `@heroui/*` packages.
- **Tailwind v4 Semantic Colors**: `src/styles/globals.css` must import `src/styles/tailwind-theme.pcss`; removing it breaks semantic classes like `bg-primary`, `text-foreground`, and `border-input`.
- **Go Versions**: `go-backend` uses Go 1.24, `go-gost` uses Go 1.23, `go-gost/x` uses Go 1.22.
## ANTI-PATTERNS (THIS PROJECT)
- **DO NOT EDIT** generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
@@ -60,6 +68,8 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
- **DO NOT** let backend handlers call `repo.DB()` directly — add a Repository method instead.
- **DO NOT ADD** frontend tests - project has no test infrastructure (Vitest/Jest not configured).
- **DO NOT REINTRODUCE** `@heroui/*` or `@nextui-org/*` dependencies; migration is now shadcn bridge-based.
- **DO NOT** use `type:jsonb` or `type:serial` in GORM tags (SQLite incompatible).
- **DO NOT** omit `TableName()` on new models — GORM pluralizes by default.
## COMMANDS
```bash
@@ -75,15 +85,21 @@ docker compose -f docker-compose-v6.yml up -d
(cd go-backend && make build)
(cd vite-frontend && npm run dev)
(cd go-gost && go run .)
# Testing
(cd go-backend && go test ./...)
(cd go-backend && go test ./tests/contract/...)
```
## UNIQUE STYLES
- **Flat Monorepo**: Language-prefixed dirs (`go-backend`, `go-gost`, `vite-frontend`) instead of `apps/`/`libs/`.
- **Asymmetric Go Layout**: `go-backend` follows `cmd/<app>/main.go` while `go-gost` uses `root/main.go`.
- **Frontend Hybrid Mode**: `App.tsx` detects "H5 mode" (mobile WebView) vs desktop, dictating layout strategy.
- **Experimental Bundler**: `vite-frontend` uses `rolldown-vite` (Rust-based) instead of standard Vite.
- **Non-minified Builds**: `vite.config.ts` sets `minify: false`, `treeshake: false` for debugging.
## NOTES
- LSP servers are not installed in this environment (gopls/jdtls/typescript-language-server); rely on grep-based navigation.
- LSP servers are not installed in this environment (gopls/typescript-language-server); rely on grep-based navigation.
- `vite-frontend/vite.config.ts` sets `minify: false` and disables treeshake; expect larger bundles.
- `vite-frontend` uses `rolldown-vite` (experimental Rust bundler) instead of standard Vite.
- Install scripts (`install.sh`, `panel_install.sh`) self-delete after execution - common pattern in one-liner installs.
@@ -91,7 +107,9 @@ docker compose -f docker-compose-v6.yml up -d
- CI dynamically injects `PINNED_VERSION` into install scripts and docker-compose files during releases.
- `panel_install.sh` auto-detects IPv6 and modifies `/etc/docker/daemon.json` to enable IPv6 bridge.
- Download proxy `https://gcode.hostcentral.cc/` used for GitHub downloads in China/restricted environments.
- Backend has contract tests in `go-backend/tests/contract/` - frontend has no test infrastructure (Vitest/Jest not configured).
- Backend has contract tests in `go-backend/tests/contract/` - frontend has no test infrastructure.
- `analysis/3x-ui/` contains a separate git repo for reference/comparison - not part of FLVX core.
- PR `#144` (shadcn migration) and PR `#142` (user-group binding) are merged into `main`; release tag `2.1.4-rc2` points to commit `137c34e`.
- Button visual parity relies on `vite-frontend/src/shadcn-bridge/heroui/button.tsx` color mapping + `vite-frontend/src/styles/tailwind-theme.pcss` token export.
- CI workflows: `ci-build.yml` (build check), `docker-build.yml` (multi-arch images + release), `deploy-docs.yml` (MkDocs).
- PostgreSQL migration supported via `panel_install.sh` menu option using pgloader.
- Repository layer is large: `repository.go` (83k LOC), `repository_mutations.go` (43k LOC).
- Button visual parity relies on `vite-frontend/src/shadcn-bridge/heroui/button.tsx` color mapping + `vite-frontend/src/styles/tailwind-theme.pcss` token export.
+12 -8
View File
@@ -2,7 +2,7 @@
## OVERVIEW
Go-based Admin API for FLVX. Replaced legacy Spring Boot backend.
**Stack:** Go 1.23, net/http (std lib), GORM + SQLite/PostgreSQL (glebarez/sqlite - CGO-free).
**Stack:** Go 1.24, net/http (std lib), GORM + SQLite/PostgreSQL (glebarez/sqlite - CGO-free).
## STRUCTURE
```
@@ -17,14 +17,15 @@ go-backend/
│ ├── store/
│ │ ├── model/model.go # GORM model structs (single source of truth)
│ │ └── repo/ # Data Access Layer (Repository pattern, GORM)
│ │ ├── repository.go # Core queries, Open/OpenPostgres, AutoMigrate
│ │ ├── repository_mutations.go # Mutation helpers (user/node/tunnel/forward CRUD)
│ │ ├── repository_federation.go# Federation-specific queries
│ │ ├── repository.go # Core queries, Open/OpenPostgres, AutoMigrate (83k LOC)
│ │ ├── repository_mutations.go # Mutation helpers (user/node/tunnel/forward CRUD, 43k LOC)
│ │ ├── repository_federation.go # Federation-specific queries
│ │ ├── repository_flow.go # Flow/forward status queries
│ │ └── repository_control.go # Control plane queries
│ │ ├── repository_control.go # Control plane queries
│ │ └── repository_groups.go # Group management queries
│ └── auth/ # Auth logic
├── tests/ # Integration/Contract tests
├── Dockerfile # Multi-stage build (alpine)
├── tests/contract/ # Integration/contract tests (14 tests)
├── Dockerfile # Multi-stage build (golang:1.24-bookworm → debian:bookworm-slim)
└── Makefile # Build commands
```
@@ -36,6 +37,7 @@ go-backend/
| **Repository** | `go-backend/internal/store/repo/` | GORM-based queries, all DB ops encapsulated |
| **Auth Middleware** | `go-backend/internal/http/middleware/jwt.go` | Extracts `Authorization` header |
| **WebSocket** | `go-backend/internal/ws/` | Real-time updates (traffic, status) |
| **Contract Tests** | `go-backend/tests/contract/` | Integration tests for auth, federation, tunnels |
## CONVENTIONS
- **GORM ORM**: Uses GORM with `glebarez/sqlite` (CGO-free) and `gorm.io/driver/postgres`.
@@ -47,6 +49,7 @@ go-backend/
- **API Envelope**: All responses use `response.R{code, msg, data, ts}` structure.
- **Config**: Loaded from environment variables (see `cmd/paneld/main.go`).
- **SQLite Constraints**: `MaxOpenConns(1)`, WAL mode, busy_timeout=5000.
- **PostgreSQL**: Supported via `DB_TYPE=postgres` and `DATABASE_URL` env vars.
## ANTI-PATTERNS
- **DO NOT** let handlers call `repo.DB()` directly — add a Repository method instead.
@@ -58,6 +61,7 @@ go-backend/
```bash
cd go-backend
go run ./cmd/paneld # Default: SERVER_ADDR=:6365
go test ./...
go test ./... # Unit tests
go test ./tests/contract/... # Contract tests
make build
```
+5 -4
View File
@@ -1,10 +1,10 @@
# BACKEND HTTP HANDLER KNOWLEDGE BASE
**Generated:** Sun Feb 15 2026
**Generated:** Thu Feb 26 2026
## OVERVIEW
HTTP request handlers for FLVX Admin API. Core business logic layer.
**Stack:** Go 1.23, net/http, GORM via Repository pattern.
**Stack:** Go 1.24, net/http, GORM via Repository pattern.
## STRUCTURE
```
@@ -14,7 +14,7 @@ handler/
├── federation.go # Federation/cluster sync API
├── flow_policy.go # Traffic policy API
├── jobs.go # Background job management (sync, cleanup)
├── mutations.go # CRUD for users, tunnels, forwards (largest: 100k+ LOC)
├── mutations.go # CRUD for users, tunnels, forwards (~3700 LOC)
└── upgrade.go # System upgrade API
```
@@ -26,10 +26,11 @@ handler/
| **Federation Sync** | `federation.go` | Panel-to-panel sync |
| **Traffic Policies** | `flow_policy.go` | Flow limiting, quota management |
| **Background Jobs** | `jobs.go` | Scheduled sync/cleanup tasks |
| **Node Control** | `control_plane.go` | Node add/delete/list operations |
## CONVENTIONS
- Inherits from parent: GORM via Repository pattern, JWT in Authorization header.
- Large files expected (`mutations.go` 3716 LOC - central mutation hub).
- Large files expected (`mutations.go` ~3700 LOC - central mutation hub).
- Uses `repo.Repository` for DB access via `h.repo.XXX()` methods.
- Handlers never call `repo.DB()` directly — all queries go through Repository methods.
- Domain-driven file split: one file per functional area (federation, jobs, etc.).
+274 -28
View File
@@ -477,9 +477,14 @@ func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Reque
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
forwardPortRows, err := h.repo.ListActiveForwardPortsForNode(nodeID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
usedSet := make(map[int]struct{})
bindings := make([]remoteUsageBindingItem, 0, len(bindingRows))
bindings := make([]remoteUsageBindingItem, 0, len(bindingRows)+len(forwardPortRows))
for _, b := range bindingRows {
bindings = append(bindings, remoteUsageBindingItem{
BindingID: b.ID,
@@ -496,6 +501,29 @@ func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Reque
usedSet[b.AllocatedPort] = struct{}{}
}
}
for _, fp := range forwardPortRows {
bindings = append(bindings, remoteUsageBindingItem{
BindingID: -fp.ForwardID,
TunnelID: fp.TunnelID,
TunnelName: fp.TunnelName,
ChainType: 1,
HopInx: 0,
AllocatedPort: fp.Port,
ResourceKey: fmt.Sprintf("forward:%d", fp.ForwardID),
RemoteBindingID: "",
UpdatedTime: fp.UpdatedTime,
})
if fp.Port > 0 {
usedSet[fp.Port] = struct{}{}
}
}
sort.Slice(bindings, func(i, j int) bool {
if bindings[i].AllocatedPort == bindings[j].AllocatedPort {
return bindings[i].BindingID < bindings[j].BindingID
}
return bindings[i].AllocatedPort < bindings[j].AllocatedPort
})
usedPorts := make([]int, 0, len(usedSet))
for port := range usedSet {
@@ -766,6 +794,37 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
return
}
usedPorts, err := h.repo.ListUsedPortsOnNode(share.NodeID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
for _, port := range usedPorts {
if port == req.RemotePort {
response.WriteJSON(w, response.Err(403, "Port already in use"))
return
}
}
runtimeOnPort, err := h.repo.GetActiveForwardPeerShareRuntimeByPort(share.ID, req.RemotePort)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if runtimeOnPort != nil {
response.WriteJSON(w, response.Err(403, "Port already in use"))
return
}
existsOnNodePort, err := h.repo.ExistsActivePeerShareRuntimeOnNodePort(share.NodeID, req.RemotePort)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if existsOnNodePort {
response.WriteJSON(w, response.Err(403, "Port already in use"))
return
}
now := time.Now().UnixMilli()
tunnelID, err := h.repo.CreateFederationTunnel(
fmt.Sprintf("Share-%d-Port-%d", share.ID, req.RemotePort),
@@ -780,6 +839,30 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
return
}
runtime := &repo.PeerShareRuntime{
ShareID: share.ID,
NodeID: share.NodeID,
ReservationID: randomToken(24),
ResourceKey: fmt.Sprintf("federation-forward-%d-%d-%d", share.ID, tunnelID, req.RemotePort),
BindingID: "",
Role: "forward",
ChainName: "",
ServiceName: "",
Protocol: defaultString(req.Protocol, "tcp"),
Strategy: "fifo",
Port: req.RemotePort,
Target: strings.TrimSpace(req.Target),
Applied: 0,
Status: 1,
CreatedTime: now,
UpdatedTime: now,
}
if err := h.repo.CreatePeerShareRuntime(runtime); err != nil {
_ = h.deleteTunnelByID(tunnelID)
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
h.wsServer.SendCommand(share.NodeID, "reload", nil, time.Second*5)
response.WriteJSON(w, response.OK(map[string]interface{}{
@@ -1211,9 +1294,182 @@ func (h *Handler) federationRuntimeCommand(w http.ResponseWriter, r *http.Reques
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if strings.EqualFold(cmd, "addservice") || strings.EqualFold(cmd, "updateservice") {
h.bindPeerShareForwardRuntimeServices(share, req.Data)
} else if strings.EqualFold(cmd, "deleteservice") {
h.releasePeerShareForwardRuntimeServices(share, req.Data)
}
response.WriteJSON(w, response.OK(res))
}
type federationForwardServiceBinding struct {
Name string
Port int
}
func extractFederationServiceEntries(data interface{}) []map[string]interface{} {
if data == nil {
return nil
}
if entries := asMapSlice(data); len(entries) > 0 {
return entries
}
dataMap, ok := data.(map[string]interface{})
if !ok {
return nil
}
if entries := asMapSlice(dataMap["services"]); len(entries) > 0 {
return entries
}
return nil
}
func parseFederationForwardServiceBindings(data interface{}) []federationForwardServiceBinding {
serviceList := extractFederationServiceEntries(data)
bindings := make([]federationForwardServiceBinding, 0, len(serviceList))
for _, svcMap := range serviceList {
name := normalizeForwardRuntimeServiceName(asString(svcMap["name"]))
if name == "" {
continue
}
if _, _, _, ok := parseFlowServiceIDs(name); !ok {
continue
}
addr := strings.TrimSpace(asString(svcMap["addr"]))
if addr == "" {
continue
}
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
continue
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 {
continue
}
bindings = append(bindings, federationForwardServiceBinding{Name: name, Port: port})
}
return bindings
}
func parseFederationForwardServiceNamesForRelease(data interface{}) []string {
names := make(map[string]struct{})
appendName := func(raw string) {
name := normalizeForwardRuntimeServiceName(raw)
if name == "" {
return
}
if _, _, _, ok := parseFlowServiceIDs(name); !ok {
return
}
names[name] = struct{}{}
}
for _, svcMap := range extractFederationServiceEntries(data) {
appendName(asString(svcMap["name"]))
}
if dataMap, ok := data.(map[string]interface{}); ok {
for _, item := range asAnySlice(dataMap["services"]) {
appendName(asString(item))
}
}
for _, item := range asAnySlice(data) {
appendName(asString(item))
}
if len(names) == 0 {
return nil
}
out := make([]string, 0, len(names))
for name := range names {
out = append(out, name)
}
sort.Strings(out)
return out
}
func (h *Handler) bindPeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) {
if h == nil || h.repo == nil || share == nil {
return
}
bindings := parseFederationForwardServiceBindings(data)
if len(bindings) == 0 {
return
}
now := time.Now().UnixMilli()
for _, binding := range bindings {
runtime, err := h.repo.GetActiveForwardPeerShareRuntimeByPort(share.ID, binding.Port)
if err != nil {
continue
}
if runtime == nil {
runtime, err = h.repo.GetActiveForwardPeerShareRuntimeByServiceName(share.ID, binding.Name)
if err != nil {
continue
}
}
if runtime == nil {
_ = h.repo.CreatePeerShareRuntime(&repo.PeerShareRuntime{
ShareID: share.ID,
NodeID: share.NodeID,
ReservationID: randomToken(24),
ResourceKey: fmt.Sprintf("forward-runtime:%d:%s:%d:%s", share.ID, binding.Name, binding.Port, randomToken(8)),
BindingID: "",
Role: "forward",
ChainName: "",
ServiceName: binding.Name,
Protocol: "tcp",
Strategy: "fifo",
Port: binding.Port,
Target: "",
Applied: 1,
Status: 1,
CreatedTime: now,
UpdatedTime: now,
})
continue
}
if runtime.ServiceName == binding.Name && runtime.Applied == 1 && runtime.Port == binding.Port && runtime.Status == 1 {
continue
}
runtime.ServiceName = binding.Name
runtime.Port = binding.Port
runtime.Applied = 1
runtime.Status = 1
runtime.UpdatedTime = now
if strings.TrimSpace(runtime.Protocol) == "" {
runtime.Protocol = "tcp"
}
if strings.TrimSpace(runtime.Strategy) == "" {
runtime.Strategy = "fifo"
}
_ = h.repo.UpdatePeerShareRuntime(runtime)
}
}
func (h *Handler) releasePeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) {
if h == nil || h.repo == nil || share == nil {
return
}
names := parseFederationForwardServiceNamesForRelease(data)
if len(names) == 0 {
return
}
now := time.Now().UnixMilli()
for _, name := range names {
_ = h.repo.MarkForwardPeerShareRuntimeReleasedByServiceName(share.ID, name, now)
}
}
func isFederationRuntimeCommandAllowed(commandType string) bool {
switch strings.ToLower(strings.TrimSpace(commandType)) {
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "deletechains", "addlimiters", "deletelimiters", "tcpping", "reload":
@@ -1236,36 +1492,26 @@ func validateFederationCommandPorts(share *repo.PeerShare, data interface{}) err
if share == nil || (share.PortRangeStart <= 0 && share.PortRangeEnd <= 0) {
return nil
}
dataMap, ok := data.(map[string]interface{})
if !ok {
serviceList := extractFederationServiceEntries(data)
if len(serviceList) == 0 {
return nil
}
if services, ok := dataMap["services"]; ok {
serviceList, ok := services.([]interface{})
if !ok {
return fmt.Errorf("invalid services format")
for _, svcMap := range serviceList {
addr := asString(svcMap["addr"])
if addr == "" {
continue
}
for _, svc := range serviceList {
svcMap, ok := svc.(map[string]interface{})
if !ok {
return fmt.Errorf("invalid service entry format")
}
addr, ok := svcMap["addr"].(string)
if !ok || addr == "" {
continue
}
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("invalid service address: %s", addr)
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 {
return fmt.Errorf("invalid port in service address: %s", addr)
}
if port < share.PortRangeStart || port > share.PortRangeEnd {
return fmt.Errorf("port %d out of allowed range %d-%d", port, share.PortRangeStart, share.PortRangeEnd)
}
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("invalid service address: %s", addr)
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 {
return fmt.Errorf("invalid port in service address: %s", addr)
}
if port < share.PortRangeStart || port > share.PortRangeEnd {
return fmt.Errorf("port %d out of allowed range %d-%d", port, share.PortRangeStart, share.PortRangeEnd)
}
}
@@ -414,6 +414,445 @@ func TestFederationShareResetFlow(t *testing.T) {
}
}
func TestFederationTunnelCreateCreatesPeerShareRuntime(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "federation-forward-node", "federation-forward-secret", "10.90.80.70", "10.90.80.70", "", "24000-24020", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, r, "federation-forward-node")
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "federation-forward-share",
NodeID: nodeID,
Token: "federation-forward-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 24000,
PortRangeEnd: 24020,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("federation-forward-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
body, err := json.Marshal(federationTunnelRequest{
Protocol: "tcp",
RemotePort: 24001,
Target: "1.1.1.1:443",
})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/tunnel/create", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+share.Token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
h.federationTunnelCreate(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
}
var payload response.R
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Code != 0 {
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
}
runtimeCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND port = ? AND status = 1`, share.ID, 24001)
if runtimeCount != 1 {
t.Fatalf("expected 1 runtime row for new federation forward tunnel, got %d", runtimeCount)
}
}
func TestFederationTunnelCreateRejectsOccupiedPort(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "federation-port-check-node", "federation-port-check-secret", "10.91.80.70", "10.91.80.70", "", "24100-24120", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, r, "federation-port-check-node")
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "federation-port-check-share",
NodeID: nodeID,
Token: "federation-port-check-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 24100,
PortRangeEnd: 24120,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
create := func() response.R {
body, err := json.Marshal(federationTunnelRequest{Protocol: "tcp", RemotePort: 24101, Target: "1.1.1.1:443"})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/tunnel/create", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer federation-port-check-token")
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
h.federationTunnelCreate(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
}
var payload response.R
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
return payload
}
first := create()
if first.Code != 0 {
t.Fatalf("expected first create success, got %d (%s)", first.Code, first.Msg)
}
second := create()
if second.Code != 403 {
t.Fatalf("expected second create to be rejected with 403, got %d (%s)", second.Code, second.Msg)
}
if second.Msg != "Port already in use" {
t.Fatalf("expected occupied port message, got %q", second.Msg)
}
}
func TestDeleteTunnelReleasesFederationForwardRuntimeByPort(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "delete-forward-share",
NodeID: 1,
Token: "delete-forward-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 25000,
PortRangeEnd: 25020,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("delete-forward-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
tunnelName := fmt.Sprintf("Share-%d-Port-%d", share.ID, 25001)
if err := r.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, 1, tunnelName, 1.0, 1, "tcp", 1, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "del-r1", "del-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 25001, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert runtime: %v", err)
}
if err := h.deleteTunnelByID(1); err != nil {
t.Fatalf("delete tunnel: %v", err)
}
activeCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND port = ? AND status = 1`, share.ID, 25001)
if activeCount != 0 {
t.Fatalf("expected runtime released after tunnel delete, active rows=%d", activeCount)
}
}
func TestBindPeerShareForwardRuntimeServicesOnlyBindsForwardRole(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "bind-forward-role-share",
NodeID: 1,
Token: "bind-forward-role-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 26000,
PortRangeEnd: 26020,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("bind-forward-role-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
1, share.ID, share.NodeID, "bind-r1", "bind-rk1", "", "forward", "", "", "tcp", "fifo", 26001, "", 0, 1, now, now,
2, share.ID, share.NodeID, "bind-r2", "bind-rk2", "", "middle", "", "", "tcp", "round", 26002, "", 0, 1, now, now,
).Error; err != nil {
t.Fatalf("insert runtimes: %v", err)
}
h.bindPeerShareForwardRuntimeServices(share, map[string]interface{}{
"services": []interface{}{
map[string]interface{}{"name": "77_2_10_tcp", "addr": "[::]:26001"},
map[string]interface{}{"name": "88_2_10_tcp", "addr": "[::]:26002"},
},
})
forwardServiceName := ""
middleServiceName := ""
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 1`).Scan(&forwardServiceName).Error; err != nil {
t.Fatalf("load forward runtime service name: %v", err)
}
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 2`).Scan(&middleServiceName).Error; err != nil {
t.Fatalf("load middle runtime service name: %v", err)
}
if forwardServiceName != "77_2_10" {
t.Fatalf("expected forward runtime service name bound, got %q", forwardServiceName)
}
if middleServiceName != "" {
t.Fatalf("expected non-forward runtime unchanged, got %q", middleServiceName)
}
}
func TestBindPeerShareForwardRuntimeServicesAcceptsTopLevelServiceArray(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "bind-array-share",
NodeID: 1,
Token: "bind-array-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 26100,
PortRangeEnd: 26120,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("bind-array-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
1, share.ID, share.NodeID, "bind-array-r1", "bind-array-rk1", "", "forward", "", "", "tcp", "fifo", 26101, "", 0, 1, now, now,
).Error; err != nil {
t.Fatalf("insert runtime: %v", err)
}
h.bindPeerShareForwardRuntimeServices(share, []interface{}{
map[string]interface{}{"name": "99_2_10_tcp", "addr": "[::]:26101"},
map[string]interface{}{"name": "99_2_10_udp", "addr": "[::]:26101"},
})
forwardServiceName := ""
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 1`).Scan(&forwardServiceName).Error; err != nil {
t.Fatalf("load forward runtime service name: %v", err)
}
if forwardServiceName != "99_2_10" {
t.Fatalf("expected forward runtime service name bound from top-level array, got %q", forwardServiceName)
}
}
func TestBindPeerShareForwardRuntimeServicesCreatesRuntimeWhenMissing(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-bind-create-runtime.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "bind-create-runtime-share",
NodeID: 1,
Token: "bind-create-runtime-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 26300,
PortRangeEnd: 26320,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("bind-create-runtime-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
h.bindPeerShareForwardRuntimeServices(share, map[string]interface{}{
"services": []interface{}{
map[string]interface{}{"name": "55_2_10_tcp", "addr": "[::]:26301"},
},
})
var count int64
if err := r.DB().Raw(`SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND role = ? AND status = 1`, share.ID, "forward").Scan(&count).Error; err != nil {
t.Fatalf("query runtime count: %v", err)
}
if count != 1 {
t.Fatalf("expected 1 active forward runtime row, got %d", count)
}
var serviceName string
var port int
var applied int
if err := r.DB().Raw(`SELECT service_name, port, applied FROM peer_share_runtime WHERE share_id = ? AND role = ? ORDER BY id DESC LIMIT 1`, share.ID, "forward").Row().Scan(&serviceName, &port, &applied); err != nil {
t.Fatalf("query created runtime: %v", err)
}
if serviceName != "55_2_10" {
t.Fatalf("expected service_name=55_2_10, got %q", serviceName)
}
if port != 26301 {
t.Fatalf("expected port=26301, got %d", port)
}
if applied != 1 {
t.Fatalf("expected applied=1, got %d", applied)
}
}
func TestReleasePeerShareForwardRuntimeServicesMarksRuntimeReleased(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-release-runtime.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "release-runtime-share",
NodeID: 1,
Token: "release-runtime-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 26400,
PortRangeEnd: 26420,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("release-runtime-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "release-r1", "release-rk1", "", "forward", "", "77_2_10", "tcp", "fifo", 26401, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert runtime: %v", err)
}
h.releasePeerShareForwardRuntimeServices(share, map[string]interface{}{
"services": []interface{}{"77_2_10_tcp"},
})
var status int
var applied int
var serviceName string
if err := r.DB().Raw(`SELECT status, applied, service_name FROM peer_share_runtime WHERE share_id = ? AND role = ? ORDER BY id DESC LIMIT 1`, share.ID, "forward").Row().Scan(&status, &applied, &serviceName); err != nil {
t.Fatalf("query released runtime: %v", err)
}
if status != 0 {
t.Fatalf("expected status=0 after release, got %d", status)
}
if applied != 0 {
t.Fatalf("expected applied=0 after release, got %d", applied)
}
if serviceName != "" {
t.Fatalf("expected service_name cleared after release, got %q", serviceName)
}
}
func TestValidateFederationCommandPortsAcceptsTopLevelServiceArray(t *testing.T) {
share := &repo.PeerShare{
PortRangeStart: 26200,
PortRangeEnd: 26210,
}
err := validateFederationCommandPorts(share, []interface{}{
map[string]interface{}{"name": "11_2_10_tcp", "addr": "[::]:26201"},
map[string]interface{}{"name": "11_2_10_udp", "addr": "[::]:26201"},
})
if err != nil {
t.Fatalf("expected top-level service array to pass port validation, got: %v", err)
}
}
func TestFederationRemoteUsageList(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
@@ -502,6 +941,110 @@ func TestFederationRemoteUsageList(t *testing.T) {
}
}
func TestFederationRemoteUsageListIncludesForwardPorts(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-usage.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "forward-usage-remote-node", "forward-usage-secret", "10.60.70.80", "10.60.70.80", "", "33000-33010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "", "", `{"shareId":99,"maxBandwidth":0,"currentFlow":0,"portRangeStart":33000,"portRangeEnd":33010}`).Error; err != nil {
t.Fatalf("insert remote node: %v", err)
}
var nodeID int64
if err := r.DB().Raw(`SELECT id FROM node WHERE name = ? ORDER BY id DESC LIMIT 1`, "forward-usage-remote-node").Row().Scan(&nodeID); err != nil {
t.Fatalf("query node id: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "forward-usage-tunnel", 1, "tls", 1, now, now, 1, "", 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
var tunnelID int64
if err := r.DB().Raw(`SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, "forward-usage-tunnel").Row().Scan(&tunnelID); err != nil {
t.Fatalf("query tunnel id: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, 1, "tester", "forward-usage-item", tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
var forwardID int64
if err := r.DB().Raw(`SELECT id FROM forward WHERE name = ? ORDER BY id DESC LIMIT 1`, "forward-usage-item").Row().Scan(&forwardID); err != nil {
t.Fatalf("query forward id: %v", err)
}
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, 33001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/remote-usage/list", nil)
res := httptest.NewRecorder()
h.federationRemoteUsageList(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
}
var payload response.R
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Code != 0 {
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
}
rows, ok := payload.Data.([]interface{})
if !ok || len(rows) == 0 {
t.Fatalf("expected non-empty usage list, got %T", payload.Data)
}
first, ok := rows[0].(map[string]interface{})
if !ok {
t.Fatalf("expected usage row map, got %T", rows[0])
}
usedPortsRaw, ok := first["usedPorts"].([]interface{})
if !ok {
t.Fatalf("expected usedPorts array, got %T", first["usedPorts"])
}
if len(usedPortsRaw) != 1 || int(usedPortsRaw[0].(float64)) != 33001 {
t.Fatalf("expected usedPorts [33001], got %v", usedPortsRaw)
}
bindingsRaw, ok := first["bindings"].([]interface{})
if !ok {
t.Fatalf("expected bindings array, got %T", first["bindings"])
}
if len(bindingsRaw) != 1 {
t.Fatalf("expected 1 binding row from forward usage, got %d", len(bindingsRaw))
}
binding, ok := bindingsRaw[0].(map[string]interface{})
if !ok {
t.Fatalf("expected binding row object, got %T", bindingsRaw[0])
}
if int(binding["allocatedPort"].(float64)) != 33001 {
t.Fatalf("expected allocatedPort=33001, got %v", binding["allocatedPort"])
}
if int(binding["chainType"].(float64)) != 1 {
t.Fatalf("expected chainType=1 for forward usage row, got %v", binding["chainType"])
}
}
func TestAuthPeerAllowedIPs(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
+193 -1
View File
@@ -2,9 +2,12 @@ package handler
import (
"encoding/json"
"log"
"strconv"
"strings"
"time"
"go-backend/internal/store/model"
)
const bytesPerGB int64 = 1024 * 1024 * 1024
@@ -30,7 +33,7 @@ type namedConfigItem struct {
Name string `json:"name"`
}
func (h *Handler) processFlowItem(item flowItem) {
func (h *Handler) processFlowItem(nodeID int64, item flowItem) {
serviceName := strings.TrimSpace(item.N)
if serviceName == "" || serviceName == "web_api" {
return
@@ -40,6 +43,7 @@ func (h *Handler) processFlowItem(item flowItem) {
if ok {
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
if userTunnelID > 0 {
h.enforceFlowPolicies(userID, userTunnelID)
@@ -87,6 +91,45 @@ func parsePeerShareRuntimeServiceID(serviceName string) (int64, bool) {
return runtimeID, true
}
func parsePeerShareInfoFromFederationTunnelName(tunnelName string) (int64, int, bool) {
tunnelName = strings.TrimSpace(tunnelName)
if !strings.HasPrefix(tunnelName, "Share-") {
return 0, 0, false
}
raw := strings.TrimPrefix(tunnelName, "Share-")
idx := strings.Index(raw, "-Port-")
if idx <= 0 {
return 0, 0, false
}
shareID, err := strconv.ParseInt(raw[:idx], 10, 64)
if err != nil || shareID <= 0 {
return 0, 0, false
}
portValue := strings.TrimSpace(raw[idx+len("-Port-"):])
port, err := strconv.Atoi(portValue)
if err != nil || port <= 0 {
return 0, 0, false
}
return shareID, port, true
}
func parsePeerShareIDFromFederationTunnelName(tunnelName string) (int64, bool) {
tunnelName = strings.TrimSpace(tunnelName)
if !strings.HasPrefix(tunnelName, "Share-") {
return 0, false
}
raw := strings.TrimPrefix(tunnelName, "Share-")
idx := strings.Index(raw, "-Port-")
if idx <= 0 {
return 0, false
}
shareID, err := strconv.ParseInt(raw[:idx], 10, 64)
if err != nil || shareID <= 0 {
return 0, false
}
return shareID, true
}
func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
if h == nil || h.repo == nil || runtimeID <= 0 {
return
@@ -113,6 +156,121 @@ func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
h.enforcePeerShareFlowLimit(share.ID)
}
func (h *Handler) processPeerShareFlowFromForward(forwardID int64, nodeID int64, serviceName string, item flowItem) {
if h == nil || h.repo == nil || forwardID <= 0 {
return
}
delta := item.D + item.U
if delta <= 0 {
return
}
forward, err := h.getForwardRecord(forwardID)
if err != nil || forward == nil {
// Forward not found in local database - might be a federation port-forward
// Try to find by service name in peer_share_runtime
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
tunnelName, err := h.repo.GetTunnelName(forward.TunnelID)
if err != nil {
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
shareID, ok := parsePeerShareIDFromFederationTunnelName(tunnelName)
if !ok {
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
if err := h.repo.AddPeerShareCurrentFlow(shareID, delta); err != nil {
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
share, err := h.repo.GetPeerShare(shareID)
if err != nil || share == nil {
return
}
if !isPeerShareFlowExceeded(share) {
return
}
h.enforcePeerShareFlowLimit(share.ID)
}
func normalizeForwardRuntimeServiceName(serviceName string) string {
name := strings.TrimSpace(serviceName)
if strings.HasSuffix(name, "_tcp") {
return strings.TrimSuffix(name, "_tcp")
}
if strings.HasSuffix(name, "_udp") {
return strings.TrimSuffix(name, "_udp")
}
return name
}
func (h *Handler) processPeerShareFlowByServiceName(nodeID int64, serviceName string, item flowItem) {
if h == nil || h.repo == nil || strings.TrimSpace(serviceName) == "" {
return
}
delta := item.D + item.U
if delta <= 0 {
return
}
normalized := normalizeForwardRuntimeServiceName(serviceName)
var runtimes []model.PeerShareRuntime
var err error
// Try node-scoped query first if nodeID is valid
if nodeID > 0 {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID, normalized)
if err != nil {
return
}
if len(runtimes) == 0 && normalized != serviceName {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID, serviceName)
if err != nil {
return
}
}
}
// Fallback to global query if node-scoped query returned nothing or nodeID is invalid
if len(runtimes) == 0 {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByServiceName(normalized)
if err != nil {
return
}
if len(runtimes) == 0 && normalized != serviceName {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByServiceName(serviceName)
if err != nil {
return
}
}
}
if len(runtimes) != 1 {
if len(runtimes) > 1 {
log.Printf("WARN: ambiguous peer share runtime match for service=%s nodeID=%d count=%d", serviceName, nodeID, len(runtimes))
}
return
}
runtime := runtimes[0]
_ = h.repo.AddPeerShareCurrentFlow(runtime.ShareID, delta)
matchedShare, err := h.repo.GetPeerShare(runtime.ShareID)
if err != nil || matchedShare == nil {
return
}
if isPeerShareFlowExceeded(matchedShare) {
h.enforcePeerShareFlowLimit(matchedShare.ID)
}
}
func (h *Handler) enforcePeerShareFlowLimit(shareID int64) {
if h == nil || h.repo == nil || shareID <= 0 {
return
@@ -271,15 +429,46 @@ func (h *Handler) cleanNodeConfigs(nodeID int64, rawConfig string) {
}
func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem) {
runtimeServiceNames, err := h.repo.ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID)
if err != nil {
return
}
minUpdatedTime := time.Now().Add(-10 * time.Minute).UnixMilli()
hasUnboundForwardPeerRuntime, err := h.repo.HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID, minUpdatedTime)
if err != nil {
hasUnboundForwardPeerRuntime = false
}
runtimeServiceSet := make(map[string]struct{}, len(runtimeServiceNames))
for _, serviceName := range runtimeServiceNames {
serviceName = strings.TrimSpace(serviceName)
if serviceName == "" {
continue
}
runtimeServiceSet[serviceName] = struct{}{}
}
for _, item := range services {
name := strings.TrimSpace(item.Name)
if name == "" || name == "web_api" {
continue
}
if strings.HasPrefix(name, "fed_svc_") {
continue
}
normalizedName := normalizeForwardRuntimeServiceName(name)
if _, ok := runtimeServiceSet[normalizedName]; ok {
continue
}
if _, ok := runtimeServiceSet[name]; ok {
continue
}
parts := strings.Split(name, "_")
if len(parts) >= 3 {
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
continue
}
if err == nil && forwardID > 0 && !h.forwardExists(forwardID) {
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name, parts[0] + "_" + parts[1] + "_" + parts[2], parts[0] + "_" + parts[1] + "_" + parts[2] + "_tcp", parts[0] + "_" + parts[1] + "_" + parts[2] + "_udp"}}, false, true)
continue
@@ -299,6 +488,9 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
continue
}
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
continue
}
if err != nil || forwardID <= 0 || h.forwardExists(forwardID) {
continue
}
@@ -2,6 +2,7 @@ package handler
import (
"path/filepath"
"strconv"
"testing"
"time"
@@ -43,7 +44,7 @@ func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
}
h := &Handler{repo: r}
h.processFlowItem(flowItem{N: "fed_svc_17", U: 1200, D: 900})
h.processFlowItem(1, flowItem{N: "fed_svc_17", U: 1200, D: 900})
updatedShare, err := r.GetPeerShare(share.ID)
if err != nil || updatedShare == nil {
@@ -61,3 +62,345 @@ func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
t.Fatalf("expected runtime status=0 after limit enforcement, got %d", runtime.Status)
}
}
func TestProcessFlowItemTracksPeerShareFlowForFederationPortForward(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "forward-share",
NodeID: 1,
Token: "forward-share-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 30000,
PortRangeEnd: 30010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := r.GetPeerShareByToken("forward-share-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'u2', 'x', 1, ?, 99999, 0, 0, 1, 1, ?, ?, 1)
`, now+24*60*60*1000, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
tunnelName := "Share-" + strconv.FormatInt(share.ID, 10) + "-Port-30001"
if err := r.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(1, ?, 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
`, tunnelName, now, now).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(10, 2, 1, NULL, 1, 99999, 0, 0, 1, ?, 1)
`, now+24*60*60*1000).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(20, 2, 'u2', 'f20', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
h := &Handler{repo: r}
h.processFlowItem(1, flowItem{N: "20_2_10", U: 120, D: 80})
updatedShare, err := r.GetPeerShare(share.ID)
if err != nil || updatedShare == nil {
t.Fatalf("reload share: %v", err)
}
if updatedShare.CurrentFlow != 200 {
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
}
}
func TestProcessFlowItemTracksPeerShareFlowByForwardServiceName(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-service.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "forward-service-share",
NodeID: 1,
Token: "forward-service-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31000,
PortRangeEnd: 31010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := r.GetPeerShareByToken("forward-service-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "svc-r1", "svc-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31001, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert peer_share_runtime: %v", err)
}
h := &Handler{repo: r}
h.processFlowItem(1, flowItem{N: "20_2_10_tcp", U: 120, D: 80})
updatedShare, err := r.GetPeerShare(share.ID)
if err != nil || updatedShare == nil {
t.Fatalf("reload share: %v", err)
}
if updatedShare.CurrentFlow != 200 {
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
}
}
func TestProcessFlowItemFallsBackToServiceNameWhenForwardIDCollidesAcrossPanels(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-collision.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "collision-share",
NodeID: 1,
Token: "collision-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31400,
PortRangeEnd: 31410,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := r.GetPeerShareByToken("collision-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "collision-r1", "collision-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31401, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert peer_share_runtime: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(2, 'local-tunnel-with-colliding-forward-id', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
`, now, now).Error; err != nil {
t.Fatalf("insert local tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(20, 1, 'local-user', 'local-f20', 2, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
`, now, now).Error; err != nil {
t.Fatalf("insert local forward: %v", err)
}
h := &Handler{repo: r}
h.processFlowItem(1, flowItem{N: "20_2_10_tcp", U: 120, D: 80})
updatedShare, err := r.GetPeerShare(share.ID)
if err != nil || updatedShare == nil {
t.Fatalf("reload share: %v", err)
}
if updatedShare.CurrentFlow != 200 {
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
}
}
func TestProcessFlowItemSkipsPeerShareFlowWhenServiceNameIsAmbiguous(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-ambiguous.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "ambiguous-share-a",
NodeID: 1,
Token: "ambiguous-token-a",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31100,
PortRangeEnd: 31110,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share A: %v", err)
}
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "ambiguous-share-b",
NodeID: 1,
Token: "ambiguous-token-b",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31200,
PortRangeEnd: 31210,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share B: %v", err)
}
shareA, _ := r.GetPeerShareByToken("ambiguous-token-a")
shareB, _ := r.GetPeerShareByToken("ambiguous-token-b")
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
shareA.ID, 1, "amb-r1", "amb-rk1", "", "forward", "", "99_2_10", "tcp", "fifo", 31101, "", 1, 1, now, now,
shareB.ID, 1, "amb-r2", "amb-rk2", "", "forward", "", "99_2_10", "tcp", "fifo", 31201, "", 1, 1, now, now,
).Error; err != nil {
t.Fatalf("insert ambiguous runtimes: %v", err)
}
h := &Handler{repo: r}
h.processFlowItem(1, flowItem{N: "99_2_10_tcp", U: 120, D: 80})
updatedA, _ := r.GetPeerShare(shareA.ID)
updatedB, _ := r.GetPeerShare(shareB.ID)
if updatedA.CurrentFlow != 0 || updatedB.CurrentFlow != 0 {
t.Fatalf("expected ambiguous service flow to be skipped, got shareA=%d shareB=%d", updatedA.CurrentFlow, updatedB.CurrentFlow)
}
}
func TestCleanOrphanedServicesSkipsActiveSharedForwardRuntimeServices(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-runtime.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "cleanup-runtime-share",
NodeID: 1,
Token: "cleanup-runtime-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31300,
PortRangeEnd: 31310,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := r.GetPeerShareByToken("cleanup-runtime-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "cleanup-r1", "cleanup-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31301, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert peer_share_runtime: %v", err)
}
h := &Handler{repo: r}
defer func() {
if rec := recover(); rec != nil {
t.Fatalf("cleanOrphanedServices should skip active shared runtime service; got panic: %v", rec)
}
}()
h.cleanOrphanedServices(share.NodeID, []namedConfigItem{{Name: "20_2_10_tcp"}})
}
func TestCleanOrphanedServicesSkipsFederationServicePrefix(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-fed-svc.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
h := &Handler{repo: r}
defer func() {
if rec := recover(); rec != nil {
t.Fatalf("cleanOrphanedServices should skip fed_svc_ service names; got panic: %v", rec)
}
}()
h.cleanOrphanedServices(1, []namedConfigItem{{Name: "fed_svc_999_tcp"}})
}
func TestCleanOrphanedServicesSkipsForwardPatternWhenNodeHasActivePeerShareForwardRuntime(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-forward-runtime-empty-service.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "cleanup-forward-runtime-empty-service",
NodeID: 1,
Token: "cleanup-forward-runtime-empty-service-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31420,
PortRangeEnd: 31430,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := r.GetPeerShareByToken("cleanup-forward-runtime-empty-service-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "cleanup-forward-empty-r1", "cleanup-forward-empty-rk1", "", "forward", "", "", "tcp", "fifo", 31421, "", 0, 1, now, now).Error; err != nil {
t.Fatalf("insert peer_share_runtime with empty service name: %v", err)
}
h := &Handler{repo: r}
defer func() {
if rec := recover(); rec != nil {
t.Fatalf("cleanOrphanedServices should skip forward-pattern services when active peer-share forward runtime exists; got panic: %v", rec)
}
}()
h.cleanOrphanedServices(share.NodeID, []namedConfigItem{{Name: "20_2_10_tcp"}})
}
+18 -3
View File
@@ -219,7 +219,7 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if captchaEnabled {
if captchaEnabled && !h.apiClientCaptchaBypassEnabled(r) {
captchaID := strings.TrimSpace(req.CaptchaID)
if captchaID == "" {
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
@@ -704,7 +704,8 @@ func (h *Handler) flowConfig(w http.ResponseWriter, r *http.Request) {
func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
secret := r.URL.Query().Get("secret")
if ok, _ := h.repo.NodeExistsBySecret(secret); !ok {
node, _ := h.repo.GetNodeBySecret(secret)
if node == nil {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok"))
return
@@ -715,7 +716,7 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
var items []flowItem
if json.Unmarshal([]byte(raw), &items) == nil {
for _, item := range items {
h.processFlowItem(item)
h.processFlowItem(node.ID, item)
}
}
}
@@ -994,6 +995,20 @@ func (h *Handler) captchaEnabled() (bool, error) {
return strings.EqualFold(cfg.Value, "true"), nil
}
func (h *Handler) apiClientCaptchaBypassEnabled(r *http.Request) bool {
if r == nil {
return false
}
client := strings.ToLower(strings.TrimSpace(r.Header.Get("X-FLVX-API-Client")))
switch client {
case "whmcs", "whmcs-module":
return true
default:
return false
}
}
func (h *Handler) markCaptchaToken(token string) {
if h == nil {
return
@@ -69,6 +69,13 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
t.Fatalf("insert expired user: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(3, 'non_expiring_user', 'x', 1, 0, 100, 1000, 2000, 15, 1, ?, ?, 1)
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert non-expiring user: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(1, 't1', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
@@ -83,6 +90,13 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
t.Fatalf("insert expired user_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(11, 3, 1, NULL, 1, 1, 300, 400, 15, 0, 1)
`).Error; err != nil {
t.Fatalf("insert non-expiring user_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(20, 2, 'expired_user', 'f1', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
@@ -90,6 +104,13 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
t.Fatalf("insert forward: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(21, 3, 'non_expiring_user', 'f2', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 1)
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert non-expiring forward: %v", err)
}
h.runResetAndExpiryJob(now)
userIn, userOut, userStatus := mustQueryInt64Int64Int(t, r, `SELECT in_flow, out_flow, status FROM user WHERE id = 2`)
@@ -106,4 +127,19 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
if forwardStatus != 0 {
t.Fatalf("expected forward status=0 after expiry handling, got %d", forwardStatus)
}
nonExpUserStatus := mustQueryInt(t, r, `SELECT status FROM user WHERE id = 3`)
if nonExpUserStatus != 1 {
t.Fatalf("expected non-expiring user to remain enabled, got status=%d", nonExpUserStatus)
}
nonExpTunnelStatus := mustQueryInt(t, r, `SELECT status FROM user_tunnel WHERE id = 11`)
if nonExpTunnelStatus != 1 {
t.Fatalf("expected non-expiring user_tunnel to remain enabled, got status=%d", nonExpTunnelStatus)
}
nonExpForwardStatus := mustQueryInt(t, r, `SELECT status FROM forward WHERE id = 21`)
if nonExpForwardStatus != 1 {
t.Fatalf("expected non-expiring forward to remain enabled, got status=%d", nonExpForwardStatus)
}
}
+15 -1
View File
@@ -2794,7 +2794,21 @@ func (h *Handler) deleteNodeByID(id int64) error {
}
func (h *Handler) deleteTunnelByID(id int64) error {
return h.repo.DeleteTunnelCascade(id)
if h == nil || h.repo == nil {
return errors.New("repository not initialized")
}
tunnelName, _ := h.repo.GetTunnelName(id)
if err := h.repo.DeleteTunnelCascade(id); err != nil {
return err
}
shareID, port, ok := parsePeerShareInfoFromFederationTunnelName(tunnelName)
if !ok {
return nil
}
return h.repo.MarkPeerShareRuntimeReleasedByPort(shareID, port, time.Now().UnixMilli())
}
func (h *Handler) deleteForwardByID(id int64) error {
+172 -2
View File
@@ -1270,6 +1270,176 @@ func (r *Repository) ListActivePeerShareRuntimePorts(shareID int64, nodeID int64
return ports, nil
}
func (r *Repository) ListActiveForwardPeerShareRuntimesByServiceName(serviceName string) ([]model.PeerShareRuntime, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var items []model.PeerShareRuntime
err := r.db.Where("service_name = ? AND status = 1 AND role = ?", serviceName, "forward").
Order("id ASC").
Find(&items).Error
if err != nil {
return nil, err
}
if items == nil {
items = make([]model.PeerShareRuntime, 0)
}
return items, nil
}
func (r *Repository) ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID int64, serviceName string) ([]model.PeerShareRuntime, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
serviceName = strings.TrimSpace(serviceName)
if serviceName == "" {
return []model.PeerShareRuntime{}, nil
}
var items []model.PeerShareRuntime
err := r.db.Where("node_id = ? AND service_name = ? AND status = 1 AND role = ?", nodeID, serviceName, "forward").
Order("id ASC").
Find(&items).Error
if err != nil {
return nil, err
}
if items == nil {
items = make([]model.PeerShareRuntime, 0)
}
return items, nil
}
func (r *Repository) ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID int64) ([]string, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var names []string
err := r.db.Model(&model.PeerShareRuntime{}).
Where("node_id = ? AND status = 1 AND role = ? AND service_name <> ''", nodeID, "forward").
Pluck("service_name", &names).Error
if err != nil {
return nil, err
}
if names == nil {
names = make([]string, 0)
}
return names, nil
}
func (r *Repository) HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID int64, minUpdatedTime int64) (bool, error) {
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
}
var count int64
err := r.db.Model(&model.PeerShareRuntime{}).
Where("node_id = ? AND status = 1 AND role = ? AND applied = 0 AND updated_time >= ? AND (service_name = '' OR service_name IS NULL)", nodeID, "forward", minUpdatedTime).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *Repository) GetActiveForwardPeerShareRuntimeByPort(shareID int64, port int) (*model.PeerShareRuntime, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var item model.PeerShareRuntime
err := r.db.Where("share_id = ? AND port = ? AND status = 1 AND role = ?", shareID, port, "forward").First(&item).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &item, nil
}
func (r *Repository) GetActiveForwardPeerShareRuntimeByServiceName(shareID int64, serviceName string) (*model.PeerShareRuntime, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
serviceName = strings.TrimSpace(serviceName)
if shareID <= 0 || serviceName == "" {
return nil, nil
}
var item model.PeerShareRuntime
err := r.db.Where("share_id = ? AND service_name = ? AND status = 1 AND role = ?", shareID, serviceName, "forward").
Order("id ASC").
First(&item).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &item, nil
}
func (r *Repository) ExistsActivePeerShareRuntimeOnNodePort(nodeID int64, port int) (bool, error) {
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
}
var count int64
err := r.db.Model(&model.PeerShareRuntime{}).
Where("node_id = ? AND port = ? AND status = 1", nodeID, port).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *Repository) UpdatePeerShareRuntimeServiceName(id int64, serviceName string, updatedTime int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
return r.db.Model(&model.PeerShareRuntime{}).Where("id = ?", id).Updates(map[string]interface{}{
"service_name": serviceName,
"applied": 1,
"updated_time": updatedTime,
}).Error
}
func (r *Repository) MarkPeerShareRuntimeReleasedByPort(shareID int64, port int, updatedTime int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
if shareID <= 0 || port <= 0 {
return nil
}
if updatedTime <= 0 {
updatedTime = unixMilliNow()
}
return r.db.Model(&model.PeerShareRuntime{}).Where("share_id = ? AND port = ? AND status = 1", shareID, port).Updates(map[string]interface{}{
"status": 0,
"applied": 0,
"service_name": "",
"updated_time": updatedTime,
}).Error
}
func (r *Repository) MarkForwardPeerShareRuntimeReleasedByServiceName(shareID int64, serviceName string, updatedTime int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
serviceName = strings.TrimSpace(serviceName)
if shareID <= 0 || serviceName == "" {
return nil
}
if updatedTime <= 0 {
updatedTime = unixMilliNow()
}
return r.db.Model(&model.PeerShareRuntime{}).
Where("share_id = ? AND status = 1 AND role = ? AND service_name = ?", shareID, "forward", serviceName).
Updates(map[string]interface{}{
"status": 0,
"applied": 0,
"service_name": "",
"updated_time": updatedTime,
}).Error
}
// ─── FederationTunnelBinding ─────────────────────────────────────────
func (r *Repository) UpsertFederationTunnelBinding(item *model.FederationTunnelBinding) error {
@@ -2235,7 +2405,7 @@ func (r *Repository) ListExpiredActiveUserIDs(nowMs int64) ([]int64, error) {
}
var ids []int64
err := r.db.Model(&model.User{}).
Where("role_id != 0 AND status = 1 AND exp_time IS NOT NULL AND exp_time < ?", nowMs).
Where("role_id != 0 AND status = 1 AND exp_time > 0 AND exp_time < ?", nowMs).
Pluck("id", &ids).Error
if err != nil {
return nil, err
@@ -2255,7 +2425,7 @@ func (r *Repository) ListExpiredActiveUserTunnels(nowMs int64) ([]model.ExpiredU
return nil, errors.New("repository not initialized")
}
var uts []model.UserTunnel
err := r.db.Where("status = 1 AND exp_time IS NOT NULL AND exp_time < ?", nowMs).Find(&uts).Error
err := r.db.Where("status = 1 AND exp_time > 0 AND exp_time < ?", nowMs).Find(&uts).Error
if err != nil {
return nil, err
}
@@ -38,6 +38,14 @@ type FederationBindingRow struct {
UpdatedTime int64
}
type ActiveForwardPortRow struct {
ForwardID int64
TunnelID int64
TunnelName string
Port int
UpdatedTime int64
}
// ListRemoteNodes returns all nodes with is_remote=1, ordered by id desc.
func (r *Repository) ListRemoteNodes() ([]RemoteNodeRow, error) {
if r == nil || r.db == nil {
@@ -87,6 +95,27 @@ func (r *Repository) ListActiveBindingsForNode(nodeID int64) ([]FederationBindin
return result, nil
}
func (r *Repository) ListActiveForwardPortsForNode(nodeID int64) ([]ActiveForwardPortRow, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var result []ActiveForwardPortRow
err := r.db.Model(&model.ForwardPort{}).
Select("forward_port.forward_id, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward_port.port, forward.updated_time").
Joins("JOIN forward ON forward.id = forward_port.forward_id").
Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
Where("forward_port.node_id = ? AND forward_port.port > 0", nodeID).
Order("forward_port.port ASC, forward_port.id ASC").
Find(&result).Error
if err != nil {
return nil, err
}
if result == nil {
result = make([]ActiveForwardPortRow, 0)
}
return result, nil
}
// GetNodeBasicInfo returns the name, server_ip, and status for a given node.
func (r *Repository) GetNodeBasicInfo(nodeID int64) (*NodeBasicInfo, error) {
if r == nil || r.db == nil {
@@ -166,7 +166,7 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, middleShareID, 1)
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, exitShareID, 1)
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ?`, entryShareID, 0)
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, entryShareID, 1)
}
func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
@@ -0,0 +1,693 @@
package contract_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
"go-backend/internal/store/repo"
)
func TestFederationForwardCardFlowLinkageContract(t *testing.T) {
secret := "federation-forward-flow-contract-jwt"
router, r := setupContractRouter(t, secret)
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "flow-local-node", "flow-local-secret", "10.20.30.40", "10.20.30.40", "", "32000-32020", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
t.Fatalf("insert local node: %v", err)
}
nodeID := mustLastInsertID(t, r, "flow-local-node")
shareToken := "flow-linkage-share-token"
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "flow-linkage-share",
NodeID: nodeID,
Token: shareToken,
MaxBandwidth: 0,
CurrentFlow: 1536,
ExpiryTime: 0,
PortRangeStart: 32000,
PortRangeEnd: 32020,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken(shareToken)
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
tunnelName := fmt.Sprintf("Share-%d-Port-%d", share.ID, 32001)
if err := r.DB().Exec(`
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
`, tunnelName, 1, "tcp", 1, now, now, 1, "", 0).Error; err != nil {
t.Fatalf("insert share tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "flow-share-tunnel")
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, 1, "admin_user", "flow-linkage-forward", tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, "flow-linkage-forward")
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, 32001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
forwardOut := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
if forwardOut.Code != 0 {
t.Fatalf("forward list failed: code=%d msg=%q", forwardOut.Code, forwardOut.Msg)
}
forwardRows := mustContractSlice(t, forwardOut.Data, "forward list data")
var targetForward map[string]interface{}
for _, row := range forwardRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
if contractValueAsInt64(m["id"]) == forwardID {
targetForward = m
break
}
}
if targetForward == nil {
t.Fatalf("target forward %d not found in /forward/list response", forwardID)
}
shareOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/list", nil)
if shareOut.Code != 0 {
t.Fatalf("share list failed: code=%d msg=%q", shareOut.Code, shareOut.Msg)
}
localShareRows := mustContractSlice(t, shareOut.Data, "share list data")
remoteUsageOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/remote-usage/list", nil)
if remoteUsageOut.Code != 0 {
t.Fatalf("remote usage list failed: code=%d msg=%q", remoteUsageOut.Code, remoteUsageOut.Msg)
}
remoteUsageRows := mustContractSlice(t, remoteUsageOut.Data, "remote usage data")
if len(remoteUsageRows) != 0 {
t.Fatalf("expected no remote usage rows in local-only fixture, got %d", len(remoteUsageRows))
}
flowByShare := make(map[int64]int64)
for _, row := range remoteUsageRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
shareID := contractValueAsInt64(m["shareId"])
currentFlow := contractValueAsInt64(m["currentFlow"])
if shareID > 0 && currentFlow > 0 {
if currentFlow > flowByShare[shareID] {
flowByShare[shareID] = currentFlow
}
}
}
for _, row := range localShareRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
shareID := contractValueAsInt64(m["id"])
currentFlow := contractValueAsInt64(m["currentFlow"])
if shareID > 0 && currentFlow > 0 {
if currentFlow > flowByShare[shareID] {
flowByShare[shareID] = currentFlow
}
}
}
parsedShareID := contractParseShareIDFromTunnelName(contractValueAsString(targetForward["tunnelName"]))
if parsedShareID != share.ID {
t.Fatalf("expected parsed shareID=%d, got %d (tunnelName=%q)", share.ID, parsedShareID, contractValueAsString(targetForward["tunnelName"]))
}
forwardCountByShare := make(map[int64]int)
for _, row := range forwardRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
sid := contractParseShareIDFromTunnelName(contractValueAsString(m["tunnelName"]))
if sid > 0 && flowByShare[sid] > 0 {
forwardCountByShare[sid] = forwardCountByShare[sid] + 1
}
}
directFlow := contractValueAsInt64(targetForward["inFlow"]) + contractValueAsInt64(targetForward["outFlow"])
if directFlow != 0 {
t.Fatalf("fixture expectation failed: directFlow should be 0, got %d", directFlow)
}
shareFlow := flowByShare[parsedShareID]
if shareFlow <= 0 {
t.Fatalf("expected merged share flow > 0 for share %d", parsedShareID)
}
count := forwardCountByShare[parsedShareID]
if count <= 0 {
count = 1
}
estimated := shareFlow / int64(count)
if estimated < 1 {
estimated = 1
}
displayFlow := estimated
if displayFlow <= 0 {
t.Fatalf("expected displayFlow > 0 after frontend-style merge, got %d", displayFlow)
}
if displayFlow != share.CurrentFlow {
t.Fatalf("expected displayFlow=%d, got %d", share.CurrentFlow, displayFlow)
}
}
func TestFederationForwardCardFlowLinkageContractSplitShareFlowAcrossMultipleForwards(t *testing.T) {
secret := "federation-forward-split-flow-contract-jwt"
router, r := setupContractRouter(t, secret)
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "flow-split-local-node", "flow-split-local-secret", "10.21.31.41", "10.21.31.41", "", "32100-32120", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
t.Fatalf("insert local node: %v", err)
}
nodeID := mustLastInsertID(t, r, "flow-split-local-node")
shareToken := "flow-split-share-token"
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "flow-split-share",
NodeID: nodeID,
Token: shareToken,
MaxBandwidth: 0,
CurrentFlow: 4097,
ExpiryTime: 0,
PortRangeStart: 32100,
PortRangeEnd: 32120,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken(shareToken)
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
createShareForward := func(name string, port int) int64 {
t.Helper()
tunnelName := fmt.Sprintf("Share-%d-Port-%d", share.ID, port)
if err := r.DB().Exec(`
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
`, tunnelName, 1, "tcp", 1, now, now, 1, "", 0).Error; err != nil {
t.Fatalf("insert share tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "flow-split-tunnel")
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, 1, "admin_user", name, tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, name)
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
return forwardID
}
forwardIDA := createShareForward("flow-split-forward-a", 32101)
forwardIDB := createShareForward("flow-split-forward-b", 32102)
forwardOut := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
if forwardOut.Code != 0 {
t.Fatalf("forward list failed: code=%d msg=%q", forwardOut.Code, forwardOut.Msg)
}
forwardRows := mustContractSlice(t, forwardOut.Data, "forward list data")
shareOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/list", nil)
if shareOut.Code != 0 {
t.Fatalf("share list failed: code=%d msg=%q", shareOut.Code, shareOut.Msg)
}
localShareRows := mustContractSlice(t, shareOut.Data, "share list data")
remoteUsageOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/remote-usage/list", nil)
if remoteUsageOut.Code != 0 {
t.Fatalf("remote usage list failed: code=%d msg=%q", remoteUsageOut.Code, remoteUsageOut.Msg)
}
remoteUsageRows := mustContractSlice(t, remoteUsageOut.Data, "remote usage data")
if len(remoteUsageRows) != 0 {
t.Fatalf("expected no remote usage rows in local-only fixture, got %d", len(remoteUsageRows))
}
flowByShare := make(map[int64]int64)
for _, row := range remoteUsageRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
shareID := contractValueAsInt64(m["shareId"])
currentFlow := contractValueAsInt64(m["currentFlow"])
if shareID > 0 && currentFlow > 0 {
if currentFlow > flowByShare[shareID] {
flowByShare[shareID] = currentFlow
}
}
}
for _, row := range localShareRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
shareID := contractValueAsInt64(m["id"])
currentFlow := contractValueAsInt64(m["currentFlow"])
if shareID > 0 && currentFlow > 0 {
if currentFlow > flowByShare[shareID] {
flowByShare[shareID] = currentFlow
}
}
}
shareFlow := flowByShare[share.ID]
if shareFlow <= 0 {
t.Fatalf("expected merged share flow > 0 for share %d", share.ID)
}
forwardCountByShare := make(map[int64]int)
for _, row := range forwardRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
sid := contractParseShareIDFromTunnelName(contractValueAsString(m["tunnelName"]))
if sid > 0 && flowByShare[sid] > 0 {
forwardCountByShare[sid] = forwardCountByShare[sid] + 1
}
}
count := forwardCountByShare[share.ID]
if count != 2 {
t.Fatalf("expected 2 forwards sharing share %d, got %d", share.ID, count)
}
expectedEach := shareFlow / int64(count)
if expectedEach < 1 {
expectedEach = 1
}
findForward := func(forwardID int64) map[string]interface{} {
t.Helper()
for _, row := range forwardRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
if contractValueAsInt64(m["id"]) == forwardID {
return m
}
}
t.Fatalf("forward %d not found in /forward/list response", forwardID)
return nil
}
for _, forwardID := range []int64{forwardIDA, forwardIDB} {
forward := findForward(forwardID)
sid := contractParseShareIDFromTunnelName(contractValueAsString(forward["tunnelName"]))
if sid != share.ID {
t.Fatalf("expected parsed shareID=%d, got %d for forward %d", share.ID, sid, forwardID)
}
directFlow := contractValueAsInt64(forward["inFlow"]) + contractValueAsInt64(forward["outFlow"])
if directFlow != 0 {
t.Fatalf("fixture expectation failed: directFlow should be 0 for forward %d, got %d", forwardID, directFlow)
}
displayFlow := int64(0)
if directFlow > 0 {
displayFlow = directFlow
} else {
shareFlowForForward := flowByShare[sid]
if shareFlowForForward > 0 {
cnt := forwardCountByShare[sid]
if cnt <= 0 {
cnt = 1
}
estimated := shareFlowForForward / int64(cnt)
if estimated < 1 {
estimated = 1
}
displayFlow = estimated
}
}
if displayFlow <= 0 {
t.Fatalf("expected displayFlow > 0 for forward %d, got %d", forwardID, displayFlow)
}
if displayFlow != expectedEach {
t.Fatalf("expected displayFlow=%d for forward %d, got %d", expectedEach, forwardID, displayFlow)
}
}
}
func TestFederationForwardCardFlowLinkageContractResolvesShareByTunnelBindingWhenTunnelNameIsCustom(t *testing.T) {
secret := "federation-forward-binding-flow-contract-jwt"
router, r := setupContractRouter(t, secret)
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
remoteShareID := int64(901)
remoteShareFlow := int64(5000)
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
"flow-binding-remote-node", "flow-binding-remote-secret", "10.31.41.51", "10.31.41.51", "", "33000-33020", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "", "", fmt.Sprintf(`{"shareId":%d,"maxBandwidth":0,"currentFlow":%d,"portRangeStart":33000,"portRangeEnd":33020}`, remoteShareID, remoteShareFlow),
).Error; err != nil {
t.Fatalf("insert remote node: %v", err)
}
remoteNodeID := mustLastInsertID(t, r, "flow-binding-remote-node")
if err := r.DB().Exec(`
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "federation-port-forward-custom-name", 1, "tcp", 1, now, now, 1, "", 0).Error; err != nil {
t.Fatalf("insert custom tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "flow-binding-custom-tunnel")
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, 1, "admin_user", "flow-binding-forward", tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, "flow-binding-forward")
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, remoteNodeID, 33001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
forwardOut := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
if forwardOut.Code != 0 {
t.Fatalf("forward list failed: code=%d msg=%q", forwardOut.Code, forwardOut.Msg)
}
forwardRows := mustContractSlice(t, forwardOut.Data, "forward list data")
shareOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/list", nil)
if shareOut.Code != 0 {
t.Fatalf("share list failed: code=%d msg=%q", shareOut.Code, shareOut.Msg)
}
localShareRows := mustContractSlice(t, shareOut.Data, "share list data")
remoteUsageOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/remote-usage/list", nil)
if remoteUsageOut.Code != 0 {
t.Fatalf("remote usage list failed: code=%d msg=%q", remoteUsageOut.Code, remoteUsageOut.Msg)
}
remoteUsageRows := mustContractSlice(t, remoteUsageOut.Data, "remote usage data")
if len(remoteUsageRows) == 0 {
t.Fatalf("expected non-empty remote usage rows")
}
findForward := func(id int64) map[string]interface{} {
t.Helper()
for _, row := range forwardRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
if contractValueAsInt64(m["id"]) == id {
return m
}
}
t.Fatalf("forward %d not found in /forward/list response", id)
return nil
}
flowByShare := make(map[int64]int64)
shareIDsByTunnel := make(map[int64]map[int64]struct{})
for _, row := range remoteUsageRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
shareID := contractValueAsInt64(m["shareId"])
currentFlow := contractValueAsInt64(m["currentFlow"])
if shareID > 0 && currentFlow > 0 {
if currentFlow > flowByShare[shareID] {
flowByShare[shareID] = currentFlow
}
}
bindings, _ := m["bindings"].([]interface{})
for _, bindingRaw := range bindings {
binding, ok := bindingRaw.(map[string]interface{})
if !ok {
continue
}
tunnelIDVal := contractValueAsInt64(binding["tunnelId"])
chainType := contractValueAsInt64(binding["chainType"])
if shareID <= 0 || tunnelIDVal <= 0 {
continue
}
if chainType != 1 {
continue
}
setByTunnel, ok := shareIDsByTunnel[tunnelIDVal]
if !ok {
setByTunnel = make(map[int64]struct{})
shareIDsByTunnel[tunnelIDVal] = setByTunnel
}
setByTunnel[shareID] = struct{}{}
}
}
for _, row := range localShareRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
shareID := contractValueAsInt64(m["id"])
currentFlow := contractValueAsInt64(m["currentFlow"])
if shareID > 0 && currentFlow > 0 {
if currentFlow > flowByShare[shareID] {
flowByShare[shareID] = currentFlow
}
}
}
targetForward := findForward(forwardID)
parsedByName := contractParseShareIDFromTunnelName(contractValueAsString(targetForward["tunnelName"]))
if parsedByName != 0 {
t.Fatalf("expected custom tunnel name cannot be parsed as Share-*-Port-*, got %d", parsedByName)
}
resolveShareIDForForward := func(forward map[string]interface{}) int64 {
candidates := make(map[int64]struct{})
shareIDFromName := contractParseShareIDFromTunnelName(contractValueAsString(forward["tunnelName"]))
if shareIDFromName > 0 {
candidates[shareIDFromName] = struct{}{}
}
tunnelIDVal := contractValueAsInt64(forward["tunnelId"])
if setByTunnel, ok := shareIDsByTunnel[tunnelIDVal]; ok {
for sid := range setByTunnel {
candidates[sid] = struct{}{}
}
}
var bestShareID int64
bestFlow := int64(0)
for sid := range candidates {
flow := flowByShare[sid]
if flow > bestFlow {
bestFlow = flow
bestShareID = sid
}
}
return bestShareID
}
resolvedShareID := resolveShareIDForForward(targetForward)
if resolvedShareID != remoteShareID {
t.Fatalf("expected resolved shareID=%d via tunnel binding, got %d", remoteShareID, resolvedShareID)
}
forwardCountByShare := make(map[int64]int)
resolvedByForwardID := make(map[int64]int64)
for _, row := range forwardRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
fid := contractValueAsInt64(m["id"])
sid := resolveShareIDForForward(m)
if sid > 0 {
resolvedByForwardID[fid] = sid
}
if sid > 0 && flowByShare[sid] > 0 {
forwardCountByShare[sid] = forwardCountByShare[sid] + 1
}
}
directFlow := contractValueAsInt64(targetForward["inFlow"]) + contractValueAsInt64(targetForward["outFlow"])
if directFlow != 0 {
t.Fatalf("fixture expectation failed: directFlow should be 0, got %d", directFlow)
}
shareFlow := flowByShare[resolvedByForwardID[forwardID]]
if shareFlow <= 0 {
t.Fatalf("expected merged share flow > 0 for resolved share %d", resolvedByForwardID[forwardID])
}
count := forwardCountByShare[resolvedByForwardID[forwardID]]
if count <= 0 {
count = 1
}
estimated := shareFlow / int64(count)
if estimated < 1 {
estimated = 1
}
displayFlow := estimated
if displayFlow <= 0 {
t.Fatalf("expected displayFlow > 0 after tunnel-binding-based merge, got %d", displayFlow)
}
if displayFlow != remoteShareFlow {
t.Fatalf("expected displayFlow=%d, got %d", remoteShareFlow, displayFlow)
}
}
func requestContractEnvelope(t *testing.T, router http.Handler, token string, path string, body interface{}) response.R {
t.Helper()
payload := []byte("{}")
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal request body for %s: %v", path, err)
}
payload = raw
}
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload))
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected http 200 for %s, got %d", path, res.Code)
}
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response for %s: %v", path, err)
}
return out
}
func mustContractSlice(t *testing.T, data interface{}, label string) []interface{} {
t.Helper()
rows, ok := data.([]interface{})
if !ok {
t.Fatalf("expected %s to be []interface{}, got %T", label, data)
}
return rows
}
func contractParseShareIDFromTunnelName(tunnelName string) int64 {
normalized := strings.TrimSpace(tunnelName)
if !strings.HasPrefix(normalized, "Share-") {
return 0
}
raw := strings.TrimPrefix(normalized, "Share-")
idx := strings.Index(raw, "-Port-")
if idx <= 0 {
return 0
}
shareID, err := strconv.ParseInt(strings.TrimSpace(raw[:idx]), 10, 64)
if err != nil || shareID <= 0 {
return 0
}
return shareID
}
func contractValueAsInt64(v interface{}) int64 {
switch n := v.(type) {
case int64:
return n
case int:
return int64(n)
case float64:
return int64(n)
case json.Number:
i, err := n.Int64()
if err == nil {
return i
}
f, err := n.Float64()
if err == nil {
return int64(f)
}
return 0
case string:
i, err := strconv.ParseInt(strings.TrimSpace(n), 10, 64)
if err == nil {
return i
}
return 0
default:
return 0
}
}
func contractValueAsString(v interface{}) string {
s, _ := v.(string)
return s
}
@@ -45,6 +45,18 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
assertCodeMsg(t, resp, -1, "验证码校验失败")
})
t.Run("whmcs api client bypasses captcha", func(t *testing.T) {
body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-FLVX-API-Client", "whmcs")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertCode(t, resp, 0)
})
t.Run("captcha token is one-time and consumed by login", func(t *testing.T) {
verifyReq := httptest.NewRequest(http.MethodPost, "/api/v1/captcha/verify", bytes.NewBufferString(`{"id":"captcha-token-1","data":"ok"}`))
verifyReq.Header.Set("Content-Type", "application/json")
+7 -5
View File
@@ -1,6 +1,6 @@
# GO-GOST SERVICE KNOWLEDGE BASE
**Generated:** Sun Feb 15 2026
**Generated:** Thu Feb 26 2026
## OVERVIEW
Forwarding agent built on GOST v3 with a local fork of `github.com/go-gost/x` under `x/`.
@@ -19,16 +19,18 @@ go-gost/
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Panel integration config | `go-gost/config.go` | Expects `config.json` in cwd by default |
| Service lifecycle/reload | `go-gost/program.go` | Parses config; handles SIGHUP reload |
| WebSocket reporting | `go-gost/main.go` | Starts reporter + sets HTTP report URL |
| Protocol behaviors | `go-gost/x/` | Handlers/listeners/dialers live here |
| **Panel integration config** | `go-gost/config.go` | Expects `config.json` in cwd by default |
| **Service lifecycle/reload** | `go-gost/program.go` | Parses config; handles SIGHUP reload |
| **WebSocket reporting** | `go-gost/main.go` | Starts reporter + sets HTTP report URL |
| **Protocol behaviors** | `go-gost/x/` | Handlers/listeners/dialers live here |
| **Build** | `go-gost/Makefile` | Cross-compile targets for amd64/arm64 |
## CONVENTIONS
- Two configs exist: panel integration uses `config.json`; forwarding services use GOST config (defaults to `gost.{json,yaml}` via viper search paths).
- `go-gost/x/` is the primary extension surface; avoid editing vendored deps.
- Agent communicates with panel via WebSocket (real-time commands) + HTTP (batch traffic reports).
- All panel communication uses AES encryption with node `secret` as PSK.
- CI builds with `CGO_ENABLED=0` for static binaries, then compresses with UPX.
## ANTI-PATTERNS
- **DO NOT EDIT** generated protobuf in `x/internal/util/grpc/proto/`.
+11 -10
View File
@@ -6,27 +6,28 @@ Local fork of `github.com/go-gost/x` used by `go-gost/` via `replace github.com/
## STRUCTURE
```
go-gost/x/
├── api/ # Gin management API + embedded swagger docs
├── api/ # Gin management API + embedded swagger docs (22 files)
├── config/ # Config model + parsing/load/reload
├── connector/ # Outbound connect implementations
├── dialer/ # Outbound dialers (tcp/tls/ws/quic/...)
├── dialer/ # Outbound dialers (tcp/tls/ws/quic/...)
├── handler/ # Protocol handlers (socks/http/tunnel/relay/...)
├── listener/ # Inbound listeners (tcp/udp/tun/tap/redirect/...)
├── limiter/ # Traffic/rate/conn limiters
├── registry/ # Registries for services/handlers/listeners/etc
├── registry/ # Registries for services/handlers/listeners/etc (20 files)
├── service/ # Service wrappers + reporting hooks
├── socket/ # WebSocket reporter / panel integration
├── socket/ # WebSocket reporter / panel integration (6 files)
└── internal/ # Shared internals (grpc proto, net utils, sniffing, tls, ...)
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Management API routes/auth | `go-gost/x/api/api.go` | `/docs`, `/config/*`; BasicAuth + interceptor |
| Service config parsing | `go-gost/x/config/parsing/` | Converts config to running services |
| Add a handler | `go-gost/x/handler/` | Per-protocol subdirs |
| Add a listener/dialer | `go-gost/x/listener/`, `go-gost/x/dialer/` | Transport variants |
| Panel reporting | `go-gost/x/socket/` | WebSocket + HTTP report URL hooks |
| **Management API routes/auth** | `go-gost/x/api/api.go` | `/docs`, `/config/*`; BasicAuth + interceptor |
| **Service config parsing** | `go-gost/x/config/parsing/` | Converts config to running services |
| **Add a handler** | `go-gost/x/handler/` | Per-protocol subdirs |
| **Add a listener/dialer** | `go-gost/x/listener/`, `go-gost/x/dialer/` | Transport variants |
| **Panel reporting** | `go-gost/x/socket/` | WebSocket + HTTP report URL hooks |
| **Register new component** | `go-gost/x/registry/` | `Register{Type}(name, creator)` |
## CONVENTIONS
- `go-gost/x/` is a standalone Go module (`go-gost/x/go.mod`); run go tooling from this dir when debugging module resolution.
@@ -41,4 +42,4 @@ go-gost/x/
```bash
cd go-gost/x
go test ./...
```
```
+11 -12
View File
@@ -1,9 +1,9 @@
# VITE FRONTEND KNOWLEDGE BASE
**Generated:** Thu Feb 19 2026
**Commit:** 137c34e
**Generated:** Thu Feb 26 2026
**Commit:** 21008cc
**Branch:** main
**Tag:** 2.1.4-rc2
**Tag:** 2.1.5-rc15
## OVERVIEW
Web management console for FLVX.
@@ -15,8 +15,8 @@ vite-frontend/
├── src/
│ ├── api/ # Axios wrapper + typed endpoint helpers
│ ├── components/ui/ # shadcn/radix primitive components
│ ├── shadcn-bridge/heroui/ # HeroUI-compatible facade used by pages/layouts
│ ├── pages/ # Route views + page modules (forward/node/tunnel split helpers)
│ ├── shadcn-bridge/heroui/ # HeroUI-compatible facade (23 components)
│ ├── pages/ # Route views + page modules (forward/node/tunnel)
│ ├── hooks/ # H5/WebView/mobile hooks
│ ├── styles/
│ │ ├── globals.css # Base styles + imports tailwind-theme.pcss
@@ -25,8 +25,8 @@ vite-frontend/
│ ├── main.tsx # ReactDOM + BrowserRouter + Provider
│ └── provider.tsx # Toast/theme/provider composition
├── components.json # shadcn/ui config
├── tailwind.config.js # Compatibility config still used by migration scaffolding
├── vite.config.ts # base '/', host 0.0.0.0:3000; build minify/treeshake disabled
├── tailwind.config.js # Compatibility config for migration scaffolding
├── vite.config.ts # base '/', host 0.0.0.0:3000; minify/treeshake disabled
└── package.json
```
@@ -47,8 +47,8 @@ vite-frontend/
- **API Envelope**: Responses follow `{code, msg, data, ts}`.
- **UI Imports**: Use `src/shadcn-bridge/heroui/*` in app pages/layouts for compatibility.
- **Semantic Colors**: Keep `globals.css -> tailwind-theme.pcss` import intact or semantic classes break.
- **Build profile**: `minify: false`, `treeshake: false` for easier debugging.
- **Layout mode**: H5/mobile mode still controlled by existing route/query and hook logic.
- **Build profile**: `minify: false`, `treeshake: false` for debugging.
- **Layout mode**: H5/mobile mode controlled by existing route/query and hook logic.
## ANTI-PATTERNS
- **DO NOT ADD** `Bearer` to auth header in frontend requests.
@@ -57,10 +57,9 @@ vite-frontend/
- **DO NOT ADD** frontend tests; no Vitest/Jest setup exists.
## NOTES
- PR `#144` (shadcn migration) and PR `#142` (user-group binding) are merged in `main`.
- Release tag `2.1.4-rc2` points to commit `137c34e`.
- Button border/color parity depends on both bridge mapping and semantic Tailwind token export.
- Uses `rolldown-vite` (experimental Rust bundler) instead of standard Vite.
- Build outputs are non-minified (debugging mode).
- No test infrastructure exists (Vitest/Jest not configured).
## COMMANDS
```bash
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 114 KiB

+6 -1
View File
@@ -2,7 +2,12 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/pwa-192x192.png" />
<meta name="theme-color" content="#2563eb" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="FLVX" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>FLVX</title>
<script>
+1
View File
@@ -78,6 +78,7 @@
"prettier": "3.5.3",
"typescript": "5.6.3",
"vite": "npm:rolldown-vite@^7.3.1",
"vite-plugin-pwa": "^1.1.0",
"vite-tsconfig-paths": "^6.0.5"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

@@ -55,7 +55,6 @@ export function SearchBar({
transition={{ duration: 0.18, ease: [0.25, 0.46, 0.45, 0.94] }}
>
<Input
autoFocus
classNames={{
base: "bg-default-100",
input:
+8 -2
View File
@@ -38,13 +38,19 @@ function Alert({
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"h5">) {
function AlertTitle({
className,
children,
...props
}: React.ComponentProps<"h5">) {
return (
<h5
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
data-slot="alert-title"
{...props}
/>
>
{children}
</h5>
);
}
+8 -2
View File
@@ -25,7 +25,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
function CardTitle({
className,
children,
...props
}: React.ComponentProps<"h3">) {
return (
<h3
className={cn(
@@ -34,7 +38,9 @@ function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
)}
data-slot="card-title"
{...props}
/>
>
{children}
</h3>
);
}
@@ -0,0 +1,110 @@
import { useEffect, useState } from "react";
import { siteConfig } from "@/config/site";
import {
UPDATE_CHANNEL_CHANGED_EVENT,
type UpdateReleaseChannel,
getLatestVersionByChannel,
getUpdateReleaseChannel,
hasVersionUpdate,
} from "@/utils/version-update";
const FALLBACK_GITHUB_REPO = "https://github.com/Sagit-chu/flux-panel";
interface VersionFooterProps {
version: string;
containerClassName?: string;
versionClassName?: string;
poweredClassName?: string;
updateBadgeClassName?: string;
}
export function VersionFooter({
version,
containerClassName,
versionClassName,
poweredClassName,
updateBadgeClassName,
}: VersionFooterProps) {
const [channel, setChannel] = useState<UpdateReleaseChannel>(
getUpdateReleaseChannel(),
);
const [updateAvailable, setUpdateAvailable] = useState(false);
const [latestUpdateVersion, setLatestUpdateVersion] = useState<string | null>(
null,
);
useEffect(() => {
const handleChannelChange = () => {
setChannel(getUpdateReleaseChannel());
};
window.addEventListener(UPDATE_CHANNEL_CHANGED_EVENT, handleChannelChange);
window.addEventListener("storage", handleChannelChange);
return () => {
window.removeEventListener(
UPDATE_CHANNEL_CHANGED_EVENT,
handleChannelChange,
);
window.removeEventListener("storage", handleChannelChange);
};
}, []);
useEffect(() => {
let active = true;
const checkUpdate = async () => {
const latestVersion = await getLatestVersionByChannel(
channel,
siteConfig.github_repo || FALLBACK_GITHUB_REPO,
);
if (!active) {
return;
}
if (!latestVersion) {
setUpdateAvailable(false);
setLatestUpdateVersion(null);
return;
}
const hasUpdate = hasVersionUpdate(version, latestVersion);
setUpdateAvailable(hasUpdate);
setLatestUpdateVersion(hasUpdate ? latestVersion : null);
};
void checkUpdate();
return () => {
active = false;
};
}, [channel, version]);
return (
<div className={containerClassName}>
<p className={versionClassName}>
v{version}
{updateAvailable && latestUpdateVersion && (
<span className={updateBadgeClassName} role="status">
{latestUpdateVersion}
</span>
)}
</p>
<p className={poweredClassName}>
Powered by{" "}
<a
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
>
FLVX
</a>
</p>
</div>
);
}
+53 -30
View File
@@ -20,6 +20,7 @@ import {
} from "@/shadcn-bridge/heroui/modal";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Logo } from "@/components/icons";
import { VersionFooter } from "@/components/version-footer";
import { updatePassword } from "@/api";
import { safeLogout } from "@/utils/logout";
import { siteConfig } from "@/config/site";
@@ -50,7 +51,9 @@ export default function AdminLayout({
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const [mobileMenuVisible, setMobileMenuVisible] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(() => localStorage.getItem("sidebar_collapsed") === "true");
const [isCollapsed, setIsCollapsed] = useState(
() => localStorage.getItem("sidebar_collapsed") === "true",
);
const [username, setUsername] = useState("");
const [isAdmin, setIsAdmin] = useState(false);
const [passwordLoading, setPasswordLoading] = useState(false);
@@ -208,6 +211,7 @@ export default function AdminLayout({
// 切换折叠状态
const toggleCollapse = () => {
const newCollapsed = !isCollapsed;
setIsCollapsed(newCollapsed);
localStorage.setItem("sidebar_collapsed", newCollapsed.toString());
};
@@ -328,11 +332,12 @@ export default function AdminLayout({
<div className="flex-shrink-0 flex items-center justify-center w-10">
<Logo size={28} />
</div>
<div className={`transition-all duration-300 overflow-hidden ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[180px] opacity-100 ml-2"}`}>
<div
className={`transition-all duration-300 overflow-hidden ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[180px] opacity-100 ml-2"}`}
>
<h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap text-ellipsis">
{siteConfig.name}
</h1>
<p className="text-xs text-default-500 overflow-hidden whitespace-nowrap text-ellipsis">v{siteConfig.version}</p>
</div>
</div>
@@ -345,15 +350,16 @@ export default function AdminLayout({
return (
<li key={item.path}>
<motion.button
title={isCollapsed ? item.label : undefined}
className={`
w-full flex items-center p-2 rounded-lg text-left
relative min-h-[44px] overflow-hidden transition-colors
${isActive
? "text-primary-600 dark:text-primary-300"
: "text-gray-700 dark:text-gray-200"
}
${
isActive
? "text-primary-600 dark:text-primary-300"
: "text-gray-700 dark:text-gray-200"
}
`}
title={isCollapsed ? item.label : undefined}
transition={{ duration: 0.15 }}
onClick={() => handleMenuClick(item.path)}
>
@@ -378,7 +384,9 @@ export default function AdminLayout({
<div className="flex-shrink-0 w-10 h-10 flex items-center justify-center relative z-10">
{item.icon}
</div>
<div className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[200px] opacity-100 ml-2"}`}>
<div
className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[200px] opacity-100 ml-2"}`}
>
<span className="font-medium text-sm relative z-10 whitespace-nowrap">
{item.label}
</span>
@@ -392,38 +400,55 @@ export default function AdminLayout({
{/* 底部版权信息和折叠按钮 */}
<div className="px-5 py-2 pb-4 mt-auto flex-shrink-0 flex items-center justify-between overflow-hidden whitespace-nowrap box-border">
<div className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"}`}>
<p className="text-xs text-gray-400 dark:text-gray-500">
Powered by{" "}
<a
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
>
FLVX
</a>
</p>
<div
className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"}`}
>
<VersionFooter
poweredClassName="text-xs text-gray-400 dark:text-gray-500"
updateBadgeClassName="ml-2 inline-flex items-center rounded-full bg-rose-500/90 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white"
version={siteConfig.version}
versionClassName="text-xs text-gray-400 dark:text-gray-500"
/>
</div>
{/* 桌面端折叠按钮 */}
{!isMobile && (
<Button
isIconOnly
variant="light"
size="sm"
className="flex-shrink-0 text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300 min-w-0 w-10 h-10 rounded-full ml-auto"
size="sm"
variant="light"
onPress={toggleCollapse}
>
{isCollapsed ? (
// 向右扩展的提示
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M13 5l7 7-7 7M5 5l7 7-7 7"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
) : (
// 向左收起的提示
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
)}
</Button>
@@ -532,9 +557,7 @@ export default function AdminLayout({
</header>
{/* 主内容 */}
<main
className="flex-1 bg-gray-100 dark:bg-black overflow-y-auto"
>
<main className="flex-1 bg-gray-100 dark:bg-black overflow-y-auto">
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
+3 -3
View File
@@ -115,10 +115,10 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
<main className="flex-1 bg-gray-100 dark:bg-black">{children}</main>
{/* 用于给固定 Tabbar 腾出空间的占位元素 */}
<div aria-hidden className="h-16 safe-bottom" />
<div aria-hidden className="h-[calc(4rem+var(--safe-area-bottom))]" />
{/* 底部Tabbar */}
<nav className="bg-white dark:bg-black border-t border-gray-200 dark:border-gray-600 h-16 safe-bottom flex-shrink-0 flex items-center justify-around px-2 fixed bottom-0 left-0 right-0 z-30">
<nav className="bg-white dark:bg-black border-t border-gray-200 dark:border-gray-600 h-[calc(4rem+var(--safe-area-bottom))] flex-shrink-0 flex items-center justify-around px-2 fixed bottom-0 left-0 right-0 z-30">
{filteredTabItems.map((item) => {
const isActive = location.pathname === item.path;
@@ -126,7 +126,7 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
<button
key={item.path}
className={`
flex flex-col items-center justify-center flex-1 h-full
flex flex-col items-center justify-center flex-1 h-full pb-[var(--safe-area-bottom)]
transition-colors duration-200 min-h-[44px]
${
isActive
+3
View File
@@ -1,10 +1,13 @@
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { registerSW } from "virtual:pwa-register";
import App from "./App.tsx";
import { Provider } from "./provider.tsx";
import "@/styles/globals.css";
registerSW({ immediate: true });
ReactDOM.createRoot(document.getElementById("root")!).render(
<BrowserRouter>
<Provider>
+52
View File
@@ -33,6 +33,11 @@ import {
clearConfigCache,
updateSiteConfig,
} from "@/config/site";
import {
type UpdateReleaseChannel,
getUpdateReleaseChannel,
setUpdateReleaseChannel,
} from "@/utils/version-update";
// 简单的保存图标组件
const SaveIcon = ({ className }: { className?: string }) => (
@@ -182,6 +187,9 @@ export default function ConfigPage() {
});
const [announcementLoading, setAnnouncementLoading] = useState(true);
const [announcementSaving, setAnnouncementSaving] = useState(false);
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
getUpdateReleaseChannel(),
);
// 权限检查
useEffect(() => {
@@ -267,6 +275,14 @@ export default function ConfigPage() {
}
};
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
setUpdateChannel(channel);
setUpdateReleaseChannel(channel);
toast.success(
`更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`,
);
};
const handleConfigChange = (key: string, value: string) => {
const newConfigs = { ...configs, [key]: value };
@@ -643,6 +659,42 @@ export default function ConfigPage() {
</div>
);
})}
<Divider className="my-2" />
<div className="space-y-3">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
更新通道
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
稳定版仅匹配纯数字版本;开发版仅匹配包含 alpha / beta / rc
的版本。
</p>
</div>
<Select
selectedKeys={[updateChannel]}
size="md"
variant="bordered"
onSelectionChange={(keys) => {
const selected =
(Array.from(keys)[0] as UpdateReleaseChannel) || "stable";
handleUpdateChannelChange(selected);
}}
>
<SelectItem key="stable" description="仅纯数字版本,如 2.1.4">
稳定版
</SelectItem>
<SelectItem
key="dev"
description="仅 alpha / beta / rc 关键字版本"
>
开发版
</SelectItem>
</Select>
</div>
</CardBody>
</Card>
File diff suppressed because it is too large Load Diff
+8 -16
View File
@@ -9,6 +9,7 @@ import { Input } from "@/shadcn-bridge/heroui/input";
import { Button } from "@/shadcn-bridge/heroui/button";
import { siteConfig } from "@/config/site";
import { title } from "@/components/primitives";
import { VersionFooter } from "@/components/version-footer";
import DefaultLayout from "@/layouts/default";
import { login, LoginData, checkCaptcha, getConfigByName } from "@/api";
import { writeLoginSession } from "@/utils/session";
@@ -213,22 +214,13 @@ export default function IndexPage() {
{/* 版权信息 - 固定在底部,不占据布局空间 */}
<div className="fixed inset-x-0 bottom-4 text-center py-4">
<p className="text-xs text-gray-400 dark:text-gray-500">
Powered by{" "}
<a
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
>
FLVX
</a>
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
v{isWebView ? siteConfig.app_version : siteConfig.version}
</p>
</div>
<VersionFooter
containerClassName="fixed inset-x-0 bottom-4 text-center py-4"
poweredClassName="text-xs text-gray-400 dark:text-gray-500"
updateBadgeClassName="ml-2 inline-flex items-center rounded-full bg-rose-500/90 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white"
version={isWebView ? siteConfig.app_version : siteConfig.version}
versionClassName="text-xs text-gray-400 dark:text-gray-500 mt-1"
/>
{/* 验证码弹层 */}
{showCaptcha && siteKey && (
+73 -56
View File
@@ -1075,64 +1075,81 @@ export default function NodePage() {
/>
</div>
<div className="flex gap-2 items-center">
<Button
color={selectMode ? "secondary" : "default"}
size="sm"
variant={selectMode ? "solid" : "flat"}
onPress={toggleSelectMode}
>
{selectMode ? "退出" : "批量"}
</Button>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
</div>
</div>
{/* 批量操作浮动工具栏 */}
{selectMode && selectedIds.size > 0 && (
<div className="fixed bottom-7 left-1/2 z-50 w-[calc(100vw-1rem)] max-w-max -translate-x-1/2 overflow-x-auto rounded-lg border border-divider bg-content1 p-2 shadow-lg">
<div className="flex min-w-max items-center gap-2">
<span className="text-sm font-medium shrink-0">
已选 {selectedIds.size} 项
</span>
<Button
color="primary"
size="sm"
variant="flat"
onPress={selectAll}
>
全选
</Button>
<Button
color="secondary"
size="sm"
variant="flat"
onPress={deselectAll}
>
清空
</Button>
<Button
color="warning"
isLoading={batchUpgradeLoading}
size="sm"
variant="flat"
onPress={() => openUpgradeModal("batch")}
>
升级
</Button>
<Button
color="danger"
size="sm"
variant="flat"
onPress={() => setBatchDeleteModalOpen(true)}
>
删除
</Button>
<div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
<div className="flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap [&>*]:shrink-0">
{selectMode ? (
<>
<span className="text-sm text-default-600 shrink-0">
已选 {selectedIds.size} 项
</span>
<Button
color="primary"
size="sm"
variant="flat"
onPress={selectAll}
>
全选
</Button>
<Button
color="secondary"
size="sm"
variant="flat"
onPress={deselectAll}
>
清空
</Button>
<Button
color="warning"
isDisabled={selectedIds.size === 0}
isLoading={batchUpgradeLoading}
size="sm"
variant="flat"
onPress={() => openUpgradeModal("batch")}
>
升级
</Button>
<Button
color="danger"
isDisabled={selectedIds.size === 0}
size="sm"
variant="flat"
onPress={() => setBatchDeleteModalOpen(true)}
>
删除
</Button>
<Button
color="secondary"
size="sm"
variant="solid"
onPress={toggleSelectMode}
>
退出
</Button>
</>
) : (
<>
<Button
className="bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45"
color="default"
size="sm"
variant="flat"
onPress={toggleSelectMode}
>
批量
</Button>
<Button
color="primary"
size="sm"
variant="flat"
onPress={handleAdd}
>
新增
</Button>
</>
)}
</div>
</div>
)}
</div>
{!wsConnected && (
<Alert
+13 -2
View File
@@ -375,6 +375,9 @@ export default function PanelSharingPage() {
};
const formatChainType = (chainType: number, hopInx: number) => {
if (chainType === 1) {
return "入口节点";
}
if (chainType === 2) {
return `中继跳点 #${hopInx}`;
}
@@ -674,7 +677,11 @@ export default function PanelSharingPage() {
</Tabs>
{/* Create Share Modal */}
<Modal isOpen={createShareOpen} onClose={() => setCreateShareOpen(false)}>
<Modal
isOpen={createShareOpen}
scrollBehavior="inside"
onClose={() => setCreateShareOpen(false)}
>
<ModalContent>
<ModalHeader>创建分享</ModalHeader>
<ModalBody>
@@ -777,7 +784,11 @@ export default function PanelSharingPage() {
</Modal>
{/* Edit Share Modal */}
<Modal isOpen={editShareOpen} onClose={() => setEditShareOpen(false)}>
<Modal
isOpen={editShareOpen}
scrollBehavior="inside"
onClose={() => setEditShareOpen(false)}
>
<ModalContent>
<ModalHeader>编辑分享</ModalHeader>
<ModalBody>
+10 -16
View File
@@ -15,6 +15,7 @@ import {
import { Input } from "@/shadcn-bridge/heroui/input";
import { isWebViewFunc } from "@/utils/panel";
import { siteConfig } from "@/config/site";
import { VersionFooter } from "@/components/version-footer";
import { updatePassword } from "@/api";
import { safeLogout } from "@/utils/logout";
import { getAdminFlag, getSessionName } from "@/utils/session";
@@ -312,22 +313,15 @@ export default function ProfilePage() {
</CardBody>
</Card>
<div className="fixed inset-x-0 bottom-20 text-center py-4">
<p className="text-xs text-gray-400 dark:text-gray-500">
Powered by{" "}
<a
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
>
FLVX
</a>
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
v{isWebViewFunc() ? siteConfig.app_version : siteConfig.version}
</p>
</div>
<VersionFooter
containerClassName="fixed inset-x-0 bottom-20 text-center py-4"
poweredClassName="text-xs text-gray-400 dark:text-gray-500"
updateBadgeClassName="ml-2 inline-flex items-center rounded-full bg-rose-500/90 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white"
version={
isWebViewFunc() ? siteConfig.app_version : siteConfig.version
}
versionClassName="text-xs text-gray-400 dark:text-gray-500 mt-1"
/>
</div>
{/* 修改密码弹窗 */}
+47
View File
@@ -5,7 +5,13 @@ import toast from "react-hot-toast";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { reinitializeBaseURL } from "@/api/network";
import {
type UpdateReleaseChannel,
getUpdateReleaseChannel,
setUpdateReleaseChannel,
} from "@/utils/version-update";
import {
getPanelAddresses,
savePanelAddress,
@@ -25,6 +31,9 @@ export const SettingsPage = () => {
const [panelAddresses, setPanelAddresses] = useState<PanelAddress[]>([]);
const [newName, setNewName] = useState("");
const [newAddress, setNewAddress] = useState("");
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
getUpdateReleaseChannel(),
);
const setPanelAddressesFunc = (newAddress: PanelAddress[]) => {
setPanelAddresses(newAddress);
@@ -79,6 +88,14 @@ export const SettingsPage = () => {
loadPanelAddresses();
}, []);
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
setUpdateChannel(channel);
setUpdateReleaseChannel(channel);
toast.success(
`更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`,
);
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-black">
{/* 顶部导航 */}
@@ -117,6 +134,36 @@ export const SettingsPage = () => {
{/* 内容区域 */}
<div className="max-w-4xl mx-auto px-4 py-6">
<div className="space-y-6">
<Card className="border border-gray-200 dark:border-gray-700">
<CardBody className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
更新设置
</h2>
<div className="space-y-2">
<Select
label="更新通道"
selectedKeys={[updateChannel]}
onSelectionChange={(keys) => {
const selected =
(Array.from(keys)[0] as UpdateReleaseChannel) || "stable";
handleUpdateChannelChange(selected);
}}
>
<SelectItem key="stable" textValue="stable">
稳定版(纯数字版本,如 2.1.4)
</SelectItem>
<SelectItem key="dev" textValue="dev">
开发版(含 alpha / beta / rc)
</SelectItem>
</Select>
<p className="text-xs text-gray-500 dark:text-gray-400">
版本提示会根据该通道检查最新版本。
</p>
</div>
</CardBody>
</Card>
{/* 添加新地址 */}
<Card className="border border-gray-200 dark:border-gray-700">
<CardBody className="p-6">
+73 -55
View File
@@ -710,63 +710,81 @@ export default function TunnelPage() {
/>
</div>
<div className="flex items-center gap-2">
<Button
color={selectMode ? "secondary" : "default"}
size="sm"
variant={selectMode ? "solid" : "flat"}
onPress={toggleSelectMode}
>
{selectMode ? "退出" : "批量"}
</Button>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
</div>
</div>
{selectMode && selectedIds.size > 0 && (
<div className="fixed bottom-7 left-1/2 z-50 w-[calc(100vw-1rem)] max-w-max -translate-x-1/2 overflow-x-auto rounded-lg border border-divider bg-content1 p-2 shadow-lg">
<div className="flex min-w-max items-center gap-2">
<span className="text-sm text-default-600 shrink-0">
已选择 {selectedIds.size} 项
</span>
<Button
color="primary"
size="sm"
variant="flat"
onPress={selectAll}
>
全选
</Button>
<Button
color="secondary"
size="sm"
variant="flat"
onPress={deselectAll}
>
清空
</Button>
<Button
color="danger"
size="sm"
variant="flat"
onPress={() => setBatchDeleteModalOpen(true)}
>
删除
</Button>
<Button
color="primary"
isLoading={batchLoading}
size="sm"
variant="flat"
onPress={handleBatchRedeploy}
>
下发
</Button>
<div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
<div className="flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap [&>*]:shrink-0">
{selectMode ? (
<>
<span className="text-sm text-default-600 shrink-0">
已选择 {selectedIds.size} 项
</span>
<Button
color="primary"
size="sm"
variant="flat"
onPress={selectAll}
>
全选
</Button>
<Button
color="secondary"
size="sm"
variant="flat"
onPress={deselectAll}
>
清空
</Button>
<Button
color="danger"
isDisabled={selectedIds.size === 0}
size="sm"
variant="flat"
onPress={() => setBatchDeleteModalOpen(true)}
>
删除
</Button>
<Button
color="primary"
isDisabled={selectedIds.size === 0}
isLoading={batchLoading}
size="sm"
variant="flat"
onPress={handleBatchRedeploy}
>
下发
</Button>
<Button
color="secondary"
size="sm"
variant="solid"
onPress={toggleSelectMode}
>
退出
</Button>
</>
) : (
<>
<Button
className="bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45"
color="default"
size="sm"
variant="flat"
onPress={toggleSelectMode}
>
批量
</Button>
<Button
color="primary"
size="sm"
variant="flat"
onPress={handleAdd}
>
新增
</Button>
</>
)}
</div>
</div>
)}
</div>
{/* 隧道卡片网格 */}
{tunnels.length > 0 ? (
-1
View File
@@ -638,7 +638,6 @@ export default function UserPage() {
) : (
<div className="flex w-full items-center gap-2 animate-appearance-in">
<Input
autoFocus
classNames={{
base: "bg-default-100",
input: "bg-transparent",
@@ -19,11 +19,18 @@ function mapColor(color: LinkColor) {
return "text-default-600 hover:text-default-700";
}
export function Link({ className, color = "default", ...props }: LinkProps) {
export function Link({
className,
color = "default",
children,
...props
}: LinkProps) {
return (
<a
className={cn("transition-colors", mapColor(color), className)}
{...props}
/>
>
{children}
</a>
);
}
+218
View File
@@ -0,0 +1,218 @@
export type UpdateReleaseChannel = "stable" | "dev";
export const UPDATE_CHANNEL_STORAGE_KEY = "update-release-channel";
export const UPDATE_CHANNEL_CHANGED_EVENT = "updateReleaseChannelChanged";
const CHANNEL_STABLE: UpdateReleaseChannel = "stable";
const CHANNEL_DEV: UpdateReleaseChannel = "dev";
const stableVersionPattern = /^\d+(?:\.\d+)+$/;
const testKeywordPattern = /(alpha|beta|rc)/i;
const VERSION_CACHE_TTL_MS = 10 * 60 * 1000;
type ReleaseItem = {
tag_name?: string;
draft?: boolean;
};
type LatestVersionCacheEntry = {
value: string | null;
expiresAt: number;
};
const latestVersionCache: Record<
UpdateReleaseChannel,
LatestVersionCacheEntry
> = {
stable: { value: null, expiresAt: 0 },
dev: { value: null, expiresAt: 0 },
};
const normalizeChannel = (
value: string | null | undefined,
): UpdateReleaseChannel => {
return value === CHANNEL_DEV ? CHANNEL_DEV : CHANNEL_STABLE;
};
export const getUpdateReleaseChannel = (): UpdateReleaseChannel => {
if (typeof window === "undefined") {
return CHANNEL_STABLE;
}
return normalizeChannel(localStorage.getItem(UPDATE_CHANNEL_STORAGE_KEY));
};
export const setUpdateReleaseChannel = (
channel: UpdateReleaseChannel,
): void => {
if (typeof window === "undefined") {
return;
}
localStorage.setItem(UPDATE_CHANNEL_STORAGE_KEY, normalizeChannel(channel));
window.dispatchEvent(new Event(UPDATE_CHANNEL_CHANGED_EVENT));
};
const normalizeTag = (tag: string): string => {
return tag.trim().replace(/^v/i, "");
};
type ReleaseTagChannel = UpdateReleaseChannel | null;
const releaseChannelFromTag = (tag: string): ReleaseTagChannel => {
const normalizedTag = normalizeTag(tag).toLowerCase();
if (!normalizedTag) {
return null;
}
if (stableVersionPattern.test(normalizedTag)) {
return CHANNEL_STABLE;
}
if (testKeywordPattern.test(normalizedTag)) {
return CHANNEL_DEV;
}
return null;
};
type VersionParts = {
numbers: number[];
stageRank: number;
stageNumber: number;
};
const parseVersionParts = (version: string): VersionParts => {
const normalized = normalizeTag(version).toLowerCase();
const numberMatches = normalized.match(/\d+/g) || [];
const numbers = numberMatches.map((item) => Number.parseInt(item, 10));
let stageRank = 0;
if (normalized.includes("rc")) {
stageRank = 3;
} else if (normalized.includes("beta")) {
stageRank = 2;
} else if (normalized.includes("alpha")) {
stageRank = 1;
} else if (stableVersionPattern.test(normalized)) {
stageRank = 4;
}
const stageNumberMatch = normalized.match(/(?:alpha|beta|rc)[.-]?(\d+)/);
const stageNumber = stageNumberMatch
? Number.parseInt(stageNumberMatch[1], 10)
: 0;
return {
numbers,
stageRank,
stageNumber,
};
};
export const compareVersions = (left: string, right: string): number => {
const a = parseVersionParts(left);
const b = parseVersionParts(right);
const maxLength = Math.max(a.numbers.length, b.numbers.length);
for (let i = 0; i < maxLength; i += 1) {
const aValue = a.numbers[i] || 0;
const bValue = b.numbers[i] || 0;
if (aValue !== bValue) {
return aValue - bValue;
}
}
if (a.stageRank !== b.stageRank) {
return a.stageRank - b.stageRank;
}
if (a.stageNumber !== b.stageNumber) {
return a.stageNumber - b.stageNumber;
}
return 0;
};
const repoPathFromUrl = (repoUrl: string): string | null => {
try {
const parsed = new URL(repoUrl);
const segments = parsed.pathname
.replace(/\.git$/i, "")
.split("/")
.filter(Boolean);
if (segments.length < 2) {
return null;
}
return `${segments[0]}/${segments[1]}`;
} catch {
return null;
}
};
export const getLatestVersionByChannel = async (
channel: UpdateReleaseChannel,
repoUrl: string,
): Promise<string | null> => {
const normalizedChannel = normalizeChannel(channel);
const now = Date.now();
const cached = latestVersionCache[normalizedChannel];
if (cached.value && cached.expiresAt > now) {
return cached.value;
}
const repoPath = repoPathFromUrl(repoUrl);
if (!repoPath) {
return null;
}
const response = await fetch(
`https://api.github.com/repos/${repoPath}/releases?per_page=50`,
{
headers: {
Accept: "application/vnd.github+json",
},
},
);
if (!response.ok) {
return null;
}
const releases = (await response.json()) as ReleaseItem[];
const candidateTags = releases
.filter((release) => !release.draft && typeof release.tag_name === "string")
.map((release) => (release.tag_name || "").trim())
.filter((tag) => releaseChannelFromTag(tag) === normalizedChannel);
if (candidateTags.length === 0) {
return null;
}
const latest = candidateTags.sort((a, b) => compareVersions(b, a))[0];
latestVersionCache[normalizedChannel] = {
value: latest,
expiresAt: now + VERSION_CACHE_TTL_MS,
};
return latest;
};
export const hasVersionUpdate = (
currentVersion: string,
latestVersion: string,
): boolean => {
return (
compareVersions(normalizeTag(currentVersion), normalizeTag(latestVersion)) <
0
);
};
+9
View File
@@ -1,4 +1,5 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />
interface ImportMetaEnv {
readonly VITE_APP_VERSION?: string;
@@ -8,3 +9,11 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare module "virtual:pwa-register" {
export function registerSW(options?: {
immediate?: boolean;
onNeedRefresh?: () => void;
onOfflineReady?: () => void;
}): (reloadPage?: boolean) => Promise<void>;
}
+42 -1
View File
@@ -2,9 +2,50 @@ import path from "path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [react()],
plugins: [
react(),
VitePWA({
registerType: "autoUpdate",
injectRegister: "auto",
includeAssets: ["favicon.ico", "apple-touch-icon.png"],
manifest: {
name: "FLVX",
short_name: "FLVX",
description: "FLVX forwarding management panel",
theme_color: "#2563eb",
background_color: "#f6f7fb",
display: "standalone",
start_url: "/",
scope: "/",
icons: [
{
src: "pwa-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "pwa-512x512.png",
sizes: "512x512",
type: "image/png",
},
{
src: "pwa-maskable-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
},
workbox: {
navigateFallback: "/index.html",
cleanupOutdatedCaches: true,
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
},
}),
],
base: "/",
resolve: {
alias: {