mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 15:46:38 +08:00
Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efc8c75dcb | |||
| e5acc49186 | |||
| d98377a297 | |||
| 950e9a9ba8 | |||
| 3f3159aafd | |||
| 5e8d0682c0 | |||
| 3c0e833cfc | |||
| 60311d3e47 | |||
| b4192c9e94 | |||
| f05e9480ee | |||
| 9861b44107 | |||
| d8144821e6 | |||
| 023be27287 | |||
| e8d5687419 | |||
| 0fbe570597 | |||
| 4c52d7fec2 | |||
| 3373e5ade9 | |||
| bd27b94909 | |||
| a5a500bc0f | |||
| edfe2a2372 | |||
| 7a9ba8bd81 | |||
| 46394388b1 | |||
| dec337d46b | |||
| 9e8d27d98e | |||
| a2000e4d98 | |||
| 2b76a9f0be | |||
| 54d7dfb7c9 | |||
| 9f19d5fe15 | |||
| 2ca3849917 | |||
| 58d2e89147 | |||
| 87a1a34ad5 | |||
| a625884d61 | |||
| 799bb66fe5 | |||
| 3f374df724 | |||
| 9b923a2d0b | |||
| d9f28f53c7 | |||
| 1d08a1ccfc | |||
| db25ba2cbe | |||
| a498067261 | |||
| b5922dccf2 | |||
| c259645227 | |||
| bdc2c4ecbb | |||
| a070d0f4d3 | |||
| eecdd62d3a | |||
| e6d3b847bb | |||
| 0b49cd720f | |||
| 4f488ae7ef | |||
| 2b2b417f91 | |||
| 01b4c3e3eb | |||
| e7c967df00 | |||
| efaf920e51 | |||
| 9aff669c0e | |||
| 6e60f5cfbd | |||
| a03c320b89 | |||
| 61dba0ae57 | |||
| f3d6366471 | |||
| c431d79403 | |||
| 5107f59d94 | |||
| 431613cb6a | |||
| a6b218f3ee | |||
| d5d26d9cf9 | |||
| c1bc795674 | |||
| 30e1473f06 | |||
| eaf16bf17b | |||
| e995d70be7 | |||
| a968a10792 | |||
| ea156c33bc | |||
| 29407c90b6 | |||
| 37341af2d1 | |||
| bb48ab00bd | |||
| f6d2ab3270 | |||
| b382caa347 | |||
| 288c5d7152 | |||
| b42516cea1 | |||
| 74ed74ba00 | |||
| 7c1f5ca660 | |||
| 26013f8dfa | |||
| ab0f36ba7b | |||
| cde379a1f4 | |||
| 96fc790ed7 | |||
| f0351107dd | |||
| 534d36f0a1 | |||
| 7668e24f4b | |||
| c1f96180f5 | |||
| 630e012ec1 | |||
| 7f14bd30fa | |||
| 8611748c46 | |||
| 77b7f066f3 | |||
| 1970a74f6a | |||
| b66c4966ba |
@@ -21,11 +21,14 @@ jobs:
|
||||
with:
|
||||
node-version: '20.19.0'
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --legacy-peer-deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
run: pnpm run build
|
||||
|
||||
backend:
|
||||
name: Build Go Backend
|
||||
|
||||
@@ -1,123 +1,62 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
# AGENTS
|
||||
|
||||
**Generated:** Tue Mar 24 2026
|
||||
**Commit:** 8ebde9d
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-rc10
|
||||
FLVX — traffic forwarding panel: Go admin API + Vite/React UI + Go agent.
|
||||
|
||||
## OVERVIEW
|
||||
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite/PostgreSQL) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
|
||||
## Structure
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
./
|
||||
├── go-gost/ # Go forwarding agent (forked gost + local x/)
|
||||
│ └── x/ # Local fork of github.com/go-gost/x (replace => ./x)
|
||||
├── go-backend/ # Go Admin API (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/test + Docker push + release artifacts
|
||||
```
|
||||
| Dir | Role | Entry |
|
||||
|-----|------|-------|
|
||||
| `go-backend/` | Admin API (GORM + SQLite/PG, net/http) | `cmd/paneld/main.go` |
|
||||
| `go-gost/` | Forwarding agent (forked GOST) | `main.go` |
|
||||
| `go-gost/x/` | Protocol handlers/dialers/listeners (own module) | — |
|
||||
| `vite-frontend/` | React dashboard (shadcn bridge + Tailwind v4) | `src/App.tsx` |
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| **Deploy (Docker)** | `docker-compose-v4.yml` | Env: `JWT_SECRET`, `BACKEND_PORT`, `FRONTEND_PORT` |
|
||||
| **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/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 |
|
||||
`go-gost/go.mod` uses `replace github.com/go-gost/x => ./x`.
|
||||
|
||||
## CODE MAP
|
||||
| Symbol | Type | Location | Role |
|
||||
|--------|------|----------|------|
|
||||
| `flvx` | Project | `.` | Root directory |
|
||||
| `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 |
|
||||
## Commands
|
||||
|
||||
## CONVENTIONS
|
||||
- **Skills & MCP**: Always prefer using available skills (via `skill` tool) and MCP tools when applicable. Check for relevant skills before implementing from scratch.
|
||||
- **Auth**: `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `go-backend/`.
|
||||
- **Module Fork**: `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
|
||||
- **Encryption**: Agent-to-panel communication uses AES encryption with node `secret` as PSK.
|
||||
- **API Envelope**: All REST responses follow `{code, msg, data, ts}` structure (code 0 = success).
|
||||
- **Frontend UI Layer**: Import UI primitives from `src/shadcn-bridge/heroui/*` (legacy-compatible facade), not direct `@heroui/*` packages.
|
||||
- **Tailwind v4 Semantic Colors**: `src/styles/globals.css` must import `src/styles/tailwind-theme.pcss`; removing it breaks semantic classes like `bg-primary`, `text-foreground`, and `border-input`.
|
||||
- **Go Versions**: `go-backend` uses Go 1.24, `go-gost` uses Go 1.23, `go-gost/x` uses Go 1.22.
|
||||
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
- **DO NOT EDIT** generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
|
||||
- **DO NOT ADD** `Bearer` prefix to Authorization header - expects raw JWT token.
|
||||
- **DO NOT MODIFY** `install.sh` or `panel_install.sh` locally - CI overwrites these on release.
|
||||
- **DO NOT** let backend handlers call `repo.DB()` directly — add a Repository method instead.
|
||||
- **DO NOT ADD** frontend tests - project has no test infrastructure (Vitest/Jest not configured).
|
||||
- **DO NOT REINTRODUCE** `@heroui/*` or `@nextui-org/*` dependencies; migration is now shadcn bridge-based.
|
||||
- **DO NOT** use `type:jsonb` or `type:serial` in GORM tags (SQLite incompatible).
|
||||
- **DO NOT** omit `TableName()` on new models — GORM pluralizes by default.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
# Panel (Docker)
|
||||
docker compose -f docker-compose-v4.yml up -d
|
||||
docker compose -f docker-compose-v6.yml up -d
|
||||
|
||||
# Release-based install scripts
|
||||
./panel_install.sh
|
||||
./install.sh
|
||||
|
||||
# Local dev (per subproject)
|
||||
# Backend
|
||||
(cd go-backend && go run ./cmd/paneld) # SERVER_ADDR defaults to :6365
|
||||
(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/...)
|
||||
|
||||
# Frontend
|
||||
(cd vite-frontend && pnpm install)
|
||||
(cd vite-frontend && pnpm run dev) # host 0.0.0.0:3000
|
||||
(cd vite-frontend && pnpm run build) # tsc && vite build
|
||||
(cd vite-frontend && pnpm run lint) # eslint --fix (no typecheck command)
|
||||
|
||||
# Agent
|
||||
(cd go-gost && go run .)
|
||||
```
|
||||
|
||||
## 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.
|
||||
## Conventions
|
||||
|
||||
## NOTES
|
||||
- LSP servers are not installed in this environment (gopls/typescript-language-server); rely on grep-based navigation.
|
||||
- `vite-frontend/vite.config.ts` sets `minify: false` and disables treeshake; expect larger bundles.
|
||||
- `vite-frontend` uses `rolldown-vite` (experimental Rust bundler) instead of standard Vite.
|
||||
- Install scripts (`install.sh`, `panel_install.sh`) self-delete after execution - common pattern in one-liner installs.
|
||||
- CI uses UPX compression (`--best --lzma`) on Go binaries before release.
|
||||
- CI dynamically injects `PINNED_VERSION` into install scripts and docker-compose files during releases.
|
||||
- `panel_install.sh` auto-detects IPv6 and modifies `/etc/docker/daemon.json` to enable IPv6 bridge.
|
||||
- Download proxy `https://gcode.hostcentral.cc/` used for GitHub downloads in China/restricted environments.
|
||||
- Backend has contract tests in `go-backend/tests/contract/` - frontend has no test infrastructure.
|
||||
- `analysis/3x-ui/` contains a separate git repo for reference/comparison - not part of FLVX core.
|
||||
- CI workflows: `ci-build.yml` (build check), `docker-build.yml` (multi-arch images + release), `deploy-docs.yml` (MkDocs).
|
||||
- PostgreSQL migration supported via `panel_install.sh` menu option using pgloader.
|
||||
- Repository layer is large: `repository.go` (83k LOC), `repository_mutations.go` (43k LOC).
|
||||
- Button visual parity relies on `vite-frontend/src/shadcn-bridge/heroui/button.tsx` color mapping + `vite-frontend/src/styles/tailwind-theme.pcss` token export.
|
||||
- **Auth**: raw JWT in `Authorization` header — **no `Bearer` prefix** (both frontend and backend).
|
||||
- **API envelope**: all responses `{code, msg, data, ts}` (code 0 = success).
|
||||
- **Frontend UI**: import from `src/shadcn-bridge/heroui/*`, never `@heroui/*` or `@nextui-org/*`.
|
||||
- **Tailwind theme**: `globals.css` must import `tailwind-theme.pcss` or semantic classes break.
|
||||
- **Backend DB**: handlers use Repository methods, never `repo.DB()` directly.
|
||||
- **GORM models**: always define `TableName()` (GORM pluralizes by default).
|
||||
- **GORM tags**: no `type:jsonb` or `type:serial` (SQLite incompatible).
|
||||
- **Go versions**: `go.mod` says 1.25.0 for all three modules; CI builds with 1.23.
|
||||
|
||||
## PLAN DOCUMENT RULE
|
||||
- Every new implementation plan must have a dedicated Markdown plan document.
|
||||
- Store plan documents under `plans/`.
|
||||
- Use an incrementing numeric prefix and a short plan-summary name: `NNN-<plan-summary>.md` (for example, `001-auth-refactor.md`, `002-federation-api-cleanup.md`).
|
||||
- The numeric prefix must increase by 1 for each new plan.
|
||||
- In each plan document, keep a task checklist and mark each task as completed immediately after finishing it.
|
||||
## Anti-patterns
|
||||
|
||||
- Don't edit `install.sh` or `panel_install.sh` locally (CI overwrites on release).
|
||||
- Don't edit `go-gost/x/internal/util/grpc/proto/*.pb.go` (generated).
|
||||
- Don't add frontend tests (no Vitest/Jest configured).
|
||||
- Don't reintroduce `@heroui/*` or `@nextui-org/*` packages.
|
||||
|
||||
## Testing
|
||||
|
||||
- Backend: `(cd go-backend && go test ./...)` — includes contract tests in `tests/contract/`.
|
||||
- Frontend: no test infrastructure.
|
||||
- CI runs one PostgreSQL contract test: env var `FLVX_POSTGRES_TEST_DSN`.
|
||||
|
||||
## Build quirks
|
||||
|
||||
- `vite-frontend` uses `rolldown-vite` (Rust bundler), not standard Vite.
|
||||
- `vite.config.ts`: `minify: false`, `treeshake: false` (debugging mode).
|
||||
- CI builds `go-gost` with `CGO_ENABLED=0` then compresses with UPX `--best --lzma`.
|
||||
|
||||
@@ -184,7 +184,6 @@ This fork (FLVX) is no longer a light patch on top of the upstream project. It h
|
||||
|
||||
| 网络 | 地址 |
|
||||
|------------|----------------------------------------------------------------------|
|
||||
| BNB(BEP20) | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
|
||||
| TRC20 | `TM8VYdU3s3gSX5PC8swjAJrAzZFCHKqG2k` |
|
||||
| Aptos | `0x49427bfcba1006a346447430689b2307ac156316bb34850d1d3029ff9d118da5` |
|
||||
| polygon | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
|
||||
| BNB(BEP20) | `0x271327ce49140e670eA0F772d9886BF90E9022Ee` |
|
||||
| TRC20 | `TARxZWggaxFqYgxGVBxPkyykgYKNmGndmE` |
|
||||
| polygon | `0x271327ce49140e670eA0F772d9886BF90E9022Ee` |
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# Flvx iOS 26 Liquid Glass UI Redesign Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Transform the Flvx frontend interface entirely into an "Apple iOS 26 Liquid Glass" visual style by utilizing high-radius squircles, heavy background blurs, mesh gradients, and highly semantic translucent containers.
|
||||
|
||||
**Architecture:** We will approach this from the ground up: first defining the global TailwindCSS design tokens and the base mesh-gradient layout, then systematically replacing the structural styling inside each React page component (`vite-frontend/src/pages/*.tsx`).
|
||||
|
||||
**Tech Stack:** React DOM, TailwindCSS (v4), shadcn-bridge (HeroUI), Vite
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Setup Global CSS Variables and App Shell
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/styles/globals.css` (or `index.css`)
|
||||
- Modify: `vite-frontend/tailwind.config.js`
|
||||
- Modify: `vite-frontend/src/App.tsx` (or `main.tsx` / `layouts` depending on structural entry point)
|
||||
|
||||
- [ ] **Step 1: Inject Liquid Glass theme variables**
|
||||
Open the main CSS file and add variables for the new blur radius, box-shadows, and background gradients.
|
||||
```css
|
||||
:root {
|
||||
--glass-bg: rgba(255, 255, 255, 0.6);
|
||||
--glass-border: rgba(255, 255, 255, 0.8);
|
||||
--glass-card: rgba(255, 255, 255, 0.7);
|
||||
--glass-overlay: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.dark {
|
||||
--glass-bg: rgba(30, 30, 30, 0.6);
|
||||
--glass-border: rgba(255, 255, 255, 0.15);
|
||||
--glass-card: rgba(40, 40, 40, 0.6);
|
||||
}
|
||||
.bg-mesh-gradient {
|
||||
background: radial-gradient(at 0% 0%, #ff9a9e 0%, transparent 50%),
|
||||
radial-gradient(at 100% 0%, #fecfef 0%, transparent 50%),
|
||||
radial-gradient(at 100% 100%, #c2e9fb 0%, transparent 50%),
|
||||
radial-gradient(at 0% 100%, #a1c4fd 0%, transparent 50%);
|
||||
background-color: #f2f2f7;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update App Layout**
|
||||
Modify the root app container to use `.bg-mesh-gradient` and ensure the main container occupies `min-h-screen`.
|
||||
|
||||
### Task 2: Refactor Global Components (Card & Modal)
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/shadcn-bridge/heroui/card.tsx`
|
||||
- Modify: `vite-frontend/src/shadcn-bridge/heroui/modal.tsx`
|
||||
|
||||
- [ ] **Step 1: Liquid Card Base**
|
||||
Update the default className string for `Card` to incorporate: `backdrop-blur-3xl bg-white/60 dark:bg-zinc-900/60 border border-white/80 dark:border-white/10 rounded-2xl shadow-[0_10px_30px_rgba(0,0,0,0.1)]`.
|
||||
|
||||
- [ ] **Step 2: Modal Overlay Base**
|
||||
Update the default overlay className for `Modal` to use `bg-black/30 backdrop-blur-sm`, and its content panel to use the same `glass_card` classes as the Card component but with `rounded-3xl`.
|
||||
|
||||
### Task 3: Redesign Dashboard Page
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/dashboard.tsx`
|
||||
|
||||
- [ ] **Step 1: Replace hardcoded borders/bg with glass semantics**
|
||||
Find hardcoded `bg-white`, `border-gray-200`, `shadow-md` inside `DashboardPage` and `MetricCard`, replace with `bg-white/60 backdrop-blur-3xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] border-white/80 rounded-2xl`.
|
||||
- [ ] **Step 2: Adjust spacing**
|
||||
Ensure all metric cards have uniform `h-48` equivalent height and are strictly `rounded-2xl` with `p-6` padding.
|
||||
- [ ] **Step 3: Update Flow Chart Card**
|
||||
Replace standard grid backgrounds in the flow chart with transparent spacing and vibrant `bg-blue-500` squircle bars without harsh borders.
|
||||
|
||||
### Task 4: Redesign Node Management Page
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/node.tsx`
|
||||
|
||||
- [ ] **Step 1: Replace standard List/Table view with Grid Cards**
|
||||
Update the node rendering map to output `glass_card` containers (`rounded-2xl`, blur, padding `p-6`).
|
||||
- [ ] **Step 2: Apply semantic status highlights**
|
||||
Refactor the Online/Offline badges into pill-shapes (`rounded-full`) using the defined semantic colors (e.g. `bg-green-500/20 text-green-600` with a 6px inner dot `bg-green-500`).
|
||||
- [ ] **Step 3: Embed Micro-charts**
|
||||
For CPU/RAM data inside the node card, switch standard progress bars to ultra-thin (height 4px) continuous lines utilizing standard brand colors.
|
||||
|
||||
### Task 5: Redesign Tunnels & Rules Configuration
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/tunnel.tsx`
|
||||
- Modify: `vite-frontend/src/pages/forward.tsx`
|
||||
|
||||
- [ ] **Step 1: Update Tunnel lists into nested Glass Panels**
|
||||
Encapsulate each tunnel configuration into a wide `glass_card`.
|
||||
- [ ] **Step 2: Create Visual Rule Tags**
|
||||
For the Forwarding rules, wrap the target IP/Port logic into visual badges: `bg-green-500/20` for Entry and `bg-blue-500/20` for Target.
|
||||
- [ ] **Step 3: Refactor the "Add Rule" Floating action**
|
||||
Ensure the plus button follows the squircle format (`rounded-full`) with a prominent diffused shadow (`shadow-[0_4px_12px_rgba(0,122,255,0.3)]`).
|
||||
|
||||
### Task 6: Redesign Monitor Page
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/monitor.tsx`
|
||||
|
||||
- [ ] **Step 1: Style the Top Hero Metrics**
|
||||
Replace flat stat boxes with high-contrast, large typography inside `glass_card` backgrounds.
|
||||
- [ ] **Step 2: Refactor Latency Indicators**
|
||||
Format the connection list rows as `bg-white/50 dark:bg-black/30` strips with pill-shaped status tags (`Healthy`, `Warning`, `Offline`) mapping exactly to the green/orange/red semantics from the design spec.
|
||||
|
||||
### Task 7: Redesign Group & Sharing Pages
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/group.tsx`
|
||||
- Modify: `vite-frontend/src/pages/panel-sharing.tsx`
|
||||
|
||||
- [ ] **Step 1: Update Tab Switchers**
|
||||
Refactor the internal navigation tabs (e.g., "Tunnel Groups" vs "User Groups") into an encapsulated `p-1 rounded-xl bg-white/40 backdrop-blur-lg` container with animated active states (`shadow-sm bg-white`).
|
||||
- [ ] **Step 2: Style Share Cards**
|
||||
Transform flat panel sharing list items into rich `glass_card` entities. Highlight expiration dates with the accent text color.
|
||||
|
||||
### Task 8: Redesign Settings, Config, and User Management
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/config.tsx`
|
||||
- Modify: `vite-frontend/src/pages/settings.tsx`
|
||||
- Modify: `vite-frontend/src/pages/user.tsx`
|
||||
- Modify: `vite-frontend/src/pages/limit.tsx`
|
||||
|
||||
- [ ] **Step 1: Flatten Forms**
|
||||
Convert traditional input groups into `rounded-xl bg-white/50 border border-white/60` containers. Remove outer boxing for standard `label + input` pairs.
|
||||
- [ ] **Step 2: iOS Toggle Switches**
|
||||
Ensure that any `<Switch>` or `<Checkbox>` components use the new Accent brand color (`#007aff`) with full `rounded-full` geometry.
|
||||
- [ ] **Step 3: Refactor User Badges**
|
||||
In `user.tsx`, replace text-based role columns with circular Avatar badges (e.g., `w-10 h-10 rounded-full bg-blue-500 text-white` with the first two letters of the username).
|
||||
|
||||
### Task 9: Profile & Password Modal Restyling
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/profile.tsx`
|
||||
- Modify: `vite-frontend/src/pages/change-password.tsx`
|
||||
|
||||
- [ ] **Step 1: Apply Profile Card Structure**
|
||||
Create a split view on desktop using flex: Left side (Avatar + User Info + Admin Shortcuts), Right side (Password Form).
|
||||
- [ ] **Step 2: Restyle Auth Inputs**
|
||||
Ensure all password inputs use `bg-white/50 backdrop-blur-md border border-white/60` and the update button has heavy shadow-glow.
|
||||
@@ -0,0 +1,482 @@
|
||||
# 最大连接数限制实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`[x]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在 FLVX 中实现基于用户的全局最大连接数限制和基于单条规则的独立最大连接数限制功能。前端输入框为 0 或空时表示不限制。
|
||||
|
||||
**Architecture:** 采用“覆盖逻辑”(方案二)。
|
||||
1. 数据库层面:在 `user` 和 `forward` 表中各增加一个整型字段 `max_conn`,默认值为 0(表示不限制)。
|
||||
2. 后端接口层面:提供 API 更新该字段,在组装下发给 GOST 的配置时,判断规则的 `max_conn` 是否大于 0:
|
||||
- 如果规则 `max_conn > 0`,则为此规则动态生成一个唯一的连接限制器配置,并在下发服务的 `climiter` 字段中引用该限制器。
|
||||
- 如果规则 `max_conn == 0`,则检查该规则所属用户的 `max_conn`。
|
||||
- 如果用户 `max_conn > 0`,则引用以用户维度的连接限制器配置(如 `user_conn_limit_<user_id>`)。
|
||||
- 否则不下发 `climiter`。
|
||||
3. 后端服务控制平面:需要在下发服务前,将需要的连接限制器(Rule 或 User 维度)推送到节点上。
|
||||
- **重要发现:** 当前 `go-gost` 的 WebSocket Reporter (`go-gost/x/socket/websocket_reporter.go`) 仅支持 `TrafficLimiter` 的动态增删(如 `AddLimiters` 等),**不支持** `ConnLimiter`(即 `CLimiters`)。
|
||||
- **计划修改:** 我们需要先在 `go-gost` 侧(`go-gost/x/socket`)添加针对 `CLimiters` 的 WebSocket 指令(`AddCLimiters`, `UpdateCLimiters`, `DeleteCLimiters`)以及对应的处理函数(参考 `AddLimiters` 等的实现,调用现有的针对 `ConnLimiterRegistry` 的相关接口和配置存储逻辑,具体需要实现类似 `createLimiter` 到 `createConnLimiter` 的逻辑)。
|
||||
- 完成底层修改后,`go-backend` 再通过这些新增加的 WebSocket 指令,在 `ensureLimiterOnNode` 时下发最大连接数限制规则。
|
||||
4. 前端层面:在用户管理和规则管理页面增加输入框组件。
|
||||
|
||||
**Tech Stack:** Go, GORM, SQLite/PostgreSQL, React, Vite, TypeScript, TailwindCSS.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 扩展 go-gost WebSocket 接口以支持 CLimiters
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-gost/x/socket/limiter.go`
|
||||
- Modify: `go-gost/x/socket/websocket_reporter.go`
|
||||
|
||||
[x] **Step 1: 实现 `createConnLimiter` 等功能**
|
||||
|
||||
在 `go-gost/x/socket/limiter.go` 中参考现有 `createLimiter` 添加对 `CLimiters` 的支持:
|
||||
|
||||
```go
|
||||
func createConnLimiter(req createLimiterRequest) error {
|
||||
name := strings.TrimSpace(req.Data.Name)
|
||||
if name == "" {
|
||||
return errors.New("limiter name is required")
|
||||
}
|
||||
req.Data.Name = name
|
||||
|
||||
if registry.ConnLimiterRegistry().IsRegistered(name) {
|
||||
return errors.New("conn limiter " + name + " already exists")
|
||||
}
|
||||
|
||||
v := parser.ParseConnLimiter(&req.Data)
|
||||
|
||||
if err := registry.ConnLimiterRegistry().Register(name, v); err != nil {
|
||||
return errors.New("conn limiter " + name + " already exists")
|
||||
}
|
||||
|
||||
if c := config.Global(); c != nil {
|
||||
c.CLimiters = append(c.CLimiters, &req.Data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateConnLimiter(req updateLimiterRequest) error {
|
||||
name := strings.TrimSpace(req.Limiter)
|
||||
req.Data.Name = name
|
||||
if registry.ConnLimiterRegistry().IsRegistered(name) {
|
||||
registry.ConnLimiterRegistry().Unregister(name)
|
||||
}
|
||||
|
||||
v := parser.ParseConnLimiter(&req.Data)
|
||||
|
||||
if err := registry.ConnLimiterRegistry().Register(name, v); err != nil {
|
||||
return errors.New("conn limiter " + name + " already exists")
|
||||
}
|
||||
|
||||
if c := config.Global(); c != nil {
|
||||
for i := range c.CLimiters {
|
||||
if c.CLimiters[i].Name == name {
|
||||
c.CLimiters[i] = &req.Data
|
||||
return nil
|
||||
}
|
||||
}
|
||||
c.CLimiters = append(c.CLimiters, &req.Data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteConnLimiter(req deleteLimiterRequest) error {
|
||||
name := strings.TrimSpace(req.Limiter)
|
||||
|
||||
if registry.ConnLimiterRegistry().IsRegistered(name) {
|
||||
registry.ConnLimiterRegistry().Unregister(name)
|
||||
}
|
||||
|
||||
if c := config.Global(); c != nil {
|
||||
limiteres := c.CLimiters
|
||||
c.CLimiters = nil
|
||||
for _, s := range limiteres {
|
||||
if s.Name == name {
|
||||
continue
|
||||
}
|
||||
c.CLimiters = append(c.CLimiters, s)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
[x] **Step 2: 在 `WebSocketReporter` 注册命令**
|
||||
|
||||
在 `go-gost/x/socket/websocket_reporter.go` 的 `ProcessCommand` 中添加 case:
|
||||
|
||||
```go
|
||||
case "AddCLimiters":
|
||||
err = w.handleAddCLimiter(cmd.Data)
|
||||
response.Type = "AddCLimitersResponse"
|
||||
needSaveConfig = true
|
||||
case "UpdateCLimiters":
|
||||
err = w.handleUpdateCLimiter(cmd.Data)
|
||||
response.Type = "UpdateCLimitersResponse"
|
||||
needSaveConfig = true
|
||||
case "DeleteCLimiters":
|
||||
err = w.handleDeleteCLimiter(cmd.Data)
|
||||
response.Type = "DeleteCLimitersResponse"
|
||||
needSaveConfig = true
|
||||
```
|
||||
|
||||
[x] **Step 3: 实现 Handler 方法**
|
||||
|
||||
在 `go-gost/x/socket/websocket_reporter.go` 中添加:
|
||||
|
||||
```go
|
||||
func (w *WebSocketReporter) handleAddCLimiter(data interface{}) error {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化数据失败: %v", err)
|
||||
}
|
||||
|
||||
var limiterConfig config.LimiterConfig
|
||||
if err := json.Unmarshal(jsonData, &limiterConfig); err != nil {
|
||||
return fmt.Errorf("解析限流器配置失败: %v", err)
|
||||
}
|
||||
|
||||
req := createLimiterRequest{Data: limiterConfig}
|
||||
return createConnLimiter(req)
|
||||
}
|
||||
|
||||
func (w *WebSocketReporter) handleUpdateCLimiter(data interface{}) error {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化数据失败: %v", err)
|
||||
}
|
||||
|
||||
var updateReq struct {
|
||||
Limiter string `json:"limiter"`
|
||||
Data config.LimiterConfig `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonData, &updateReq); err != nil {
|
||||
var limiterConfig config.LimiterConfig
|
||||
if err := json.Unmarshal(jsonData, &limiterConfig); err != nil {
|
||||
return fmt.Errorf("解析更新请求失败: %v", err)
|
||||
}
|
||||
updateReq.Limiter = limiterConfig.Name
|
||||
updateReq.Data = limiterConfig
|
||||
}
|
||||
|
||||
req := updateLimiterRequest{
|
||||
Limiter: updateReq.Limiter,
|
||||
Data: updateReq.Data,
|
||||
}
|
||||
return updateConnLimiter(req)
|
||||
}
|
||||
|
||||
func (w *WebSocketReporter) handleDeleteCLimiter(data interface{}) error {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化数据失败: %v", err)
|
||||
}
|
||||
|
||||
var deleteReq deleteLimiterRequest
|
||||
|
||||
if err := json.Unmarshal(jsonData, &deleteReq); err != nil {
|
||||
var limiterName string
|
||||
if err := json.Unmarshal(jsonData, &limiterName); err != nil {
|
||||
return fmt.Errorf("解析删除请求失败: %v", err)
|
||||
}
|
||||
deleteReq.Limiter = limiterName
|
||||
}
|
||||
|
||||
return deleteConnLimiter(deleteReq)
|
||||
}
|
||||
```
|
||||
|
||||
[x] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd go-gost
|
||||
git add x/socket/limiter.go x/socket/websocket_reporter.go
|
||||
git commit -m "feat: add CLimiters support for websocket reporter"
|
||||
cd ..
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 数据库迁移与模型更新
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/store/model/model.go`
|
||||
- Modify: `go-backend/internal/store/repo/repository.go`
|
||||
|
||||
[x] **Step 1: 更新数据库模型**
|
||||
|
||||
在 `go-backend/internal/store/model/model.go` 的 `User` 和 `Forward` 结构体中添加 `MaxConn` 字段。
|
||||
|
||||
```go
|
||||
// 在 User 结构体中
|
||||
type User struct {
|
||||
// ...
|
||||
MaxConn int `gorm:"column:max_conn;not null;default:0"`
|
||||
// ...
|
||||
}
|
||||
|
||||
// 在 Forward 结构体中
|
||||
type Forward struct {
|
||||
// ...
|
||||
MaxConn int `gorm:"column:max_conn;not null;default:0"`
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
[x] **Step 2: 编写数据库迁移**
|
||||
|
||||
在 `go-backend/internal/store/repo/repository.go` 的 `AutoMigrate` 逻辑前(如果有自定义迁移)或利用 gorm 自动迁移机制,由于这是 autoMigrate,添加字段只要 `db.AutoMigrate(&model.User{}, &model.Forward{})` 被调用就能自动加上。确认已执行迁移。由于 `FLVX` 通常会自动执行迁移,只需修改模型即可。我们需要处理默认值,由于使用了 `default:0`,GORM 会处理新增字段的默认值,但为了安全起见,如果在旧环境中,可能直接 alter table。
|
||||
|
||||
```go
|
||||
// 无需手动编写 SQL,依赖现有的 gorm AutoMigrate 即可。
|
||||
```
|
||||
|
||||
[x] **Step 3: 运行并验证迁移通过**
|
||||
|
||||
Run: `make build` (在 go-backend 中),或者运行一个相关的存储单元测试。
|
||||
|
||||
[x] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd go-backend
|
||||
git add internal/store/model/model.go
|
||||
git commit -m "feat: add max_conn field to user and forward models"
|
||||
cd ..
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 后端控制平面 - 连接数限制器的组装与下发
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/http/handler/control_plane.go`
|
||||
- Modify: `go-backend/internal/store/repo/repository_control.go`
|
||||
|
||||
[x] **Step 1: 更新存储层以获取 User 的 MaxConn**
|
||||
|
||||
在 `go-backend/internal/store/repo/repository_control.go` 中:
|
||||
|
||||
需要一个方法获取 User,或者如果已经有,确保可以拿到 `MaxConn`。
|
||||
|
||||
[x] **Step 2: 编写下发 CLimiter 到节点的辅助函数**
|
||||
|
||||
在 `go-backend/internal/http/handler/control_plane.go`,参考 `ensureLimiterOnNode` 和 `upsertLimiterOnNode`:
|
||||
|
||||
```go
|
||||
func (h *Handler) ensureConnLimiterOnNode(nodeID int64, limiterName string, maxConn int) error {
|
||||
limitStr := fmt.Sprintf("$ %d", maxConn)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"name": limiterName,
|
||||
"limits": []string{limitStr},
|
||||
}
|
||||
|
||||
if _, err := h.sendNodeCommand(nodeID, "AddCLimiters", payload, false, false); err != nil {
|
||||
if !isAlreadyExistsMessage(err.Error()) {
|
||||
return fmt.Errorf("连接限制器下发失败: %w", err)
|
||||
}
|
||||
updatePayload := map[string]interface{}{
|
||||
"limiter": limiterName,
|
||||
"data": payload,
|
||||
}
|
||||
if _, updateErr := h.sendNodeCommand(nodeID, "UpdateCLimiters", updatePayload, false, false); updateErr != nil {
|
||||
return fmt.Errorf("连接限制器更新失败: %w", updateErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
[x] **Step 3: 更新组装配置逻辑以绑定 `climiter`**
|
||||
|
||||
在 `control_plane.go` 的 `syncForwardServicesWithWarnings` 及其辅助函数 `buildForwardServiceConfigs` 附近:
|
||||
|
||||
修改 `buildForwardServiceConfigs` 的签名,传入 `maxConn int` 和对应的 `cLimiterName string`。
|
||||
|
||||
```go
|
||||
func buildForwardServiceConfigs(baseName string, forward *model.Forward, tunnel *model.Tunnel, node *model.Node, port int, bindIP string, limiterID *int64, cLimiterName string) []map[string]interface{} {
|
||||
// ... 现有逻辑
|
||||
// 在服务配置生成的部分增加:
|
||||
if cLimiterName != "" {
|
||||
service["climiter"] = cLimiterName
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
[x] **Step 4: 在转发服务同步主流程中决定并下发 `climiter`**
|
||||
|
||||
在 `syncForwardServicesWithWarnings` (可能在多个重载/处理入口处,如 `ensureForwardServices`),查出转发所属 user 的 `MaxConn`,以及转发本身的 `MaxConn`。
|
||||
|
||||
```go
|
||||
// 获取 User
|
||||
user, err := h.repo.GetUser(forward.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cLimiterName string
|
||||
var maxConnToSet int
|
||||
|
||||
if forward.MaxConn > 0 {
|
||||
maxConnToSet = forward.MaxConn
|
||||
cLimiterName = fmt.Sprintf("rule_conn_limit_%d", forward.ID)
|
||||
} else if user != nil && user.MaxConn > 0 {
|
||||
maxConnToSet = user.MaxConn
|
||||
cLimiterName = fmt.Sprintf("user_conn_limit_%d", user.ID)
|
||||
}
|
||||
|
||||
if cLimiterName != "" {
|
||||
for _, fp := range ports {
|
||||
if err := h.ensureConnLimiterOnNode(fp.NodeID, cLimiterName, maxConnToSet); err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("节点 %d 连接限制器下发失败: %v", fp.NodeID, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 传递给 buildForwardServiceConfigs
|
||||
// ...
|
||||
```
|
||||
*(注意:需要确保更新涉及 `buildForwardServiceConfigs` 的所有调用点)*
|
||||
|
||||
[x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd go-backend
|
||||
git add internal/http/handler/control_plane.go internal/store/repo/repository_control.go
|
||||
git commit -m "feat: implement max conn limiter dispatching"
|
||||
cd ..
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 后端接口 - 用户和规则的 CRUD 支持
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/http/handler/admin_user.go`
|
||||
- Modify: `go-backend/internal/http/handler/forward.go`
|
||||
|
||||
[x] **Step 1: 用户接口更新**
|
||||
|
||||
在 `go-backend/internal/http/handler/admin_user.go`,修改用户创建和更新请求的结构体(如果有),接收 `MaxConn`,并在保存到数据库时赋值。
|
||||
|
||||
```go
|
||||
type CreateUserReq struct {
|
||||
// ...
|
||||
MaxConn *int `json:"maxConn"`
|
||||
}
|
||||
// 接收后:
|
||||
if req.MaxConn != nil {
|
||||
user.MaxConn = *req.MaxConn
|
||||
}
|
||||
```
|
||||
|
||||
在获取用户列表时,确保 `MaxConn` 返回给前端。
|
||||
|
||||
[x] **Step 2: 规则接口更新**
|
||||
|
||||
在 `go-backend/internal/http/handler/forward.go` 中,更新 `CreateForwardReq` 和 `UpdateForwardReq` 结构体,增加 `MaxConn`,并在创建/更新 Forward 时保存到数据库。
|
||||
|
||||
如果转发规则的 `MaxConn` 或相关信息改变,触发节点上的规则重载(重新下发服务)。这一步由于更改了数据库,复用现有的 `syncForwardServices` 就会带上最新的配置。
|
||||
|
||||
[x] **Step 3: 测试接口**
|
||||
|
||||
Run: 可以启动后使用 curl 测试。
|
||||
|
||||
[x] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd go-backend
|
||||
git add internal/http/handler/admin_user.go internal/http/handler/forward.go
|
||||
git commit -m "feat: add maxConn to user and forward CRUD API"
|
||||
cd ..
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 前端 - 用户管理页面集成
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/api/types.ts`
|
||||
- Modify: `vite-frontend/src/api/index.ts`
|
||||
- Modify: `vite-frontend/src/pages/users.tsx` (或者对应的用户管理页面文件)
|
||||
|
||||
[x] **Step 1: 类型更新**
|
||||
|
||||
在 `vite-frontend/src/api/types.ts` 中:
|
||||
为 `UserApiItem` 和相关的 mutation payload 增加 `maxConn?: number` 属性。
|
||||
|
||||
[x] **Step 2: UI 修改**
|
||||
|
||||
在用户创建/编辑弹窗中,增加“最大连接数”输入框:
|
||||
(假设使用 `@nextui-org/react` 的 `Input`)
|
||||
|
||||
```tsx
|
||||
<Input
|
||||
type="number"
|
||||
label="最大并发连接数"
|
||||
placeholder="0 或空表示不限制"
|
||||
value={formData.maxConn === 0 ? "" : String(formData.maxConn || "")}
|
||||
onValueChange={(val) => {
|
||||
const num = parseInt(val, 10);
|
||||
setFormData({ ...formData, maxConn: isNaN(num) ? 0 : num });
|
||||
}}
|
||||
/>
|
||||
```
|
||||
并在用户的表格列中展示 `最大连接数`(值为 0 显示“不限制”)。
|
||||
|
||||
[x] **Step 3: 运行 Vite 进行验证**
|
||||
|
||||
[x] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd vite-frontend
|
||||
git add src/api/types.ts src/api/index.ts src/pages/users.tsx
|
||||
git commit -m "feat: add max conn UI to user management"
|
||||
cd ..
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 前端 - 转发规则页面集成
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/forward.tsx`
|
||||
|
||||
[x] **Step 1: 类型更新**
|
||||
|
||||
在 `api/types.ts` 中 `ForwardMutationPayload` 和 `ForwardApiItem` 中增加 `maxConn?: number`。
|
||||
|
||||
[x] **Step 2: UI 修改**
|
||||
|
||||
在 `vite-frontend/src/pages/forward.tsx` 的创建/编辑规则弹窗(在 "规则限速" 附近)增加“最大连接数”输入框:
|
||||
|
||||
```tsx
|
||||
<Input
|
||||
type="number"
|
||||
label="最大并发连接数"
|
||||
placeholder="0 或空表示不限制"
|
||||
value={formData.maxConn === 0 ? "" : String(formData.maxConn || "")}
|
||||
onValueChange={(val) => {
|
||||
const num = parseInt(val, 10);
|
||||
setFormData({ ...formData, maxConn: isNaN(num) ? 0 : num });
|
||||
}}
|
||||
description="此设置优先于用户的全局连接数限制。0 表示不限制(或使用用户的全局限制)。"
|
||||
/>
|
||||
```
|
||||
|
||||
如果是在列表/卡片中展示,可以增加一个小标签或者 Tooltip 显示其最大连接数设置。
|
||||
|
||||
[x] **Step 3: 验证**
|
||||
|
||||
在前端验证该功能能正确读写规则的连接限制字段。
|
||||
|
||||
[x] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd vite-frontend
|
||||
git add src/pages/forward.tsx src/api/types.ts
|
||||
git commit -m "feat: add max conn UI to forward rules"
|
||||
cd ..
|
||||
```
|
||||
@@ -0,0 +1,171 @@
|
||||
# Allow Local Remote Address Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a global settings toggle that allows non-admin forward rules to target local/private addresses when explicitly enabled.
|
||||
|
||||
**Architecture:** Keep the existing remote-address safety validator as the default path for non-admin rule changes, but gate its use behind a single backend config lookup in forward create/update handlers. Surface the toggle through the existing `vite_config` settings page and prove behavior with backend contract tests first.
|
||||
|
||||
**Tech Stack:** Go `net/http` + GORM backend, React + TypeScript frontend settings page, Go contract tests.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend Contract Coverage
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/tests/contract/forward_contract_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add contract tests that prove the desired behavior:
|
||||
|
||||
```go
|
||||
t.Run("local remote address is rejected when toggle is off", func(t *testing.T) {
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "deny-local-remote",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "127.0.0.1:8080",
|
||||
"strategy": "fifo",
|
||||
}
|
||||
createBody, _ := json.Marshal(createPayload)
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||
createReq.Header.Set("Authorization", adminToken)
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRes, createReq)
|
||||
|
||||
var out response.R
|
||||
_ = json.NewDecoder(createRes.Body).Decode(&out)
|
||||
if out.Code == 0 {
|
||||
t.Fatalf("expected local remote address to be rejected when toggle is off")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("local remote address is allowed when toggle is on", func(t *testing.T) {
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO vite_config(name, value, time)
|
||||
VALUES(?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
|
||||
`, "allow_local_remote_addr", "1", time.Now().UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("enable allow_local_remote_addr: %v", err)
|
||||
}
|
||||
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "allow-local-remote",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "127.0.0.1:8080",
|
||||
"strategy": "fifo",
|
||||
}
|
||||
createBody, _ := json.Marshal(createPayload)
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||
createReq.Header.Set("Authorization", adminToken)
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRes, createReq)
|
||||
assertCode(t, createRes, 0)
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `go test ./tests/contract/... -run 'TestForwardContracts|local remote address'`
|
||||
Expected: FAIL because backend still rejects local/private addresses unconditionally.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
Do not commit yet; combine with Task 2 after implementation passes.
|
||||
|
||||
### Task 2: Backend Toggle Implementation
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/http/handler/mutations.go`
|
||||
|
||||
- [ ] **Step 1: Add a tiny config helper**
|
||||
|
||||
Add a helper near other handler helpers:
|
||||
|
||||
```go
|
||||
func (h *Handler) allowLocalRemoteAddr() bool {
|
||||
if h == nil || h.repo == nil {
|
||||
return false
|
||||
}
|
||||
cfg, err := h.repo.GetConfigByName("allow_local_remote_addr")
|
||||
if err != nil || cfg == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(cfg.Value) == "1"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Gate create/update validation behind the helper**
|
||||
|
||||
Replace the unconditional checks with:
|
||||
|
||||
```go
|
||||
if !h.allowLocalRemoteAddr() {
|
||||
if err := IsSafeRemoteAddr(remoteAddr); err != nil {
|
||||
response.WriteJSON(w, response.Err(403, err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run contract tests to verify they pass**
|
||||
|
||||
Run: `go test ./tests/contract/... -run 'TestForwardContracts|local remote address'`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Run full backend tests**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
### Task 3: Settings Page Toggle
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/config.tsx`
|
||||
|
||||
- [ ] **Step 1: Add the config item to the settings schema**
|
||||
|
||||
Add a switch-style item for `allow_local_remote_addr` with warning copy about reduced safety.
|
||||
|
||||
- [ ] **Step 2: Ensure the key is included in config loading/saving paths**
|
||||
|
||||
Add `allow_local_remote_addr` anywhere the page enumerates config keys or groups persisted config values.
|
||||
|
||||
- [ ] **Step 3: Run frontend build**
|
||||
|
||||
Run: `pnpm run build`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Run frontend lint**
|
||||
|
||||
Run: `pnpm run lint`
|
||||
Expected: 0 errors; existing warnings may remain.
|
||||
|
||||
### Task 4: Final Verification
|
||||
|
||||
**Files:**
|
||||
- Verify only
|
||||
|
||||
- [ ] **Step 1: Re-run backend contracts for the toggle**
|
||||
|
||||
Run: `go test ./tests/contract/... -run 'TestForwardContracts|local remote address'`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 2: Re-run full backend tests**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: Re-run frontend build/lint**
|
||||
|
||||
Run: `pnpm run build && pnpm run lint`
|
||||
Expected: Build passes, lint has no errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add go-backend/internal/http/handler/mutations.go go-backend/tests/contract/forward_contract_test.go vite-frontend/src/pages/config.tsx docs/superpowers/specs/2026-04-26-allow-local-remote-addr-design.md docs/superpowers/plans/2026-04-26-allow-local-remote-addr.md
|
||||
git commit -m "feat: add allow-local-remote-address toggle"
|
||||
```
|
||||
@@ -0,0 +1,849 @@
|
||||
# flow/upload Batch Optimization Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Reduce `POST /flow/upload` database pressure by converting the hot path from per-item queries and per-item transactions to per-request aggregation, batched metadata reads, and batched writes, while preserving immediate quota disable / forward pause behavior inside the same upload.
|
||||
|
||||
**Architecture:** Parse one upload into a batch object in the handler layer, fetch one shared `forward+tunnel` metadata map, then reuse that map for flow accounting and tunnel metric aggregation. Replace `AddFlow` and `AddUserQuotaUsage` per-item transactions with one batched flow transaction and one batched quota transaction; run policy enforcement, orphan cleanup, and peer-share flow handling once per affected target instead of once per item.
|
||||
|
||||
**Tech Stack:** Go, net/http, GORM, SQLite/PostgreSQL, existing backend contract tests.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Create: `go-backend/internal/http/handler/flow_upload_batch.go`
|
||||
Responsibility: request-scoped parsing, aggregation, and application of one `/flow/upload` batch.
|
||||
- Create: `go-backend/internal/http/handler/flow_upload_batch_test.go`
|
||||
Responsibility: unit coverage for batch aggregation semantics.
|
||||
- Create: `go-backend/internal/store/repo/repository_flow_batch_test.go`
|
||||
Responsibility: unit coverage for batched flow and quota persistence.
|
||||
- Create: `go-backend/tests/contract/flow_upload_batch_contract_test.go`
|
||||
Responsibility: contract coverage that repeated items still accumulate correctly and still disable quota immediately.
|
||||
- Modify: `go-backend/internal/http/handler/handler.go`
|
||||
Responsibility: switch `/flow/upload` entrypoint to the new batch pipeline.
|
||||
- Modify: `go-backend/internal/http/handler/tunnel_metrics_ingestion.go`
|
||||
Responsibility: accept pre-aggregated forward deltas plus shared forward metadata instead of reparsing the raw items.
|
||||
- Modify: `go-backend/internal/store/repo/repository.go`
|
||||
Responsibility: add batched flow persistence primitives near the existing flow update code.
|
||||
- Modify: `go-backend/internal/store/repo/repository_flow.go`
|
||||
Responsibility: add shared flow-upload metadata query helpers.
|
||||
- Modify: `go-backend/internal/store/repo/repository_user_quota.go`
|
||||
Responsibility: add batched quota usage persistence that still returns normalized quota views for immediate enforcement.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Failing Tests For Batched flow/upload Semantics
|
||||
|
||||
**Files:**
|
||||
- Create: `go-backend/internal/http/handler/flow_upload_batch_test.go`
|
||||
- Create: `go-backend/tests/contract/flow_upload_batch_contract_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing handler unit test**
|
||||
|
||||
Create `go-backend/internal/http/handler/flow_upload_batch_test.go` with a unit test that locks in the new aggregation contract.
|
||||
|
||||
```go
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestBuildFlowUploadBatchAggregatesForwardQuotaPeerShareAndCleanupTargets(t *testing.T) {
|
||||
h := &Handler{}
|
||||
metas := map[int64]repo.FlowUploadForwardMeta{
|
||||
20: {
|
||||
ForwardID: 20,
|
||||
TunnelID: 1,
|
||||
TrafficRatio: 2,
|
||||
TunnelFlow: 3,
|
||||
},
|
||||
}
|
||||
|
||||
batch := h.buildFlowUploadBatch([]flowItem{
|
||||
{N: "20_2_10", U: 70, D: 50},
|
||||
{N: "20_2_10_tcp", U: 40, D: 30},
|
||||
{N: "99_2_10", U: 12, D: 8},
|
||||
{N: "fed_svc_17", U: 9, D: 1},
|
||||
}, metas)
|
||||
|
||||
if len(batch.flowDeltas) != 1 {
|
||||
t.Fatalf("expected 1 flow delta, got %d", len(batch.flowDeltas))
|
||||
}
|
||||
delta := batch.flowDeltas[0]
|
||||
if delta.ForwardID != 20 || delta.UserID != 2 || delta.UserTunnelID != 10 {
|
||||
t.Fatalf("unexpected flow delta identity: %#v", delta)
|
||||
}
|
||||
if delta.InFlow != 480 || delta.OutFlow != 660 {
|
||||
t.Fatalf("expected scaled flow in=480 out=660, got in=%d out=%d", delta.InFlow, delta.OutFlow)
|
||||
}
|
||||
if batch.quotaUsage[2] != 1140 {
|
||||
t.Fatalf("expected quota usage 1140, got %d", batch.quotaUsage[2])
|
||||
}
|
||||
if len(batch.policyTargets) != 1 {
|
||||
t.Fatalf("expected 1 policy target, got %d", len(batch.policyTargets))
|
||||
}
|
||||
if batch.policyTargets[0].UserID != 2 || batch.policyTargets[0].UserTunnelID != 10 {
|
||||
t.Fatalf("unexpected policy target: %#v", batch.policyTargets[0])
|
||||
}
|
||||
traffic := batch.forwardTraffic[20]
|
||||
if traffic.bytesIn != 80 || traffic.bytesOut != 110 {
|
||||
t.Fatalf("expected raw traffic in=80 out=110, got in=%d out=%d", traffic.bytesIn, traffic.bytesOut)
|
||||
}
|
||||
if _, ok := batch.orphanServices["99_2_10"]; !ok {
|
||||
t.Fatalf("expected orphan service cleanup target for 99_2_10")
|
||||
}
|
||||
if item, ok := batch.peerShareForwardItems["20_2_10"]; !ok || item.U != 110 || item.D != 80 {
|
||||
t.Fatalf("expected merged peer-share forward item, got %#v ok=%v", item, ok)
|
||||
}
|
||||
if item, ok := batch.peerShareRuntimeItems[17]; !ok || item.U != 9 || item.D != 1 {
|
||||
t.Fatalf("expected merged peer-share runtime item, got %#v ok=%v", item, ok)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the handler unit test to verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/http/handler -run TestBuildFlowUploadBatchAggregatesForwardQuotaPeerShareAndCleanupTargets -v
|
||||
```
|
||||
|
||||
Expected: FAIL because `FlowUploadForwardMeta`, `buildFlowUploadBatch`, and the new batch fields do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Write the contract test that guards current behavior**
|
||||
|
||||
Create `go-backend/tests/contract/flow_upload_batch_contract_test.go` so the optimization cannot weaken same-request quota enforcement.
|
||||
|
||||
```go
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
func TestFlowUploadAggregatesRepeatedItemsAndDisablesQuotaImmediately(t *testing.T) {
|
||||
secret := "monitoring-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now()
|
||||
nowMs := now.UnixMilli()
|
||||
dayKey := int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
|
||||
monthKey := int64(now.Year()*100 + int(now.Month()))
|
||||
const bytesPerGB = int64(1024 * 1024 * 1024)
|
||||
|
||||
node := &model.Node{Name: "node-1", Secret: "node-secret", ServerIP: "127.0.0.1", Port: "10000-10010", TCPListenAddr: "[::]", UDPListenAddr: "[::]", CreatedTime: nowMs, Status: 1}
|
||||
if err := repo.DB().Create(node).Error; err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) VALUES(2, 'flow_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
tunnel := &model.Tunnel{Name: "tunnel-1", TrafficRatio: 1.0, Type: 1, Protocol: "tls", Flow: 1, CreatedTime: nowMs, UpdatedTime: nowMs, Status: 1}
|
||||
if err := repo.DB().Create(tunnel).Error; err != nil {
|
||||
t.Fatalf("seed tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(10, 2, ?, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)`, tunnel.ID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
forward := &model.Forward{ID: 20, UserID: 2, UserName: "flow_user", Name: "forward-20", TunnelID: tunnel.ID, RemoteAddr: "1.1.1.1:80", Strategy: "fifo", CreatedTime: nowMs, UpdatedTime: nowMs, Status: 1}
|
||||
if err := repo.DB().Create(forward).Error; err != nil {
|
||||
t.Fatalf("seed forward: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`INSERT INTO user_quota(user_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time) VALUES(2, 1, 0, ?, ?, ?, ?, 0, 0, '', ?, ?)`, bytesPerGB-100, bytesPerGB-100, dayKey, monthKey, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user_quota: %v", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal([]map[string]interface{}{
|
||||
{"n": "20_2_10", "u": 70, "d": 50},
|
||||
{"n": "20_2_10_tcp", "u": 40, "d": 30},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/flow/upload?secret="+node.Secret, bytes.NewReader(body))
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", res.Code)
|
||||
}
|
||||
if got := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = 20`); got != 0 {
|
||||
t.Fatalf("expected forward paused immediately, got status=%d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, repo, `SELECT disabled_by_quota FROM user_quota WHERE user_id = 2`); got != 1 {
|
||||
t.Fatalf("expected quota disabled flag=1, got %d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, repo, `SELECT in_flow FROM forward WHERE id = 20`); got != 80 {
|
||||
t.Fatalf("expected forward in_flow=80, got %d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, repo, `SELECT out_flow FROM forward WHERE id = 20`); got != 110 {
|
||||
t.Fatalf("expected forward out_flow=110, got %d", got)
|
||||
}
|
||||
metrics, err := repo.GetTunnelMetrics(tunnel.ID, 0, nowMs+60_000)
|
||||
if err != nil {
|
||||
t.Fatalf("get tunnel metrics: %v", err)
|
||||
}
|
||||
if len(metrics) != 1 || metrics[0].BytesIn != 80 || metrics[0].BytesOut != 110 {
|
||||
t.Fatalf("expected one aggregated metric row, got %#v", metrics)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the contract test to verify the same-request guard stays green or reveals an existing regression**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./tests/contract/... -run TestFlowUploadAggregatesRepeatedItemsAndDisablesQuotaImmediately -v
|
||||
```
|
||||
|
||||
Expected: this test may already PASS before the refactor because it locks in existing external behavior. Keep it either way; it is the guardrail for the optimization.
|
||||
|
||||
- [ ] **Step 5: Optional commit if the user explicitly requested commits**
|
||||
|
||||
```bash
|
||||
git add go-backend/internal/http/handler/flow_upload_batch_test.go go-backend/tests/contract/flow_upload_batch_contract_test.go
|
||||
git commit -m "test: cover flow upload batch semantics"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add Batched Repository Primitives
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/store/repo/repository_flow.go`
|
||||
- Modify: `go-backend/internal/store/repo/repository.go`
|
||||
- Modify: `go-backend/internal/store/repo/repository_user_quota.go`
|
||||
- Create: `go-backend/internal/store/repo/repository_flow_batch_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing repository tests**
|
||||
|
||||
Create `go-backend/internal/store/repo/repository_flow_batch_test.go` with coverage for both the shared metadata query and the batched counter/quota writes.
|
||||
|
||||
```go
|
||||
package repo
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGetFlowUploadForwardMetasAndApplyFlowUploadDeltasBatch(t *testing.T) {
|
||||
r, err := Open(filepath.Join(t.TempDir(), "flow-batch.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) VALUES(2, 'u2', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(1, 't1', 2.0, 1, 'tls', 3, ?, ?, 1, NULL, 0)`, 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, 99999, 99999, 0, 0, 1, 2727251700000, 1)`).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:80', 'fifo', 0, 0, ?, ?, 1, 0)`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
|
||||
metas, err := r.GetFlowUploadForwardMetas([]int64{20, 99})
|
||||
if err != nil {
|
||||
t.Fatalf("get metas: %v", err)
|
||||
}
|
||||
if metas[20].TunnelID != 1 || metas[20].TrafficRatio != 2 || metas[20].TunnelFlow != 3 {
|
||||
t.Fatalf("unexpected meta for forward 20: %#v", metas[20])
|
||||
}
|
||||
if _, ok := metas[99]; ok {
|
||||
t.Fatalf("did not expect meta for missing forward 99")
|
||||
}
|
||||
|
||||
err = r.ApplyFlowUploadDeltasBatch([]FlowUploadCounterDelta{{ForwardID: 20, UserID: 2, UserTunnelID: 10, InFlow: 480, OutFlow: 660}})
|
||||
if err != nil {
|
||||
t.Fatalf("apply flow batch: %v", err)
|
||||
}
|
||||
if got := mustFlowBatchCount(t, r, `SELECT in_flow FROM forward WHERE id = 20`); got != 480 {
|
||||
t.Fatalf("expected forward in_flow=480, got %d", got)
|
||||
}
|
||||
if got := mustFlowBatchCount(t, r, `SELECT out_flow FROM user WHERE id = 2`); got != 660 {
|
||||
t.Fatalf("expected user out_flow=660, got %d", got)
|
||||
}
|
||||
if got := mustFlowBatchCount(t, r, `SELECT in_flow FROM user_tunnel WHERE id = 10`); got != 480 {
|
||||
t.Fatalf("expected user_tunnel in_flow=480, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddUserQuotaUsageBatchReturnsNormalizedViews(t *testing.T) {
|
||||
r, err := Open(filepath.Join(t.TempDir(), "quota-batch.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now()
|
||||
nowMs := now.UnixMilli()
|
||||
if err := r.DB().Exec(`INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) VALUES(2, 'u2', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
views, err := r.AddUserQuotaUsageBatch(map[int64]int64{2: 1140}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("batch quota update: %v", err)
|
||||
}
|
||||
if views[2] == nil || views[2].DailyUsedBytes != 1140 || views[2].MonthlyUsedBytes != 1140 {
|
||||
t.Fatalf("unexpected quota view: %#v", views[2])
|
||||
}
|
||||
}
|
||||
|
||||
func mustFlowBatchCount(t *testing.T, r *Repository, query string, args ...interface{}) int64 {
|
||||
t.Helper()
|
||||
var value int64
|
||||
if err := r.DB().Raw(query, args...).Row().Scan(&value); err != nil {
|
||||
t.Fatalf("query %q failed: %v", query, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the repository tests to verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/store/repo -run 'TestGetFlowUploadForwardMetasAndApplyFlowUploadDeltasBatch|TestAddUserQuotaUsageBatchReturnsNormalizedViews' -v
|
||||
```
|
||||
|
||||
Expected: FAIL because `GetFlowUploadForwardMetas`, `ApplyFlowUploadDeltasBatch`, `FlowUploadCounterDelta`, and `AddUserQuotaUsageBatch` do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement shared flow-upload metadata and batched persistence**
|
||||
|
||||
Update `go-backend/internal/store/repo/repository_flow.go`, `repository.go`, and `repository_user_quota.go` with the following concrete APIs. Add `sort` to the `repository_user_quota.go` import list.
|
||||
|
||||
```go
|
||||
// repository_flow.go
|
||||
type FlowUploadForwardMeta struct {
|
||||
ForwardID int64
|
||||
TunnelID int64
|
||||
TrafficRatio float64
|
||||
TunnelFlow int64
|
||||
}
|
||||
|
||||
func (r *Repository) GetFlowUploadForwardMetas(forwardIDs []int64) (map[int64]FlowUploadForwardMeta, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
if len(forwardIDs) == 0 {
|
||||
return map[int64]FlowUploadForwardMeta{}, nil
|
||||
}
|
||||
ids := make([]int64, 0, len(forwardIDs))
|
||||
seen := make(map[int64]struct{}, len(forwardIDs))
|
||||
for _, id := range forwardIDs {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
type row struct {
|
||||
ForwardID int64 `gorm:"column:forward_id"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id"`
|
||||
TrafficRatio float64 `gorm:"column:traffic_ratio"`
|
||||
TunnelFlow int64 `gorm:"column:tunnel_flow"`
|
||||
}
|
||||
var rows []row
|
||||
err := r.db.Table("forward AS f").
|
||||
Select("f.id AS forward_id, f.tunnel_id AS tunnel_id, t.traffic_ratio AS traffic_ratio, t.flow AS tunnel_flow").
|
||||
Joins("JOIN tunnel t ON t.id = f.tunnel_id").
|
||||
Where("f.id IN ?", ids).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[int64]FlowUploadForwardMeta, len(rows))
|
||||
for _, row := range rows {
|
||||
if row.TunnelFlow <= 0 {
|
||||
row.TunnelFlow = 1
|
||||
}
|
||||
if row.TrafficRatio <= 0 {
|
||||
row.TrafficRatio = 1
|
||||
}
|
||||
out[row.ForwardID] = FlowUploadForwardMeta{ForwardID: row.ForwardID, TunnelID: row.TunnelID, TrafficRatio: row.TrafficRatio, TunnelFlow: row.TunnelFlow}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// repository.go
|
||||
type FlowUploadCounterDelta struct {
|
||||
ForwardID int64
|
||||
UserID int64
|
||||
UserTunnelID int64
|
||||
InFlow int64
|
||||
OutFlow int64
|
||||
}
|
||||
|
||||
func (r *Repository) ApplyFlowUploadDeltasBatch(deltas []FlowUploadCounterDelta) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if len(deltas) == 0 {
|
||||
return nil
|
||||
}
|
||||
forwardTotals := make(map[int64][2]int64, len(deltas))
|
||||
userTotals := make(map[int64][2]int64, len(deltas))
|
||||
userTunnelTotals := make(map[int64][2]int64, len(deltas))
|
||||
for _, delta := range deltas {
|
||||
if delta.ForwardID > 0 {
|
||||
current := forwardTotals[delta.ForwardID]
|
||||
current[0] += delta.InFlow
|
||||
current[1] += delta.OutFlow
|
||||
forwardTotals[delta.ForwardID] = current
|
||||
}
|
||||
if delta.UserID > 0 {
|
||||
current := userTotals[delta.UserID]
|
||||
current[0] += delta.InFlow
|
||||
current[1] += delta.OutFlow
|
||||
userTotals[delta.UserID] = current
|
||||
}
|
||||
if delta.UserTunnelID > 0 {
|
||||
current := userTunnelTotals[delta.UserTunnelID]
|
||||
current[0] += delta.InFlow
|
||||
current[1] += delta.OutFlow
|
||||
userTunnelTotals[delta.UserTunnelID] = current
|
||||
}
|
||||
}
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
for forwardID, total := range forwardTotals {
|
||||
if err := tx.Model(&model.Forward{}).Where("id = ?", forwardID).UpdateColumns(map[string]interface{}{"in_flow": gorm.Expr("in_flow + ?", total[0]), "out_flow": gorm.Expr("out_flow + ?", total[1])}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for userID, total := range userTotals {
|
||||
if err := tx.Model(&model.User{}).Where("id = ?", userID).UpdateColumns(map[string]interface{}{"in_flow": gorm.Expr("in_flow + ?", total[0]), "out_flow": gorm.Expr("out_flow + ?", total[1])}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for userTunnelID, total := range userTunnelTotals {
|
||||
if err := tx.Model(&model.UserTunnel{}).Where("id = ?", userTunnelID).UpdateColumns(map[string]interface{}{"in_flow": gorm.Expr("in_flow + ?", total[0]), "out_flow": gorm.Expr("out_flow + ?", total[1])}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// repository_user_quota.go
|
||||
func (r *Repository) AddUserQuotaUsageBatch(usages map[int64]int64, now time.Time) (map[int64]*model.UserQuotaView, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
if len(usages) == 0 {
|
||||
return map[int64]*model.UserQuotaView{}, nil
|
||||
}
|
||||
result := make(map[int64]*model.UserQuotaView, len(usages))
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
userIDs := make([]int64, 0, len(usages))
|
||||
for userID := range usages {
|
||||
if userID > 0 {
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
}
|
||||
sort.Slice(userIDs, func(i, j int) bool { return userIDs[i] < userIDs[j] })
|
||||
for _, userID := range userIDs {
|
||||
q, err := r.loadOrCreateUserQuotaTx(tx, userID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyUserQuotaWindowRoll(q, now)
|
||||
if usages[userID] > 0 {
|
||||
q.DailyUsedBytes += usages[userID]
|
||||
q.MonthlyUsedBytes += usages[userID]
|
||||
}
|
||||
q.UpdatedTime = now.UnixMilli()
|
||||
if err := tx.Model(&model.UserQuota{}).Where("user_id = ?", userID).Updates(map[string]interface{}{"daily_used_bytes": q.DailyUsedBytes, "monthly_used_bytes": q.MonthlyUsedBytes, "day_key": q.DayKey, "month_key": q.MonthKey, "updated_time": q.UpdatedTime}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result[userID] = normalizeUserQuotaView(cloneUserQuotaView(*q), now)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the repository tests to verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/store/repo -run 'TestGetFlowUploadForwardMetasAndApplyFlowUploadDeltasBatch|TestAddUserQuotaUsageBatchReturnsNormalizedViews' -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Optional commit if the user explicitly requested commits**
|
||||
|
||||
```bash
|
||||
git add go-backend/internal/store/repo/repository.go go-backend/internal/store/repo/repository_flow.go go-backend/internal/store/repo/repository_user_quota.go go-backend/internal/store/repo/repository_flow_batch_test.go
|
||||
git commit -m "refactor: batch flow upload persistence"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Refactor flow/upload To Use One Parsed Batch
|
||||
|
||||
**Files:**
|
||||
- Create: `go-backend/internal/http/handler/flow_upload_batch.go`
|
||||
- Modify: `go-backend/internal/http/handler/handler.go`
|
||||
- Modify: `go-backend/internal/http/handler/tunnel_metrics_ingestion.go`
|
||||
- Modify: `go-backend/internal/http/handler/flow_upload_batch_test.go`
|
||||
- Modify: `go-backend/tests/contract/flow_upload_batch_contract_test.go`
|
||||
|
||||
- [ ] **Step 1: Write the new handler batch implementation**
|
||||
|
||||
Create `go-backend/internal/http/handler/flow_upload_batch.go` and move the request-scoped aggregation there.
|
||||
|
||||
```go
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
type flowPolicyTarget struct {
|
||||
UserID int64
|
||||
UserTunnelID int64
|
||||
}
|
||||
|
||||
type flowUploadBatch struct {
|
||||
flowDeltas []repo.FlowUploadCounterDelta
|
||||
quotaUsage map[int64]int64
|
||||
policyTargets []flowPolicyTarget
|
||||
forwardTraffic map[int64]tunnelTrafficDelta
|
||||
orphanServices map[string]struct{}
|
||||
peerShareForwardItems map[string]flowItem
|
||||
peerShareRuntimeItems map[int64]flowItem
|
||||
}
|
||||
|
||||
func (h *Handler) buildFlowUploadBatch(items []flowItem, metas map[int64]repo.FlowUploadForwardMeta) flowUploadBatch {
|
||||
batch := flowUploadBatch{
|
||||
quotaUsage: make(map[int64]int64),
|
||||
forwardTraffic: make(map[int64]tunnelTrafficDelta),
|
||||
orphanServices: make(map[string]struct{}),
|
||||
peerShareForwardItems: make(map[string]flowItem),
|
||||
peerShareRuntimeItems: make(map[int64]flowItem),
|
||||
}
|
||||
policySeen := map[flowPolicyTarget]struct{}{}
|
||||
flowSeen := map[int64]int{}
|
||||
|
||||
for _, item := range items {
|
||||
serviceName := strings.TrimSpace(item.N)
|
||||
if serviceName == "" || serviceName == "web_api" {
|
||||
continue
|
||||
}
|
||||
if runtimeID, ok := parsePeerShareRuntimeServiceID(serviceName); ok {
|
||||
merged := batch.peerShareRuntimeItems[runtimeID]
|
||||
merged.N = serviceName
|
||||
merged.U += item.U
|
||||
merged.D += item.D
|
||||
batch.peerShareRuntimeItems[runtimeID] = merged
|
||||
continue
|
||||
}
|
||||
forwardID, userID, userTunnelID, ok := parseFlowServiceIDs(serviceName)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
meta, exists := metas[forwardID]
|
||||
if !exists {
|
||||
batch.orphanServices[serviceName] = struct{}{}
|
||||
continue
|
||||
}
|
||||
raw := batch.forwardTraffic[forwardID]
|
||||
raw.bytesIn += item.D
|
||||
raw.bytesOut += item.U
|
||||
batch.forwardTraffic[forwardID] = raw
|
||||
|
||||
scaledIn := int64(float64(item.D)*meta.TrafficRatio) * meta.TunnelFlow
|
||||
scaledOut := int64(float64(item.U)*meta.TrafficRatio) * meta.TunnelFlow
|
||||
if idx, ok := flowSeen[forwardID]; ok {
|
||||
batch.flowDeltas[idx].InFlow += scaledIn
|
||||
batch.flowDeltas[idx].OutFlow += scaledOut
|
||||
} else {
|
||||
flowSeen[forwardID] = len(batch.flowDeltas)
|
||||
batch.flowDeltas = append(batch.flowDeltas, repo.FlowUploadCounterDelta{ForwardID: forwardID, UserID: userID, UserTunnelID: userTunnelID, InFlow: scaledIn, OutFlow: scaledOut})
|
||||
}
|
||||
batch.quotaUsage[userID] += scaledIn + scaledOut
|
||||
target := flowPolicyTarget{UserID: userID, UserTunnelID: userTunnelID}
|
||||
if _, seen := policySeen[target]; !seen {
|
||||
policySeen[target] = struct{}{}
|
||||
batch.policyTargets = append(batch.policyTargets, target)
|
||||
}
|
||||
merged := batch.peerShareForwardItems[normalizeForwardRuntimeServiceName(serviceName)]
|
||||
merged.N = normalizeForwardRuntimeServiceName(serviceName)
|
||||
merged.U += item.U
|
||||
merged.D += item.D
|
||||
batch.peerShareForwardItems[normalizeForwardRuntimeServiceName(serviceName)] = merged
|
||||
}
|
||||
|
||||
sort.Slice(batch.policyTargets, func(i, j int) bool {
|
||||
if batch.policyTargets[i].UserID == batch.policyTargets[j].UserID {
|
||||
return batch.policyTargets[i].UserTunnelID < batch.policyTargets[j].UserTunnelID
|
||||
}
|
||||
return batch.policyTargets[i].UserID < batch.policyTargets[j].UserID
|
||||
})
|
||||
return batch
|
||||
}
|
||||
|
||||
func (h *Handler) applyFlowUploadBatch(nodeID int64, batch flowUploadBatch, now time.Time) {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
if err := h.repo.ApplyFlowUploadDeltasBatch(batch.flowDeltas); err != nil {
|
||||
log.Printf("flow upload write failed op=flow.batch_apply node_id=%d err=%v", nodeID, err)
|
||||
return
|
||||
}
|
||||
quotaViews, err := h.repo.AddUserQuotaUsageBatch(batch.quotaUsage, now)
|
||||
if err != nil {
|
||||
log.Printf("flow upload write failed op=quota.batch_apply node_id=%d err=%v", nodeID, err)
|
||||
return
|
||||
}
|
||||
for userID, quota := range quotaViews {
|
||||
h.enforceUserQuotaIfNeeded(userID, quota)
|
||||
}
|
||||
for _, target := range batch.policyTargets {
|
||||
if target.UserID <= 0 || target.UserTunnelID <= 0 {
|
||||
continue
|
||||
}
|
||||
h.enforceFlowPolicies(target.UserID, target.UserTunnelID)
|
||||
}
|
||||
for serviceName := range batch.orphanServices {
|
||||
h.sendDeleteOrphanedForwardService(nodeID, serviceName)
|
||||
}
|
||||
for serviceName, item := range batch.peerShareForwardItems {
|
||||
forwardID, _, _, ok := parseFlowServiceIDs(serviceName)
|
||||
if ok {
|
||||
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
|
||||
}
|
||||
}
|
||||
for runtimeID, item := range batch.peerShareRuntimeItems {
|
||||
h.processPeerShareFlow(runtimeID, item)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Switch the `/flow/upload` entrypoint and tunnel metric ingestion to the shared batch**
|
||||
|
||||
Modify `handler.go` and `tunnel_metrics_ingestion.go` so the raw JSON is parsed once and the same forward metadata powers both flow counters and tunnel metrics.
|
||||
|
||||
```go
|
||||
// handler.go
|
||||
func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
|
||||
secret := r.URL.Query().Get("secret")
|
||||
node, _ := h.repo.GetNodeBySecret(secret)
|
||||
if node == nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
return
|
||||
}
|
||||
|
||||
raw, err := readAndDecryptFlowBody(r.Body, secret)
|
||||
if err == nil && strings.TrimSpace(raw) != "" {
|
||||
var items []flowItem
|
||||
if json.Unmarshal([]byte(raw), &items) == nil {
|
||||
now := time.Now()
|
||||
forwardIDs := collectFlowUploadForwardIDs(items)
|
||||
metas, metaErr := h.repo.GetFlowUploadForwardMetas(forwardIDs)
|
||||
if metaErr != nil {
|
||||
log.Printf("flow upload metadata lookup failed node_id=%d err=%v", node.ID, metaErr)
|
||||
metas = map[int64]repo.FlowUploadForwardMeta{}
|
||||
}
|
||||
batch := h.buildFlowUploadBatch(items, metas)
|
||||
h.recordTunnelMetricsFromForwardBatch(node.ID, batch.forwardTraffic, metas, now.UnixMilli())
|
||||
h.applyFlowUploadBatch(node.ID, batch, now)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// tunnel_metrics_ingestion.go
|
||||
func collectFlowUploadForwardIDs(items []flowItem) []int64 {
|
||||
ids := make([]int64, 0, len(items))
|
||||
seen := make(map[int64]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
forwardID, _, _, ok := parseFlowServiceIDs(strings.TrimSpace(item.N))
|
||||
if !ok || forwardID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[forwardID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[forwardID] = struct{}{}
|
||||
ids = append(ids, forwardID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (h *Handler) recordTunnelMetricsFromForwardBatch(nodeID int64, forwardDeltas map[int64]tunnelTrafficDelta, metas map[int64]repo.FlowUploadForwardMeta, nowMs int64) {
|
||||
if h == nil || h.repo == nil || nodeID <= 0 || len(forwardDeltas) == 0 {
|
||||
return
|
||||
}
|
||||
bucketTs := unixMilliBucketMinute(nowMs)
|
||||
if bucketTs <= 0 {
|
||||
return
|
||||
}
|
||||
tunnelAgg := make(map[int64]tunnelTrafficDelta)
|
||||
for forwardID, delta := range forwardDeltas {
|
||||
meta, ok := metas[forwardID]
|
||||
if !ok || meta.TunnelID <= 0 {
|
||||
continue
|
||||
}
|
||||
current := tunnelAgg[meta.TunnelID]
|
||||
current.bytesIn += delta.bytesIn
|
||||
current.bytesOut += delta.bytesOut
|
||||
tunnelAgg[meta.TunnelID] = current
|
||||
}
|
||||
metrics := make([]*model.TunnelMetric, 0, len(tunnelAgg))
|
||||
for tunnelID, delta := range tunnelAgg {
|
||||
if delta.bytesIn == 0 && delta.bytesOut == 0 {
|
||||
continue
|
||||
}
|
||||
metrics = append(metrics, &model.TunnelMetric{TunnelID: tunnelID, NodeID: nodeID, Timestamp: bucketTs, BytesIn: delta.bytesIn, BytesOut: delta.bytesOut})
|
||||
}
|
||||
if len(metrics) == 0 {
|
||||
return
|
||||
}
|
||||
if err := h.repo.UpsertTunnelMetricBuckets(metrics); err != nil {
|
||||
log.Printf("monitoring write failed op=tunnel_metric.upsert_buckets node_id=%d bucket_ts=%d count=%d err=%v", nodeID, bucketTs, len(metrics), err)
|
||||
return
|
||||
}
|
||||
log.Printf("monitoring ok op=tunnel_metric.upsert_buckets node_id=%d bucket_ts=%d count=%d", nodeID, bucketTs, len(metrics))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run focused handler and contract tests to verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/http/handler -run TestBuildFlowUploadBatchAggregatesForwardQuotaPeerShareAndCleanupTargets -v
|
||||
go test ./tests/contract/... -run TestFlowUploadAggregatesRepeatedItemsAndDisablesQuotaImmediately -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Run the full backend suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Expected: PASS across the backend module.
|
||||
|
||||
- [ ] **Step 5: Optional commit if the user explicitly requested commits**
|
||||
|
||||
```bash
|
||||
git add go-backend/internal/http/handler/handler.go go-backend/internal/http/handler/tunnel_metrics_ingestion.go go-backend/internal/http/handler/flow_upload_batch.go go-backend/internal/http/handler/flow_upload_batch_test.go go-backend/tests/contract/flow_upload_batch_contract_test.go
|
||||
git commit -m "refactor: batch flow upload processing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Final Verification And Performance Sanity Check
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/tests/contract/flow_upload_batch_contract_test.go`
|
||||
|
||||
- [ ] **Step 1: Add a same-batch duplicate-item stress assertion**
|
||||
|
||||
Extend the contract test with a second request that repeats the same service name multiple times and assert the counters advance by exactly the summed amount.
|
||||
|
||||
```go
|
||||
body, err = json.Marshal([]map[string]interface{}{
|
||||
{"n": "20_2_10", "u": 10, "d": 20},
|
||||
{"n": "20_2_10", "u": 10, "d": 20},
|
||||
{"n": "20_2_10_tcp", "u": 10, "d": 20},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodPost, "/flow/upload?secret="+node.Secret, bytes.NewReader(body))
|
||||
res = httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
if got := mustQueryInt(t, repo, `SELECT in_flow FROM forward WHERE id = 20`); got != 140 {
|
||||
t.Fatalf("expected forward in_flow=140 after second request, got %d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, repo, `SELECT out_flow FROM forward WHERE id = 20`); got != 140 {
|
||||
t.Fatalf("expected forward out_flow=140 after second request, got %d", got)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the targeted contract test again**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./tests/contract/... -run TestFlowUploadAggregatesRepeatedItemsAndDisablesQuotaImmediately -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Re-run the full backend suite before claiming completion**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Optional local profiling sanity check**
|
||||
|
||||
Run a short local comparison before and after the change with the same repeated flow payload.
|
||||
|
||||
```bash
|
||||
go test ./tests/contract/... -run TestFlowUploadAggregatesRepeatedItemsAndDisablesQuotaImmediately -count=10
|
||||
```
|
||||
|
||||
Expected: the test remains stable across repeated runs and does not introduce flakiness.
|
||||
|
||||
- [ ] **Step 5: Optional commit if the user explicitly requested commits**
|
||||
|
||||
```bash
|
||||
git add go-backend/tests/contract/flow_upload_batch_contract_test.go
|
||||
git commit -m "test: harden flow upload batch regression coverage"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,598 @@
|
||||
# Monitoring Retention And Storage Display Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add configurable monitoring data retention and show database storage usage on the config page.
|
||||
|
||||
**Architecture:** Store retention in `vite_config` as `monitor_retention_days`, parse it through a focused monitoring helper, and reuse it from existing cleanup loops. Add a repository storage-summary helper, expose it via an admin-only API, and render it in the existing React config page.
|
||||
|
||||
**Tech Stack:** Go `net/http`, GORM, SQLite/PostgreSQL, Vite/React/TypeScript, existing shadcn bridge components.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create: `go-backend/internal/monitoring/retention.go` for retention constants, parsing, and validation.
|
||||
- Test: `go-backend/internal/monitoring/retention_test.go`.
|
||||
- Modify: `go-backend/internal/metrics/ingestion.go` and `go-backend/internal/metrics/ingestion_test.go` for config-driven cleanup.
|
||||
- Modify: `go-backend/internal/http/handler/tunnel_quality_prober.go` so `tunnel_quality` uses the same retention and still prunes when probing is disabled.
|
||||
- Create: `go-backend/internal/store/repo/repository_storage.go` and `go-backend/internal/store/repo/repository_storage_test.go` for database size summaries.
|
||||
- Modify: `go-backend/internal/store/repo/repository.go` to keep the SQLite DB path on `Repository`.
|
||||
- Create: `go-backend/internal/http/handler/storage.go` for the storage endpoint.
|
||||
- Modify: `go-backend/internal/http/handler/handler.go` to register `/api/v1/system/storage` and validate `monitor_retention_days`.
|
||||
- Modify: `go-backend/internal/http/middleware/auth.go` so `/api/v1/system/*` is admin-only.
|
||||
- Create: `go-backend/tests/contract/storage_contract_test.go` for endpoint auth/shape coverage.
|
||||
- Modify: `vite-frontend/src/api/types.ts`, `vite-frontend/src/api/index.ts`, and `vite-frontend/src/pages/config.tsx` for UI display.
|
||||
|
||||
Implementation should not create git commits unless the user explicitly requests them.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Retention Config Helper
|
||||
|
||||
**Files:**
|
||||
- Create: `go-backend/internal/monitoring/retention.go`
|
||||
- Create: `go-backend/internal/monitoring/retention_test.go`
|
||||
- Modify: `go-backend/internal/http/handler/handler.go`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `go-backend/internal/monitoring/retention_test.go`:
|
||||
|
||||
```go
|
||||
package monitoring
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMonitoringRetentionDaysFromConfigMap(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
want int
|
||||
}{
|
||||
{"missing uses default", nil, 7},
|
||||
{"valid custom", map[string]string{ConfigMonitorRetentionDays: "3"}, 3},
|
||||
{"trimmed custom", map[string]string{ConfigMonitorRetentionDays: " 30 "}, 30},
|
||||
{"invalid uses default", map[string]string{ConfigMonitorRetentionDays: "abc"}, 7},
|
||||
{"too small uses default", map[string]string{ConfigMonitorRetentionDays: "0"}, 7},
|
||||
{"too large uses default", map[string]string{ConfigMonitorRetentionDays: "3651"}, 7},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := MonitoringRetentionDaysFromConfigMap(tc.cfg); got != tc.want {
|
||||
t.Fatalf("expected %d, got %d", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMonitoringRetentionDays(t *testing.T) {
|
||||
for _, value := range []string{"1", "7", "3650", " 30 "} {
|
||||
if got, err := NormalizeMonitoringRetentionDays(value); err != nil || got == "" {
|
||||
t.Fatalf("expected %q valid, got value=%q err=%v", value, got, err)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"", "0", "-1", "3651", "abc", "1.5"} {
|
||||
if got, err := NormalizeMonitoringRetentionDays(value); err == nil {
|
||||
t.Fatalf("expected %q invalid, got value=%q", value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run: `go test ./internal/monitoring -run 'TestMonitoringRetentionDaysFromConfigMap|TestNormalizeMonitoringRetentionDays' -count=1`
|
||||
|
||||
Expected: FAIL with undefined `ConfigMonitorRetentionDays`, `MonitoringRetentionDaysFromConfigMap`, and `NormalizeMonitoringRetentionDays`.
|
||||
|
||||
- [ ] **Step 3: Implement helper**
|
||||
|
||||
Create `go-backend/internal/monitoring/retention.go`:
|
||||
|
||||
```go
|
||||
package monitoring
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ConfigMonitorRetentionDays = "monitor_retention_days"
|
||||
DefaultMonitorRetentionDays = 7
|
||||
MinMonitorRetentionDays = 1
|
||||
MaxMonitorRetentionDays = 3650
|
||||
)
|
||||
|
||||
func MonitoringRetentionDaysFromConfigMap(cfg map[string]string) int {
|
||||
if cfg == nil {
|
||||
return DefaultMonitorRetentionDays
|
||||
}
|
||||
days, err := parseMonitoringRetentionDays(cfg[ConfigMonitorRetentionDays])
|
||||
if err != nil {
|
||||
return DefaultMonitorRetentionDays
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
func NormalizeMonitoringRetentionDays(value string) (string, error) {
|
||||
days, err := parseMonitoringRetentionDays(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strconv.Itoa(days), nil
|
||||
}
|
||||
|
||||
func parseMonitoringRetentionDays(value string) (int, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return 0, fmt.Errorf("监控数据保留天数不能为空")
|
||||
}
|
||||
days, err := strconv.Atoi(trimmed)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("监控数据保留天数必须是整数")
|
||||
}
|
||||
if days < MinMonitorRetentionDays || days > MaxMonitorRetentionDays {
|
||||
return 0, fmt.Errorf("监控数据保留天数必须在 %d 到 %d 之间", MinMonitorRetentionDays, MaxMonitorRetentionDays)
|
||||
}
|
||||
return days, nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Validate config updates**
|
||||
|
||||
In `go-backend/internal/http/handler/handler.go`, add this case to `normalizeAndValidateConfigValue`:
|
||||
|
||||
```go
|
||||
case monitoring.ConfigMonitorRetentionDays:
|
||||
return monitoring.NormalizeMonitoringRetentionDays(value)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests**
|
||||
|
||||
Run: `go test ./internal/monitoring ./internal/http/handler -run 'TestMonitoringRetention|TestNormalize|Test' -count=1`
|
||||
|
||||
Expected: PASS or only unrelated pre-existing failures, which must be investigated before continuing.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Use Retention Config In Cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/metrics/ingestion.go`
|
||||
- Modify: `go-backend/internal/metrics/ingestion_test.go`
|
||||
- Modify: `go-backend/internal/http/handler/tunnel_quality_prober.go`
|
||||
|
||||
- [ ] **Step 1: Write failing cleanup test**
|
||||
|
||||
Append to `go-backend/internal/metrics/ingestion_test.go`, adding `go-backend/internal/store/model` to imports:
|
||||
|
||||
```go
|
||||
func TestPruneMetricsUsesConfiguredRetentionDays(t *testing.T) {
|
||||
r, err := repo.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.UpsertConfig("monitor_retention_days", "2", now); err != nil {
|
||||
t.Fatalf("upsert retention config: %v", err)
|
||||
}
|
||||
|
||||
oldMetric := &model.NodeMetric{NodeID: 1, Timestamp: now - int64(3*24*time.Hour/time.Millisecond), CPUUsage: 10}
|
||||
newMetric := &model.NodeMetric{NodeID: 1, Timestamp: now - int64(1*24*time.Hour/time.Millisecond), CPUUsage: 20}
|
||||
if err := r.InsertNodeMetric(oldMetric); err != nil {
|
||||
t.Fatalf("insert old metric: %v", err)
|
||||
}
|
||||
if err := r.InsertNodeMetric(newMetric); err != nil {
|
||||
t.Fatalf("insert new metric: %v", err)
|
||||
}
|
||||
|
||||
svc := NewIngestionService(r)
|
||||
svc.pruneMetricsAt(time.UnixMilli(now))
|
||||
|
||||
metrics, err := r.GetNodeMetrics(1, now-int64(4*24*time.Hour/time.Millisecond), now+1000)
|
||||
if err != nil {
|
||||
t.Fatalf("get node metrics: %v", err)
|
||||
}
|
||||
if len(metrics) != 1 || metrics[0].CPUUsage != 20 {
|
||||
t.Fatalf("expected only newer metric to remain, got %#v", metrics)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify failure**
|
||||
|
||||
Run: `go test ./internal/metrics -run TestPruneMetricsUsesConfiguredRetentionDays -count=1`
|
||||
|
||||
Expected: FAIL with undefined `pruneMetricsAt`.
|
||||
|
||||
- [ ] **Step 3: Implement config-driven prune**
|
||||
|
||||
In `go-backend/internal/metrics/ingestion.go`, import `go-backend/internal/monitoring` and replace `pruneMetrics` with:
|
||||
|
||||
```go
|
||||
func (s *IngestionService) retentionDaysFromConfig() int {
|
||||
if s == nil || s.repo == nil {
|
||||
return monitoring.DefaultMonitorRetentionDays
|
||||
}
|
||||
cfg, err := s.repo.GetConfigsByNames([]string{monitoring.ConfigMonitorRetentionDays})
|
||||
if err != nil {
|
||||
return monitoring.DefaultMonitorRetentionDays
|
||||
}
|
||||
return monitoring.MonitoringRetentionDaysFromConfigMap(cfg)
|
||||
}
|
||||
|
||||
func (s *IngestionService) pruneMetrics() {
|
||||
s.pruneMetricsAt(time.Now())
|
||||
}
|
||||
|
||||
func (s *IngestionService) pruneMetricsAt(now time.Time) {
|
||||
cutoff := now.Add(-time.Duration(s.retentionDaysFromConfig()) * 24 * time.Hour).UnixMilli()
|
||||
if s.repo == nil {
|
||||
return
|
||||
}
|
||||
if err := s.repo.PruneNodeMetrics(cutoff); err != nil {
|
||||
log.Printf("monitoring prune failed op=node_metric cutoff=%d err=%v", cutoff, err)
|
||||
}
|
||||
if err := s.repo.PruneTunnelMetrics(cutoff); err != nil {
|
||||
log.Printf("monitoring prune failed op=tunnel_metric cutoff=%d err=%v", cutoff, err)
|
||||
}
|
||||
if err := s.repo.PruneServiceMonitorResults(cutoff); err != nil {
|
||||
log.Printf("monitoring prune failed op=service_monitor_result cutoff=%d err=%v", cutoff, err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Remove the unused `retentionDays` field from `IngestionService` and remove `svc.retentionDays = 1` from existing tests.
|
||||
|
||||
- [ ] **Step 4: Update tunnel quality pruning**
|
||||
|
||||
In `go-backend/internal/http/handler/tunnel_quality_prober.go`, import `go-backend/internal/monitoring`, remove `tunnelQualityRetention`, remove the `if !p.isEnabled() { return }` guard from `maybePrune`, and calculate cutoff with:
|
||||
|
||||
```go
|
||||
func (p *tunnelQualityProber) retentionDays() int {
|
||||
if p == nil || p.handler == nil || p.handler.repo == nil {
|
||||
return monitoring.DefaultMonitorRetentionDays
|
||||
}
|
||||
cfg, err := p.handler.repo.GetConfigsByNames([]string{monitoring.ConfigMonitorRetentionDays})
|
||||
if err != nil {
|
||||
return monitoring.DefaultMonitorRetentionDays
|
||||
}
|
||||
return monitoring.MonitoringRetentionDaysFromConfigMap(cfg)
|
||||
}
|
||||
```
|
||||
|
||||
Then use:
|
||||
|
||||
```go
|
||||
cutoff := now - int64(time.Duration(p.retentionDays())*24*time.Hour/time.Millisecond)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run cleanup tests**
|
||||
|
||||
Run: `go test ./internal/metrics ./internal/http/handler -run 'TestPruneMetrics|TestPruneMetricsUsesConfiguredRetentionDays|TunnelQuality' -count=1`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add Storage Summary Backend API
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/store/repo/repository.go`
|
||||
- Create: `go-backend/internal/store/repo/repository_storage.go`
|
||||
- Create: `go-backend/internal/store/repo/repository_storage_test.go`
|
||||
- Create: `go-backend/internal/http/handler/storage.go`
|
||||
- Modify: `go-backend/internal/http/handler/handler.go`
|
||||
- Modify: `go-backend/internal/http/middleware/auth.go`
|
||||
- Create: `go-backend/tests/contract/storage_contract_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing repository tests**
|
||||
|
||||
Create `go-backend/internal/store/repo/repository_storage_test.go`:
|
||||
|
||||
```go
|
||||
package repo
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
func TestDatabaseStorageSummarySQLiteIncludesSize(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "storage.db")
|
||||
r, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
if err := r.InsertNodeMetric(&model.NodeMetric{NodeID: 1, Timestamp: 123, CPUUsage: 1}); err != nil {
|
||||
t.Fatalf("insert metric: %v", err)
|
||||
}
|
||||
summary, err := r.DatabaseStorageSummary()
|
||||
if err != nil {
|
||||
t.Fatalf("storage summary: %v", err)
|
||||
}
|
||||
if summary.DBType != "sqlite" || summary.DatabaseSizeBytes <= 0 || summary.DatabaseSizeText == "" {
|
||||
t.Fatalf("unexpected summary: %#v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDatabaseSize(t *testing.T) {
|
||||
for _, tc := range []struct{ bytes int64; want string }{{0, "0 B"}, {512, "512 B"}, {1024, "1.0 KB"}, {1024 * 1024, "1.0 MB"}} {
|
||||
if got := formatDatabaseSize(tc.bytes); got != tc.want {
|
||||
t.Fatalf("formatDatabaseSize(%d)=%q want %q", tc.bytes, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify failure**
|
||||
|
||||
Run: `go test ./internal/store/repo -run 'TestDatabaseStorageSummarySQLiteIncludesSize|TestFormatDatabaseSize' -count=1`
|
||||
|
||||
Expected: FAIL with undefined `DatabaseStorageSummary` and `formatDatabaseSize`.
|
||||
|
||||
- [ ] **Step 3: Implement repository helper**
|
||||
|
||||
Modify `Repository` in `repository.go`:
|
||||
|
||||
```go
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
dbPath string
|
||||
}
|
||||
```
|
||||
|
||||
Return `&Repository{db: db, dbPath: path}` from `Open` and `&Repository{db: db}` from `OpenPostgres`.
|
||||
|
||||
Create `go-backend/internal/store/repo/repository_storage.go`:
|
||||
|
||||
```go
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type DatabaseStorageSummary struct {
|
||||
DBType string `json:"dbType"`
|
||||
DatabaseSizeBytes int64 `json:"databaseSizeBytes"`
|
||||
DatabaseSizeText string `json:"databaseSizeText"`
|
||||
}
|
||||
|
||||
func (r *Repository) DatabaseStorageSummary() (DatabaseStorageSummary, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return DatabaseStorageSummary{}, errors.New("repository not initialized")
|
||||
}
|
||||
switch r.db.Dialector.Name() {
|
||||
case "sqlite":
|
||||
size, err := sqliteDatabaseFileSize(r.dbPath)
|
||||
if err != nil { return DatabaseStorageSummary{}, err }
|
||||
return DatabaseStorageSummary{"sqlite", size, formatDatabaseSize(size)}, nil
|
||||
case "postgres":
|
||||
var size int64
|
||||
if err := r.db.Raw("SELECT pg_database_size(current_database())").Scan(&size).Error; err != nil { return DatabaseStorageSummary{}, err }
|
||||
return DatabaseStorageSummary{"postgres", size, formatDatabaseSize(size)}, nil
|
||||
default:
|
||||
return DatabaseStorageSummary{}, fmt.Errorf("unsupported database dialect %q", r.db.Dialector.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func sqliteDatabaseFileSize(path string) (int64, error) {
|
||||
if path == "" || path == ":memory:" { return 0, nil }
|
||||
var total int64
|
||||
for _, candidate := range []string{path, path + "-wal", path + "-shm"} {
|
||||
info, err := os.Stat(candidate)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) { continue }
|
||||
return 0, err
|
||||
}
|
||||
if !info.IsDir() { total += info.Size() }
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func formatDatabaseSize(bytes int64) string {
|
||||
if bytes < 1024 { return fmt.Sprintf("%d B", bytes) }
|
||||
units := []string{"KB", "MB", "GB", "TB"}
|
||||
value := float64(bytes) / 1024
|
||||
for _, unit := range units {
|
||||
if value < 1024 || unit == "TB" { return fmt.Sprintf("%.1f %s", value, unit) }
|
||||
value /= 1024
|
||||
}
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add API handler and route**
|
||||
|
||||
Create `go-backend/internal/http/handler/storage.go`:
|
||||
|
||||
```go
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func (h *Handler) storageSummary(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if h == nil || h.repo == nil {
|
||||
response.WriteJSON(w, response.Err(-2, "repository not initialized"))
|
||||
return
|
||||
}
|
||||
summary, err := h.repo.DatabaseStorageSummary()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(summary))
|
||||
}
|
||||
```
|
||||
|
||||
Register in `Handler.Register`: `mux.HandleFunc("/api/v1/system/storage", h.storageSummary)`.
|
||||
|
||||
In `requiresAdmin`, add:
|
||||
|
||||
```go
|
||||
if strings.HasPrefix(path, "/api/v1/system/") {
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Write contract test for auth and shape**
|
||||
|
||||
Create `go-backend/tests/contract/storage_contract_test.go` with a test that sends GET `/api/v1/system/storage` as non-admin and expects `403`, then as admin and expects `code == 0`, `dbType`, numeric `databaseSizeBytes`, and `databaseSizeText`.
|
||||
|
||||
- [ ] **Step 6: Run storage tests**
|
||||
|
||||
Run: `go test ./internal/store/repo ./tests/contract -run 'TestDatabaseStorageSummarySQLiteIncludesSize|TestFormatDatabaseSize|TestStorageSummaryRequiresAdminAndReturnsSize' -count=1`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add Frontend Config UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/api/types.ts`
|
||||
- Modify: `vite-frontend/src/api/index.ts`
|
||||
- Modify: `vite-frontend/src/pages/config.tsx`
|
||||
|
||||
- [ ] **Step 1: Add API type and function**
|
||||
|
||||
In `types.ts` add:
|
||||
|
||||
```ts
|
||||
export interface StorageSummaryApiData {
|
||||
dbType: string;
|
||||
databaseSizeBytes: number;
|
||||
databaseSizeText: string;
|
||||
}
|
||||
```
|
||||
|
||||
In `index.ts`, import `StorageSummaryApiData` and add:
|
||||
|
||||
```ts
|
||||
export const getStorageSummary = () =>
|
||||
Network.get<StorageSummaryApiData>("/system/storage");
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add retention config item**
|
||||
|
||||
In `config.tsx`, add to `CONFIG_ITEMS` near monitoring:
|
||||
|
||||
```ts
|
||||
{
|
||||
key: "monitor_retention_days",
|
||||
label: "监控数据保留天数",
|
||||
placeholder: "7",
|
||||
description:
|
||||
"统一清理节点指标、隧道流量、服务监控结果和隧道质量历史;默认 7 天。",
|
||||
type: "input",
|
||||
},
|
||||
```
|
||||
|
||||
Add `"monitor_retention_days"` to `getInitialConfigs()` keys.
|
||||
|
||||
- [ ] **Step 3: Fetch and display database size**
|
||||
|
||||
In `config.tsx`, add state:
|
||||
|
||||
```ts
|
||||
const [storageSummary, setStorageSummary] = useState<string>("加载中...");
|
||||
```
|
||||
|
||||
Add a load effect:
|
||||
|
||||
```ts
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
getStorageSummary()
|
||||
.then((response) => {
|
||||
if (!mounted) return;
|
||||
if (response.code === 0 && response.data?.databaseSizeText) {
|
||||
setStorageSummary(response.data.databaseSizeText);
|
||||
} else {
|
||||
setStorageSummary("获取失败");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) setStorageSummary("获取失败");
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
Render inside the basic settings card before the save button:
|
||||
|
||||
```tsx
|
||||
<Divider className="my-2" />
|
||||
<div className="space-y-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">
|
||||
当前后端数据库文件/实例占用空间,仅用于容量参考。
|
||||
</p>
|
||||
<div className="rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/10 px-4 py-3 text-sm font-semibold text-default-800 dark:text-default-200">
|
||||
{storageSummary}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build frontend**
|
||||
|
||||
Run: `pnpm run build` from `vite-frontend`.
|
||||
|
||||
Expected: TypeScript and Vite build pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Final Verification
|
||||
|
||||
**Files:**
|
||||
- All files changed by previous tasks.
|
||||
|
||||
- [ ] **Step 1: Run backend tests**
|
||||
|
||||
Run: `go test ./...` from `go-backend`.
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run frontend build**
|
||||
|
||||
Run: `pnpm run build` from `vite-frontend`.
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Review diff**
|
||||
|
||||
Run: `git diff --stat` and `git diff -- docs/superpowers/specs/2026-04-28-monitoring-retention-storage-design.md docs/superpowers/plans/2026-04-28-monitoring-retention-storage.md go-backend vite-frontend`.
|
||||
|
||||
Expected: Diff is limited to retention config, storage summary, tests, and config UI.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: retention config, uniform cleanup, storage summary API, frontend display, validation, and verification are covered.
|
||||
- Placeholder scan: no TBD/TODO placeholders; the one contract-test step describes exact assertions even though the surrounding helper functions already exist in contract tests.
|
||||
- Type consistency: backend JSON fields match frontend `StorageSummaryApiData` exactly: `dbType`, `databaseSizeBytes`, `databaseSizeText`.
|
||||
@@ -0,0 +1,892 @@
|
||||
# Best Exit Current Display Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Show the currently applied `best` exit selection in the tunnel list information, including per-entry/per-final-hop details for multi-owner tunnels.
|
||||
|
||||
**Architecture:** Add a backend-only display layer that snapshots `bestExitManager` state and attaches `bestExitState` to existing `tunnelList`/`tunnelGet` responses. Render that state in the existing tunnel table/grid topology area using compact text and a native `title` detail tooltip. No routing, scoring, persistence, polling, or runtime update behavior changes.
|
||||
|
||||
**Tech Stack:** Go `net/http` handlers + existing repository methods, React/TypeScript in `vite-frontend/src/pages/tunnel.tsx`, Tailwind/shadcn bridge components already in the file.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create `go-backend/internal/http/handler/tunnel_best_exit_display.go`: response DTOs, manager snapshot method, tunnel-response parsing helpers, and `Handler.attachBestExitStates`.
|
||||
- Create `go-backend/internal/http/handler/tunnel_best_exit_display_test.go`: backend display-state unit tests.
|
||||
- Modify `go-backend/internal/http/handler/handler.go`: call `h.attachBestExitStatesOrLog(items)` in `tunnelList`.
|
||||
- Modify `go-backend/internal/http/handler/mutations.go`: call `h.attachBestExitStatesOrLog(items)` before returning a single tunnel in `tunnelGet`.
|
||||
- Modify `vite-frontend/src/pages/tunnel.tsx`: add `bestExitState` types, map API state, helper render functions, and table/grid display.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend Snapshot And Display-State Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `go-backend/internal/http/handler/tunnel_best_exit_display_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing backend display tests**
|
||||
|
||||
Create `go-backend/internal/http/handler/tunnel_best_exit_display_test.go`:
|
||||
|
||||
```go
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBestExitDecisionSnapshotIsDefensiveCopy(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
key := bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}
|
||||
now := time.Unix(100, 0)
|
||||
score := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30, NodeName: "exit-a"}, 10, 0, 20, 0)
|
||||
|
||||
m.observeScores(key, []bestExitCandidateScore{score}, now)
|
||||
snapshot, ok := m.snapshot(key)
|
||||
if !ok {
|
||||
t.Fatalf("expected snapshot")
|
||||
}
|
||||
if snapshot.AppliedExitNodeID != 30 || snapshot.UpdatedAt != now.UnixMilli() {
|
||||
t.Fatalf("unexpected snapshot: %+v", snapshot)
|
||||
}
|
||||
if len(snapshot.Scores) != 1 {
|
||||
t.Fatalf("expected one score in snapshot, got %+v", snapshot.Scores)
|
||||
}
|
||||
snapshot.Scores[0].ExitNodeID = 99
|
||||
|
||||
again, ok := m.snapshot(key)
|
||||
if !ok {
|
||||
t.Fatalf("expected second snapshot")
|
||||
}
|
||||
if again.Scores[0].ExitNodeID != 30 {
|
||||
t.Fatalf("snapshot score mutation leaked into manager state: %+v", again.Scores)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBestExitDisplayStateForDirectMultiEntryOwners(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
now := time.Unix(100, 0)
|
||||
m.setApplied(bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}, 30, now)
|
||||
m.setApplied(bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 11}, 31, now.Add(time.Second))
|
||||
|
||||
tunnel := map[string]interface{}{
|
||||
"id": int64(77),
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(10)},
|
||||
{"nodeId": int64(11)},
|
||||
},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": tunnelStrategyBest},
|
||||
{"nodeId": int64(31), "strategy": tunnelStrategyBest},
|
||||
},
|
||||
"chainNodes": [][]map[string]interface{}{},
|
||||
}
|
||||
names := map[int64]string{10: "入口 A", 11: "入口 B", 30: "香港节点", 31: "日本节点"}
|
||||
|
||||
state, ok := buildBestExitDisplayState(tunnel, m, testBestExitNameLookup(names))
|
||||
if !ok {
|
||||
t.Fatalf("expected best exit state")
|
||||
}
|
||||
if !state.Enabled || state.Summary != "多个出口" || state.Status != "applied" {
|
||||
t.Fatalf("unexpected state summary: %+v", state)
|
||||
}
|
||||
if state.UpdatedAt != now.Add(time.Second).UnixMilli() {
|
||||
t.Fatalf("expected latest updatedAt, got %d", state.UpdatedAt)
|
||||
}
|
||||
if len(state.Items) != 2 {
|
||||
t.Fatalf("expected two owner items, got %+v", state.Items)
|
||||
}
|
||||
if state.Items[0].OwnerRole != "entry" || state.Items[0].OwnerNodeName != "入口 A" || state.Items[0].ExitNodeName != "香港节点" {
|
||||
t.Fatalf("unexpected first item: %+v", state.Items[0])
|
||||
}
|
||||
if state.Items[1].OwnerRole != "entry" || state.Items[1].OwnerNodeName != "入口 B" || state.Items[1].ExitNodeName != "日本节点" {
|
||||
t.Fatalf("unexpected second item: %+v", state.Items[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBestExitDisplayStateForFinalChainHopOwners(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
now := time.Unix(200, 0)
|
||||
m.setApplied(bestExitOwnerKey{TunnelID: 88, OwnerNodeID: 20}, 30, now)
|
||||
m.setApplied(bestExitOwnerKey{TunnelID: 88, OwnerNodeID: 21}, 30, now.Add(time.Second))
|
||||
|
||||
tunnel := map[string]interface{}{
|
||||
"id": int64(88),
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(10)},
|
||||
},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": tunnelStrategyBest},
|
||||
{"nodeId": int64(31), "strategy": tunnelStrategyBest},
|
||||
},
|
||||
"chainNodes": [][]map[string]interface{}{
|
||||
{{"nodeId": int64(15), "inx": int64(0)}},
|
||||
{{"nodeId": int64(20), "inx": int64(1)}, {"nodeId": int64(21), "inx": int64(1)}},
|
||||
},
|
||||
}
|
||||
names := map[int64]string{20: "中转 M1", 21: "中转 M2", 30: "香港节点", 31: "日本节点"}
|
||||
|
||||
state, ok := buildBestExitDisplayState(tunnel, m, testBestExitNameLookup(names))
|
||||
if !ok {
|
||||
t.Fatalf("expected best exit state")
|
||||
}
|
||||
if state.Summary != "香港节点" || state.Status != "applied" {
|
||||
t.Fatalf("expected single-exit summary, got %+v", state)
|
||||
}
|
||||
if len(state.Items) != 2 {
|
||||
t.Fatalf("expected two final-hop owner items, got %+v", state.Items)
|
||||
}
|
||||
if state.Items[0].OwnerRole != "chain" || state.Items[0].OwnerNodeName != "中转 M1" || state.Items[0].ExitNodeName != "香港节点" {
|
||||
t.Fatalf("unexpected first chain owner item: %+v", state.Items[0])
|
||||
}
|
||||
if state.Items[1].OwnerRole != "chain" || state.Items[1].OwnerNodeName != "中转 M2" || state.Items[1].ExitNodeName != "香港节点" {
|
||||
t.Fatalf("unexpected second chain owner item: %+v", state.Items[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBestExitDisplayStateWaitingWhenNoAppliedDecisionExists(t *testing.T) {
|
||||
tunnel := map[string]interface{}{
|
||||
"id": int64(77),
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(10)},
|
||||
},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": tunnelStrategyBest},
|
||||
{"nodeId": int64(31), "strategy": tunnelStrategyBest},
|
||||
},
|
||||
"chainNodes": [][]map[string]interface{}{},
|
||||
}
|
||||
names := map[int64]string{10: "入口 A", 30: "香港节点", 31: "日本节点"}
|
||||
|
||||
state, ok := buildBestExitDisplayState(tunnel, newBestExitManager(), testBestExitNameLookup(names))
|
||||
if !ok {
|
||||
t.Fatalf("expected waiting best exit state")
|
||||
}
|
||||
if state.Summary != "等待探测" || state.Status != "waiting" {
|
||||
t.Fatalf("expected waiting state, got %+v", state)
|
||||
}
|
||||
if len(state.Items) != 1 || state.Items[0].ExitNodeID != 0 || state.Items[0].ExitNodeName != "等待探测" {
|
||||
t.Fatalf("unexpected waiting item: %+v", state.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBestExitDisplayStateSkipsNonBestAndSingleExitTunnels(t *testing.T) {
|
||||
nonBest := map[string]interface{}{
|
||||
"id": int64(77),
|
||||
"inNodeId": []map[string]interface{}{{"nodeId": int64(10)}},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": "round"},
|
||||
{"nodeId": int64(31), "strategy": "round"},
|
||||
},
|
||||
}
|
||||
if state, ok := buildBestExitDisplayState(nonBest, newBestExitManager(), testBestExitNameLookup(nil)); ok || state != nil {
|
||||
t.Fatalf("expected non-best tunnel to skip state, got %+v", state)
|
||||
}
|
||||
|
||||
singleExit := map[string]interface{}{
|
||||
"id": int64(78),
|
||||
"inNodeId": []map[string]interface{}{{"nodeId": int64(10)}},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": tunnelStrategyBest},
|
||||
},
|
||||
}
|
||||
if state, ok := buildBestExitDisplayState(singleExit, newBestExitManager(), testBestExitNameLookup(nil)); ok || state != nil {
|
||||
t.Fatalf("expected single-exit tunnel to skip state, got %+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func testBestExitNameLookup(names map[int64]string) bestExitNodeNameLookup {
|
||||
return func(nodeID int64) (string, bool) {
|
||||
name := names[nodeID]
|
||||
return name, name != ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run backend display tests to verify failure**
|
||||
|
||||
Run from `go-backend`:
|
||||
|
||||
```bash
|
||||
go test ./internal/http/handler -run 'TestBestExitDecisionSnapshot|TestBuildBestExitDisplayState' -count=1
|
||||
```
|
||||
|
||||
Expected: FAIL with undefined `snapshot`, `buildBestExitDisplayState`, and `bestExitNodeNameLookup`.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Backend Display State Implementation
|
||||
|
||||
**Files:**
|
||||
- Create: `go-backend/internal/http/handler/tunnel_best_exit_display.go`
|
||||
- Modify: `go-backend/internal/http/handler/tunnel_best_exit.go`
|
||||
- Test: `go-backend/internal/http/handler/tunnel_best_exit_display_test.go`
|
||||
|
||||
- [ ] **Step 1: Implement display state and snapshot helpers**
|
||||
|
||||
Create `go-backend/internal/http/handler/tunnel_best_exit_display.go`:
|
||||
|
||||
```go
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
bestExitDisplayStatusApplied = "applied"
|
||||
bestExitDisplayStatusWaiting = "waiting"
|
||||
bestExitDisplaySummaryMulti = "多个出口"
|
||||
bestExitDisplaySummaryWait = "等待探测"
|
||||
bestExitUnknownExitName = "未知出口"
|
||||
bestExitUnknownEntryName = "未知入口"
|
||||
bestExitUnknownChainName = "未知中转"
|
||||
)
|
||||
|
||||
type bestExitDecisionSnapshot struct {
|
||||
AppliedExitNodeID int64
|
||||
UpdatedAt int64
|
||||
Reason string
|
||||
Scores []bestExitCandidateScore
|
||||
}
|
||||
|
||||
type bestExitDisplayState struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Summary string `json:"summary"`
|
||||
Status string `json:"status"`
|
||||
UpdatedAt int64 `json:"updatedAt,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Items []bestExitDisplayItem `json:"items"`
|
||||
}
|
||||
|
||||
type bestExitDisplayItem struct {
|
||||
OwnerNodeID int64 `json:"ownerNodeId"`
|
||||
OwnerNodeName string `json:"ownerNodeName"`
|
||||
OwnerRole string `json:"ownerRole"`
|
||||
ExitNodeID int64 `json:"exitNodeId,omitempty"`
|
||||
ExitNodeName string `json:"exitNodeName"`
|
||||
UpdatedAt int64 `json:"updatedAt,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type bestExitNodeNameLookup func(nodeID int64) (string, bool)
|
||||
|
||||
func (m *bestExitManager) snapshot(key bestExitOwnerKey) (bestExitDecisionSnapshot, bool) {
|
||||
if m == nil {
|
||||
return bestExitDecisionSnapshot{}, false
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
d := m.decisions[key]
|
||||
if d == nil {
|
||||
return bestExitDecisionSnapshot{}, false
|
||||
}
|
||||
updatedAt := int64(0)
|
||||
if !d.LastSwitchAt.IsZero() {
|
||||
updatedAt = d.LastSwitchAt.UnixMilli()
|
||||
}
|
||||
return bestExitDecisionSnapshot{
|
||||
AppliedExitNodeID: d.AppliedExitNodeID,
|
||||
UpdatedAt: updatedAt,
|
||||
Reason: d.LastReason,
|
||||
Scores: cloneBestExitScores(d.Scores),
|
||||
}, true
|
||||
}
|
||||
|
||||
func (h *Handler) attachBestExitStates(items []map[string]interface{}) {
|
||||
if h == nil || len(items) == 0 {
|
||||
return
|
||||
}
|
||||
lookup := h.bestExitNodeNameLookup()
|
||||
for _, item := range items {
|
||||
state, ok := buildBestExitDisplayState(item, h.bestExit, lookup)
|
||||
if !ok {
|
||||
delete(item, "bestExitState")
|
||||
continue
|
||||
}
|
||||
item["bestExitState"] = state
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) bestExitNodeNameLookup() bestExitNodeNameLookup {
|
||||
cache := map[int64]string{}
|
||||
return func(nodeID int64) (string, bool) {
|
||||
if nodeID <= 0 || h == nil {
|
||||
return "", false
|
||||
}
|
||||
if name, ok := cache[nodeID]; ok {
|
||||
return name, name != ""
|
||||
}
|
||||
node, err := h.getNodeRecord(nodeID)
|
||||
if err != nil || node == nil {
|
||||
cache[nodeID] = ""
|
||||
return "", false
|
||||
}
|
||||
name := strings.TrimSpace(node.Name)
|
||||
cache[nodeID] = name
|
||||
return name, name != ""
|
||||
}
|
||||
}
|
||||
|
||||
func buildBestExitDisplayState(tunnel map[string]interface{}, manager *bestExitManager, lookup bestExitNodeNameLookup) (*bestExitDisplayState, bool) {
|
||||
if tunnel == nil {
|
||||
return nil, false
|
||||
}
|
||||
tunnelID := asInt64(tunnel["id"], 0)
|
||||
outNodes := bestExitDisplayMapSlice(tunnel["outNodeId"])
|
||||
if tunnelID <= 0 || len(outNodes) <= 1 {
|
||||
return nil, false
|
||||
}
|
||||
if !isBestTunnelStrategy(asString(outNodes[0]["strategy"])) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
owners, ownerRole := bestExitDisplayOwners(tunnel)
|
||||
state := &bestExitDisplayState{
|
||||
Enabled: true,
|
||||
Summary: bestExitDisplaySummaryWait,
|
||||
Status: bestExitDisplayStatusWaiting,
|
||||
Items: make([]bestExitDisplayItem, 0, len(owners)),
|
||||
}
|
||||
|
||||
exitsByID := map[int64]map[string]interface{}{}
|
||||
for _, exit := range outNodes {
|
||||
if id := asInt64(exit["nodeId"], 0); id > 0 {
|
||||
exitsByID[id] = exit
|
||||
}
|
||||
}
|
||||
appliedExitIDs := map[int64]string{}
|
||||
appliedCount := 0
|
||||
latestUpdatedAt := int64(0)
|
||||
latestReason := ""
|
||||
for _, owner := range owners {
|
||||
ownerNodeID := asInt64(owner["nodeId"], 0)
|
||||
if ownerNodeID <= 0 {
|
||||
continue
|
||||
}
|
||||
item := bestExitDisplayItem{
|
||||
OwnerNodeID: ownerNodeID,
|
||||
OwnerNodeName: bestExitDisplayNodeName(owner, ownerNodeID, lookup, bestExitUnknownOwnerName(ownerRole)),
|
||||
OwnerRole: ownerRole,
|
||||
ExitNodeName: bestExitDisplaySummaryWait,
|
||||
Reason: bestExitDisplayStatusWaiting,
|
||||
}
|
||||
if snapshot, ok := manager.snapshot(bestExitOwnerKey{TunnelID: tunnelID, OwnerNodeID: ownerNodeID}); ok && snapshot.AppliedExitNodeID > 0 {
|
||||
item.ExitNodeID = snapshot.AppliedExitNodeID
|
||||
item.ExitNodeName = bestExitDisplayNodeName(exitsByID[snapshot.AppliedExitNodeID], snapshot.AppliedExitNodeID, lookup, bestExitUnknownExitName)
|
||||
item.UpdatedAt = snapshot.UpdatedAt
|
||||
item.Reason = snapshot.Reason
|
||||
appliedExitIDs[item.ExitNodeID] = item.ExitNodeName
|
||||
appliedCount++
|
||||
if snapshot.UpdatedAt > latestUpdatedAt {
|
||||
latestUpdatedAt = snapshot.UpdatedAt
|
||||
latestReason = snapshot.Reason
|
||||
}
|
||||
}
|
||||
state.Items = append(state.Items, item)
|
||||
}
|
||||
|
||||
if appliedCount == 0 {
|
||||
return state, true
|
||||
}
|
||||
state.Status = bestExitDisplayStatusApplied
|
||||
state.UpdatedAt = latestUpdatedAt
|
||||
state.Reason = latestReason
|
||||
if len(appliedExitIDs) == 1 {
|
||||
for _, name := range appliedExitIDs {
|
||||
state.Summary = name
|
||||
}
|
||||
} else {
|
||||
state.Summary = bestExitDisplaySummaryMulti
|
||||
}
|
||||
return state, true
|
||||
}
|
||||
|
||||
func bestExitDisplayOwners(tunnel map[string]interface{}) ([]map[string]interface{}, string) {
|
||||
chainGroups := bestExitDisplayChainGroups(tunnel["chainNodes"])
|
||||
if len(chainGroups) > 0 {
|
||||
return chainGroups[len(chainGroups)-1], "chain"
|
||||
}
|
||||
return bestExitDisplayMapSlice(tunnel["inNodeId"]), "entry"
|
||||
}
|
||||
|
||||
func bestExitDisplayMapSlice(v interface{}) []map[string]interface{} {
|
||||
switch arr := v.(type) {
|
||||
case []map[string]interface{}:
|
||||
return arr
|
||||
case []interface{}:
|
||||
out := make([]map[string]interface{}, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func bestExitDisplayChainGroups(v interface{}) [][]map[string]interface{} {
|
||||
switch groups := v.(type) {
|
||||
case [][]map[string]interface{}:
|
||||
return groups
|
||||
case []interface{}:
|
||||
out := make([][]map[string]interface{}, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
items := bestExitDisplayMapSlice(group)
|
||||
if len(items) > 0 {
|
||||
out = append(out, items)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func bestExitDisplayNodeName(source map[string]interface{}, nodeID int64, lookup bestExitNodeNameLookup, fallback string) string {
|
||||
if source != nil {
|
||||
for _, key := range []string{"nodeName", "name"} {
|
||||
if name := strings.TrimSpace(asString(source[key])); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
if lookup != nil {
|
||||
if name, ok := lookup(nodeID); ok && strings.TrimSpace(name) != "" {
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func bestExitUnknownOwnerName(role string) string {
|
||||
if role == "chain" {
|
||||
return bestExitUnknownChainName
|
||||
}
|
||||
return bestExitUnknownEntryName
|
||||
}
|
||||
|
||||
func (h *Handler) attachBestExitStatesOrLog(items []map[string]interface{}) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
log.Printf("best_exit: attach display state failed: %v", recovered)
|
||||
}
|
||||
}()
|
||||
h.attachBestExitStates(items)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace direct attach calls with panic-safe wrapper**
|
||||
|
||||
Keep `attachBestExitStates` for tests, and use `attachBestExitStatesOrLog` from handlers in Task 3. This step only creates the function above; no handler wiring yet.
|
||||
|
||||
- [ ] **Step 3: Run backend display tests**
|
||||
|
||||
Run from `go-backend`:
|
||||
|
||||
```bash
|
||||
go test ./internal/http/handler -run 'TestBestExitDecisionSnapshot|TestBuildBestExitDisplayState' -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Run gofmt**
|
||||
|
||||
```bash
|
||||
gofmt -w internal/http/handler/tunnel_best_exit_display.go internal/http/handler/tunnel_best_exit_display_test.go
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit backend display implementation**
|
||||
|
||||
```bash
|
||||
git add go-backend/internal/http/handler/tunnel_best_exit_display.go go-backend/internal/http/handler/tunnel_best_exit_display_test.go
|
||||
git commit -m "feat: build best exit display state"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Attach Best-Exit State To Tunnel List And Get Responses
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-backend/internal/http/handler/handler.go`
|
||||
- Modify: `go-backend/internal/http/handler/mutations.go`
|
||||
- Test: `go-backend/internal/http/handler/tunnel_best_exit_display_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing handler attach tests**
|
||||
|
||||
Append to `go-backend/internal/http/handler/tunnel_best_exit_display_test.go`:
|
||||
|
||||
```go
|
||||
func TestAttachBestExitStatesAddsStateToBestTunnelOnly(t *testing.T) {
|
||||
h := &Handler{bestExit: newBestExitManager()}
|
||||
now := time.Unix(300, 0)
|
||||
h.bestExit.setApplied(bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}, 30, now)
|
||||
|
||||
items := []map[string]interface{}{
|
||||
{
|
||||
"id": int64(77),
|
||||
"inNodeId": []map[string]interface{}{{"nodeId": int64(10)}},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": tunnelStrategyBest},
|
||||
{"nodeId": int64(31), "strategy": tunnelStrategyBest},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": int64(78),
|
||||
"inNodeId": []map[string]interface{}{{"nodeId": int64(12)}},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(40), "strategy": "round"},
|
||||
{"nodeId": int64(41), "strategy": "round"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
h.attachBestExitStates(items)
|
||||
state, ok := items[0]["bestExitState"].(*bestExitDisplayState)
|
||||
if !ok {
|
||||
t.Fatalf("expected bestExitState on best tunnel, got %#v", items[0]["bestExitState"])
|
||||
}
|
||||
if state.Summary != bestExitUnknownExitName || state.Items[0].ExitNodeID != 30 {
|
||||
t.Fatalf("unexpected state with fallback names: %+v", state)
|
||||
}
|
||||
if _, exists := items[1]["bestExitState"]; exists {
|
||||
t.Fatalf("non-best tunnel should not have bestExitState: %+v", items[1])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run attach test to verify failure**
|
||||
|
||||
Run from `go-backend`:
|
||||
|
||||
```bash
|
||||
go test ./internal/http/handler -run TestAttachBestExitStatesAddsStateToBestTunnelOnly -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Wire tunnel list response**
|
||||
|
||||
In `go-backend/internal/http/handler/handler.go`, change `tunnelList` from:
|
||||
|
||||
```go
|
||||
items, err := h.repo.ListTunnels()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```go
|
||||
items, err := h.repo.ListTunnels()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
h.attachBestExitStatesOrLog(items)
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire single tunnel response**
|
||||
|
||||
In `go-backend/internal/http/handler/mutations.go`, change `tunnelGet` from:
|
||||
|
||||
```go
|
||||
items, err := h.repo.ListTunnels()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
for _, it := range items {
|
||||
if asInt64(it["id"], 0) == id {
|
||||
response.WriteJSON(w, response.OK(it))
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```go
|
||||
items, err := h.repo.ListTunnels()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
h.attachBestExitStatesOrLog(items)
|
||||
for _, it := range items {
|
||||
if asInt64(it["id"], 0) == id {
|
||||
response.WriteJSON(w, response.OK(it))
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run focused backend tests**
|
||||
|
||||
Run from `go-backend`:
|
||||
|
||||
```bash
|
||||
go test ./internal/http/handler -run 'TestBestExitDecisionSnapshot|TestBuildBestExitDisplayState|TestAttachBestExitStatesAddsStateToBestTunnelOnly' -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Run gofmt**
|
||||
|
||||
```bash
|
||||
gofmt -w internal/http/handler/handler.go internal/http/handler/mutations.go internal/http/handler/tunnel_best_exit_display.go internal/http/handler/tunnel_best_exit_display_test.go
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit response wiring**
|
||||
|
||||
```bash
|
||||
git add go-backend/internal/http/handler/handler.go go-backend/internal/http/handler/mutations.go go-backend/internal/http/handler/tunnel_best_exit_display.go go-backend/internal/http/handler/tunnel_best_exit_display_test.go
|
||||
git commit -m "feat: expose best exit display state"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Frontend Tunnel List Display
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/tunnel.tsx`
|
||||
|
||||
- [ ] **Step 1: Add TypeScript types**
|
||||
|
||||
In `vite-frontend/src/pages/tunnel.tsx`, add these interfaces after `interface ChainTunnel`:
|
||||
|
||||
```ts
|
||||
interface BestExitStateItem {
|
||||
ownerNodeId: number;
|
||||
ownerNodeName: string;
|
||||
ownerRole: "entry" | "chain";
|
||||
exitNodeId?: number;
|
||||
exitNodeName: string;
|
||||
updatedAt?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface BestExitState {
|
||||
enabled: boolean;
|
||||
summary: string;
|
||||
status: "applied" | "waiting";
|
||||
updatedAt?: number;
|
||||
reason?: string;
|
||||
items: BestExitStateItem[];
|
||||
}
|
||||
```
|
||||
|
||||
Then add the optional field to `interface Tunnel`:
|
||||
|
||||
```ts
|
||||
bestExitState?: BestExitState | null;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Preserve API state during mapping**
|
||||
|
||||
In `mapTunnelApiItems`, add `bestExitState` to the returned object:
|
||||
|
||||
```ts
|
||||
bestExitState:
|
||||
tunnel.bestExitState && typeof tunnel.bestExitState === "object"
|
||||
? {
|
||||
...tunnel.bestExitState,
|
||||
items: Array.isArray(tunnel.bestExitState.items)
|
||||
? tunnel.bestExitState.items
|
||||
: [],
|
||||
}
|
||||
: null,
|
||||
```
|
||||
|
||||
The mapped object should include this field before `createdTime` or immediately after it.
|
||||
|
||||
- [ ] **Step 3: Add render helpers**
|
||||
|
||||
Add these helper functions after `mapTunnelApiItems` and before `export default function TunnelPage()`:
|
||||
|
||||
```tsx
|
||||
const bestExitOwnerRoleText = (role: BestExitStateItem["ownerRole"]) => {
|
||||
return role === "chain" ? "中转" : "入口";
|
||||
};
|
||||
|
||||
const bestExitDetailTitle = (state?: BestExitState | null) => {
|
||||
if (!state?.enabled || !state.items?.length) {
|
||||
return "";
|
||||
}
|
||||
return state.items
|
||||
.map((item) => {
|
||||
const ownerName = item.ownerNodeName || `${bestExitOwnerRoleText(item.ownerRole)} ${item.ownerNodeId}`;
|
||||
const exitName = item.exitNodeName || "等待探测";
|
||||
return `${ownerName} -> ${exitName}`;
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
const renderBestExitState = (state?: BestExitState | null) => {
|
||||
if (!state?.enabled) {
|
||||
return null;
|
||||
}
|
||||
const title = bestExitDetailTitle(state);
|
||||
const isWaiting = state.status === "waiting";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mt-1 text-[11px] leading-4 ${
|
||||
isWaiting
|
||||
? "text-default-500"
|
||||
: "text-emerald-700 dark:text-emerald-300"
|
||||
}`}
|
||||
title={title || undefined}
|
||||
>
|
||||
最优出口:{state.summary || "等待探测"}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Render in table topology cell**
|
||||
|
||||
In the table topology `<TableCell>` around line 1674, change the cell content from:
|
||||
|
||||
```tsx
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="font-semibold text-primary-700 dark:text-primary-400">
|
||||
{tunnel.inNodeId?.length || 0}入口
|
||||
</span>
|
||||
<span className="text-default-400">→</span>
|
||||
<span className="font-semibold text-secondary-700 dark:text-secondary-400">
|
||||
{tunnel.type === 2
|
||||
? tunnel.chainNodes?.length || 0
|
||||
: 0}
|
||||
跳
|
||||
</span>
|
||||
<span className="text-default-400">→</span>
|
||||
<span className="font-semibold text-success-700 dark:text-success-400">
|
||||
{tunnel.type === 2
|
||||
? tunnel.outNodeId?.length || 0
|
||||
: tunnel.inNodeId?.length || 0}
|
||||
出口
|
||||
</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```tsx
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="font-semibold text-primary-700 dark:text-primary-400">
|
||||
{tunnel.inNodeId?.length || 0}入口
|
||||
</span>
|
||||
<span className="text-default-400">→</span>
|
||||
<span className="font-semibold text-secondary-700 dark:text-secondary-400">
|
||||
{tunnel.type === 2
|
||||
? tunnel.chainNodes?.length || 0
|
||||
: 0}
|
||||
跳
|
||||
</span>
|
||||
<span className="text-default-400">→</span>
|
||||
<span className="font-semibold text-success-700 dark:text-success-400">
|
||||
{tunnel.type === 2
|
||||
? tunnel.outNodeId?.length || 0
|
||||
: tunnel.inNodeId?.length || 0}
|
||||
出口
|
||||
</span>
|
||||
</div>
|
||||
{renderBestExitState(tunnel.bestExitState)}
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Render in grid card topology section**
|
||||
|
||||
In the grid card topology section, after the closing `</div>` for the topology row at the end of the block containing `出口` and before the enclosing border section closes, add:
|
||||
|
||||
```tsx
|
||||
<div className="text-center">
|
||||
{renderBestExitState(tunnel.bestExitState)}
|
||||
</div>
|
||||
```
|
||||
|
||||
The result should put the best-exit summary under the entry -> hop -> exit row inside the topology section.
|
||||
|
||||
- [ ] **Step 6: Run frontend build**
|
||||
|
||||
Run from `vite-frontend`:
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
Expected: PASS with `tsc && vite build` completing successfully.
|
||||
|
||||
- [ ] **Step 7: Commit frontend display**
|
||||
|
||||
```bash
|
||||
git add vite-frontend/src/pages/tunnel.tsx
|
||||
git commit -m "feat: show current best exit in tunnel list"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Full Verification And Review
|
||||
|
||||
**Files:**
|
||||
- Verify only.
|
||||
|
||||
- [ ] **Step 1: Run backend tests**
|
||||
|
||||
Run from `go-backend`:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run frontend build**
|
||||
|
||||
Run from `vite-frontend`:
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Inspect final diff**
|
||||
|
||||
Run from repository root:
|
||||
|
||||
```bash
|
||||
git diff --stat origin/main...HEAD
|
||||
git diff -- go-backend/internal/http/handler/tunnel_best_exit_display.go go-backend/internal/http/handler/tunnel_best_exit_display_test.go go-backend/internal/http/handler/handler.go go-backend/internal/http/handler/mutations.go vite-frontend/src/pages/tunnel.tsx
|
||||
```
|
||||
|
||||
Expected: Diff only adds best-exit display state, response attachment, frontend list display, and tests. It must not change best-exit scoring, switching, runtime chain update, or agent code.
|
||||
|
||||
- [ ] **Step 4: Request final code review**
|
||||
|
||||
Ask a reviewer to check:
|
||||
|
||||
```text
|
||||
Review the best-exit current display implementation. Confirm it only exposes current in-memory best-exit state in tunnel list/get responses and renders it in the tunnel list. Verify it does not change routing, scoring, switching, persistence, or polling behavior.
|
||||
```
|
||||
|
||||
Expected: No blocking findings.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: Backend response state is Task 2 and Task 3; direct vs final-hop owner semantics are covered by Task 1 tests; frontend list/grid display is Task 4; no polling and no routing changes are preserved by Task 5 review instructions.
|
||||
- Placeholder scan: The plan contains concrete files, function names, code blocks, commands, and expected outcomes.
|
||||
- Type consistency: `BestExitState`, `BestExitStateItem`, `bestExitDisplayState`, `bestExitDisplayItem`, `bestExitDecisionSnapshot`, and `bestExitNodeNameLookup` are defined before use and names match across tasks.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Flvx iOS 26 Liquid Glass UI Redesign Spec
|
||||
|
||||
## 1. Overview
|
||||
This document specifies the comprehensive UI/UX redesign of the Flvx frontend using an "Apple iOS 26 Liquid Glass" design language. The goal is to elevate the visual quality of the entire application, making it modern, spatially aware, and highly legible through extensive use of blur, translucency, squircle borders, and semantic contrast.
|
||||
|
||||
## 2. Scope
|
||||
The redesign covers 100% of the frontend routing pages and overlay components under `vite-frontend/src/pages/` and global UI modules:
|
||||
- Dashboard (`dashboard.tsx`)
|
||||
- Node Management (`node.tsx`)
|
||||
- Tunnel & Rule Configurations (`tunnel.tsx`, `forward.tsx`)
|
||||
- System Monitor (`monitor.tsx`)
|
||||
- User Management (`user.tsx`)
|
||||
- Speed Limit Management (`limit.tsx`)
|
||||
- Group Management (`group.tsx`)
|
||||
- Panel Sharing (`panel-sharing.tsx`)
|
||||
- Global Settings & Config (`config.tsx`, `settings.tsx`)
|
||||
- Profile & Change Password (`profile.tsx`, `change-password.tsx`)
|
||||
- All related Modals, Drawers, and floating UI (e.g., "Create Node", "Add Rule" forms).
|
||||
|
||||
## 3. Design System & Tokens
|
||||
The new UI replaces traditional solid-color borders and flat surfaces with the following spatial design tokens:
|
||||
|
||||
### 3.1. Corner Radii (Squircles)
|
||||
- **Outer Shell / Viewports**: 32px (`rounded-3xl` equivalent)
|
||||
- **Cards / Containers**: 24px (`rounded-2xl`)
|
||||
- **Buttons / Inputs**: 16px (`rounded-xl` or `rounded-full`)
|
||||
- **Badges / Tags**: 6px or fully rounded.
|
||||
|
||||
### 3.2. Backgrounds & Blurs
|
||||
- **Global Background**: A mesh gradient blending soft pinks and blues (`#ff9a9e`, `#fecfef`, `#a1c4fd`, `#c2e9fb`).
|
||||
- **Glass Base (Primary Containers)**: `backdrop-blur-3xl` with an ultra-thin white overlay (`rgba(255, 255, 255, 0.6)` or `#ffffff99`).
|
||||
- **Glass Inner Glow (Borders)**: 1px solid `rgba(255, 255, 255, 0.8)` (`#ffffffcc`).
|
||||
- **Modals Background Overlay**: 30% black overlay (`#0000004d`).
|
||||
|
||||
### 3.3. Semantic Colors
|
||||
- **Brand / Active / Primary / TCP**: Blue `#007aff`
|
||||
- **Healthy / Success / Online**: Green `#34c759`
|
||||
- **Warning / Wait / UDP**: Orange `#ff9500`
|
||||
- **Danger / Offline / Delete**: Red `#ff3b30`
|
||||
- **Secondary / Purple / Data**: Purple `#af52de`
|
||||
- **Text**: Primary (`#1d1d1f`), Secondary (`#86868b`).
|
||||
|
||||
### 3.4. Elevation & Shadow
|
||||
- Soft, highly diffused drop shadows rather than sharp lines: e.g., `box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1)`.
|
||||
|
||||
## 4. Implementation Strategy
|
||||
We will implement the redesign systematically across the React + TailwindCSS + shadcn/HeroUI stack:
|
||||
1. **CSS Variables / Tailwind Config**: Inject the new Liquid Glass design tokens (colors, extended radiuses, customized backdrop blurs, box shadows) into `tailwind.config.js` and `globals.css`.
|
||||
2. **Global App Shell**: Update the root layout (`index.tsx` or main `App` layout) to host the dynamic mesh gradient background and the new translucent sidebar.
|
||||
3. **Component Re-styling**:
|
||||
- Override HeroUI default card, input, and modal styles using custom `classNames`.
|
||||
- Update `MetricCard`, `PageEmptyState`, `PageLoadingState`, and other base components to support the `glass_card` spec.
|
||||
4. **Page-by-Page Integration**: Rewrite the JSX of each page to utilize the new layout structure, ensuring all existing interactive state and API logic is seamlessly preserved.
|
||||
5. **Modal System Update**: Apply the transparent `#0000004d` overlay and 480px width glassy card style to all global dialogs.
|
||||
|
||||
## 5. Success Criteria
|
||||
- [ ] No regression in business logic; all forms, interactions, and data rendering operate exactly as before.
|
||||
- [ ] The visual system consistently employs the `glass_bg`, `glass_card`, and corresponding squircle radiuses across 100% of the UI.
|
||||
- [ ] Modals and Overlays correctly blur the background mesh gradient.
|
||||
- [ ] All responsive layouts appropriately wrap the card components on smaller displays.
|
||||
@@ -0,0 +1,133 @@
|
||||
# 允许转发到本地地址开关设计
|
||||
|
||||
**日期**: 2026-04-26
|
||||
**状态**: 待审核
|
||||
**作者**: AI Assistant
|
||||
|
||||
## 概述
|
||||
|
||||
新增一个全局设置开关,控制规则目标地址是否允许指向本地/内网地址。默认关闭,保持当前安全策略不变;开启后,规则创建和编辑时允许将目标地址设置为 `127.0.0.1`、`10.x.x.x`、`172.16-31.x.x`、`192.168.x.x` 等本地或私网地址。
|
||||
|
||||
## 背景
|
||||
|
||||
当前后端在规则创建和编辑时会调用 `IsSafeRemoteAddr()`,统一禁止目标地址指向本地/内网地址,用来降低 SSRF / 开放代理风险。这一行为是全局硬编码的,无法按部署场景调整。
|
||||
|
||||
有些用户需要把规则转发到本机或内网服务,因此需要一个显式、全局的开关来放宽这条限制。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 在设置页提供一个全局开关控制该行为。
|
||||
2. 默认关闭,不改变现有安全默认值。
|
||||
3. 开启后,规则创建和编辑允许本地/内网目标地址。
|
||||
4. 不影响其他安全校验和其他业务流程。
|
||||
|
||||
## 影响范围
|
||||
|
||||
### 后端
|
||||
- `go-backend/internal/http/handler/security_utils.go`
|
||||
- `go-backend/internal/http/handler/mutations.go`
|
||||
- `go-backend/internal/http/handler/handler.go`
|
||||
|
||||
### 前端
|
||||
- `vite-frontend/src/pages/config.tsx`
|
||||
|
||||
### 测试
|
||||
- `go-backend/tests/contract/forward_contract_test.go` 或新增独立 contract test
|
||||
|
||||
## 详细设计
|
||||
|
||||
### 1. 配置存储
|
||||
|
||||
使用现有 `vite_config` 表新增一个配置项:
|
||||
|
||||
| name | value | 说明 |
|
||||
|------|-------|------|
|
||||
| `allow_local_remote_addr` | `"1"` / `"0"` | 是否允许规则目标地址指向本地/内网地址 |
|
||||
|
||||
约定:
|
||||
- 未配置时按 `"0"` 处理
|
||||
- `"1"` 表示允许
|
||||
- 其他值一律按关闭处理
|
||||
|
||||
### 2. 后端行为
|
||||
|
||||
新增一个轻量辅助函数,用于读取该配置开关:
|
||||
|
||||
```go
|
||||
func (h *Handler) allowLocalRemoteAddr() bool {
|
||||
if h == nil || h.repo == nil {
|
||||
return false
|
||||
}
|
||||
cfg, err := h.repo.GetConfigByName("allow_local_remote_addr")
|
||||
if err != nil || cfg == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(cfg.Value) == "1"
|
||||
}
|
||||
```
|
||||
|
||||
在以下路径中应用:
|
||||
- `forwardCreate`
|
||||
- `forwardUpdate`
|
||||
|
||||
行为改为:
|
||||
- 当开关关闭时,继续执行 `IsSafeRemoteAddr(remoteAddr)`
|
||||
- 当开关开启时,跳过这条“本地/内网地址禁止”校验
|
||||
|
||||
这样可以把改动范围限定在规则创建/编辑,不改变其他依赖 `IsSafeRemoteAddr()` 的场景。
|
||||
|
||||
### 3. 前端设置页
|
||||
|
||||
在 `vite-frontend/src/pages/config.tsx` 增加一个全局开关配置项。
|
||||
|
||||
建议文案:
|
||||
|
||||
- 标签:`允许转发到本地地址`
|
||||
- 描述:`开启后,规则目标地址可指向 127.0.0.1、10.x.x.x、172.16-31.x.x、192.168.x.x 等本地或内网地址。默认关闭以降低开放代理风险。`
|
||||
|
||||
控件类型:
|
||||
- 使用现有设置页的布尔开关模式
|
||||
|
||||
默认显示策略:
|
||||
- 不依赖其他配置项
|
||||
- 直接显示在设置页的网络/安全相关区域;若现有页面没有单独分区,则先按现有配置项组织方式加入即可
|
||||
|
||||
### 4. 错误与兼容性
|
||||
|
||||
关闭开关时:
|
||||
- 保持现有错误行为,继续阻止本地/内网地址
|
||||
|
||||
开启开关时:
|
||||
- 仅放开“本地/内网地址禁止”这条限制
|
||||
- 仍保留地址格式解析失败等其他错误
|
||||
|
||||
### 5. 测试
|
||||
|
||||
需要补两类后端契约测试:
|
||||
|
||||
1. 开关关闭时拒绝本地/内网地址
|
||||
- 创建规则时使用本地/内网地址
|
||||
- 断言接口返回非 0 code
|
||||
|
||||
2. 开关开启时允许本地/内网地址
|
||||
- 先写入 `vite_config(name=allow_local_remote_addr, value=1)`
|
||||
- 创建或更新规则时使用相同地址
|
||||
- 断言接口成功
|
||||
|
||||
建议至少覆盖:
|
||||
- create 路径
|
||||
- update 路径
|
||||
- 多目标地址输入(逗号或换行分隔)中包含本地地址时的行为
|
||||
|
||||
## 风险与约束
|
||||
|
||||
1. 该开关会降低默认安全防护,应明确标注风险。
|
||||
2. 这是全局开关,不做用户级或规则级细分控制。
|
||||
3. 该开关只影响规则目标地址校验,不影响其他独立的安全策略。
|
||||
|
||||
## 推荐实施顺序
|
||||
|
||||
1. 先补失败的后端契约测试
|
||||
2. 实现后端配置读取与创建/更新分支控制
|
||||
3. 在设置页增加开关
|
||||
4. 跑后端测试与前端构建验证
|
||||
@@ -0,0 +1,321 @@
|
||||
# 规则每 IP 连接数与限速设计
|
||||
|
||||
**日期**: 2026-04-27
|
||||
**状态**: 待审核
|
||||
**作者**: AI Assistant
|
||||
|
||||
## 概述
|
||||
|
||||
在转发规则的高级设置中新增两类每客户端 IP 限制:每 IP 最大连接数、每 IP 带宽限速。保留现有总量限制语义不变,新增字段只在用户显式配置时生效。
|
||||
|
||||
实现优先复用 GOST 已有能力:`climiters` 的 `$$ N` 表示每个客户端 IP 独立最大连接数;`limiters` 支持 IP/CIDR 级带宽桶,可用 `0.0.0.0/0` 和 `::/0` 实现默认覆盖所有 IPv4/IPv6 客户端的每 IP 带宽限速。
|
||||
|
||||
## 背景
|
||||
|
||||
当前 FLVX 已经支持规则级最大连接数和规则级限速,但这两个限制都是规则总量:
|
||||
|
||||
- `maxConn` 下发为 GOST `climiters` 的 `$ N`,限制整条规则的总并发连接数。
|
||||
- `speedId` 下发为 GOST `limiters` 的 `$ in out`,限制整条规则的总带宽。
|
||||
|
||||
用户需要的是按客户端 IP 隔离的限制,例如每个 IP 最多 5 个连接、每个 IP 最多 10 Mbps,而不是所有客户端共享同一个总量。
|
||||
|
||||
## GOST 能力确认
|
||||
|
||||
### 连接数限制
|
||||
|
||||
`go-gost/x/limiter/conn/conn.go` 已内置以下语义:
|
||||
|
||||
| Key | 含义 |
|
||||
|-----|------|
|
||||
| `$` | 全局连接数限制,所有客户端共享一个 limiter |
|
||||
| `$$` | 每个客户端 IP 独立连接数限制,每个 IP 创建自己的 limiter |
|
||||
| `IP` / `CIDR` | 指定 IP 或 CIDR 的连接数限制 |
|
||||
|
||||
因此每 IP 连接数无需新增 agent 限制器,只需后端下发 `$$ N`。
|
||||
|
||||
### 带宽限制
|
||||
|
||||
`go-gost/x/limiter/traffic/traffic.go` 已内置以下语义:
|
||||
|
||||
| Key | 含义 |
|
||||
|-----|------|
|
||||
| `$` | 服务级总带宽限制 |
|
||||
| `$$` | 连接级带宽限制 |
|
||||
| `IP` / `CIDR` | 客户端 IP 或 CIDR 级带宽限制 |
|
||||
|
||||
CIDR 级限制使用 generator,为命中的客户端 IP 创建独立 limiter。使用 `0.0.0.0/0` 和 `::/0` 可以覆盖所有 IPv4/IPv6 客户端,实现每 IP 带宽限速。
|
||||
|
||||
### 现有缺口
|
||||
|
||||
TCP listener 已在 Accept 后用客户端地址包装连接级 traffic limiter,路径可用于每 IP 带宽。UDP listener 当前只在 PacketConn 上应用服务级 limiter,没有在 `Accept()` 后按客户端 UDP pseudo-connection 包装 limiter,也没有挂接 connection limiter。因此要让 UDP 与 TCP 语义一致,需要补齐 UDP listener 的 per-client wrapper。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 保留现有 `maxConn` 和 `speedId` 的总量语义。
|
||||
2. 在规则上新增每 IP 最大连接数。
|
||||
3. 在规则上新增每 IP 带宽限速。
|
||||
4. 同一规则允许同时配置总量限制和每 IP 限制。
|
||||
5. 普通用户不能设置或修改限速规则字段,保持现有权限模型。
|
||||
6. TCP 和 UDP 入口都尽量遵循相同限制语义。
|
||||
|
||||
## 非目标
|
||||
|
||||
1. 不新增按用户组、节点组、国家地区、ASN 的限制。
|
||||
2. 不新增请求频率限制;本次“每个 IP 限速”指带宽限速,不是新建连接频率。
|
||||
3. 不改变已有 speed limit 规则表的单位和含义。
|
||||
4. 不把用户级默认最大连接数改成每 IP 语义;用户级 `maxConn` 继续作为默认总连接数。
|
||||
|
||||
## 数据模型
|
||||
|
||||
在 `forward` 表新增两个字段:
|
||||
|
||||
| 字段 | 类型 | 默认 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `ip_max_conn` | int | `0` | 每 IP 最大连接数,`0` 表示不启用 |
|
||||
| `ip_speed_id` | nullable int64 | `NULL` | 每 IP 带宽限速规则 ID,`NULL` 表示不启用 |
|
||||
|
||||
Go 模型新增:
|
||||
|
||||
```go
|
||||
IPMaxConn int `gorm:"column:ip_max_conn;not null;default:0"`
|
||||
IPSpeedID sql.NullInt64 `gorm:"column:ip_speed_id"`
|
||||
```
|
||||
|
||||
字段会通过现有 auto-migrate 机制创建,保持 SQLite/PostgreSQL 兼容,不使用 SQLite 不兼容的 GORM tags。
|
||||
|
||||
## API 行为
|
||||
|
||||
### 创建规则
|
||||
|
||||
`/forward/create` 新增入参:
|
||||
|
||||
```json
|
||||
{
|
||||
"ipMaxConn": 5,
|
||||
"ipSpeedId": 123
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `ipMaxConn` 缺省或小于等于 `0` 时按 `0` 存储,不启用每 IP 连接数限制。
|
||||
- `ipSpeedId` 缺省或不存在时存为 `NULL`,不启用每 IP 带宽限速。
|
||||
- `ipSpeedId` 指向不存在的限速规则时按 `NULL` 处理,沿用现有 `speedId` 的容错策略。
|
||||
- 普通用户提交非空 `ipSpeedId` 时返回错误,保持与 `speedId` 一致的权限边界。
|
||||
|
||||
### 更新规则
|
||||
|
||||
`/forward/update` 新增入参:
|
||||
|
||||
```json
|
||||
{
|
||||
"ipMaxConn": 5,
|
||||
"ipSpeedId": 123
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 未提交 `ipMaxConn` 时保留原值;提交空值或 `0` 时清除每 IP 连接数限制。
|
||||
- 未提交 `ipSpeedId` 时保留原值;提交 `null` 时清除每 IP 带宽限速。
|
||||
- 普通用户不能把 `ipSpeedId` 改成不同的非空值。
|
||||
- 更新后重新同步运行时服务和 limiter。
|
||||
|
||||
### 列表返回
|
||||
|
||||
`/forward/list` 返回项新增:
|
||||
|
||||
```json
|
||||
{
|
||||
"ipMaxConn": 5,
|
||||
"ipSpeedId": 123,
|
||||
"ipSpeedLimitName": "每IP 10Mbps"
|
||||
}
|
||||
```
|
||||
|
||||
`ipSpeedLimitName` 可选,但建议返回,便于前端显示缺失或已删除的限速规则。
|
||||
|
||||
## 后端运行时同步
|
||||
|
||||
### 连接数限制器
|
||||
|
||||
将现有连接限制器构建从单一总量扩展为组合规则。
|
||||
|
||||
当前行为:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "rule_conn_limit_42",
|
||||
"limits": ["$ 100"]
|
||||
}
|
||||
```
|
||||
|
||||
新增行为:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "rule_conn_limit_42",
|
||||
"limits": ["$ 100", "$$ 5"]
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `maxConn > 0` 时追加 `$ maxConn`。
|
||||
- `ipMaxConn > 0` 时追加 `$$ ipMaxConn`。
|
||||
- 如果规则未配置 `maxConn` 且用户有 `MaxConn > 0`,继续继承用户级总连接数,追加 `$ user.MaxConn`。
|
||||
- 如果两者都没有,则不下发 `climiter`,服务不引用 `climiter`。
|
||||
- limiter 名称继续优先使用 `rule_conn_limit_<forwardID>`;只有用户级默认总连接数且规则没有任何连接限制时可继续使用 `user_conn_limit_<userID>`,避免不必要的 per-rule limiter。
|
||||
|
||||
### 带宽限制器
|
||||
|
||||
将现有规则限速从单一 `speedId` 扩展为组合 limiter。
|
||||
|
||||
当前行为:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "123",
|
||||
"limits": ["$ 1.3MB 1.3MB"]
|
||||
}
|
||||
```
|
||||
|
||||
新增每 IP 行为:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "rule_traffic_limit_42",
|
||||
"limits": [
|
||||
"$ 1.3MB 1.3MB",
|
||||
"0.0.0.0/0 1.3MB 1.3MB",
|
||||
"::/0 1.3MB 1.3MB"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 只有总量 `speedId` 时,保持现有名称和下发路径,服务继续引用 `speedId` 字符串。
|
||||
- 只有每 IP `ipSpeedId` 时,创建 `rule_traffic_limit_<forwardID>`,只包含 IPv4/IPv6 CIDR 行。
|
||||
- 总量和每 IP 同时存在时,创建 `rule_traffic_limit_<forwardID>`,同时包含 `$` 和 CIDR 行。
|
||||
- 如果规则没有 `speedId`,则总量仍可继承 user tunnel 的 `speedId`,保持现有 fallback 语义;当继承的总量限速与 `ipSpeedId` 同时存在时,也使用 `rule_traffic_limit_<forwardID>` 组合 limiter。
|
||||
- 每 IP 限速不从 user tunnel 继承,只由规则字段控制。
|
||||
- `AddLimiters` 失败且提示已存在时,使用 `UpdateLimiters` 更新。
|
||||
|
||||
### 服务配置
|
||||
|
||||
`buildForwardServiceConfigs` 需要从当前 `limiterID *int64` / `cLimiterName string` 扩展为更明确的运行时限制描述,例如:
|
||||
|
||||
```go
|
||||
type forwardRuntimeLimiters struct {
|
||||
TrafficLimiter string
|
||||
ConnLimiter string
|
||||
}
|
||||
```
|
||||
|
||||
服务配置只关心最终引用的 limiter 名称:
|
||||
|
||||
- `service["limiter"] = runtimeLimiters.TrafficLimiter`
|
||||
- `service["climiter"] = runtimeLimiters.ConnLimiter`
|
||||
|
||||
这样可以把“如何构建 limiter payload”的逻辑和“如何构建 service JSON”的逻辑分开。
|
||||
|
||||
## Agent/GOST 调整
|
||||
|
||||
### WebSocket 命令
|
||||
|
||||
当前 agent WebSocket 已支持:
|
||||
|
||||
- `AddLimiters` / `UpdateLimiters` / `DeleteLimiters`
|
||||
- `AddCLimiters` / `UpdateCLimiters` / `DeleteCLimiters`
|
||||
|
||||
本设计无需新增命令类型。
|
||||
|
||||
### UDP listener
|
||||
|
||||
补齐 `go-gost/x/listener/udp/listener.go` 的 `Accept()` 包装逻辑,使 UDP pseudo-connection 与 TCP listener 一致:
|
||||
|
||||
- 对 `l.options.ConnLimiter` 按客户端地址应用连接数限制。
|
||||
- 对 `l.options.TrafficLimiter` 按 `conn.RemoteAddr().String()` 应用连接级 traffic wrapper。
|
||||
|
||||
需要注意 UDP pseudo-connection 的生命周期由内部 UDP listener 的 TTL/keepalive 控制;connection limiter 必须在 pseudo-connection 关闭时释放计数。
|
||||
|
||||
## 前端设计
|
||||
|
||||
在 `vite-frontend/src/pages/forward.tsx` 的规则高级设置中新增两个控件:
|
||||
|
||||
1. `每 IP 最大连接数`
|
||||
- 类型:number input。
|
||||
- 文案:`每个客户端 IP 可同时建立的最大连接数;0 或空表示不限制。`
|
||||
- 字段:`ipMaxConn`。
|
||||
|
||||
2. `每 IP 限速`
|
||||
- 类型:Select,复用现有限速规则列表。
|
||||
- 文案:`每个客户端 IP 独享该带宽限制;不选择表示不限制。`
|
||||
- 字段:`ipSpeedId`。
|
||||
- 只对管理员显示,保持与 `规则限速` 一致。
|
||||
|
||||
前端类型需要同步更新:
|
||||
|
||||
- `ForwardApiItem`
|
||||
- `ForwardMutationPayload`
|
||||
- `ForwardForm` 或页面内等价类型
|
||||
|
||||
## 错误处理与兼容性
|
||||
|
||||
1. 旧数据默认 `ip_max_conn=0`、`ip_speed_id=NULL`,行为与当前版本一致。
|
||||
2. 现有 agent 已支持 limiter 命令和 GOST limiter 语法;发布时需要包含 UDP 修复,才能让 TCP/UDP 都获得完整语义。
|
||||
3. 节点离线时沿用现有 warning 行为,规则仍可保存,在线节点跳过下发。
|
||||
4. 如果每 IP speed limit ID 被删除,更新时按 `NULL` 处理,列表页可提示或自动清除,和现有 `speedId` 行为一致。
|
||||
5. 如果 IPv6 CIDR 在某些监听路径未命中,IPv4 行仍正常生效;测试应覆盖 IPv4,IPv6 通过 payload 合同保证下发。
|
||||
|
||||
## 测试计划
|
||||
|
||||
### 后端 contract 测试
|
||||
|
||||
新增或扩展 `go-backend/tests/contract/max_conn_limit_contract_test.go`:
|
||||
|
||||
1. 创建规则时设置 `ipMaxConn=5`,断言 `AddCLimiters` payload 包含 `$$ 5`。
|
||||
2. 同时设置 `maxConn=100` 和 `ipMaxConn=5`,断言 payload 包含 `$ 100` 和 `$$ 5`。
|
||||
3. 用户级 `MaxConn` 存在且规则 `ipMaxConn=5` 时,断言 payload 包含 `$ userMaxConn` 和 `$$ 5`。
|
||||
|
||||
新增每 IP 限速 contract 测试:
|
||||
|
||||
1. 创建规则时设置 `ipSpeedId`,断言 `AddLimiters` payload 包含 `0.0.0.0/0 ...` 和 `::/0 ...`。
|
||||
2. 同时设置 `speedId` 和 `ipSpeedId`,断言组合 limiter 包含 `$ ...` 与两个 CIDR 行,服务引用 `rule_traffic_limit_<forwardID>`。
|
||||
3. 普通用户提交 `ipSpeedId` 返回错误。
|
||||
|
||||
### Repository/API 测试
|
||||
|
||||
1. `CreateForwardTx`、`UpdateForward`、列表查询读写 `ip_max_conn` 和 `ip_speed_id`。
|
||||
2. `/forward/list` 返回 `ipMaxConn`、`ipSpeedId`。
|
||||
|
||||
### GOST/x 测试
|
||||
|
||||
1. `go-gost/x/limiter/conn`:验证 `$$ N` 为不同 IP 创建独立 limiter。
|
||||
2. `go-gost/x/limiter/traffic`:验证 `0.0.0.0/0` 为不同 IPv4 创建独立 limiter。
|
||||
3. UDP listener:验证 Accept 返回的 UDP pseudo-connection 关闭后释放 connection limiter。
|
||||
|
||||
### 验证命令
|
||||
|
||||
```bash
|
||||
(cd go-backend && go test ./...)
|
||||
(cd go-gost/x && go test ./limiter/... ./listener/udp/...)
|
||||
(cd vite-frontend && pnpm run build)
|
||||
```
|
||||
|
||||
## 推荐实施顺序
|
||||
|
||||
1. 后端模型、repo DTO、API 字段读写。
|
||||
2. 后端 limiter payload 构建与服务引用重构。
|
||||
3. Contract 测试覆盖连接数和带宽 payload。
|
||||
4. GOST UDP listener per-client wrapper 与相关测试。
|
||||
5. 前端高级设置表单和类型更新。
|
||||
6. 运行后端测试、GOST/x 相关测试、前端构建。
|
||||
|
||||
## 风险
|
||||
|
||||
1. UDP pseudo-connection 生命周期和 TCP 连接不同,连接数释放必须依赖 Close 包装正确执行。
|
||||
2. 总带宽和每 IP 带宽组合时 limiter 名称从纯 speed ID 变为 rule-level 名称,需要确保更新已有规则时不会留下错误引用。
|
||||
3. 旧节点如果没有 UDP wrapper 修复,TCP 生效但 UDP 每 IP 语义可能不完整;发布时应要求 agent 同步升级。
|
||||
4. 每 IP 带宽是每个入口节点本地独立限制,不是跨节点全局聚合限制。
|
||||
@@ -0,0 +1,74 @@
|
||||
# Monitoring Retention And Storage Display Design
|
||||
|
||||
## Goal
|
||||
|
||||
Add an administrator-facing configuration for monitoring data retention and display the current database storage usage in the configuration page.
|
||||
|
||||
## Scope
|
||||
|
||||
- Add a single config key: `monitor_retention_days`.
|
||||
- Default retention is `7` days.
|
||||
- Apply the retention window uniformly to:
|
||||
- `node_metric`
|
||||
- `tunnel_metric`
|
||||
- `service_monitor_result`
|
||||
- `tunnel_quality`
|
||||
- Show database usage on the config page as a read-only operational value.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No per-table retention settings.
|
||||
- No manual purge button.
|
||||
- No database vacuum/compaction action.
|
||||
- No frontend test framework changes.
|
||||
|
||||
## Backend Design
|
||||
|
||||
### Retention Config
|
||||
|
||||
- Store `monitor_retention_days` in `vite_config`, consistent with existing site settings.
|
||||
- Accept integer values from `1` through `3650`.
|
||||
- Missing or invalid stored values fall back to `7` days.
|
||||
- `normalizeAndValidateConfigValue` rejects invalid user-submitted values so bad config does not get saved through the API.
|
||||
|
||||
### Cleanup Flow
|
||||
|
||||
- `metrics.IngestionService.pruneMetrics()` reads `monitor_retention_days` from the repository each hourly cleanup cycle.
|
||||
- The computed cutoff is used for `node_metric`, `tunnel_metric`, and `service_monitor_result`.
|
||||
- `tunnel_quality` uses the same retention config.
|
||||
- `tunnel_quality` cleanup must run even when real-time tunnel quality probing is disabled; disabling probing should stop new probe writes, not stop cleanup.
|
||||
|
||||
### Database Storage API
|
||||
|
||||
- Add an admin-only API endpoint for storage summary, for example `/api/v1/system/storage`.
|
||||
- Response fields:
|
||||
- `dbType`: `sqlite` or `postgres`
|
||||
- `databaseSizeBytes`: raw byte count
|
||||
- `databaseSizeText`: human-readable formatted size
|
||||
- SQLite implementation reports the DB file size and includes `-wal` and `-shm` sidecar files when present.
|
||||
- PostgreSQL implementation uses `pg_database_size(current_database())`.
|
||||
- If size cannot be determined, return an API error rather than a misleading zero.
|
||||
|
||||
## Frontend Design
|
||||
|
||||
- Add `monitor_retention_days` to the config page.
|
||||
- Label: `监控数据保留天数`.
|
||||
- Description: `统一清理节点指标、隧道流量、服务监控结果和隧道质量历史;默认 7 天。`
|
||||
- Use a regular numeric input through the existing config rendering path.
|
||||
- Fetch database storage summary when the config page loads.
|
||||
- Display a read-only card/row named `数据库占用` with `databaseSizeText`.
|
||||
- If fetching fails, show `获取失败` and keep config editing usable.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Invalid retention values return a validation error on save.
|
||||
- Cleanup logs individual prune failures and continues with other tables, matching existing monitoring cleanup behavior.
|
||||
- Storage summary failures are non-blocking in the frontend.
|
||||
|
||||
## Testing
|
||||
|
||||
- Backend unit tests for retention config parsing and validation.
|
||||
- Backend tests proving custom retention is used by monitoring cleanup.
|
||||
- Backend API/repository test for SQLite storage size returning a non-negative byte count and formatted text.
|
||||
- Run `go test ./...` in `go-backend`.
|
||||
- Run `pnpm run build` in `vite-frontend`.
|
||||
@@ -0,0 +1,156 @@
|
||||
# Best Exit Current Selection Display Design
|
||||
|
||||
## Goal
|
||||
|
||||
When a tunnel uses the `best` multi-exit strategy, show the currently applied best exit in the tunnel list information. Users should be able to see which exit is currently selected without opening logs or diagnosing the tunnel manually.
|
||||
|
||||
The display is informational only. It must not change routing, scoring, switching behavior, or the saved tunnel configuration.
|
||||
|
||||
## Current Context
|
||||
|
||||
- `3.0.0-beta6` adds `best` as a multi-exit strategy.
|
||||
- Runtime selection is stored in the backend `bestExitManager` in memory, keyed by `TunnelID + OwnerNodeID`.
|
||||
- Direct multi-entry tunnels make one independent best-exit decision per entry node.
|
||||
- Tunnels with intermediate chain hops make one independent best-exit decision per final-hop chain node before the exits.
|
||||
- `tunnelList` and `tunnelGet` currently return `repo.ListTunnels()` output directly, so frontend tunnel data only includes configured exits from the database, not the currently applied runtime choice.
|
||||
- The frontend tunnel page maps API items in `vite-frontend/src/pages/tunnel.tsx` and renders list information from that data.
|
||||
|
||||
## User Decisions
|
||||
|
||||
- Show the current best-exit choice in the tunnel list information.
|
||||
- Use a summary plus detail model for multiple owners.
|
||||
- Follow the existing tunnel list refresh cadence; do not add polling or a realtime stream in this phase.
|
||||
- Work text-only; no visual companion is needed.
|
||||
|
||||
## Approach
|
||||
|
||||
Extend the existing tunnel list/detail response with a lightweight runtime state object for `best` tunnels, then render that state beside the tunnel's exit/strategy information in the existing frontend list UI.
|
||||
|
||||
This keeps the display close to the data users already inspect and avoids a separate API or extra frontend request.
|
||||
|
||||
## Backend Design
|
||||
|
||||
### Response Shape
|
||||
|
||||
Add a `bestExitState` object to each tunnel item returned by `tunnelList` and `tunnelGet` when the tunnel has a multi-exit group whose strategy is `best`.
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"summary": "香港节点",
|
||||
"status": "applied",
|
||||
"updatedAt": 1777584000000,
|
||||
"reason": "current exit remains best",
|
||||
"items": [
|
||||
{
|
||||
"ownerNodeId": 10,
|
||||
"ownerNodeName": "入口 A",
|
||||
"ownerRole": "entry",
|
||||
"exitNodeId": 30,
|
||||
"exitNodeName": "香港节点",
|
||||
"updatedAt": 1777584000000,
|
||||
"reason": "current exit remains best"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If the tunnel is not using `best`, omit `bestExitState` or set it to `null`.
|
||||
|
||||
### Owner Semantics
|
||||
|
||||
The display must match the routing model:
|
||||
|
||||
- If there are no middle chain hops, each entry node is an owner.
|
||||
- If there are middle chain hops, each node in the final middle-hop group is an owner.
|
||||
|
||||
Each owner can have a different current best exit. The UI must not imply that a multi-owner tunnel has one global best exit when the owners differ.
|
||||
|
||||
### Summary Rules
|
||||
|
||||
- If all owners currently apply the same exit, `summary` is that exit node name.
|
||||
- If owners apply different exits, `summary` is `多个出口`.
|
||||
- If no applied decision exists yet, `summary` is `等待探测`.
|
||||
- If the tunnel has only one exit, `bestExitState` is not needed because there is no dynamic choice.
|
||||
|
||||
### State Source
|
||||
|
||||
Use the in-memory `bestExitManager` as the source of currently applied decisions.
|
||||
|
||||
Add a read-only snapshot method that returns defensive copies of decision state without exposing mutable internal slices. The handler should convert node IDs to display names from the existing tunnel response data first, then fall back to `h.getNodeRecord` only when the current response does not contain the node.
|
||||
|
||||
The feature should not persist current choices to the database in this phase. A panel restart may reset the displayed runtime state to `等待探测` until the prober initializes it again from the current saved first exit.
|
||||
|
||||
## Frontend Design
|
||||
|
||||
Extend the tunnel item type with optional `bestExitState`.
|
||||
|
||||
In the tunnel list, only render the current best-exit display when:
|
||||
|
||||
- `bestExitState.enabled === true`, or
|
||||
- the tunnel has an exit group with `strategy === "best"` and the backend returns a waiting state.
|
||||
|
||||
Display format:
|
||||
|
||||
- Single applied exit: `最优出口:香港节点`
|
||||
- Multiple applied exits: `最优出口:多个出口`
|
||||
- Waiting: `最优出口:等待探测`
|
||||
|
||||
For multiple owners, render the summary as compact secondary text in the topology/list information cell and set its native `title` attribute to newline-separated detail rows. This avoids adding a new UI dependency or a custom popover. Detail rows should use:
|
||||
|
||||
```text
|
||||
入口 A -> 香港节点
|
||||
入口 B -> 日本节点
|
||||
```
|
||||
|
||||
For tunnels with middle chain hops, label owners as chain nodes when useful:
|
||||
|
||||
```text
|
||||
中转 M1 -> 香港节点
|
||||
中转 M2 -> 日本节点
|
||||
```
|
||||
|
||||
Do not add a new periodic refresh. The display updates when the existing tunnel list is refreshed.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If the manager has no decision for an owner, show that owner as `等待探测`.
|
||||
- If an exit node ID no longer exists in the current tunnel response, show `未知出口` for that item and keep the list usable.
|
||||
- If an owner node ID no longer exists, show `未知入口` or `未知中转` based on the owner role.
|
||||
- If the backend cannot compute state for one tunnel, omit `bestExitState` for that tunnel and log the error; do not fail the whole tunnel list response.
|
||||
|
||||
## Testing
|
||||
|
||||
Backend tests:
|
||||
|
||||
- `bestExitManager` snapshot returns applied exit IDs without exposing mutable manager state.
|
||||
- Direct multi-entry `best` tunnel produces one display item per entry owner.
|
||||
- Middle-hop tunnel produces one display item per final-hop owner.
|
||||
- Summary is the single exit name when all owners choose the same exit.
|
||||
- Summary is `多个出口` when owners choose different exits.
|
||||
- Summary is `等待探测` when no applied decision exists.
|
||||
- Non-`best` tunnels do not receive `bestExitState`.
|
||||
|
||||
Frontend verification:
|
||||
|
||||
- Tunnel list renders `最优出口:<name>` for a single applied exit.
|
||||
- Tunnel list renders `最优出口:多个出口` plus owner details for multiple applied exits.
|
||||
- Tunnel list renders `最优出口:等待探测` for waiting state.
|
||||
- `pnpm run build` passes.
|
||||
|
||||
Verification commands:
|
||||
|
||||
```bash
|
||||
(cd go-backend && go test ./...)
|
||||
(cd vite-frontend && pnpm run build)
|
||||
```
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not add a new realtime stream or polling loop.
|
||||
- Do not add a detailed best-exit scoring dashboard.
|
||||
- Do not persist current best-exit choices to the database.
|
||||
- Do not change switching thresholds, probing targets, or runtime chain update behavior.
|
||||
- Do not change existing non-`best` tunnel display behavior.
|
||||
@@ -0,0 +1,186 @@
|
||||
# Best Exit Selection Design
|
||||
|
||||
## Goal
|
||||
|
||||
Add a multi-exit tunnel strategy named `best` that always sends new connections through the currently best-quality exit. The feature should prevent traffic from continuing to use an exit whose latency or packet loss has degraded while the exit is still technically online.
|
||||
|
||||
Existing connections must not be interrupted. Switching affects only new connections created after the runtime chain update is applied.
|
||||
|
||||
## Current Context
|
||||
|
||||
- Tunnel forwarding stores entry, chain, and exit nodes in `chain_tunnel`.
|
||||
- Multi-exit runtime chains are currently rendered as one GOST hop with multiple nodes.
|
||||
- GOST selectors support `fifo`, `round`, `rand`, and `hash`, plus fail filtering through `maxFails` and `failTimeout`.
|
||||
- The current fail filter only reacts to dial, handshake, or transport failures. It does not react to high latency when the exit is still reachable.
|
||||
- `tunnel_quality_prober` already runs panel-side TCP probes and stores tunnel quality history, but it currently probes representative nodes and does not drive runtime routing decisions.
|
||||
|
||||
## User Decisions
|
||||
|
||||
- Add a `best` option for multi-exit tunnels.
|
||||
- `best` means always choose the current best exit for new connections.
|
||||
- Score exits by end-to-end quality.
|
||||
- Keep the existing public probe target: `www.bing.com:443`.
|
||||
- Do not disrupt established connections.
|
||||
|
||||
## Approach
|
||||
|
||||
Implement `best` as a panel-driven control-plane strategy.
|
||||
|
||||
The database stores the user's intended strategy as `best`. When the panel renders runtime GOST config for a `best` exit group, it sends a GOST selector strategy of `fifo`. The panel dynamically sorts the candidate exits so the current best exit is first. GOST then chooses the first node for new connections.
|
||||
|
||||
This avoids adding active probing logic inside every GOST agent and reuses the existing panel-to-agent command path.
|
||||
|
||||
## Components
|
||||
|
||||
### Frontend
|
||||
|
||||
The tunnel form adds `最优` to the multi-exit load strategy selector.
|
||||
|
||||
- Label: `最优`
|
||||
- Value: `best`
|
||||
- Scope: tunnel forwarding exit groups, alongside `主备/fifo`, `轮询/round`, and `随机/rand`
|
||||
- Create and edit forms must submit and restore `best` unchanged.
|
||||
|
||||
### Backend Data Model
|
||||
|
||||
No schema change is required.
|
||||
|
||||
The existing `chain_tunnel.strategy` column stores `best`. Repository and handler paths should preserve the value in API responses and updates.
|
||||
|
||||
### Runtime Chain Rendering
|
||||
|
||||
When building runtime chain config:
|
||||
|
||||
- If the configured strategy is not `best`, keep existing behavior.
|
||||
- If the configured strategy is `best`, emit GOST selector strategy `fifo`.
|
||||
- Sort the target nodes using the panel's latest best-exit decision before rendering the node list.
|
||||
- If no quality decision exists yet, keep the saved node order.
|
||||
|
||||
This preserves the user's `best` intent in storage while using a GOST selector that can execute the panel's sorted decision.
|
||||
|
||||
### Quality Prober
|
||||
|
||||
Extend `tunnel_quality_prober` to evaluate all candidates in `best` exit groups.
|
||||
|
||||
For each chain owner node and candidate exit, measure:
|
||||
|
||||
- Chain owner node to candidate exit using TCP ping.
|
||||
- Candidate exit to `www.bing.com:443` using TCP ping.
|
||||
|
||||
For direct entry-to-exit tunnels, each entry node owns its own chain decision. For tunnels with intermediate chain hops, each node in the last hop group before the exits owns its own chain decision. This allows different entry or chain nodes to choose different best exits when their path quality differs.
|
||||
|
||||
### Scoring
|
||||
|
||||
Each exit candidate gets an end-to-end score for a specific chain owner node.
|
||||
|
||||
- Total latency is the sum of owner-to-exit latency and exit-to-Bing latency.
|
||||
- Total loss combines both legs by success probability: `1 - (1 - lossA) * (1 - lossB)`.
|
||||
- Failed or unreachable candidates are sorted behind successful candidates.
|
||||
- The score should heavily penalize packet loss so that low-latency but lossy exits are not selected over stable exits.
|
||||
|
||||
A practical scoring formula can be:
|
||||
|
||||
```text
|
||||
score = totalLatencyMs + (totalLossPercent * lossPenaltyMsPerPercent)
|
||||
```
|
||||
|
||||
Use `lossPenaltyMsPerPercent = 100` initially. For example, 5% loss adds 500ms to the score.
|
||||
|
||||
### Switching Rules
|
||||
|
||||
The panel should not update chains on every probe round.
|
||||
|
||||
Switch only when all conditions are true:
|
||||
|
||||
- The candidate best exit is different from the currently applied first exit.
|
||||
- The candidate is successful.
|
||||
- The candidate remains best for consecutive probe rounds.
|
||||
- The candidate beats the current exit by a minimum advantage threshold.
|
||||
- The chain owner node has passed a minimum switch cooldown.
|
||||
|
||||
Initial constants:
|
||||
|
||||
- Consecutive confirmations: 3 rounds.
|
||||
- Switch cooldown: 30 seconds per chain owner node.
|
||||
- Minimum advantage: the candidate score must improve by at least `max(20ms, currentScore * 0.15)`.
|
||||
|
||||
If all exits fail, keep the current runtime order and do not issue a destructive update.
|
||||
|
||||
### Runtime Update
|
||||
|
||||
When a `best` chain owner node changes best exit:
|
||||
|
||||
1. Rebuild that node's `chains_<tunnelID>` payload with the best exit first and remaining candidates sorted by quality for that node.
|
||||
2. Send `UpdateChains` to that chain owner node.
|
||||
3. Do not restart or update tunnel services.
|
||||
4. Record success or failure in logs and in the in-memory decision state.
|
||||
|
||||
This affects only future connections. Existing TCP connections keep using the `net.Conn` created before the update and continue through their original exit.
|
||||
|
||||
### Agent Safety Improvement
|
||||
|
||||
The current agent `UpdateChains` path unregisters the old chain before registering the new chain. This does not kill existing connections, but it creates a small window where a new connection can fail because the chain name is temporarily absent.
|
||||
|
||||
Improve the update path so it parses the new chain first and only replaces the registered chain after parsing succeeds. The replacement window should be as small as possible. If parsing fails, the old chain must remain active.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If probing one candidate fails, continue scoring other candidates.
|
||||
- If a chain owner node is offline or times out, skip decisions for that owner during the round instead of marking every candidate failed.
|
||||
- If a candidate has no successful required probe data, mark it failed for that round.
|
||||
- If `UpdateChains` fails, keep the current applied order and retry on a later round.
|
||||
- If the tunnel has one exit or an incomplete config, `best` behaves like the saved order and does not trigger dynamic switching.
|
||||
- If `monitor_tunnel_quality_enabled=false`, dynamic `best` switching pauses. The last applied runtime order remains in effect.
|
||||
|
||||
## Observability
|
||||
|
||||
The prober should maintain in-memory decision state per `best` tunnel and chain owner node.
|
||||
|
||||
Useful fields:
|
||||
|
||||
- Tunnel ID and chain owner node ID.
|
||||
- Current applied best exit node ID.
|
||||
- Candidate best exit node ID.
|
||||
- Candidate scores.
|
||||
- Last switch timestamp.
|
||||
- Last switch result.
|
||||
- Reason for not switching, such as cooldown, insufficient advantage, candidate unstable, or all exits failed.
|
||||
|
||||
Initial UI scope is limited to supporting create, update, and display of the `best` strategy. A later enhancement can expose current best exit and candidate scores in the tunnel monitor view.
|
||||
|
||||
## Testing
|
||||
|
||||
Backend tests:
|
||||
|
||||
- Score calculation orders candidates by latency and packet loss.
|
||||
- Packet loss penalty prevents lossy exits from winning only because latency is low.
|
||||
- All-failed candidates do not trigger a switch.
|
||||
- Consecutive confirmation and cooldown prevent flapping.
|
||||
- `strategy=best` persists in `chain_tunnel.strategy` and is returned by tunnel list/get APIs.
|
||||
- Runtime rendering maps `best` to GOST `fifo` and places the chosen best exit first.
|
||||
|
||||
Agent tests:
|
||||
|
||||
- `UpdateChains` parse failure keeps the old chain registered.
|
||||
- Successful `UpdateChains` updates the chain used by new connections.
|
||||
|
||||
Frontend verification:
|
||||
|
||||
- Tunnel form includes `最优` in the exit strategy selector.
|
||||
- Existing tunnels with `strategy=best` render correctly.
|
||||
- Create and update requests submit `best` unchanged.
|
||||
|
||||
Verification commands:
|
||||
|
||||
```bash
|
||||
(cd go-backend && go test ./...)
|
||||
(cd go-gost && go test ./...)
|
||||
(cd vite-frontend && pnpm run build)
|
||||
```
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not move existing live connections to a new exit.
|
||||
- Do not add per-tunnel custom probe targets in this phase.
|
||||
- Do not implement active best-exit probing inside GOST agents.
|
||||
- Do not add a detailed best-exit UI dashboard in this phase.
|
||||
+28
-62
@@ -1,72 +1,38 @@
|
||||
# GO BACKEND KNOWLEDGE BASE
|
||||
# go-backend
|
||||
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
Admin API for FLVX. Go + net/http + GORM (SQLite/PostgreSQL).
|
||||
|
||||
## OVERVIEW
|
||||
Go-based Admin API for FLVX. Replaced legacy Spring Boot backend.
|
||||
**Stack:** Go 1.24, net/http (std lib), GORM + SQLite/PostgreSQL (glebarez/sqlite - CGO-free).
|
||||
## Structure
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
go-backend/
|
||||
├── cmd/paneld/main.go # Entry point; starts HTTP server + WebSocket
|
||||
├── internal/
|
||||
│ ├── http/ # HTTP layer
|
||||
│ │ ├── router.go # Routes (NewServeMux) + Middleware chain
|
||||
│ │ ├── handler/ # API Handlers (User, Tunnel, Node, etc.)
|
||||
│ │ ├── middleware/ # JWT, CORS, Logging, Recover
|
||||
│ │ └── response/ # JSON response helpers
|
||||
│ ├── store/
|
||||
│ │ ├── model/model.go # GORM model structs (single source of truth)
|
||||
│ │ └── repo/ # Data Access Layer (Repository pattern, GORM)
|
||||
│ │ ├── repository.go # Core queries, Open/OpenPostgres, AutoMigrate (83k LOC)
|
||||
│ │ ├── repository_mutations.go # Mutation helpers (user/node/tunnel/forward CRUD, 43k LOC)
|
||||
│ │ ├── repository_federation.go # Federation-specific queries
|
||||
│ │ ├── repository_flow.go # Flow/forward status queries
|
||||
│ │ ├── repository_control.go # Control plane queries
|
||||
│ │ └── repository_groups.go # Group management queries
|
||||
│ └── auth/ # Auth logic
|
||||
├── tests/contract/ # Integration/contract tests (14 tests)
|
||||
├── Dockerfile # Multi-stage build (golang:1.24-bookworm → debian:bookworm-slim)
|
||||
└── Makefile # Build commands
|
||||
```
|
||||
| Dir | Role |
|
||||
|-----|------|
|
||||
| `cmd/paneld/main.go` | Entry point, HTTP server + WebSocket |
|
||||
| `internal/http/router.go` | Route registration (`http.ServeMux`) + middleware chain |
|
||||
| `internal/http/handler/` | API handlers |
|
||||
| `internal/http/middleware/` | JWT, CORS, logging, recover |
|
||||
| `internal/http/response/` | JSON envelope helpers |
|
||||
| `internal/store/model/model.go` | All GORM models (single file) |
|
||||
| `internal/store/repo/` | Repository layer (never access DB directly) |
|
||||
| `internal/auth/` | Auth logic |
|
||||
| `tests/contract/` | Integration tests |
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| **API Routes** | `go-backend/internal/http/router.go` | Registers handlers to `http.ServeMux` |
|
||||
| **DB Models** | `go-backend/internal/store/model/model.go` | GORM structs with `TableName()` methods |
|
||||
| **Repository** | `go-backend/internal/store/repo/` | GORM-based queries, all DB ops encapsulated |
|
||||
| **Auth Middleware** | `go-backend/internal/http/middleware/jwt.go` | Extracts `Authorization` header |
|
||||
| **WebSocket** | `go-backend/internal/ws/` | Real-time updates (traffic, status) |
|
||||
| **Contract Tests** | `go-backend/tests/contract/` | Integration tests for auth, federation, tunnels |
|
||||
## Conventions
|
||||
|
||||
## CONVENTIONS
|
||||
- **GORM ORM**: Uses GORM with `glebarez/sqlite` (CGO-free) and `gorm.io/driver/postgres`.
|
||||
- **AutoMigrate**: Schema created at startup via `autoMigrateAll()` — no hand-written DDL.
|
||||
- **TableName()**: All models define explicit `TableName()` returning singular snake_case names.
|
||||
- **Repository Pattern**: Handlers never access `*gorm.DB` directly — all queries go through `repo.Repository` methods.
|
||||
- **Standard Lib**: Uses `net/http` for routing (Go 1.22+ patterns).
|
||||
- **Auth**: Expects raw JWT in `Authorization` header (no `Bearer` prefix).
|
||||
- **API Envelope**: All responses use `response.R{code, msg, data, ts}` structure.
|
||||
- **Config**: Loaded from environment variables (see `cmd/paneld/main.go`).
|
||||
- **SQLite Constraints**: `MaxOpenConns(1)`, WAL mode, busy_timeout=5000.
|
||||
- **PostgreSQL**: Supported via `DB_TYPE=postgres` and `DATABASE_URL` env vars.
|
||||
- **Auth**: raw JWT in `Authorization` header — no `Bearer` prefix.
|
||||
- **API envelope**: `{code, msg, data, ts}`, code 0 = success.
|
||||
- **Repository pattern**: handlers call repo methods, never `repo.DB()` directly.
|
||||
- **GORM**: `TableName()` on every model (GORM pluralizes by default).
|
||||
- **GORM tags**: no `type:jsonb` or `type:serial` (SQLite incompatible).
|
||||
- **SQLite**: `MaxOpenConns(1)`, WAL mode, `busy_timeout=5000`.
|
||||
- **Schema**: created via `autoMigrateAll()` at startup, no hand-written DDL.
|
||||
- **PostgreSQL**: set `DB_TYPE=postgres` and `DATABASE_URL` env vars.
|
||||
- **Config**: all from environment variables.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- **DO NOT** let handlers call `repo.DB()` directly — add a Repository method instead.
|
||||
- **DO NOT CHANGE** handler signatures without updating `router.go`.
|
||||
- **DO NOT** use `type:jsonb` or `type:serial` in GORM tags (SQLite incompatible).
|
||||
- **DO NOT** omit `TableName()` on new models — GORM pluralizes by default.
|
||||
## Commands
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-backend
|
||||
go run ./cmd/paneld # Default: SERVER_ADDR=:6365
|
||||
go test ./... # Unit tests
|
||||
go test ./tests/contract/... # Contract tests
|
||||
go run ./cmd/paneld # SERVER_ADDR defaults to :6365
|
||||
make build
|
||||
go test ./... # includes contract tests
|
||||
go test ./tests/contract/... # contract tests only
|
||||
```
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.24-bookworm AS builder
|
||||
FROM golang:1.25-bookworm AS builder
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
| 表名 | Model | 特殊处理 |
|
||||
|------|-------|----------|
|
||||
| `user` | `User` | `TableName()` 返回 `"user"` (PG 保留字) |
|
||||
| `forward` | `Forward` | |
|
||||
| `forward` | `Forward` | 增加 `proxy_protocol` 字段 |
|
||||
| `forward_port` | `ForwardPort` | |
|
||||
| `node` | `Node` | |
|
||||
| `speed_limit` | `SpeedLimit` | |
|
||||
|
||||
+5
-7
@@ -1,8 +1,6 @@
|
||||
module go-backend
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.4
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
@@ -24,11 +22,11 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
modernc.org/libc v1.65.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
+12
-12
@@ -38,21 +38,21 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -71,10 +71,11 @@ type RuntimeReleaseRoleRequest struct {
|
||||
}
|
||||
|
||||
type RuntimeDiagnoseRequest struct {
|
||||
IP string `json:"ip"`
|
||||
Port int `json:"port"`
|
||||
Count int `json:"count"`
|
||||
Timeout int `json:"timeout"`
|
||||
IP string `json:"ip"`
|
||||
Port int `json:"port"`
|
||||
Count int `json:"count"`
|
||||
Timeout int `json:"timeout"`
|
||||
Protocol string `json:"protocol"`
|
||||
}
|
||||
|
||||
type RuntimeNodeCommandRequest struct {
|
||||
|
||||
@@ -27,6 +27,16 @@ type nodeRecord = model.NodeRecord
|
||||
|
||||
type chainNodeRecord = model.ChainNodeRecord
|
||||
|
||||
type forwardRuntimeLimiters struct {
|
||||
TrafficLimiter string
|
||||
ConnLimiter string
|
||||
}
|
||||
|
||||
type forwardLimiterConfig struct {
|
||||
Name string
|
||||
Limits []string
|
||||
}
|
||||
|
||||
type diagnosisTarget struct {
|
||||
Address string
|
||||
IP string
|
||||
@@ -42,6 +52,7 @@ type diagnosisWorkItem struct {
|
||||
toNode chainNodeRecord
|
||||
hasChainHop bool
|
||||
ipPreference string
|
||||
protocol string
|
||||
}
|
||||
|
||||
type diagnosisExecOptions struct {
|
||||
@@ -185,14 +196,6 @@ func (h *Handler) listForwardPorts(forwardID int64) ([]forwardPortRecord, error)
|
||||
return h.repo.ListForwardPorts(forwardID)
|
||||
}
|
||||
|
||||
func (h *Handler) isTunnelSelectedTLSProtocol(tunnelID int64) (bool, error) {
|
||||
protocol, err := h.repo.GetTunnelOutProtocol(tunnelID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return isTLSTunnelProtocol(protocol), nil
|
||||
}
|
||||
|
||||
func (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {
|
||||
n, err := h.repo.GetNodeRecord(nodeID)
|
||||
if err != nil {
|
||||
@@ -271,14 +274,31 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
|
||||
speed = utSpeed
|
||||
}
|
||||
|
||||
var ipSpeed *int
|
||||
if forward.IPSpeedID.Valid && forward.IPSpeedID.Int64 > 0 {
|
||||
if speedVal, err := h.repo.GetSpeedLimitSpeed(forward.IPSpeedID.Int64); err == nil && speedVal > 0 {
|
||||
ipSpeed = &speedVal
|
||||
}
|
||||
}
|
||||
|
||||
serviceBase := buildForwardServiceBaseWithResolvedUserTunnel(forward.ID, forward.UserID, userTunnelID)
|
||||
tunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)
|
||||
|
||||
user, err := h.repo.GetUserByID(forward.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userMaxConn := 0
|
||||
if user != nil && user.MaxConn > 0 {
|
||||
userMaxConn = user.MaxConn
|
||||
}
|
||||
connLimiterConfigs := buildConnLimiterConfigs(forward, userMaxConn)
|
||||
|
||||
for _, fp := range ports {
|
||||
runtimeLimiters := forwardRuntimeLimiters{ConnLimiter: joinLimiterNames(connLimiterConfigs)}
|
||||
trafficLimiterNames := make([]string, 0, 2)
|
||||
if limiterID != nil && speed != nil {
|
||||
totalLimiterName := strconv.FormatInt(*limiterID, 10)
|
||||
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {
|
||||
// If the limiter push fails because the node is offline, skip it with a warning
|
||||
if isNodeOfflineOrTimeoutError(err) {
|
||||
@@ -292,13 +312,38 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
trafficLimiterNames = append(trafficLimiterNames, totalLimiterName)
|
||||
}
|
||||
if ipSpeed != nil {
|
||||
ruleLimiterName := fmt.Sprintf("rule_traffic_limit_%d", forward.ID)
|
||||
if err := h.ensureTrafficLimiterOnNode(fp.NodeID, ruleLimiterName, nil, ipSpeed); err != nil {
|
||||
// If the limiter push fails because the node is offline, skip it with a warning
|
||||
if isNodeOfflineOrTimeoutError(err) {
|
||||
node, _ := h.getNodeRecord(fp.NodeID)
|
||||
nodeName := fmt.Sprintf("%d", fp.NodeID)
|
||||
if node != nil && strings.TrimSpace(node.Name) != "" {
|
||||
nodeName = strings.TrimSpace(node.Name)
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("节点 %s 不在线,已跳过下发", nodeName))
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
trafficLimiterNames = append(trafficLimiterNames, ruleLimiterName)
|
||||
}
|
||||
runtimeLimiters.TrafficLimiter = strings.Join(trafficLimiterNames, ",")
|
||||
|
||||
for _, connLimiterConfig := range connLimiterConfigs {
|
||||
if err := h.ensureConnLimiterOnNode(fp.NodeID, connLimiterConfig); err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("节点 %d 连接限制器下发失败: %v", fp.NodeID, err))
|
||||
}
|
||||
}
|
||||
|
||||
node, err := h.getNodeRecord(fp.NodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, tunnelTLSProtocol)
|
||||
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), runtimeLimiters)
|
||||
_, err = h.sendNodeCommand(node.ID, method, services, true, false)
|
||||
if err != nil && allowFallbackAdd && method == "UpdateService" {
|
||||
if isNotFoundError(err) {
|
||||
@@ -313,7 +358,7 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
|
||||
}
|
||||
if err != nil && strings.EqualFold(strings.TrimSpace(method), "UpdateService") && isCannotAssignRequestedAddressError(err) {
|
||||
var warning string
|
||||
warning, err = h.fallbackForwardPortToDefaultBind(forward, tunnel, node, fp, serviceBase, limiterID, tunnelTLSProtocol)
|
||||
warning, err = h.fallbackForwardPortToDefaultBind(forward, tunnel, node, fp, serviceBase, runtimeLimiters)
|
||||
if err == nil && warning != "" {
|
||||
warnings = append(warnings, warning)
|
||||
}
|
||||
@@ -339,7 +384,7 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, fp forwardPortRecord, serviceBase string, limiterID *int64, tunnelTLSProtocol bool) (string, error) {
|
||||
func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, fp forwardPortRecord, serviceBase string, runtimeLimiters forwardRuntimeLimiters) (string, error) {
|
||||
if h == nil || forward == nil || tunnel == nil || node == nil {
|
||||
return "", errors.New("invalid bind fallback context")
|
||||
}
|
||||
@@ -356,7 +401,7 @@ func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunne
|
||||
}
|
||||
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
defaultServices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, "", limiterID, tunnelTLSProtocol)
|
||||
defaultServices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, "", runtimeLimiters)
|
||||
if _, err := h.sendNodeCommand(node.ID, "AddService", defaultServices, true, false); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -439,13 +484,39 @@ func (h *Handler) forwardServiceBaseCandidates(forward *forwardRecord) ([]string
|
||||
}
|
||||
|
||||
func (h *Handler) deleteForwardServiceBasesOnNode(nodeID int64, bases []string) error {
|
||||
return deleteForwardServiceCandidates(bases, func(name string) error {
|
||||
payload := map[string]interface{}{
|
||||
"services": []string{name},
|
||||
names := buildForwardServiceDeleteNames(bases)
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
payload := map[string]interface{}{"services": names}
|
||||
_, err := h.sendNodeCommand(nodeID, "DeleteService", payload, false, true)
|
||||
return err
|
||||
}
|
||||
|
||||
func buildForwardServiceDeleteNames(bases []string) []string {
|
||||
names := make([]string, 0, len(bases)*3)
|
||||
seen := make(map[string]struct{}, len(bases)*3)
|
||||
appendName := func(name string) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
_, err := h.sendNodeCommand(nodeID, "DeleteService", payload, false, false)
|
||||
return err
|
||||
})
|
||||
if _, ok := seen[name]; ok {
|
||||
return
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
names = append(names, name)
|
||||
}
|
||||
for _, base := range bases {
|
||||
base = strings.TrimSpace(base)
|
||||
if base == "" {
|
||||
continue
|
||||
}
|
||||
appendName(base + "_tcp")
|
||||
appendName(base + "_udp")
|
||||
appendName(base)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (h *Handler) controlForwardServices(forward *forwardRecord, commandType string, tolerateNotFound bool) error {
|
||||
@@ -475,6 +546,25 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
|
||||
candidateTunnelIDs = append(candidateTunnelIDs, userTunnelIDs...)
|
||||
candidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)
|
||||
bases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)
|
||||
if strings.EqualFold(strings.TrimSpace(commandType), "DeleteService") {
|
||||
seen := map[int64]struct{}{}
|
||||
for _, fp := range ports {
|
||||
if _, ok := seen[fp.NodeID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[fp.NodeID] = struct{}{}
|
||||
if err := h.deleteForwardServiceBasesOnNode(fp.NodeID, bases); err != nil {
|
||||
if isNodeOfflineOrTimeoutError(err) {
|
||||
continue
|
||||
}
|
||||
if tolerateNotFound && isNotFoundError(err) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
seen := map[int64]struct{}{}
|
||||
healed := false
|
||||
for _, fp := range ports {
|
||||
@@ -485,6 +575,9 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
|
||||
|
||||
nodeHandled, lastNotFoundErr, err := h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
|
||||
if err != nil {
|
||||
if isNodeOfflineOrTimeoutError(err) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -704,6 +797,7 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
|
||||
}
|
||||
|
||||
ipPreference := h.repo.GetTunnelIPPreference(forward.TunnelID)
|
||||
protocol := strings.ToLower(strings.TrimSpace(tunnel.Protocol))
|
||||
|
||||
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
|
||||
workItems := make([]diagnosisWorkItem, 0, len(chainRows)*2+len(targets))
|
||||
@@ -718,6 +812,7 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
|
||||
targetIP: target.IP,
|
||||
targetPort: target.Port,
|
||||
description: description,
|
||||
protocol: "tcp",
|
||||
metadata: map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
},
|
||||
@@ -735,6 +830,7 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
|
||||
hasChainHop: true,
|
||||
ipPreference: ipPreference,
|
||||
description: description,
|
||||
protocol: defaultString(strings.ToLower(strings.TrimSpace(firstNode.Protocol)), protocol),
|
||||
metadata: map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
"toChainType": 2,
|
||||
@@ -751,6 +847,7 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
|
||||
hasChainHop: true,
|
||||
ipPreference: ipPreference,
|
||||
description: description,
|
||||
protocol: defaultString(strings.ToLower(strings.TrimSpace(outNode.Protocol)), protocol),
|
||||
metadata: map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
"toChainType": 3,
|
||||
@@ -771,6 +868,7 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
|
||||
hasChainHop: true,
|
||||
ipPreference: ipPreference,
|
||||
description: description,
|
||||
protocol: defaultString(strings.ToLower(strings.TrimSpace(nextNode.Protocol)), protocol),
|
||||
metadata: map[string]interface{}{
|
||||
"fromChainType": 2,
|
||||
"fromInx": currentNode.Inx,
|
||||
@@ -788,6 +886,7 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
|
||||
hasChainHop: true,
|
||||
ipPreference: ipPreference,
|
||||
description: description,
|
||||
protocol: defaultString(strings.ToLower(strings.TrimSpace(outNode.Protocol)), protocol),
|
||||
metadata: map[string]interface{}{
|
||||
"fromChainType": 2,
|
||||
"fromInx": currentNode.Inx,
|
||||
@@ -807,6 +906,7 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
|
||||
targetIP: target.IP,
|
||||
targetPort: target.Port,
|
||||
description: description,
|
||||
protocol: "tcp",
|
||||
metadata: map[string]interface{}{
|
||||
"fromChainType": 3,
|
||||
},
|
||||
@@ -822,6 +922,7 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
|
||||
targetIP: target.IP,
|
||||
targetPort: target.Port,
|
||||
description: description,
|
||||
protocol: "tcp",
|
||||
metadata: map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
},
|
||||
@@ -876,6 +977,7 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
|
||||
}
|
||||
|
||||
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
|
||||
protocol := strings.ToLower(strings.TrimSpace(tunnel.Protocol))
|
||||
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
|
||||
workItems := make([]diagnosisWorkItem, 0, len(chainRows)*2)
|
||||
|
||||
@@ -888,6 +990,7 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
|
||||
targetIP: "www.bing.com",
|
||||
targetPort: 443,
|
||||
description: description,
|
||||
protocol: "tcp",
|
||||
metadata: map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
},
|
||||
@@ -904,6 +1007,7 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
|
||||
hasChainHop: true,
|
||||
ipPreference: ipPreference,
|
||||
description: description,
|
||||
protocol: defaultString(strings.ToLower(strings.TrimSpace(firstNode.Protocol)), protocol),
|
||||
metadata: map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
"toChainType": 2,
|
||||
@@ -920,6 +1024,7 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
|
||||
hasChainHop: true,
|
||||
ipPreference: ipPreference,
|
||||
description: description,
|
||||
protocol: defaultString(strings.ToLower(strings.TrimSpace(outNode.Protocol)), protocol),
|
||||
metadata: map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
"toChainType": 3,
|
||||
@@ -940,6 +1045,7 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
|
||||
hasChainHop: true,
|
||||
ipPreference: ipPreference,
|
||||
description: description,
|
||||
protocol: defaultString(strings.ToLower(strings.TrimSpace(nextNode.Protocol)), protocol),
|
||||
metadata: map[string]interface{}{
|
||||
"fromChainType": 2,
|
||||
"fromInx": currentNode.Inx,
|
||||
@@ -957,6 +1063,7 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
|
||||
hasChainHop: true,
|
||||
ipPreference: ipPreference,
|
||||
description: description,
|
||||
protocol: defaultString(strings.ToLower(strings.TrimSpace(outNode.Protocol)), protocol),
|
||||
metadata: map[string]interface{}{
|
||||
"fromChainType": 2,
|
||||
"fromInx": currentNode.Inx,
|
||||
@@ -975,6 +1082,7 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
|
||||
targetIP: "www.bing.com",
|
||||
targetPort: 443,
|
||||
description: description,
|
||||
protocol: "tcp",
|
||||
metadata: map[string]interface{}{
|
||||
"fromChainType": 3,
|
||||
},
|
||||
@@ -988,6 +1096,7 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
|
||||
targetIP: "www.bing.com",
|
||||
targetPort: 443,
|
||||
description: description,
|
||||
protocol: "tcp",
|
||||
metadata: map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
},
|
||||
@@ -1107,9 +1216,9 @@ func (h *Handler) executeDiagnosisWorkItem(workItem diagnosisWorkItem, options d
|
||||
single := make([]map[string]interface{}, 0, 1)
|
||||
nodeCache := map[int64]*nodeRecord{}
|
||||
if workItem.hasChainHop {
|
||||
h.appendChainHopDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.toNode, workItem.description, workItem.metadata, workItem.ipPreference, options)
|
||||
h.appendChainHopDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.toNode, workItem.description, workItem.metadata, workItem.ipPreference, workItem.protocol, options)
|
||||
} else {
|
||||
h.appendPathDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.targetIP, workItem.targetPort, workItem.description, workItem.metadata, options)
|
||||
h.appendPathDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.targetIP, workItem.targetPort, workItem.description, workItem.metadata, workItem.protocol, options)
|
||||
}
|
||||
|
||||
if len(single) == 0 {
|
||||
@@ -1235,14 +1344,14 @@ func (h *Handler) appendFailedDiagnosis(results *[]map[string]interface{}, nodeC
|
||||
item["nodeName"] = node.Name
|
||||
}
|
||||
if strings.TrimSpace(message) == "" {
|
||||
message = "TCP连接失败"
|
||||
message = "连接失败"
|
||||
}
|
||||
item["success"] = false
|
||||
item["message"] = message
|
||||
*results = append(*results, item)
|
||||
}
|
||||
|
||||
func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}, options diagnosisExecOptions) {
|
||||
func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}, protocol string, options diagnosisExecOptions) {
|
||||
item := newDiagnosisResultItem(fromNodeID, targetIP, targetPort, description, metadata)
|
||||
|
||||
fromNode, err := h.cachedNode(nodeCache, fromNodeID)
|
||||
@@ -1259,9 +1368,9 @@ func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCac
|
||||
pingErr error
|
||||
)
|
||||
if fromNode.IsRemote == 1 {
|
||||
pingData, pingErr = h.tcpPingViaRemoteNode(fromNode, targetIP, targetPort, options)
|
||||
pingData, pingErr = h.pingViaRemoteNode(fromNode, targetIP, targetPort, protocol, options)
|
||||
} else {
|
||||
pingData, pingErr = h.tcpPingViaNode(fromNodeID, targetIP, targetPort, options)
|
||||
pingData, pingErr = h.pingViaNode(fromNodeID, targetIP, targetPort, protocol, options)
|
||||
}
|
||||
if pingErr != nil {
|
||||
item["success"] = false
|
||||
@@ -1278,21 +1387,21 @@ func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCac
|
||||
message := strings.TrimSpace(asString(pingData["message"]))
|
||||
if success {
|
||||
if message == "" {
|
||||
message = "TCP连接成功"
|
||||
message = "连接成功"
|
||||
}
|
||||
} else {
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(asString(pingData["errorMessage"]))
|
||||
}
|
||||
if message == "" {
|
||||
message = "TCP连接失败"
|
||||
message = "连接失败"
|
||||
}
|
||||
}
|
||||
item["message"] = message
|
||||
*results = append(*results, item)
|
||||
}
|
||||
|
||||
func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, toNode chainNodeRecord, description string, metadata map[string]interface{}, ipPreference string, options diagnosisExecOptions) {
|
||||
func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, toNode chainNodeRecord, description string, metadata map[string]interface{}, ipPreference string, protocol string, options diagnosisExecOptions) {
|
||||
fromNode, _ := h.cachedNode(nodeCache, fromNodeID)
|
||||
targetNode, err := h.cachedNode(nodeCache, toNode.NodeID)
|
||||
if err != nil {
|
||||
@@ -1304,7 +1413,7 @@ func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nod
|
||||
h.appendFailedDiagnosis(results, nodeCache, fromNodeID, strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]"), toNode.Port, description, metadata, err.Error())
|
||||
return
|
||||
}
|
||||
h.appendPathDiagnosis(results, nodeCache, fromNodeID, targetIP, targetPort, description, metadata, options)
|
||||
h.appendPathDiagnosis(results, nodeCache, fromNodeID, targetIP, targetPort, description, metadata, protocol, options)
|
||||
}
|
||||
|
||||
func resolveChainProbeTarget(fromNode, targetNode *nodeRecord, preferredPort int, ipPreference string, connectIp string) (string, int, error) {
|
||||
@@ -1397,13 +1506,81 @@ func (h *Handler) tcpPingViaRemoteNode(node *nodeRecord, ip string, port int, op
|
||||
|
||||
fc := client.NewFederationClientWithTimeout(options.commandTimeout)
|
||||
return fc.Diagnose(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeDiagnoseRequest{
|
||||
IP: strings.TrimSpace(ip),
|
||||
Port: port,
|
||||
Count: 4,
|
||||
Timeout: options.pingTimeoutMS,
|
||||
IP: strings.TrimSpace(ip),
|
||||
Port: port,
|
||||
Count: 4,
|
||||
Timeout: options.pingTimeoutMS,
|
||||
Protocol: "tcp",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) udpPingViaRemoteNode(node *nodeRecord, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {
|
||||
if node == nil {
|
||||
return nil, errors.New("节点不存在")
|
||||
}
|
||||
remoteURL := strings.TrimSpace(node.RemoteURL)
|
||||
remoteToken := strings.TrimSpace(node.RemoteToken)
|
||||
if remoteURL == "" || remoteToken == "" {
|
||||
return nil, errors.New("远程节点缺少共享配置")
|
||||
}
|
||||
if options.commandTimeout <= 0 {
|
||||
options.commandTimeout = diagnosisCommandTimeout
|
||||
}
|
||||
if options.pingTimeoutMS <= 0 {
|
||||
options.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)
|
||||
}
|
||||
|
||||
fc := client.NewFederationClientWithTimeout(options.commandTimeout)
|
||||
return fc.Diagnose(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeDiagnoseRequest{
|
||||
IP: strings.TrimSpace(ip),
|
||||
Port: port,
|
||||
Count: 4,
|
||||
Timeout: options.pingTimeoutMS,
|
||||
Protocol: "udp",
|
||||
})
|
||||
}
|
||||
|
||||
func isUDPBasedProtocol(protocol string) bool {
|
||||
p := strings.ToLower(strings.TrimSpace(protocol))
|
||||
return p == "kcp" || p == "udp" || p == "quic"
|
||||
}
|
||||
|
||||
func (h *Handler) pingViaNode(nodeID int64, ip string, port int, protocol string, options diagnosisExecOptions) (map[string]interface{}, error) {
|
||||
if isUDPBasedProtocol(protocol) {
|
||||
return h.udpPingViaNode(nodeID, ip, port, options)
|
||||
}
|
||||
return h.tcpPingViaNode(nodeID, ip, port, options)
|
||||
}
|
||||
|
||||
func (h *Handler) pingViaRemoteNode(node *nodeRecord, ip string, port int, protocol string, options diagnosisExecOptions) (map[string]interface{}, error) {
|
||||
if isUDPBasedProtocol(protocol) {
|
||||
return h.udpPingViaRemoteNode(node, ip, port, options)
|
||||
}
|
||||
return h.tcpPingViaRemoteNode(node, ip, port, options)
|
||||
}
|
||||
|
||||
func (h *Handler) udpPingViaNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {
|
||||
if options.commandTimeout <= 0 {
|
||||
options.commandTimeout = diagnosisCommandTimeout
|
||||
}
|
||||
if options.pingTimeoutMS <= 0 {
|
||||
options.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)
|
||||
}
|
||||
res, err := h.sendNodeCommandWithTimeout(nodeID, "UdpPing", map[string]interface{}{
|
||||
"ip": ip,
|
||||
"port": port,
|
||||
"count": 4,
|
||||
"timeout": options.pingTimeoutMS,
|
||||
}, options.commandTimeout, false, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Data == nil {
|
||||
return nil, errors.New("节点未返回诊断数据")
|
||||
}
|
||||
return res.Data, nil
|
||||
}
|
||||
|
||||
func splitRemoteTargets(remoteAddr string) []string {
|
||||
parts := strings.Split(remoteAddr, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
@@ -1561,7 +1738,7 @@ func compactErrorMessage(msg string) string {
|
||||
return strings.Join(strings.Fields(strings.ToLower(msg)), "")
|
||||
}
|
||||
|
||||
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {
|
||||
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, runtimeLimiters forwardRuntimeLimiters) []map[string]interface{} {
|
||||
protocols := []string{"tcp", "udp"}
|
||||
services := make([]map[string]interface{}, 0, 2)
|
||||
targets := splitRemoteTargets(forward.RemoteAddr)
|
||||
@@ -1604,10 +1781,23 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
|
||||
},
|
||||
},
|
||||
}
|
||||
if runtimeLimiters.ConnLimiter != "" {
|
||||
service["climiter"] = runtimeLimiters.ConnLimiter
|
||||
}
|
||||
if runtimeLimiters.TrafficLimiter != "" {
|
||||
service["limiter"] = runtimeLimiters.TrafficLimiter
|
||||
}
|
||||
if forward.ProxyProtocol > 0 {
|
||||
handlerConfig := service["handler"].(map[string]interface{})
|
||||
if handlerConfig["metadata"] == nil {
|
||||
handlerConfig["metadata"] = map[string]interface{}{}
|
||||
}
|
||||
handlerConfig["metadata"].(map[string]interface{})["proxyProtocol"] = forward.ProxyProtocol
|
||||
}
|
||||
if protocol == "udp" {
|
||||
listenerMetadata := map[string]interface{}{"keepAlive": true}
|
||||
if tunnelTLSProtocol {
|
||||
listenerMetadata["ttl"] = "10s"
|
||||
listenerMetadata := map[string]interface{}{
|
||||
"keepAlive": true,
|
||||
"ttl": "30s",
|
||||
}
|
||||
service["listener"].(map[string]interface{})["metadata"] = listenerMetadata
|
||||
}
|
||||
@@ -1615,10 +1805,10 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
|
||||
service["handler"].(map[string]interface{})["chain"] = fmt.Sprintf("chains_%d", forward.TunnelID)
|
||||
}
|
||||
if tunnel != nil && tunnel.Type == 1 && strings.TrimSpace(node.InterfaceName) != "" {
|
||||
service["metadata"] = map[string]interface{}{"interface": node.InterfaceName}
|
||||
}
|
||||
if limiterID != nil && *limiterID > 0 {
|
||||
service["limiter"] = strconv.FormatInt(*limiterID, 10)
|
||||
if service["metadata"] == nil {
|
||||
service["metadata"] = map[string]interface{}{}
|
||||
}
|
||||
service["metadata"].(map[string]interface{})["interface"] = node.InterfaceName
|
||||
}
|
||||
services = append(services, service)
|
||||
}
|
||||
@@ -1719,14 +1909,76 @@ func (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildLimiterAddPayload(limiterID int64, speed int) (string, map[string]interface{}) {
|
||||
func (h *Handler) ensureConnLimiterOnNode(nodeID int64, cfg forwardLimiterConfig) error {
|
||||
if cfg.Name == "" || len(cfg.Limits) == 0 {
|
||||
return nil
|
||||
}
|
||||
payload := map[string]interface{}{"name": cfg.Name, "limits": cfg.Limits}
|
||||
if _, err := h.sendNodeCommand(nodeID, "AddCLimiters", payload, false, false); err != nil {
|
||||
if !isAlreadyExistsMessage(err.Error()) {
|
||||
return fmt.Errorf("连接限制器下发失败: %w", err)
|
||||
}
|
||||
updatePayload := map[string]interface{}{"limiter": cfg.Name, "data": payload}
|
||||
if _, updateErr := h.sendNodeCommand(nodeID, "UpdateCLimiters", updatePayload, false, false); updateErr != nil {
|
||||
return fmt.Errorf("连接限制器更新失败: %w", updateErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildConnLimiterConfigs(forward *forwardRecord, userMaxConn int) []forwardLimiterConfig {
|
||||
if forward == nil {
|
||||
return nil
|
||||
}
|
||||
if forward.MaxConn > 0 {
|
||||
limits := []string{fmt.Sprintf("$ %d", forward.MaxConn)}
|
||||
if forward.IPMaxConn > 0 {
|
||||
limits = append(limits, fmt.Sprintf("$$ %d", forward.IPMaxConn))
|
||||
}
|
||||
return []forwardLimiterConfig{{Name: fmt.Sprintf("rule_conn_limit_%d", forward.ID), Limits: limits}}
|
||||
}
|
||||
configs := make([]forwardLimiterConfig, 0, 2)
|
||||
if userMaxConn > 0 {
|
||||
configs = append(configs, forwardLimiterConfig{Name: fmt.Sprintf("user_conn_limit_%d", forward.UserID), Limits: []string{fmt.Sprintf("$ %d", userMaxConn)}})
|
||||
}
|
||||
if forward.IPMaxConn > 0 {
|
||||
configs = append(configs, forwardLimiterConfig{Name: fmt.Sprintf("rule_conn_limit_%d", forward.ID), Limits: []string{fmt.Sprintf("$$ %d", forward.IPMaxConn)}})
|
||||
}
|
||||
return configs
|
||||
}
|
||||
|
||||
func joinLimiterNames(configs []forwardLimiterConfig) string {
|
||||
names := make([]string, 0, len(configs))
|
||||
for _, cfg := range configs {
|
||||
if cfg.Name != "" {
|
||||
names = append(names, cfg.Name)
|
||||
}
|
||||
}
|
||||
return strings.Join(names, ",")
|
||||
}
|
||||
|
||||
func speedToLimitLine(key string, speed int) string {
|
||||
rate := float64(speed) / 8.0
|
||||
limitStr := fmt.Sprintf("$ %.1fMB %.1fMB", rate, rate)
|
||||
return fmt.Sprintf("%s %.1fMB %.1fMB", key, rate, rate)
|
||||
}
|
||||
|
||||
func buildTrafficLimiterPayload(name string, totalSpeed *int, ipSpeed *int) map[string]interface{} {
|
||||
limits := make([]string, 0, 3)
|
||||
if totalSpeed != nil && *totalSpeed > 0 {
|
||||
limits = append(limits, speedToLimitLine("$", *totalSpeed))
|
||||
}
|
||||
if ipSpeed != nil && *ipSpeed > 0 {
|
||||
limits = append(limits, speedToLimitLine("0.0.0.0/0", *ipSpeed), speedToLimitLine("::/0", *ipSpeed))
|
||||
}
|
||||
return map[string]interface{}{"name": name, "limits": limits}
|
||||
}
|
||||
|
||||
func buildLimiterAddPayload(limiterID int64, speed int) (string, map[string]interface{}) {
|
||||
name := strconv.FormatInt(limiterID, 10)
|
||||
|
||||
return name, map[string]interface{}{
|
||||
"name": name,
|
||||
"limits": []string{limitStr},
|
||||
"limits": []string{speedToLimitLine("$", speed)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1754,3 +2006,20 @@ func (h *Handler) upsertLimiterOnNode(nodeID int64, limiterID int64, speed int)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) ensureTrafficLimiterOnNode(nodeID int64, name string, totalSpeed *int, ipSpeed *int) error {
|
||||
payload := buildTrafficLimiterPayload(name, totalSpeed, ipSpeed)
|
||||
limits, _ := payload["limits"].([]string)
|
||||
if name == "" || len(limits) == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := h.sendNodeCommand(nodeID, "AddLimiters", payload, false, false); err != nil {
|
||||
if !isAlreadyExistsMessage(err.Error()) {
|
||||
return fmt.Errorf("限速规则下发失败: %w", err)
|
||||
}
|
||||
if _, updateErr := h.sendNodeCommand(nodeID, "UpdateLimiters", buildLimiterUpdatePayload(name, payload), false, false); updateErr != nil {
|
||||
return fmt.Errorf("限速规则更新失败: %w", updateErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -201,6 +201,52 @@ func TestDeleteForwardServiceCandidatesDeletesAllMatchingVariants(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceDeleteNamesBatchesAndDeduplicatesVariants(t *testing.T) {
|
||||
bases := []string{"57_7_7", "57_7_0", "57_7_7"}
|
||||
got := buildForwardServiceDeleteNames(bases)
|
||||
want := []string{"57_7_7_tcp", "57_7_7_udp", "57_7_7", "57_7_0_tcp", "57_7_0_udp", "57_7_0"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("expected %v, got %v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovedTunnelRuntimeNodeIDsSeparatesChainAndServiceRoles(t *testing.T) {
|
||||
oldRows := []chainNodeRecord{
|
||||
{NodeID: 1, ChainType: 1},
|
||||
{NodeID: 2, ChainType: 2},
|
||||
{NodeID: 3, ChainType: 3},
|
||||
{NodeID: 5, ChainType: 2},
|
||||
{NodeID: 6, ChainType: 3},
|
||||
}
|
||||
newRows := []chainNodeRecord{
|
||||
{NodeID: 2, ChainType: 3},
|
||||
{NodeID: 3, ChainType: 3},
|
||||
{NodeID: 5, ChainType: 1},
|
||||
}
|
||||
|
||||
removedChains := removedTunnelRuntimeNodeIDs(oldRows, newRows, tunnelRuntimeNeedsChain)
|
||||
if want := []int64{1, 2}; !reflect.DeepEqual(removedChains, want) {
|
||||
t.Fatalf("expected removed chains %v, got %v", want, removedChains)
|
||||
}
|
||||
|
||||
removedServices := removedTunnelRuntimeNodeIDs(oldRows, newRows, tunnelRuntimeNeedsService)
|
||||
if want := []int64{5, 6}; !reflect.DeepEqual(removedServices, want) {
|
||||
t.Fatalf("expected removed services %v, got %v", want, removedServices)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelForwardRuntimeNeedsSyncOnlyWhenTypeOrEntriesChange(t *testing.T) {
|
||||
if tunnelForwardRuntimeNeedsSync(2, 2, []int64{1, 2}, []int64{2, 1}) {
|
||||
t.Fatalf("same tunnel type and same entry set should not resync forwards")
|
||||
}
|
||||
if !tunnelForwardRuntimeNeedsSync(1, 2, []int64{1}, []int64{1}) {
|
||||
t.Fatalf("type change should resync forwards")
|
||||
}
|
||||
if !tunnelForwardRuntimeNeedsSync(2, 2, []int64{1}, []int64{1, 2}) {
|
||||
t.Fatalf("entry set change should resync forwards")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateForwardPortAvailabilityRejectsOtherForwardOccupancy(t *testing.T) {
|
||||
h := &Handler{repo: nil}
|
||||
node := &nodeRecord{ID: 9, Name: "test-node"}
|
||||
@@ -378,7 +424,7 @@ func TestRetryTunnelServiceAddWithCleanupReturnsCleanupError(t *testing.T) {
|
||||
func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {
|
||||
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
|
||||
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
|
||||
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22000, "10.9.8.7", nil, false)
|
||||
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22000, "10.9.8.7", forwardRuntimeLimiters{})
|
||||
if len(services) != 2 {
|
||||
t.Fatalf("expected 2 services, got %d", len(services))
|
||||
}
|
||||
@@ -393,7 +439,7 @@ func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {
|
||||
func TestBuildForwardServiceConfigs_DefaultListenAddrWhenBindIPEmpty(t *testing.T) {
|
||||
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
|
||||
node := &nodeRecord{TCPListenAddr: "0.0.0.0", UDPListenAddr: "[::]"}
|
||||
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22001, "", nil, false)
|
||||
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22001, "", forwardRuntimeLimiters{})
|
||||
if len(services) != 2 {
|
||||
t.Fatalf("expected 2 services, got %d", len(services))
|
||||
}
|
||||
@@ -409,7 +455,7 @@ func TestBuildForwardServiceConfigs_DefaultListenAddrWhenBindIPEmpty(t *testing.
|
||||
func TestBuildForwardServiceConfigs_BindIPAlreadyContainsPort(t *testing.T) {
|
||||
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
|
||||
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
|
||||
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 55555, "3.3.3.3:12345", nil, false)
|
||||
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 55555, "3.3.3.3:12345", forwardRuntimeLimiters{})
|
||||
if len(services) != 2 {
|
||||
t.Fatalf("expected 2 services, got %d", len(services))
|
||||
}
|
||||
@@ -464,7 +510,7 @@ func TestBuildForwardServiceConfigs_IPv6BindIP(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
|
||||
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
|
||||
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, tt.port, tt.bindIP, nil, false)
|
||||
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, tt.port, tt.bindIP, forwardRuntimeLimiters{})
|
||||
if len(services) != 2 {
|
||||
t.Fatalf("expected 2 services, got %d", len(services))
|
||||
}
|
||||
@@ -478,6 +524,58 @@ func TestBuildForwardServiceConfigs_IPv6BindIP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConnLimiterConfigCombinesTotalAndPerIP(t *testing.T) {
|
||||
cfgs := buildConnLimiterConfigs(&forwardRecord{ID: 42, UserID: 9, MaxConn: 100, IPMaxConn: 5}, 37)
|
||||
want := []forwardLimiterConfig{{Name: "rule_conn_limit_42", Limits: []string{"$ 100", "$$ 5"}}}
|
||||
if !reflect.DeepEqual(cfgs, want) {
|
||||
t.Fatalf("expected %+v, got %+v", want, cfgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConnLimiterConfigUsesUserTotalWithRulePerIP(t *testing.T) {
|
||||
cfgs := buildConnLimiterConfigs(&forwardRecord{ID: 42, UserID: 9, IPMaxConn: 5}, 37)
|
||||
want := []forwardLimiterConfig{
|
||||
{Name: "user_conn_limit_9", Limits: []string{"$ 37"}},
|
||||
{Name: "rule_conn_limit_42", Limits: []string{"$$ 5"}},
|
||||
}
|
||||
if !reflect.DeepEqual(cfgs, want) {
|
||||
t.Fatalf("expected %+v, got %+v", want, cfgs)
|
||||
}
|
||||
if got := joinLimiterNames(cfgs); got != "user_conn_limit_9,rule_conn_limit_42" {
|
||||
t.Fatalf("expected composite limiter names, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTrafficLimiterPayloadUsesOnlyPerIPRulesWhenTotalIsSeparate(t *testing.T) {
|
||||
payload := buildTrafficLimiterPayload("rule_traffic_limit_42", nil, intPtr(40))
|
||||
wantLimits := []string{"0.0.0.0/0 5.0MB 5.0MB", "::/0 5.0MB 5.0MB"}
|
||||
if payload["name"] != "rule_traffic_limit_42" {
|
||||
t.Fatalf("expected name rule_traffic_limit_42, got %v", payload["name"])
|
||||
}
|
||||
if !reflect.DeepEqual(payload["limits"], wantLimits) {
|
||||
t.Fatalf("expected limits %v, got %v", wantLimits, payload["limits"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceConfigsUsesRuntimeLimiterNames(t *testing.T) {
|
||||
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
|
||||
node := &nodeRecord{TCPListenAddr: "0.0.0.0", UDPListenAddr: "[::]"}
|
||||
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22001, "", forwardRuntimeLimiters{TrafficLimiter: "rule_traffic_limit_42", ConnLimiter: "rule_conn_limit_42"})
|
||||
if len(services) != 2 {
|
||||
t.Fatalf("expected 2 services, got %d", len(services))
|
||||
}
|
||||
for _, service := range services {
|
||||
if service["limiter"] != "rule_traffic_limit_42" {
|
||||
t.Fatalf("expected traffic limiter rule_traffic_limit_42, got %v", service["limiter"])
|
||||
}
|
||||
if service["climiter"] != "rule_conn_limit_42" {
|
||||
t.Fatalf("expected conn limiter rule_conn_limit_42, got %v", service["climiter"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func intPtr(v int) *int { return &v }
|
||||
|
||||
func TestProcessServerAddress_StripsURLSchemeAndPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -84,11 +85,87 @@ type federationRuntimeReleaseRoleRequest struct {
|
||||
ResourceKey string `json:"resourceKey"`
|
||||
}
|
||||
|
||||
func federationRuntimeChainName(bindingID string) string {
|
||||
bindingID = strings.TrimSpace(bindingID)
|
||||
if bindingID == "" {
|
||||
return ""
|
||||
}
|
||||
return "fed_chain_" + bindingID
|
||||
}
|
||||
|
||||
func buildFederationMiddleChainConfig(chainName string, runtimeID int64, protocol, strategy string, targets []federationRuntimeTarget, interfaceName string) (map[string]interface{}, error) {
|
||||
chainName = strings.TrimSpace(chainName)
|
||||
if chainName == "" {
|
||||
return nil, fmt.Errorf("chain name is required")
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
return nil, fmt.Errorf("targets are required for middle role")
|
||||
}
|
||||
protocol = defaultString(protocol, "tls")
|
||||
nodeItems := make([]map[string]interface{}, 0, len(targets))
|
||||
for i, target := range targets {
|
||||
host := strings.TrimSpace(target.Host)
|
||||
if host == "" || target.Port <= 0 {
|
||||
return nil, fmt.Errorf("Invalid target")
|
||||
}
|
||||
targetProtocol := defaultString(target.Protocol, protocol)
|
||||
connector := map[string]interface{}{
|
||||
"type": "relay",
|
||||
}
|
||||
if isTCPTunnelProtocol(targetProtocol) {
|
||||
connector["metadata"] = map[string]interface{}{
|
||||
"nodelay": true,
|
||||
"mux.keepaliveInterval": "15s",
|
||||
"mux.keepaliveTimeout": "45s",
|
||||
}
|
||||
}
|
||||
if isKCPTunnelProtocol(targetProtocol) {
|
||||
connector["metadata"] = map[string]interface{}{
|
||||
"connectTimeout": "30s",
|
||||
}
|
||||
}
|
||||
nodeItems = append(nodeItems, map[string]interface{}{
|
||||
"name": fmt.Sprintf("node_%d", i+1),
|
||||
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, target.Port)),
|
||||
"connector": connector,
|
||||
"dialer": buildTunnelDialerConfig(targetProtocol),
|
||||
})
|
||||
}
|
||||
|
||||
chainData := map[string]interface{}{
|
||||
"name": chainName,
|
||||
"hops": []map[string]interface{}{
|
||||
{
|
||||
"name": fmt.Sprintf("hop_%d", runtimeID),
|
||||
"selector": map[string]interface{}{
|
||||
"strategy": runtimeTunnelStrategy(strategy),
|
||||
"maxFails": 1,
|
||||
"failTimeout": int64(600000000000),
|
||||
},
|
||||
"nodes": nodeItems,
|
||||
},
|
||||
},
|
||||
}
|
||||
if strings.TrimSpace(interfaceName) != "" {
|
||||
hops := chainData["hops"].([]map[string]interface{})
|
||||
hops[0]["interface"] = interfaceName
|
||||
}
|
||||
return chainData, nil
|
||||
}
|
||||
|
||||
func updateChainPayload(chainName string, chainData map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"chain": chainName,
|
||||
"data": chainData,
|
||||
}
|
||||
}
|
||||
|
||||
type federationRuntimeDiagnoseRequest struct {
|
||||
IP string `json:"ip"`
|
||||
Port int `json:"port"`
|
||||
Count int `json:"count"`
|
||||
Timeout int `json:"timeout"`
|
||||
IP string `json:"ip"`
|
||||
Port int `json:"port"`
|
||||
Count int `json:"count"`
|
||||
Timeout int `json:"timeout"`
|
||||
Protocol string `json:"protocol"`
|
||||
}
|
||||
|
||||
type federationRuntimeCommandRequest struct {
|
||||
@@ -148,12 +225,19 @@ func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName s
|
||||
"handler": map[string]interface{}{
|
||||
"type": "relay",
|
||||
},
|
||||
"listener": map[string]interface{}{
|
||||
"type": protocol,
|
||||
},
|
||||
"listener": buildTunnelListenerConfig(protocol),
|
||||
}
|
||||
if isTLSTunnelProtocol(protocol) {
|
||||
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{"nodelay": true}
|
||||
if isTCPTunnelProtocol(protocol) {
|
||||
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{
|
||||
"nodelay": true,
|
||||
"mux.keepaliveInterval": "15s",
|
||||
"mux.keepaliveTimeout": "45s",
|
||||
}
|
||||
}
|
||||
if isKCPTunnelProtocol(protocol) {
|
||||
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{
|
||||
"connectTimeout": "30s",
|
||||
}
|
||||
}
|
||||
if role == "middle" {
|
||||
service["handler"].(map[string]interface{})["chain"] = chainName
|
||||
@@ -635,6 +719,15 @@ func (h *Handler) nodeImport(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("Remote URL and Token are required"))
|
||||
return
|
||||
}
|
||||
rURL, err := url.Parse(req.RemoteURL)
|
||||
if err != nil || (rURL.Scheme != "http" && rURL.Scheme != "https") {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid Remote URL format"))
|
||||
return
|
||||
}
|
||||
if err := IsSafeRemoteAddr(rURL.Host); err != nil {
|
||||
response.WriteJSON(w, response.Err(403, "禁止将远程节点地址设置为内部网络"))
|
||||
return
|
||||
}
|
||||
|
||||
domainCfg, _ := h.repo.GetConfigByName("panel_domain")
|
||||
localDomain := ""
|
||||
@@ -1038,7 +1131,43 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
node, err := h.getNodeRecord(share.NodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
protocol := defaultString(req.Protocol, runtime.Protocol)
|
||||
strategy := defaultString(req.Strategy, "round")
|
||||
chainName := defaultString(runtime.ChainName, federationRuntimeChainName(runtime.BindingID))
|
||||
if chainName == "" {
|
||||
chainName = federationRuntimeChainName(fmt.Sprintf("%d", runtime.ID))
|
||||
}
|
||||
serviceName := fmt.Sprintf("fed_svc_%d", runtime.ID)
|
||||
if runtime.Applied == 1 && strings.TrimSpace(runtime.BindingID) != "" {
|
||||
if req.Role == "middle" && len(req.Targets) > 0 {
|
||||
chainData, buildErr := buildFederationMiddleChainConfig(chainName, runtime.ID, protocol, strategy, req.Targets, node.InterfaceName)
|
||||
if buildErr != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(buildErr.Error()))
|
||||
return
|
||||
}
|
||||
if _, err := h.sendNodeCommand(share.NodeID, "UpdateChains", updateChainPayload(chainName, chainData), false, false); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
targetBytes, _ := json.Marshal(req.Targets)
|
||||
runtime.Role = req.Role
|
||||
runtime.ChainName = chainName
|
||||
runtime.Protocol = protocol
|
||||
runtime.Strategy = strategy
|
||||
runtime.Target = string(targetBytes)
|
||||
runtime.Status = 1
|
||||
runtime.UpdatedTime = time.Now().UnixMilli()
|
||||
if err := h.repo.UpdatePeerShareRuntime(runtime); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"bindingId": runtime.BindingID,
|
||||
"allocatedPort": runtime.Port,
|
||||
@@ -1058,64 +1187,12 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
}
|
||||
|
||||
node, err := h.getNodeRecord(share.NodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
protocol := defaultString(req.Protocol, runtime.Protocol)
|
||||
strategy := defaultString(req.Strategy, "round")
|
||||
chainName := fmt.Sprintf("fed_chain_%d", runtime.ID)
|
||||
serviceName := fmt.Sprintf("fed_svc_%d", runtime.ID)
|
||||
|
||||
if req.Role == "middle" {
|
||||
if len(req.Targets) == 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("targets are required for middle role"))
|
||||
chainData, buildErr := buildFederationMiddleChainConfig(chainName, runtime.ID, protocol, strategy, req.Targets, node.InterfaceName)
|
||||
if buildErr != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(buildErr.Error()))
|
||||
return
|
||||
}
|
||||
nodeItems := make([]map[string]interface{}, 0, len(req.Targets))
|
||||
for i, target := range req.Targets {
|
||||
host := strings.TrimSpace(target.Host)
|
||||
if host == "" || target.Port <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid target"))
|
||||
return
|
||||
}
|
||||
targetProtocol := defaultString(target.Protocol, protocol)
|
||||
connector := map[string]interface{}{
|
||||
"type": "relay",
|
||||
}
|
||||
if isTLSTunnelProtocol(targetProtocol) {
|
||||
connector["metadata"] = map[string]interface{}{"nodelay": true}
|
||||
}
|
||||
nodeItems = append(nodeItems, map[string]interface{}{
|
||||
"name": fmt.Sprintf("node_%d", i+1),
|
||||
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, target.Port)),
|
||||
"connector": connector,
|
||||
"dialer": map[string]interface{}{
|
||||
"type": targetProtocol,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
chainData := map[string]interface{}{
|
||||
"name": chainName,
|
||||
"hops": []map[string]interface{}{
|
||||
{
|
||||
"name": fmt.Sprintf("hop_%d", runtime.ID),
|
||||
"selector": map[string]interface{}{
|
||||
"strategy": strategy,
|
||||
"maxFails": 1,
|
||||
"failTimeout": int64(600000000000),
|
||||
},
|
||||
"nodes": nodeItems,
|
||||
},
|
||||
},
|
||||
}
|
||||
if strings.TrimSpace(node.InterfaceName) != "" {
|
||||
hops := chainData["hops"].([]map[string]interface{})
|
||||
hops[0]["interface"] = node.InterfaceName
|
||||
}
|
||||
if _, err := h.sendNodeCommand(share.NodeID, "AddChains", chainData, true, false); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
@@ -1257,7 +1334,12 @@ func (h *Handler) federationRuntimeDiagnose(w http.ResponseWriter, r *http.Reque
|
||||
commandTimeout = diagnosisCommandTimeout
|
||||
}
|
||||
|
||||
res, err := h.sendNodeCommandWithTimeout(share.NodeID, "TcpPing", map[string]interface{}{
|
||||
commandType := "TcpPing"
|
||||
if isUDPBasedProtocol(req.Protocol) {
|
||||
commandType = "UdpPing"
|
||||
}
|
||||
|
||||
res, err := h.sendNodeCommandWithTimeout(share.NodeID, commandType, map[string]interface{}{
|
||||
"ip": req.IP,
|
||||
"port": req.Port,
|
||||
"count": req.Count,
|
||||
|
||||
@@ -281,6 +281,63 @@ func TestBuildFederationServiceConfig_NonTLSProtocol_NoNodelay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRuntimeChainNameDerivesFromBindingID(t *testing.T) {
|
||||
if got := federationRuntimeChainName("12"); got != "fed_chain_12" {
|
||||
t.Fatalf("expected fed_chain_12, got %q", got)
|
||||
}
|
||||
if got := federationRuntimeChainName(" 12 "); got != "fed_chain_12" {
|
||||
t.Fatalf("expected trimmed fed_chain_12, got %q", got)
|
||||
}
|
||||
if got := federationRuntimeChainName(""); got != "" {
|
||||
t.Fatalf("expected blank binding ID to stay blank, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFederationMiddleChainConfigUsesExistingChainNameAndBestStrategy(t *testing.T) {
|
||||
chainData, err := buildFederationMiddleChainConfig("fed_chain_12", 12, "tls", tunnelStrategyBest, []federationRuntimeTarget{
|
||||
{Host: "10.0.0.31", Port: 30031, Protocol: "tls"},
|
||||
{Host: "10.0.0.30", Port: 30030, Protocol: "tls"},
|
||||
}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("build chain: %v", err)
|
||||
}
|
||||
if chainData["name"] != "fed_chain_12" {
|
||||
t.Fatalf("expected existing chain name, got %v", chainData["name"])
|
||||
}
|
||||
hops := chainData["hops"].([]map[string]interface{})
|
||||
selector := hops[0]["selector"].(map[string]interface{})
|
||||
if selector["strategy"] != bestExitRuntimeStrategy {
|
||||
t.Fatalf("expected best strategy to map to fifo, got %v", selector["strategy"])
|
||||
}
|
||||
nodes := hops[0]["nodes"].([]map[string]interface{})
|
||||
if nodes[0]["addr"] != "10.0.0.31:30031" || nodes[1]["addr"] != "10.0.0.30:30030" {
|
||||
t.Fatalf("expected target order to be preserved, got %+v", nodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateChainPayloadWrapsChainDataForAgentUpdate(t *testing.T) {
|
||||
chainData := map[string]interface{}{
|
||||
"name": "fed_chain_12",
|
||||
"hops": []map[string]interface{}{},
|
||||
}
|
||||
|
||||
payload := updateChainPayload("fed_chain_12", chainData)
|
||||
if len(payload) != 2 {
|
||||
t.Fatalf("expected exact wrapper with 2 keys, got %+v", payload)
|
||||
}
|
||||
if payload["chain"] != "fed_chain_12" {
|
||||
t.Fatalf("expected chain name in wrapper, got %v", payload["chain"])
|
||||
}
|
||||
wrappedData, ok := payload["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected wrapped chain data map, got %T", payload["data"])
|
||||
}
|
||||
chainData["name"] = "fed_chain_12_updated"
|
||||
if wrappedData["name"] != "fed_chain_12_updated" {
|
||||
t.Fatalf("expected wrapper to preserve chainData identity, got %+v", wrappedData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRuntimeReservePortRejectsWhenShareFlowExceeded(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
|
||||
@@ -43,16 +43,19 @@ func (h *Handler) processFlowItem(nodeID int64, item flowItem) {
|
||||
|
||||
forwardID, userID, userTunnelID, ok := parseFlowServiceIDs(serviceName)
|
||||
if ok {
|
||||
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
|
||||
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
|
||||
if quota, quotaErr := h.repo.AddUserQuotaUsage(userID, inFlow+outFlow, time.Now()); quotaErr == nil {
|
||||
h.enforceUserQuotaIfNeeded(userID, quota)
|
||||
if h.forwardExists(forwardID) {
|
||||
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
|
||||
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
|
||||
if quota, quotaErr := h.repo.AddUserQuotaUsage(userID, inFlow+outFlow, time.Now()); quotaErr == nil {
|
||||
h.enforceUserQuotaIfNeeded(userID, quota)
|
||||
}
|
||||
if userTunnelID > 0 {
|
||||
h.enforceFlowPolicies(userID, userTunnelID)
|
||||
}
|
||||
} else if nodeID > 0 {
|
||||
h.sendDeleteOrphanedForwardService(nodeID, serviceName)
|
||||
}
|
||||
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
|
||||
|
||||
if userTunnelID > 0 {
|
||||
h.enforceFlowPolicies(userID, userTunnelID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -553,6 +556,14 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
|
||||
}
|
||||
|
||||
parts := strings.Split(name, "_")
|
||||
if len(parts) == 2 && parts[0] == "tunnel" {
|
||||
tunnelID, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err == nil && tunnelID > 0 && !h.tunnelExists(tunnelID) {
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name}}, false, true)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if len(parts) >= 3 {
|
||||
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
|
||||
@@ -566,7 +577,7 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
|
||||
suffix := parts[len(parts)-1]
|
||||
|
||||
switch suffix {
|
||||
case "tls":
|
||||
case "tls", "kcp", "wss", "mtls", "mwss", "mtcp":
|
||||
tunnelID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || tunnelID <= 0 || h.tunnelExists(tunnelID) {
|
||||
continue
|
||||
@@ -574,6 +585,10 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name}}, false, true)
|
||||
case "tcp":
|
||||
if len(parts) < 4 {
|
||||
tunnelID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err == nil && tunnelID > 0 && !h.tunnelExists(tunnelID) {
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name}}, false, true)
|
||||
}
|
||||
continue
|
||||
}
|
||||
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
@@ -628,6 +643,21 @@ func (h *Handler) forwardExists(forwardID int64) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
func (h *Handler) sendDeleteOrphanedForwardService(nodeID int64, serviceName string) {
|
||||
parts := strings.Split(serviceName, "_")
|
||||
if len(parts) < 3 {
|
||||
return
|
||||
}
|
||||
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || forwardID <= 0 {
|
||||
return
|
||||
}
|
||||
base := parts[0] + "_" + parts[1] + "_" + parts[2]
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{
|
||||
"services": []string{base + "_tcp", base + "_udp"},
|
||||
}, false, true)
|
||||
}
|
||||
|
||||
func (h *Handler) speedLimiterExists(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
type flowPolicyTarget struct {
|
||||
UserID int64
|
||||
UserTunnelID int64
|
||||
}
|
||||
|
||||
type flowUploadBatch struct {
|
||||
flowDeltas []repo.FlowUploadCounterDelta
|
||||
quotaUsage map[int64]int64
|
||||
policyTargets []flowPolicyTarget
|
||||
forwardTraffic map[int64]tunnelTrafficDelta
|
||||
orphanServices map[string]struct{}
|
||||
peerShareForwardItems map[string]flowItem
|
||||
peerShareRuntimeItems map[int64]flowItem
|
||||
}
|
||||
|
||||
func (h *Handler) buildFlowUploadBatch(items []flowItem, metas map[int64]repo.FlowUploadForwardMeta) flowUploadBatch {
|
||||
batch := flowUploadBatch{
|
||||
quotaUsage: make(map[int64]int64),
|
||||
forwardTraffic: make(map[int64]tunnelTrafficDelta),
|
||||
orphanServices: make(map[string]struct{}),
|
||||
peerShareForwardItems: make(map[string]flowItem),
|
||||
peerShareRuntimeItems: make(map[int64]flowItem),
|
||||
}
|
||||
policySeen := map[flowPolicyTarget]struct{}{}
|
||||
flowSeen := map[int64]int{}
|
||||
|
||||
for _, item := range items {
|
||||
serviceName := strings.TrimSpace(item.N)
|
||||
if serviceName == "" || serviceName == "web_api" {
|
||||
continue
|
||||
}
|
||||
if runtimeID, ok := parsePeerShareRuntimeServiceID(serviceName); ok {
|
||||
merged := batch.peerShareRuntimeItems[runtimeID]
|
||||
merged.N = serviceName
|
||||
merged.U += item.U
|
||||
merged.D += item.D
|
||||
batch.peerShareRuntimeItems[runtimeID] = merged
|
||||
continue
|
||||
}
|
||||
forwardID, userID, userTunnelID, ok := parseFlowServiceIDs(serviceName)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
normalized := normalizeForwardRuntimeServiceName(serviceName)
|
||||
merged := batch.peerShareForwardItems[normalized]
|
||||
merged.N = normalized
|
||||
merged.U += item.U
|
||||
merged.D += item.D
|
||||
batch.peerShareForwardItems[normalized] = merged
|
||||
|
||||
meta, exists := metas[forwardID]
|
||||
if !exists {
|
||||
batch.orphanServices[serviceName] = struct{}{}
|
||||
continue
|
||||
}
|
||||
|
||||
raw := batch.forwardTraffic[forwardID]
|
||||
raw.bytesIn += item.D
|
||||
raw.bytesOut += item.U
|
||||
batch.forwardTraffic[forwardID] = raw
|
||||
|
||||
scaledIn := int64(float64(item.D)*meta.TrafficRatio) * meta.TunnelFlow
|
||||
scaledOut := int64(float64(item.U)*meta.TrafficRatio) * meta.TunnelFlow
|
||||
if idx, ok := flowSeen[forwardID]; ok {
|
||||
batch.flowDeltas[idx].InFlow += scaledIn
|
||||
batch.flowDeltas[idx].OutFlow += scaledOut
|
||||
} else {
|
||||
flowSeen[forwardID] = len(batch.flowDeltas)
|
||||
batch.flowDeltas = append(batch.flowDeltas, repo.FlowUploadCounterDelta{
|
||||
ForwardID: forwardID,
|
||||
UserID: userID,
|
||||
UserTunnelID: userTunnelID,
|
||||
InFlow: scaledIn,
|
||||
OutFlow: scaledOut,
|
||||
})
|
||||
}
|
||||
batch.quotaUsage[userID] += scaledIn + scaledOut
|
||||
|
||||
target := flowPolicyTarget{UserID: userID, UserTunnelID: userTunnelID}
|
||||
if _, seen := policySeen[target]; !seen {
|
||||
policySeen[target] = struct{}{}
|
||||
batch.policyTargets = append(batch.policyTargets, target)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
sort.Slice(batch.policyTargets, func(i, j int) bool {
|
||||
if batch.policyTargets[i].UserID == batch.policyTargets[j].UserID {
|
||||
return batch.policyTargets[i].UserTunnelID < batch.policyTargets[j].UserTunnelID
|
||||
}
|
||||
return batch.policyTargets[i].UserID < batch.policyTargets[j].UserID
|
||||
})
|
||||
|
||||
return batch
|
||||
}
|
||||
|
||||
func (h *Handler) applyFlowUploadBatch(nodeID int64, batch flowUploadBatch, now time.Time) {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
h.applyFlowDeltasWithFallback(nodeID, batch.flowDeltas)
|
||||
for userID, quota := range h.applyQuotaUsageWithFallback(nodeID, batch.quotaUsage, now) {
|
||||
h.enforceUserQuotaIfNeeded(userID, quota)
|
||||
}
|
||||
for _, target := range batch.policyTargets {
|
||||
if target.UserID <= 0 || target.UserTunnelID <= 0 {
|
||||
continue
|
||||
}
|
||||
h.enforceFlowPolicies(target.UserID, target.UserTunnelID)
|
||||
}
|
||||
for serviceName := range batch.orphanServices {
|
||||
h.sendDeleteOrphanedForwardService(nodeID, serviceName)
|
||||
}
|
||||
for serviceName, item := range batch.peerShareForwardItems {
|
||||
forwardID, _, _, ok := parseFlowServiceIDs(serviceName)
|
||||
if ok {
|
||||
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
|
||||
}
|
||||
}
|
||||
for runtimeID, item := range batch.peerShareRuntimeItems {
|
||||
h.processPeerShareFlow(runtimeID, item)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) applyFlowDeltasWithFallback(nodeID int64, deltas []repo.FlowUploadCounterDelta) {
|
||||
if h == nil || h.repo == nil || len(deltas) == 0 {
|
||||
return
|
||||
}
|
||||
if err := h.repo.ApplyFlowUploadDeltasBatch(deltas); err == nil {
|
||||
return
|
||||
} else {
|
||||
log.Printf("flow upload write failed op=flow.batch_apply node_id=%d err=%v", nodeID, err)
|
||||
}
|
||||
for _, delta := range deltas {
|
||||
if err := h.repo.AddFlow(delta.ForwardID, delta.UserID, delta.UserTunnelID, delta.InFlow, delta.OutFlow); err != nil {
|
||||
log.Printf("flow upload write failed op=flow.single_apply node_id=%d forward_id=%d user_id=%d user_tunnel_id=%d err=%v", nodeID, delta.ForwardID, delta.UserID, delta.UserTunnelID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) applyQuotaUsageWithFallback(nodeID int64, usages map[int64]int64, now time.Time) map[int64]*model.UserQuotaView {
|
||||
if h == nil || h.repo == nil || len(usages) == 0 {
|
||||
return map[int64]*model.UserQuotaView{}
|
||||
}
|
||||
quotaViews, err := h.repo.AddUserQuotaUsageBatch(usages, now)
|
||||
if err == nil {
|
||||
return quotaViews
|
||||
}
|
||||
log.Printf("flow upload write failed op=quota.batch_apply node_id=%d err=%v", nodeID, err)
|
||||
|
||||
userIDs := make([]int64, 0, len(usages))
|
||||
for userID := range usages {
|
||||
if userID > 0 {
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
}
|
||||
sort.Slice(userIDs, func(i, j int) bool { return userIDs[i] < userIDs[j] })
|
||||
|
||||
quotaViews = make(map[int64]*model.UserQuotaView, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
quota, singleErr := h.repo.AddUserQuotaUsage(userID, usages[userID], now)
|
||||
if singleErr != nil {
|
||||
log.Printf("flow upload write failed op=quota.single_apply node_id=%d user_id=%d err=%v", nodeID, userID, singleErr)
|
||||
continue
|
||||
}
|
||||
if quota != nil {
|
||||
quotaViews[userID] = quota
|
||||
}
|
||||
}
|
||||
return quotaViews
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestBuildFlowUploadBatchAggregatesForwardQuotaPeerShareAndCleanupTargets(t *testing.T) {
|
||||
h := &Handler{}
|
||||
metas := map[int64]repo.FlowUploadForwardMeta{
|
||||
20: {
|
||||
ForwardID: 20,
|
||||
TunnelID: 1,
|
||||
TrafficRatio: 2,
|
||||
TunnelFlow: 3,
|
||||
},
|
||||
}
|
||||
|
||||
batch := h.buildFlowUploadBatch([]flowItem{
|
||||
{N: "20_2_10", U: 70, D: 50},
|
||||
{N: "20_2_10_tcp", U: 40, D: 30},
|
||||
{N: "99_2_10", U: 12, D: 8},
|
||||
{N: "fed_svc_17", U: 9, D: 1},
|
||||
}, metas)
|
||||
|
||||
if len(batch.flowDeltas) != 1 {
|
||||
t.Fatalf("expected 1 flow delta, got %d", len(batch.flowDeltas))
|
||||
}
|
||||
delta := batch.flowDeltas[0]
|
||||
if delta.ForwardID != 20 || delta.UserID != 2 || delta.UserTunnelID != 10 {
|
||||
t.Fatalf("unexpected flow delta identity: %#v", delta)
|
||||
}
|
||||
if delta.InFlow != 480 || delta.OutFlow != 660 {
|
||||
t.Fatalf("expected scaled flow in=480 out=660, got in=%d out=%d", delta.InFlow, delta.OutFlow)
|
||||
}
|
||||
if batch.quotaUsage[2] != 1140 {
|
||||
t.Fatalf("expected quota usage 1140, got %d", batch.quotaUsage[2])
|
||||
}
|
||||
if len(batch.policyTargets) != 1 {
|
||||
t.Fatalf("expected 1 policy target, got %d", len(batch.policyTargets))
|
||||
}
|
||||
if batch.policyTargets[0].UserID != 2 || batch.policyTargets[0].UserTunnelID != 10 {
|
||||
t.Fatalf("unexpected policy target: %#v", batch.policyTargets[0])
|
||||
}
|
||||
traffic := batch.forwardTraffic[20]
|
||||
if traffic.bytesIn != 80 || traffic.bytesOut != 110 {
|
||||
t.Fatalf("expected raw traffic in=80 out=110, got in=%d out=%d", traffic.bytesIn, traffic.bytesOut)
|
||||
}
|
||||
if _, ok := batch.orphanServices["99_2_10"]; !ok {
|
||||
t.Fatalf("expected orphan service cleanup target for 99_2_10")
|
||||
}
|
||||
if item, ok := batch.peerShareForwardItems["99_2_10"]; !ok || item.U != 12 || item.D != 8 {
|
||||
t.Fatalf("expected orphan forward to remain eligible for peer-share accounting, got %#v ok=%v", item, ok)
|
||||
}
|
||||
if item, ok := batch.peerShareForwardItems["20_2_10"]; !ok || item.U != 110 || item.D != 80 {
|
||||
t.Fatalf("expected merged peer-share forward item, got %#v ok=%v", item, ok)
|
||||
}
|
||||
if item, ok := batch.peerShareRuntimeItems[17]; !ok || item.U != 9 || item.D != 1 {
|
||||
t.Fatalf("expected merged peer-share runtime item, got %#v ok=%v", item, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFlowUploadBatchContinuesPolicyAndPeerShareSideEffectsWhenQuotaBatchFails(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "flow-upload-batch-quota-fail.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now()
|
||||
nowMs := now.UnixMilli()
|
||||
if err := r.DB().Create(&model.User{ID: 2, User: "flow-user", Pwd: "pwd", RoleID: 1, ExpTime: 2727251700000, Flow: 99999, Num: 99999, CreatedTime: nowMs, Status: 1}).Error; err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
if err := r.DB().Create(&model.Tunnel{ID: 1, Name: "tunnel-1", TrafficRatio: 1, Type: 1, Protocol: "tls", Flow: 1, CreatedTime: nowMs, UpdatedTime: nowMs, Status: 1}).Error; err != nil {
|
||||
t.Fatalf("seed tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Create(&model.UserTunnel{ID: 10, UserID: 2, TunnelID: 1, Num: 99999, Flow: 0, ExpTime: 2727251700000, Status: 1}).Error; err != nil {
|
||||
t.Fatalf("seed user tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Create(&model.Forward{ID: 20, UserID: 2, UserName: "flow-user", Name: "forward-20", TunnelID: 1, RemoteAddr: "1.1.1.1:80", Strategy: "fifo", CreatedTime: nowMs, UpdatedTime: nowMs, Status: 1}).Error; err != nil {
|
||||
t.Fatalf("seed forward: %v", err)
|
||||
}
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{Name: "share", NodeID: 1, Token: "token", MaxBandwidth: 0, CurrentFlow: 0, PortRangeStart: 31000, PortRangeEnd: 31010, IsActive: 1, CreatedTime: nowMs, UpdatedTime: nowMs}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("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, 1, "svc-r1", "svc-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31001, "", 1, 1, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert peer share runtime: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
CREATE TRIGGER fail_user_quota_insert
|
||||
BEFORE INSERT ON user_quota
|
||||
BEGIN
|
||||
SELECT RAISE(FAIL, 'quota insert blocked for test');
|
||||
END;
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create quota failure trigger: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.applyFlowUploadBatch(1, flowUploadBatch{
|
||||
flowDeltas: []repo.FlowUploadCounterDelta{{ForwardID: 20, UserID: 2, UserTunnelID: 10, InFlow: 80, OutFlow: 120}},
|
||||
quotaUsage: map[int64]int64{2: 200},
|
||||
policyTargets: []flowPolicyTarget{{UserID: 2, UserTunnelID: 10}},
|
||||
peerShareForwardItems: map[string]flowItem{"20_2_10": {N: "20_2_10", U: 120, D: 80}},
|
||||
}, now)
|
||||
|
||||
if got := mustQueryInt(t, r, `SELECT status FROM forward WHERE id = 20`); got != 0 {
|
||||
t.Fatalf("expected flow-policy enforcement to pause forward after quota failure, got status=%d", got)
|
||||
}
|
||||
updatedShare, err := r.GetPeerShare(share.ID)
|
||||
if err != nil || updatedShare == nil {
|
||||
t.Fatalf("reload peer share: %v", err)
|
||||
}
|
||||
if updatedShare.CurrentFlow != 200 {
|
||||
t.Fatalf("expected peer-share flow accounting to continue after quota failure, got %d", updatedShare.CurrentFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFlowUploadBatchContinuesPeerShareSideEffectsWhenFlowBatchFails(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "flow-upload-batch-flow-fail.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now()
|
||||
nowMs := now.UnixMilli()
|
||||
if err := r.DB().Create(&model.User{ID: 2, User: "flow-user", Pwd: "pwd", RoleID: 1, ExpTime: 2727251700000, Flow: 99999, Num: 99999, CreatedTime: nowMs, Status: 1}).Error; err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
if err := r.DB().Create(&model.Tunnel{ID: 1, Name: "tunnel-1", TrafficRatio: 1, Type: 1, Protocol: "tls", Flow: 1, CreatedTime: nowMs, UpdatedTime: nowMs, Status: 1}).Error; err != nil {
|
||||
t.Fatalf("seed tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Create(&model.UserTunnel{ID: 10, UserID: 2, TunnelID: 1, Num: 99999, Flow: 0, ExpTime: 2727251700000, Status: 1}).Error; err != nil {
|
||||
t.Fatalf("seed user tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Create(&model.Forward{ID: 20, UserID: 2, UserName: "flow-user", Name: "forward-20", TunnelID: 1, RemoteAddr: "1.1.1.1:80", Strategy: "fifo", CreatedTime: nowMs, UpdatedTime: nowMs, Status: 1}).Error; err != nil {
|
||||
t.Fatalf("seed forward: %v", err)
|
||||
}
|
||||
if err := r.DB().Create(&model.Forward{ID: 21, UserID: 2, UserName: "flow-user", Name: "forward-21", TunnelID: 1, RemoteAddr: "1.1.1.1:81", Strategy: "fifo", CreatedTime: nowMs, UpdatedTime: nowMs, Status: 1}).Error; err != nil {
|
||||
t.Fatalf("seed second forward: %v", err)
|
||||
}
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{Name: "share", NodeID: 1, Token: "token", MaxBandwidth: 0, CurrentFlow: 0, PortRangeStart: 31000, PortRangeEnd: 31010, IsActive: 1, CreatedTime: nowMs, UpdatedTime: nowMs}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("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, 1, "svc-r1", "svc-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31001, "", 1, 1, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert peer share runtime: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
CREATE TRIGGER fail_forward_flow_update
|
||||
BEFORE UPDATE ON forward
|
||||
WHEN NEW.id = 21 AND (NEW.in_flow != OLD.in_flow OR NEW.out_flow != OLD.out_flow)
|
||||
BEGIN
|
||||
SELECT RAISE(FAIL, 'forward flow update blocked for test');
|
||||
END;
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create flow failure trigger: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.applyFlowUploadBatch(1, flowUploadBatch{
|
||||
flowDeltas: []repo.FlowUploadCounterDelta{
|
||||
{ForwardID: 20, UserID: 2, UserTunnelID: 10, InFlow: 80, OutFlow: 120},
|
||||
{ForwardID: 21, UserID: 2, UserTunnelID: 10, InFlow: 30, OutFlow: 40},
|
||||
},
|
||||
quotaUsage: map[int64]int64{2: 200},
|
||||
policyTargets: []flowPolicyTarget{{UserID: 2, UserTunnelID: 10}},
|
||||
peerShareForwardItems: map[string]flowItem{"20_2_10": {N: "20_2_10", U: 120, D: 80}},
|
||||
}, now)
|
||||
|
||||
if got := mustQueryInt(t, r, `SELECT status FROM forward WHERE id = 20`); got != 0 {
|
||||
t.Fatalf("expected flow-policy enforcement to pause forward after flow batch failure, got status=%d", got)
|
||||
}
|
||||
updatedShare, err := r.GetPeerShare(share.ID)
|
||||
if err != nil || updatedShare == nil {
|
||||
t.Fatalf("reload peer share: %v", err)
|
||||
}
|
||||
if updatedShare.CurrentFlow != 200 {
|
||||
t.Fatalf("expected peer-share flow accounting to continue after flow batch failure, got %d", updatedShare.CurrentFlow)
|
||||
}
|
||||
if got := mustQueryInt(t, r, `SELECT in_flow FROM forward WHERE id = 20`); got != 80 {
|
||||
t.Fatalf("expected flow fallback to persist forward 20 in_flow=80, got %d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, r, `SELECT in_flow FROM forward WHERE id = 21`); got != 0 {
|
||||
t.Fatalf("expected failed forward 21 delta to remain unapplied, got %d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, r, `SELECT in_flow FROM user WHERE id = 2`); got != 80 {
|
||||
t.Fatalf("expected flow fallback to preserve successful user totals, got %d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, r, `SELECT in_flow FROM user_tunnel WHERE id = 10`); got != 80 {
|
||||
t.Fatalf("expected flow fallback to preserve successful user_tunnel totals, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFlowUploadBatchFallsBackToPerUserQuotaUpdates(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "flow-upload-batch-quota-fallback.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now()
|
||||
nowMs := now.UnixMilli()
|
||||
dayKey := int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
|
||||
monthKey := int64(now.Year()*100 + int(now.Month()))
|
||||
if err := 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', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user 2: %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, 'u3', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user 3: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`INSERT INTO user_quota(user_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time) VALUES(2, 0, 0, 0, 0, ?, ?, 0, 0, '', ?, ?), (3, 0, 0, 0, 0, ?, ?, 0, 0, '', ?, ?)`, dayKey, monthKey, nowMs, nowMs, dayKey, monthKey, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user quotas: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
CREATE TRIGGER fail_user_3_quota_update
|
||||
BEFORE UPDATE ON user_quota
|
||||
WHEN NEW.user_id = 3 AND (NEW.daily_used_bytes != OLD.daily_used_bytes OR NEW.monthly_used_bytes != OLD.monthly_used_bytes)
|
||||
BEGIN
|
||||
SELECT RAISE(FAIL, 'quota update blocked for user 3');
|
||||
END;
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create quota fallback trigger: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.applyFlowUploadBatch(1, flowUploadBatch{quotaUsage: map[int64]int64{2: 200, 3: 300}}, now)
|
||||
|
||||
if got := mustQueryInt(t, r, `SELECT daily_used_bytes FROM user_quota WHERE user_id = 2`); got != 200 {
|
||||
t.Fatalf("expected quota fallback to persist user 2 usage, got %d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, r, `SELECT daily_used_bytes FROM user_quota WHERE user_id = 3`); got != 0 {
|
||||
t.Fatalf("expected failed user 3 quota delta to remain unapplied, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestBuildForwardServiceConfigsSendsProxyProtocolToForwardHandler(t *testing.T) {
|
||||
forward := &forwardRecord{
|
||||
ID: 1,
|
||||
UserID: 2,
|
||||
TunnelID: 3,
|
||||
RemoteAddr: "1.1.1.1:443",
|
||||
Strategy: "fifo",
|
||||
ProxyProtocol: 2,
|
||||
}
|
||||
tunnel := &tunnelRecord{Type: 1}
|
||||
node := &nodeRecord{
|
||||
InterfaceName: "eth0",
|
||||
TCPListenAddr: "0.0.0.0",
|
||||
UDPListenAddr: "0.0.0.0",
|
||||
}
|
||||
|
||||
services := buildForwardServiceConfigs("1_2_3", forward, tunnel, node, 4001, "", forwardRuntimeLimiters{})
|
||||
if len(services) != 2 {
|
||||
t.Fatalf("expected 2 services, got %d", len(services))
|
||||
}
|
||||
|
||||
for _, service := range services {
|
||||
serviceMetadata, ok := service["metadata"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected metadata map, got %T", service["metadata"])
|
||||
}
|
||||
if serviceMetadata["interface"] != "eth0" {
|
||||
t.Fatalf("expected interface metadata eth0, got %v", serviceMetadata["interface"])
|
||||
}
|
||||
if _, ok := serviceMetadata["proxyProtocol"]; ok {
|
||||
t.Fatalf("proxyProtocol should not be listener metadata: %v", serviceMetadata)
|
||||
}
|
||||
|
||||
handlerConfig, ok := service["handler"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected handler config map, got %T", service["handler"])
|
||||
}
|
||||
handlerMetadata, ok := handlerConfig["metadata"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected handler metadata map, got %T", handlerConfig["metadata"])
|
||||
}
|
||||
if handlerMetadata["proxyProtocol"] != 2 {
|
||||
t.Fatalf("expected handler proxyProtocol 2, got %v", handlerMetadata["proxyProtocol"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackForwardMutationRestoresProxyProtocol(t *testing.T) {
|
||||
r, err := repo.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Create(&model.Forward{
|
||||
UserID: 2,
|
||||
UserName: "rollback-user",
|
||||
Name: "rollback-forward",
|
||||
TunnelID: 3,
|
||||
RemoteAddr: "9.9.9.9:443",
|
||||
Strategy: "fifo",
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
Status: 1,
|
||||
IPMaxConn: 5,
|
||||
IPSpeedID: sql.NullInt64{Int64: 21, Valid: true},
|
||||
ProxyProtocol: 2,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create forward: %v", err)
|
||||
}
|
||||
|
||||
forwardID := mustLastInsertID(t, r, "rollback-forward")
|
||||
if err := r.DB().Model(&model.Forward{}).Where("id = ?", forwardID).Updates(map[string]interface{}{
|
||||
"name": "changed-forward",
|
||||
"ip_max_conn": 0,
|
||||
"ip_speed_id": nil,
|
||||
"proxy_protocol": 0,
|
||||
"updated_time": now + 1,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("mutate forward: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.rollbackForwardMutation(&forwardRecord{
|
||||
ID: forwardID,
|
||||
UserID: 2,
|
||||
UserName: "rollback-user",
|
||||
Name: "rollback-forward",
|
||||
TunnelID: 3,
|
||||
RemoteAddr: "9.9.9.9:443",
|
||||
Strategy: "fifo",
|
||||
Status: 1,
|
||||
IPMaxConn: 5,
|
||||
IPSpeedID: sql.NullInt64{Int64: 21, Valid: true},
|
||||
ProxyProtocol: 2,
|
||||
}, nil)
|
||||
|
||||
var record model.Forward
|
||||
if err := r.DB().Where("id = ?", forwardID).First(&record).Error; err != nil {
|
||||
t.Fatalf("query forward: %v", err)
|
||||
}
|
||||
if record.ProxyProtocol != 2 {
|
||||
t.Fatalf("expected proxyProtocol restored to 2, got %d", record.ProxyProtocol)
|
||||
}
|
||||
if record.IPMaxConn != 5 {
|
||||
t.Fatalf("expected ipMaxConn restored to 5, got %d", record.IPMaxConn)
|
||||
}
|
||||
if !record.IPSpeedID.Valid || record.IPSpeedID.Int64 != 21 {
|
||||
t.Fatalf("expected ipSpeedId restored to 21, got %+v", record.IPSpeedID)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
@@ -21,12 +22,14 @@ import (
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/license"
|
||||
"go-backend/internal/metrics"
|
||||
"go-backend/internal/monitoring"
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/repo"
|
||||
"go-backend/internal/ws"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
repo *repo.Repository
|
||||
jwtSecret string
|
||||
@@ -42,13 +45,18 @@ type Handler struct {
|
||||
jobsStarted bool
|
||||
jobsWG sync.WaitGroup
|
||||
|
||||
upgradeMu sync.Mutex
|
||||
pendingUpgradeRedeploy map[int64]struct{}
|
||||
upgradeMu sync.Mutex
|
||||
pendingUpgradeRedeploy map[int64]struct{}
|
||||
nodeOnlineRedeployAt map[int64]time.Time
|
||||
nodeOnlineRedeployQueued map[int64]struct{}
|
||||
nodeOnlineRedeploying map[int64]struct{}
|
||||
|
||||
qualityProber *tunnelQualityProber
|
||||
bestExit *bestExitManager
|
||||
}
|
||||
|
||||
const monitorTunnelQualityEnabledConfigKey = "monitor_tunnel_quality_enabled"
|
||||
const allowLocalRemoteAddrConfigKey = "allow_local_remote_addr"
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
@@ -94,13 +102,17 @@ const (
|
||||
|
||||
func New(repo *repo.Repository, jwtSecret string) *Handler {
|
||||
h := &Handler{
|
||||
repo: repo,
|
||||
jwtSecret: jwtSecret,
|
||||
wsServer: ws.NewServer(repo, jwtSecret),
|
||||
metrics: metrics.NewIngestionService(repo),
|
||||
healthCheck: nil,
|
||||
captchaTokens: make(map[string]int64),
|
||||
pendingUpgradeRedeploy: make(map[int64]struct{}),
|
||||
repo: repo,
|
||||
jwtSecret: jwtSecret,
|
||||
wsServer: ws.NewServer(repo, jwtSecret),
|
||||
metrics: metrics.NewIngestionService(repo),
|
||||
healthCheck: nil,
|
||||
captchaTokens: make(map[string]int64),
|
||||
pendingUpgradeRedeploy: make(map[int64]struct{}),
|
||||
nodeOnlineRedeployAt: make(map[int64]time.Time),
|
||||
nodeOnlineRedeployQueued: make(map[int64]struct{}),
|
||||
nodeOnlineRedeploying: make(map[int64]struct{}),
|
||||
bestExit: newBestExitManager(),
|
||||
}
|
||||
h.healthCheck = health.NewChecker(repo, h.wsServer)
|
||||
h.qualityProber = newTunnelQualityProber(h)
|
||||
@@ -143,6 +155,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/config/list", h.getConfigs)
|
||||
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
|
||||
mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig)
|
||||
mux.HandleFunc("/api/v1/system/storage", h.storageSummary)
|
||||
mux.HandleFunc("/api/v1/license/activate", h.licenseActivate)
|
||||
mux.HandleFunc("/api/v1/backup/export", h.backupExport)
|
||||
mux.HandleFunc("/api/v1/backup/import", h.backupImport)
|
||||
@@ -356,6 +369,12 @@ func (h *Handler) getConfigByName(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||
return
|
||||
}
|
||||
configName := strings.ToLower(strings.TrimSpace(req.Name))
|
||||
switch configName {
|
||||
case "license_key", "cloudflare_secret_key", "jwt_secret":
|
||||
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.repo.GetConfigByName(req.Name)
|
||||
if err != nil {
|
||||
@@ -381,6 +400,12 @@ func (h *Handler) getConfigs(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
ctxClaims := r.Context().Value(middleware.ClaimsContextKey)
|
||||
if claims, ok := ctxClaims.(auth.Claims); !ok || claims.RoleID != 0 {
|
||||
delete(cfgMap, "license_key")
|
||||
delete(cfgMap, "cloudflare_secret_key")
|
||||
delete(cfgMap, "jwt_secret")
|
||||
}
|
||||
response.WriteJSON(w, response.OK(cfgMap))
|
||||
}
|
||||
|
||||
@@ -450,6 +475,7 @@ func (h *Handler) tunnelList(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
h.attachBestExitStates(items)
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
}
|
||||
|
||||
@@ -783,11 +809,16 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if err == nil && strings.TrimSpace(raw) != "" {
|
||||
var items []flowItem
|
||||
if json.Unmarshal([]byte(raw), &items) == nil {
|
||||
nowMs := time.Now().UnixMilli()
|
||||
h.recordTunnelMetricsFromFlowItems(node.ID, items, nowMs)
|
||||
for _, item := range items {
|
||||
h.processFlowItem(node.ID, item)
|
||||
now := time.Now()
|
||||
forwardIDs := collectFlowUploadForwardIDs(items)
|
||||
metas, metaErr := h.repo.GetFlowUploadForwardMetas(forwardIDs)
|
||||
if metaErr != nil {
|
||||
log.Printf("flow upload metadata lookup failed node_id=%d err=%v", node.ID, metaErr)
|
||||
metas = map[int64]repo.FlowUploadForwardMeta{}
|
||||
}
|
||||
batch := h.buildFlowUploadBatch(items, metas)
|
||||
h.recordTunnelMetricsFromForwardBatch(node.ID, batch.forwardTraffic, metas, now.UnixMilli())
|
||||
h.applyFlowUploadBatch(node.ID, batch, now)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -852,7 +883,7 @@ func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("设备绑定失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Validation might still fail with scope if we don't query via machine id, but since activate machine succeeded
|
||||
// we can consider the license valid for our simple usecase
|
||||
} else {
|
||||
@@ -1010,6 +1041,8 @@ func normalizeAndValidateConfigValue(key, value string) (string, error) {
|
||||
default:
|
||||
return "", fmt.Errorf("隧道质量检测开关配置值无效")
|
||||
}
|
||||
case monitoring.ConfigMonitorRetentionDays:
|
||||
return monitoring.NormalizeMonitoringRetentionDays(value)
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
@@ -1028,6 +1061,19 @@ func (h *Handler) isTunnelQualityMonitoringEnabled() bool {
|
||||
return strings.TrimSpace(strings.ToLower(cfg.Value)) != "false"
|
||||
}
|
||||
|
||||
func (h *Handler) allowLocalRemoteAddr() bool {
|
||||
if h == nil || h.repo == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
cfg, err := h.repo.GetConfigByName(allowLocalRemoteAddrConfigKey)
|
||||
if err != nil || cfg == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.TrimSpace(strings.ToLower(cfg.Value)) == "true"
|
||||
}
|
||||
|
||||
func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
|
||||
@@ -121,7 +121,7 @@ func (h *Handler) runHealthChecks(ctx context.Context) {
|
||||
|
||||
func (h *Handler) runTunnelQualityProber(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
if h == nil || h.qualityProber == nil || !h.isTunnelQualityMonitoringEnabled() {
|
||||
if h == nil || h.qualityProber == nil {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -68,8 +69,9 @@ func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
roleID := 1
|
||||
now := time.Now().UnixMilli()
|
||||
maxConn := asInt(req["maxConn"], 0)
|
||||
|
||||
userID, err := h.repo.CreateUser(username, security.MD5(pwd), roleID, expTime, flow, flowResetTime, num, status, now)
|
||||
userID, err := h.repo.CreateUser(username, security.MD5(pwd), roleID, expTime, flow, flowResetTime, num, status, maxConn, now)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
@@ -137,6 +139,15 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("请不要作死"))
|
||||
return
|
||||
}
|
||||
oldUser, err := h.repo.GetUserByID(id)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if oldUser == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("用户不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
dup, err := h.repo.UserExistsExcluding(username, id)
|
||||
if err != nil {
|
||||
@@ -156,15 +167,16 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
_, hasDailyQuota := req["dailyQuotaGB"]
|
||||
_, hasMonthlyQuota := req["monthlyQuotaGB"]
|
||||
now := time.Now().UnixMilli()
|
||||
maxConn := asInt(req["maxConn"], 0)
|
||||
|
||||
pwd := asString(req["pwd"])
|
||||
if strings.TrimSpace(pwd) == "" {
|
||||
if err := h.repo.UpdateUserWithoutPassword(id, username, flow, num, expTime, flowResetTime, status, now); err != nil {
|
||||
if err := h.repo.UpdateUserWithoutPassword(id, username, flow, num, expTime, flowResetTime, status, maxConn, now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := h.repo.UpdateUserWithPassword(id, username, security.MD5(pwd), flow, num, expTime, flowResetTime, status, now); err != nil {
|
||||
if err := h.repo.UpdateUserWithPassword(id, username, security.MD5(pwd), flow, num, expTime, flowResetTime, status, maxConn, now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -208,6 +220,17 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if oldUser.MaxConn != maxConn {
|
||||
warnings, syncErr := h.syncUserMaxConnForwards(id)
|
||||
if syncErr != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(fmt.Sprintf("最大连接数下发失败: %v", syncErr)))
|
||||
return
|
||||
}
|
||||
if len(warnings) > 0 {
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{"warnings": warnings}))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
@@ -301,6 +324,10 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("节点名称和地址不能为空"))
|
||||
return
|
||||
}
|
||||
if err := IsValidNodeAddress(serverIP); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
inx := h.repo.NextIndex("node")
|
||||
@@ -365,6 +392,13 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
newHTTP := asInt(req["http"], currentHTTP)
|
||||
newTLS := asInt(req["tls"], currentTLS)
|
||||
newSocks := asInt(req["socks"], currentSocks)
|
||||
serverIP := asString(req["serverIp"])
|
||||
if serverIP != "" {
|
||||
if err := IsValidNodeAddress(serverIP); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
if currentStatus == 1 && (newHTTP != currentHTTP || newTLS != currentTLS || newSocks != currentSocks) {
|
||||
if err := h.applyNodeProtocolChange(id, newHTTP, newTLS, newSocks); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
@@ -375,7 +409,7 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
now := time.Now().UnixMilli()
|
||||
if err := h.repo.UpdateNode(id,
|
||||
asString(req["name"]),
|
||||
asString(req["serverIp"]),
|
||||
serverIP,
|
||||
nullableText(asString(req["serverIpV4"])),
|
||||
nullableText(asString(req["serverIpV6"])),
|
||||
defaultString(asString(req["port"]), "1000-65535"),
|
||||
@@ -646,11 +680,15 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if trimmed := strings.TrimSpace(inIP); trimmed != "" {
|
||||
tunnelInIP = sql.NullString{String: trimmed, Valid: true}
|
||||
}
|
||||
tunnelProtocol := "tls"
|
||||
if len(runtimeState.InNodes) > 0 && strings.TrimSpace(runtimeState.InNodes[0].Protocol) != "" {
|
||||
tunnelProtocol = strings.TrimSpace(runtimeState.InNodes[0].Protocol)
|
||||
}
|
||||
tunnel := model.Tunnel{
|
||||
Name: name,
|
||||
TrafficRatio: trafficRatio,
|
||||
Type: typeVal,
|
||||
Protocol: "tls",
|
||||
Protocol: tunnelProtocol,
|
||||
Flow: flow,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
@@ -691,7 +729,7 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if typeVal == 2 {
|
||||
createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)
|
||||
if applyErr != nil {
|
||||
h.rollbackTunnelRuntime(createdChains, createdServices, tunnelID)
|
||||
h.rollbackTunnelRuntime(createdChains, createdServices, tunnelID, tunnelProtocol)
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
_ = h.deleteTunnelByID(tunnelID)
|
||||
response.WriteJSON(w, response.ErrDefault(applyErr.Error()))
|
||||
@@ -711,21 +749,85 @@ func (h *Handler) cleanupTunnelRuntime(tunnelID int64) {
|
||||
return
|
||||
}
|
||||
|
||||
serviceName := fmt.Sprintf("%d_tls", tunnelID)
|
||||
chainName := fmt.Sprintf("chains_%d", tunnelID)
|
||||
serviceNames := tunnelRuntimeServiceNames(tunnelID)
|
||||
|
||||
for _, row := range chainRows {
|
||||
if row.ChainType == 1 {
|
||||
_, _ = h.sendNodeCommand(row.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
|
||||
} else if row.ChainType == 2 {
|
||||
_, _ = h.sendNodeCommand(row.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
|
||||
_, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": []string{serviceName}}, false, true)
|
||||
_, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": serviceNames}, false, true)
|
||||
} else if row.ChainType == 3 {
|
||||
_, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": []string{serviceName}}, false, true)
|
||||
_, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": serviceNames}, false, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tunnelRuntimeServiceNames(tunnelID int64) []string {
|
||||
return []string{
|
||||
fmt.Sprintf("tunnel_%d", tunnelID),
|
||||
fmt.Sprintf("%d_tls", tunnelID),
|
||||
fmt.Sprintf("%d_kcp", tunnelID),
|
||||
fmt.Sprintf("%d_wss", tunnelID),
|
||||
fmt.Sprintf("%d_mtls", tunnelID),
|
||||
fmt.Sprintf("%d_mwss", tunnelID),
|
||||
fmt.Sprintf("%d_tcp", tunnelID),
|
||||
fmt.Sprintf("%d_mtcp", tunnelID),
|
||||
}
|
||||
}
|
||||
|
||||
func tunnelRuntimeNeedsChain(row chainNodeRecord) bool {
|
||||
return row.ChainType == 1 || row.ChainType == 2
|
||||
}
|
||||
|
||||
func tunnelRuntimeNeedsService(row chainNodeRecord) bool {
|
||||
return row.ChainType == 2 || row.ChainType == 3
|
||||
}
|
||||
|
||||
func removedTunnelRuntimeNodeIDs(oldRows, newRows []chainNodeRecord, needsRuntime func(chainNodeRecord) bool) []int64 {
|
||||
if len(oldRows) == 0 || needsRuntime == nil {
|
||||
return nil
|
||||
}
|
||||
newRuntimeNodes := make(map[int64]struct{}, len(newRows))
|
||||
for _, row := range newRows {
|
||||
if row.NodeID <= 0 || !needsRuntime(row) {
|
||||
continue
|
||||
}
|
||||
newRuntimeNodes[row.NodeID] = struct{}{}
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(oldRows))
|
||||
removed := make([]int64, 0)
|
||||
for _, row := range oldRows {
|
||||
if row.NodeID <= 0 || !needsRuntime(row) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[row.NodeID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[row.NodeID] = struct{}{}
|
||||
if _, stillNeeded := newRuntimeNodes[row.NodeID]; stillNeeded {
|
||||
continue
|
||||
}
|
||||
removed = append(removed, row.NodeID)
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
func (h *Handler) cleanupObsoleteTunnelRuntime(tunnelID int64, oldRows, newRows []chainNodeRecord) {
|
||||
if h == nil || tunnelID <= 0 || len(oldRows) == 0 {
|
||||
return
|
||||
}
|
||||
chainName := fmt.Sprintf("chains_%d", tunnelID)
|
||||
for _, nodeID := range removedTunnelRuntimeNodeIDs(oldRows, newRows, tunnelRuntimeNeedsChain) {
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
|
||||
}
|
||||
serviceNames := tunnelRuntimeServiceNames(tunnelID)
|
||||
for _, nodeID := range removedTunnelRuntimeNodeIDs(oldRows, newRows, tunnelRuntimeNeedsService) {
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": serviceNames}, false, true)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelGet(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
@@ -740,6 +842,7 @@ func (h *Handler) tunnelGet(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
h.attachBestExitStates(items)
|
||||
for _, it := range items {
|
||||
if asInt64(it["id"], 0) == id {
|
||||
response.WriteJSON(w, response.OK(it))
|
||||
@@ -769,12 +872,15 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
oldEntryNodeIDs, _ := h.tunnelEntryNodeIDs(id)
|
||||
|
||||
h.cleanupTunnelRuntime(id)
|
||||
typeVal := asInt(req["type"], 1)
|
||||
oldTunnel, _ := h.getTunnelRecord(id)
|
||||
oldChainRows, _ := h.listChainNodesForTunnel(id)
|
||||
if oldTunnel != nil && oldTunnel.Type == 2 && typeVal != 2 {
|
||||
h.cleanupTunnelRuntime(id)
|
||||
}
|
||||
h.cleanupFederationRuntime(id)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
typeVal := asInt(req["type"], 1)
|
||||
ipPreference := asString(req["ipPreference"])
|
||||
localDomain := h.federationLocalDomain()
|
||||
|
||||
@@ -805,6 +911,12 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer func() { tx.Rollback() }()
|
||||
|
||||
updateProtocol := "tls"
|
||||
if len(runtimeState.OutNodes) > 0 && strings.TrimSpace(runtimeState.OutNodes[0].Protocol) != "" {
|
||||
updateProtocol = strings.TrimSpace(runtimeState.OutNodes[0].Protocol)
|
||||
} else if len(runtimeState.InNodes) > 0 && strings.TrimSpace(runtimeState.InNodes[0].Protocol) != "" {
|
||||
updateProtocol = strings.TrimSpace(runtimeState.InNodes[0].Protocol)
|
||||
}
|
||||
if err := h.repo.UpdateTunnelTx(
|
||||
tx,
|
||||
id,
|
||||
@@ -815,6 +927,7 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
asInt(req["status"], 1),
|
||||
inIp,
|
||||
ipPreference,
|
||||
updateProtocol,
|
||||
now,
|
||||
); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
@@ -860,9 +973,19 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if typeVal == 2 {
|
||||
createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)
|
||||
applyRuntime := h.applyTunnelRuntime
|
||||
if oldTunnel != nil && oldTunnel.Type == 2 {
|
||||
applyRuntime = h.applyTunnelRuntimeUpsert
|
||||
}
|
||||
createdChains, createdServices, applyErr := applyRuntime(runtimeState)
|
||||
if applyErr != nil {
|
||||
h.rollbackTunnelRuntime(createdChains, createdServices, id)
|
||||
updateProtocol := "tls"
|
||||
if len(runtimeState.InNodes) > 0 && strings.TrimSpace(runtimeState.InNodes[0].Protocol) != "" {
|
||||
updateProtocol = strings.TrimSpace(runtimeState.InNodes[0].Protocol)
|
||||
}
|
||||
if oldTunnel == nil || oldTunnel.Type != 2 {
|
||||
h.rollbackTunnelRuntime(createdChains, createdServices, id, updateProtocol)
|
||||
}
|
||||
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||
_ = h.repo.DeleteFederationTunnelBindingsByTunnel(id)
|
||||
if len(federationReleaseRefs) == 0 && shouldDeferTunnelRuntimeApplyError(applyErr) {
|
||||
@@ -872,9 +995,20 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault(applyErr.Error()))
|
||||
return
|
||||
}
|
||||
newChainRows, _ := h.listChainNodesForTunnel(id)
|
||||
h.cleanupObsoleteTunnelRuntime(id, oldChainRows, newChainRows)
|
||||
}
|
||||
|
||||
if forwards, fwdErr := h.listForwardsByTunnel(id); fwdErr == nil {
|
||||
oldType := 0
|
||||
if oldTunnel != nil {
|
||||
oldType = oldTunnel.Type
|
||||
}
|
||||
if tunnelForwardRuntimeNeedsSync(oldType, typeVal, oldEntryNodeIDs, newEntryNodeIDs) {
|
||||
forwards, fwdErr := h.listForwardsByTunnel(id)
|
||||
if fwdErr != nil {
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
return
|
||||
}
|
||||
for i := range forwards {
|
||||
_ = h.syncForwardServices(&forwards[i], "UpdateService", true)
|
||||
}
|
||||
@@ -883,6 +1017,13 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func tunnelForwardRuntimeNeedsSync(oldType, newType int, oldEntryNodeIDs, newEntryNodeIDs []int64) bool {
|
||||
if oldType != newType {
|
||||
return true
|
||||
}
|
||||
return !sameInt64Set(oldEntryNodeIDs, newEntryNodeIDs)
|
||||
}
|
||||
|
||||
func sameInt64Set(a, b []int64) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
@@ -1685,6 +1826,12 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("转发名称和目标地址不能为空"))
|
||||
return
|
||||
}
|
||||
if roleID != 0 && !h.allowLocalRemoteAddr() {
|
||||
if err := IsSafeRemoteAddr(remoteAddr); err != nil {
|
||||
response.WriteJSON(w, response.Err(403, err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
if roleID != 0 {
|
||||
if speedIDVal, ok := req["speedId"]; ok && speedIDVal != nil {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法设置限速规则"))
|
||||
@@ -1697,6 +1844,18 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if roleID != 0 {
|
||||
if ipSpeedIDVal, ok := req["ipSpeedId"]; ok && ipSpeedIDVal != nil {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法设置每 IP 限速规则"))
|
||||
return
|
||||
}
|
||||
}
|
||||
ipSpeedID := asAnyToInt64Ptr(req["ipSpeedId"])
|
||||
ipSpeedID, err = h.normalizeSpeedLimitReference(ipSpeedID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
port := asInt(req["inPort"], 0)
|
||||
if port <= 0 {
|
||||
port = h.pickTunnelPort(tunnelID)
|
||||
@@ -1734,7 +1893,14 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if userName == "" {
|
||||
userName = "user"
|
||||
}
|
||||
forwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, inx, entryNodes, port, inIp, nullableInt(speedID))
|
||||
maxConn := asInt(req["maxConn"], 0)
|
||||
ipMaxConn := asInt(req["ipMaxConn"], 0)
|
||||
if ipMaxConn < 0 {
|
||||
ipMaxConn = 0
|
||||
}
|
||||
proxyProtocol := asInt(req["proxyProtocol"], 0)
|
||||
|
||||
forwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, inx, entryNodes, port, inIp, nullableInt(speedID), maxConn, ipMaxConn, nullableInt(ipSpeedID), proxyProtocol)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
@@ -1805,6 +1971,12 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if remoteAddr == "" {
|
||||
remoteAddr = forward.RemoteAddr
|
||||
}
|
||||
if actorRole != 0 && !h.allowLocalRemoteAddr() {
|
||||
if err := IsSafeRemoteAddr(remoteAddr); err != nil {
|
||||
response.WriteJSON(w, response.Err(403, err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
strategy := strings.TrimSpace(asString(req["strategy"]))
|
||||
if strategy == "" {
|
||||
strategy = forward.Strategy
|
||||
@@ -1827,6 +1999,27 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
} else if _, ok := req["speedId"]; ok {
|
||||
newSpeedID = sql.NullInt64{Valid: false}
|
||||
}
|
||||
rawIPSpeedID, hasIPSpeedID := req["ipSpeedId"]
|
||||
requestedIPSpeedID := asAnyToInt64Ptr(rawIPSpeedID)
|
||||
newIPSpeedID := forward.IPSpeedID
|
||||
if actorRole != 0 {
|
||||
if hasIPSpeedID && !sameSpeedLimitSelection(forward.IPSpeedID, requestedIPSpeedID) {
|
||||
response.WriteJSON(w, response.Err(-1, "普通用户无法修改每 IP 限速规则"))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
ipSpeedID := requestedIPSpeedID
|
||||
ipSpeedID, err = h.normalizeSpeedLimitReference(ipSpeedID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if ipSpeedID != nil {
|
||||
newIPSpeedID = sql.NullInt64{Int64: *ipSpeedID, Valid: true}
|
||||
} else if hasIPSpeedID {
|
||||
newIPSpeedID = sql.NullInt64{Valid: false}
|
||||
}
|
||||
}
|
||||
|
||||
port := asInt(req["inPort"], 0)
|
||||
if port <= 0 {
|
||||
@@ -1879,7 +2072,14 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
if err := h.repo.UpdateForward(id, name, tunnelID, remoteAddr, strategy, now, newSpeedID); err != nil {
|
||||
maxConn := asInt(req["maxConn"], forward.MaxConn)
|
||||
ipMaxConn := asInt(req["ipMaxConn"], forward.IPMaxConn)
|
||||
if ipMaxConn < 0 {
|
||||
ipMaxConn = 0
|
||||
}
|
||||
proxyProtocol := asInt(req["proxyProtocol"], forward.ProxyProtocol)
|
||||
|
||||
if err := h.repo.UpdateForward(id, name, tunnelID, remoteAddr, strategy, now, newSpeedID, maxConn, ipMaxConn, newIPSpeedID, proxyProtocol); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -3116,6 +3316,8 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
|
||||
nextTargets := state.OutNodes
|
||||
if hopIdx+1 < len(state.ChainHops) {
|
||||
nextTargets = state.ChainHops[hopIdx+1]
|
||||
} else {
|
||||
nextTargets = h.orderBestExitTargets(state.TunnelID, chainNode.NodeID, nextTargets)
|
||||
}
|
||||
applyTargets := make([]client.RuntimeTarget, 0, len(nextTargets))
|
||||
for _, target := range nextTargets {
|
||||
@@ -3145,7 +3347,7 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s
|
||||
ResourceKey: resourceKey,
|
||||
Role: "middle",
|
||||
Protocol: defaultString(chainNode.Protocol, "tls"),
|
||||
Strategy: defaultString(chainNode.Strategy, "round"),
|
||||
Strategy: runtimeStrategyForTargets(chainNode, nextTargets),
|
||||
Targets: applyTargets,
|
||||
}
|
||||
applyRes, err := fc.ApplyRole(remoteURL, remoteToken, localDomain, applyReq)
|
||||
@@ -3238,6 +3440,14 @@ func (h *Handler) cleanupFederationRuntime(tunnelID int64) {
|
||||
}
|
||||
|
||||
func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64, error) {
|
||||
return h.applyTunnelRuntimeWithMode(state, false)
|
||||
}
|
||||
|
||||
func (h *Handler) applyTunnelRuntimeUpsert(state *tunnelCreateState) ([]int64, []int64, error) {
|
||||
return h.applyTunnelRuntimeWithMode(state, true)
|
||||
}
|
||||
|
||||
func (h *Handler) applyTunnelRuntimeWithMode(state *tunnelCreateState, upsert bool) ([]int64, []int64, error) {
|
||||
if h == nil || state == nil {
|
||||
return nil, nil, errors.New("invalid tunnel runtime state")
|
||||
}
|
||||
@@ -3251,12 +3461,14 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
targets := state.OutNodes
|
||||
if len(state.ChainHops) > 0 {
|
||||
targets = state.ChainHops[0]
|
||||
} else {
|
||||
targets = h.orderBestExitTargets(state.TunnelID, inNode.NodeID, targets)
|
||||
}
|
||||
chainData, err := buildTunnelChainConfig(state.TunnelID, inNode.NodeID, targets, state.Nodes, state.IPPreference)
|
||||
if err != nil {
|
||||
return createdChains, createdServices, err
|
||||
}
|
||||
if _, err := h.sendNodeCommand(inNode.NodeID, "AddChains", chainData, true, false); err != nil {
|
||||
if err := h.applyTunnelChainOnNode(inNode.NodeID, chainData, upsert); err != nil {
|
||||
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||
continue
|
||||
}
|
||||
@@ -3266,11 +3478,13 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
}
|
||||
|
||||
for i, hop := range state.ChainHops {
|
||||
nextTargets := state.OutNodes
|
||||
if i+1 < len(state.ChainHops) {
|
||||
nextTargets = state.ChainHops[i+1]
|
||||
}
|
||||
for _, chainNode := range hop {
|
||||
nextTargets := state.OutNodes
|
||||
if i+1 < len(state.ChainHops) {
|
||||
nextTargets = state.ChainHops[i+1]
|
||||
} else {
|
||||
nextTargets = h.orderBestExitTargets(state.TunnelID, chainNode.NodeID, nextTargets)
|
||||
}
|
||||
node := state.Nodes[chainNode.NodeID]
|
||||
if node != nil && (node.IsRemote == 1 || node.Status != 1) {
|
||||
continue
|
||||
@@ -3279,7 +3493,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
if err != nil {
|
||||
return createdChains, createdServices, err
|
||||
}
|
||||
if _, err := h.sendNodeCommand(chainNode.NodeID, "AddChains", chainData, true, false); err != nil {
|
||||
if err := h.applyTunnelChainOnNode(chainNode.NodeID, chainData, upsert); err != nil {
|
||||
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||
continue
|
||||
}
|
||||
@@ -3288,7 +3502,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
createdChains = append(createdChains, chainNode.NodeID)
|
||||
|
||||
serviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID], len(nextTargets))
|
||||
if err := h.addTunnelServiceOnNode(chainNode.NodeID, state.TunnelID, serviceData); err != nil {
|
||||
if err := h.addTunnelServiceOnNodeWithMode(chainNode.NodeID, state.TunnelID, serviceData, upsert); err != nil {
|
||||
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||
continue
|
||||
}
|
||||
@@ -3304,7 +3518,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
continue
|
||||
}
|
||||
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID], 1)
|
||||
if err := h.addTunnelServiceOnNode(outNode.NodeID, state.TunnelID, serviceData); err != nil {
|
||||
if err := h.addTunnelServiceOnNodeWithMode(outNode.NodeID, state.TunnelID, serviceData, upsert); err != nil {
|
||||
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||
continue
|
||||
}
|
||||
@@ -3316,6 +3530,151 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
return createdChains, createdServices, nil
|
||||
}
|
||||
|
||||
func (h *Handler) applyTunnelChainOnNode(nodeID int64, chainData map[string]interface{}, upsert bool) error {
|
||||
if upsert {
|
||||
return h.upsertTunnelChainOnNode(nodeID, chainData)
|
||||
}
|
||||
_, err := h.sendNodeCommand(nodeID, "AddChains", chainData, true, false)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *Handler) applyBestExitChainOrder(tunnelID, ownerNodeID int64, outNodes []chainNodeRecord, scores []bestExitCandidateScore, ipPreference string) error {
|
||||
if h == nil {
|
||||
log.Printf("best_exit: invalid chain update context tunnel=%d owner=%d", tunnelID, ownerNodeID)
|
||||
return errors.New("invalid best exit chain update context")
|
||||
}
|
||||
if tunnelID <= 0 || ownerNodeID <= 0 || len(outNodes) == 0 {
|
||||
log.Printf("best_exit: invalid chain update input tunnel=%d owner=%d exits=%d", tunnelID, ownerNodeID, len(outNodes))
|
||||
return fmt.Errorf("invalid best exit chain update input tunnel=%d owner=%d exits=%d", tunnelID, ownerNodeID, len(outNodes))
|
||||
}
|
||||
targets := chainRecordsToRuntimeTargets(outNodes)
|
||||
orderedIDs := make([]int64, 0, len(scores))
|
||||
for _, score := range scores {
|
||||
if score.ExitNodeID > 0 {
|
||||
orderedIDs = append(orderedIDs, score.ExitNodeID)
|
||||
}
|
||||
}
|
||||
targets = orderRuntimeTargetsByNodeID(targets, orderedIDs)
|
||||
nodes := make(map[int64]*nodeRecord, len(targets)+1)
|
||||
if owner, err := h.getNodeRecord(ownerNodeID); err == nil && owner != nil {
|
||||
nodes[ownerNodeID] = owner
|
||||
}
|
||||
for _, target := range targets {
|
||||
if node, err := h.getNodeRecord(target.NodeID); err == nil && node != nil {
|
||||
nodes[target.NodeID] = node
|
||||
}
|
||||
}
|
||||
owner := nodes[ownerNodeID]
|
||||
if owner != nil && owner.IsRemote == 1 {
|
||||
if err := h.applyRemoteBestExitChainOrder(tunnelID, ownerNodeID, owner, targets, nodes, ipPreference); err != nil {
|
||||
log.Printf("best_exit: update remote federation chain failed tunnel=%d owner=%d err=%v", tunnelID, ownerNodeID, err)
|
||||
return err
|
||||
}
|
||||
log.Printf("best_exit: updated remote federation chain tunnel=%d owner=%d best_exit=%d", tunnelID, ownerNodeID, targets[0].NodeID)
|
||||
return nil
|
||||
}
|
||||
chainData, err := buildTunnelChainConfig(tunnelID, ownerNodeID, targets, nodes, ipPreference)
|
||||
if err != nil {
|
||||
log.Printf("best_exit: build chain failed tunnel=%d owner=%d err=%v", tunnelID, ownerNodeID, err)
|
||||
return err
|
||||
}
|
||||
if err := h.applyTunnelChainOnNode(ownerNodeID, chainData, true); err != nil {
|
||||
log.Printf("best_exit: update chain failed tunnel=%d owner=%d err=%v", tunnelID, ownerNodeID, err)
|
||||
return err
|
||||
}
|
||||
log.Printf("best_exit: updated chain tunnel=%d owner=%d best_exit=%d", tunnelID, ownerNodeID, targets[0].NodeID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) applyRemoteBestExitChainOrder(tunnelID, ownerNodeID int64, owner *nodeRecord, targets []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) error {
|
||||
if h == nil || h.repo == nil || owner == nil {
|
||||
return errors.New("invalid remote best exit update context")
|
||||
}
|
||||
bindings, err := h.repo.ListActiveFederationTunnelBindingsByTunnel(tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var binding *repo.FederationTunnelBinding
|
||||
for i := range bindings {
|
||||
if bindings[i].NodeID == ownerNodeID && bindings[i].ChainType == 2 && bindings[i].Status == 1 {
|
||||
binding = &bindings[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if binding == nil {
|
||||
return fmt.Errorf("active federation middle binding not found for tunnel=%d owner=%d", tunnelID, ownerNodeID)
|
||||
}
|
||||
|
||||
remoteURL := strings.TrimSpace(owner.RemoteURL)
|
||||
if remoteURL == "" {
|
||||
remoteURL = strings.TrimSpace(binding.RemoteURL)
|
||||
}
|
||||
remoteToken := strings.TrimSpace(owner.RemoteToken)
|
||||
if remoteURL == "" || remoteToken == "" {
|
||||
return errors.New("远程节点缺少共享配置")
|
||||
}
|
||||
|
||||
applyTargets := make([]client.RuntimeTarget, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
targetNode := nodes[target.NodeID]
|
||||
if targetNode == nil {
|
||||
return errors.New("节点不存在")
|
||||
}
|
||||
host, hostErr := selectTunnelDialHost(owner, targetNode, ipPreference, target.ConnectIP)
|
||||
if hostErr != nil {
|
||||
return hostErr
|
||||
}
|
||||
if target.Port <= 0 {
|
||||
return errors.New("节点端口不能为空")
|
||||
}
|
||||
applyTargets = append(applyTargets, client.RuntimeTarget{
|
||||
Host: host,
|
||||
Port: target.Port,
|
||||
Protocol: defaultString(target.Protocol, "tls"),
|
||||
})
|
||||
}
|
||||
|
||||
ownerRuntimeNode := tunnelRuntimeNode{NodeID: ownerNodeID, Protocol: "tls", Strategy: "round", ChainType: 2}
|
||||
if chainRows, listErr := h.repo.ListChainNodesForTunnel(tunnelID); listErr == nil {
|
||||
for _, row := range chainRows {
|
||||
if row.NodeID == ownerNodeID && row.ChainType == 2 {
|
||||
ownerRuntimeNode = tunnelRuntimeNode{
|
||||
NodeID: row.NodeID,
|
||||
Protocol: row.Protocol,
|
||||
Strategy: row.Strategy,
|
||||
Inx: int(row.Inx),
|
||||
ChainType: row.ChainType,
|
||||
Port: row.Port,
|
||||
ConnectIP: row.ConnectIP,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err = client.NewFederationClient().ApplyRole(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeApplyRoleRequest{
|
||||
ResourceKey: strings.TrimSpace(binding.ResourceKey),
|
||||
Role: "middle",
|
||||
Protocol: defaultString(ownerRuntimeNode.Protocol, "tls"),
|
||||
Strategy: runtimeStrategyForTargets(ownerRuntimeNode, targets),
|
||||
Targets: applyTargets,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *Handler) upsertTunnelChainOnNode(nodeID int64, chainData map[string]interface{}) error {
|
||||
if h == nil {
|
||||
return errors.New("invalid tunnel chain context")
|
||||
}
|
||||
chainName := asString(chainData["name"])
|
||||
if strings.TrimSpace(chainName) == "" {
|
||||
return errors.New("转发链名称不能为空")
|
||||
}
|
||||
payload := map[string]interface{}{"chain": chainName, "data": chainData}
|
||||
_, err := h.sendNodeCommand(nodeID, "UpdateChains", payload, true, false)
|
||||
return err
|
||||
}
|
||||
|
||||
func retryTunnelServiceAddWithCleanup(add func() error, cleanup func() error, wait time.Duration) error {
|
||||
if add == nil {
|
||||
return errors.New("invalid tunnel service add callback")
|
||||
@@ -3337,13 +3696,26 @@ func retryTunnelServiceAddWithCleanup(add func() error, cleanup func() error, wa
|
||||
}
|
||||
|
||||
func (h *Handler) addTunnelServiceOnNode(nodeID, tunnelID int64, serviceData []map[string]interface{}) error {
|
||||
return h.addTunnelServiceOnNodeWithMode(nodeID, tunnelID, serviceData, false)
|
||||
}
|
||||
|
||||
func (h *Handler) addTunnelServiceOnNodeWithMode(nodeID, tunnelID int64, serviceData []map[string]interface{}, upsert bool) error {
|
||||
if h == nil {
|
||||
return errors.New("invalid tunnel service context")
|
||||
}
|
||||
serviceName := fmt.Sprintf("%d_tls", tunnelID)
|
||||
if len(serviceData) > 0 {
|
||||
if name, ok := serviceData[0]["name"].(string); ok && strings.TrimSpace(name) != "" {
|
||||
serviceName = strings.TrimSpace(name)
|
||||
}
|
||||
}
|
||||
command := "AddService"
|
||||
if upsert {
|
||||
command = "UpdateService"
|
||||
}
|
||||
return retryTunnelServiceAddWithCleanup(
|
||||
func() error {
|
||||
_, err := h.sendNodeCommand(nodeID, "AddService", serviceData, true, false)
|
||||
_, err := h.sendNodeCommand(nodeID, command, serviceData, true, false)
|
||||
return err
|
||||
},
|
||||
func() error {
|
||||
@@ -3354,19 +3726,22 @@ func (h *Handler) addTunnelServiceOnNode(nodeID, tunnelID int64, serviceData []m
|
||||
)
|
||||
}
|
||||
|
||||
func (h *Handler) rollbackTunnelRuntime(chainNodeIDs, serviceNodeIDs []int64, tunnelID int64) {
|
||||
func (h *Handler) rollbackTunnelRuntime(chainNodeIDs, serviceNodeIDs []int64, tunnelID int64, protocol string) {
|
||||
if h == nil || tunnelID <= 0 {
|
||||
return
|
||||
}
|
||||
if protocol == "" {
|
||||
protocol = "tls"
|
||||
}
|
||||
seenServices := make(map[int64]struct{})
|
||||
serviceName := fmt.Sprintf("%d_tls", tunnelID)
|
||||
serviceNames := tunnelRuntimeServiceNames(tunnelID)
|
||||
for i := len(serviceNodeIDs) - 1; i >= 0; i-- {
|
||||
nodeID := serviceNodeIDs[i]
|
||||
if _, ok := seenServices[nodeID]; ok {
|
||||
continue
|
||||
}
|
||||
seenServices[nodeID] = struct{}{}
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{serviceName}}, false, true)
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": serviceNames}, false, true)
|
||||
}
|
||||
|
||||
seenChains := make(map[int64]struct{})
|
||||
@@ -3431,20 +3806,33 @@ func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRu
|
||||
connector := map[string]interface{}{
|
||||
"type": "relay",
|
||||
}
|
||||
if isTLSTunnelProtocol(protocol) {
|
||||
connector["metadata"] = map[string]interface{}{"nodelay": true}
|
||||
connectorMetadata := map[string]interface{}{}
|
||||
if isTCPTunnelProtocol(protocol) {
|
||||
connectorMetadata["nodelay"] = true
|
||||
connectorMetadata["mux.keepaliveInterval"] = "15s"
|
||||
connectorMetadata["mux.keepaliveTimeout"] = "45s"
|
||||
connectorMetadata["mux.maxFrameSize"] = 32768
|
||||
connectorMetadata["mux.maxStreamBuffer"] = 2097152
|
||||
}
|
||||
if isKCPTunnelProtocol(protocol) {
|
||||
connectorMetadata["connectTimeout"] = "30s"
|
||||
connectorMetadata["mux.keepaliveInterval"] = "15s"
|
||||
connectorMetadata["mux.keepaliveTimeout"] = "45s"
|
||||
connectorMetadata["mux.maxFrameSize"] = 32768
|
||||
connectorMetadata["mux.maxStreamBuffer"] = 2097152
|
||||
}
|
||||
if len(connectorMetadata) > 0 {
|
||||
connector["metadata"] = connectorMetadata
|
||||
}
|
||||
nodeItems = append(nodeItems, map[string]interface{}{
|
||||
"name": fmt.Sprintf("node_%d", idx+1),
|
||||
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, port)),
|
||||
"connector": connector,
|
||||
"dialer": map[string]interface{}{
|
||||
"type": protocol,
|
||||
},
|
||||
"dialer": buildTunnelDialerConfig(protocol),
|
||||
})
|
||||
}
|
||||
|
||||
strategy := defaultString(strings.TrimSpace(targets[0].Strategy), "round")
|
||||
strategy := runtimeTunnelStrategy(defaultString(strings.TrimSpace(targets[0].Strategy), "round"))
|
||||
hop := map[string]interface{}{
|
||||
"name": fmt.Sprintf("hop_%d", tunnelID),
|
||||
"selector": map[string]interface{}{
|
||||
@@ -3464,6 +3852,24 @@ func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRu
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) orderBestExitTargets(tunnelID, ownerNodeID int64, targets []tunnelRuntimeNode) []tunnelRuntimeNode {
|
||||
if len(targets) <= 1 || !isBestTunnelStrategy(targets[0].Strategy) {
|
||||
return append([]tunnelRuntimeNode(nil), targets...)
|
||||
}
|
||||
if h == nil || h.bestExit == nil {
|
||||
return append([]tunnelRuntimeNode(nil), targets...)
|
||||
}
|
||||
return h.bestExit.orderTargets(bestExitOwnerKey{TunnelID: tunnelID, OwnerNodeID: ownerNodeID}, targets)
|
||||
}
|
||||
|
||||
func runtimeStrategyForTargets(owner tunnelRuntimeNode, targets []tunnelRuntimeNode) string {
|
||||
strategy := defaultString(owner.Strategy, "round")
|
||||
if len(targets) > 0 {
|
||||
strategy = defaultString(targets[0].Strategy, strategy)
|
||||
}
|
||||
return runtimeTunnelStrategy(strategy)
|
||||
}
|
||||
|
||||
func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode, node *nodeRecord, nextHopCandidateCount int) []map[string]interface{} {
|
||||
if node == nil {
|
||||
return nil
|
||||
@@ -3472,19 +3878,32 @@ func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode,
|
||||
handlerCfg := map[string]interface{}{
|
||||
"type": "relay",
|
||||
}
|
||||
if isTLSTunnelProtocol(protocol) {
|
||||
handlerCfg["metadata"] = map[string]interface{}{"nodelay": true}
|
||||
handlerMetadata := map[string]interface{}{}
|
||||
if isTCPTunnelProtocol(protocol) {
|
||||
handlerMetadata["nodelay"] = true
|
||||
handlerMetadata["mux.keepaliveInterval"] = "15s"
|
||||
handlerMetadata["mux.keepaliveTimeout"] = "45s"
|
||||
handlerMetadata["mux.maxFrameSize"] = 32768
|
||||
handlerMetadata["mux.maxStreamBuffer"] = 2097152
|
||||
}
|
||||
if isKCPTunnelProtocol(protocol) {
|
||||
handlerMetadata["connectTimeout"] = "30s"
|
||||
handlerMetadata["mux.keepaliveInterval"] = "15s"
|
||||
handlerMetadata["mux.keepaliveTimeout"] = "45s"
|
||||
handlerMetadata["mux.maxFrameSize"] = 32768
|
||||
handlerMetadata["mux.maxStreamBuffer"] = 2097152
|
||||
}
|
||||
if len(handlerMetadata) > 0 {
|
||||
handlerCfg["metadata"] = handlerMetadata
|
||||
}
|
||||
if nextHopCandidateCount > 1 {
|
||||
handlerCfg["retries"] = nextHopCandidateCount - 1
|
||||
}
|
||||
service := map[string]interface{}{
|
||||
"name": fmt.Sprintf("%d_tls", tunnelID),
|
||||
"addr": processServerAddress(fmt.Sprintf("%s:%d", defaultString(strings.TrimSpace(chainNode.ConnectIP), node.TCPListenAddr), chainNode.Port)),
|
||||
"handler": handlerCfg,
|
||||
"listener": map[string]interface{}{
|
||||
"type": protocol,
|
||||
},
|
||||
"name": fmt.Sprintf("tunnel_%d", tunnelID),
|
||||
"addr": processServerAddress(fmt.Sprintf("%s:%d", defaultString(strings.TrimSpace(chainNode.ConnectIP), node.TCPListenAddr), chainNode.Port)),
|
||||
"handler": handlerCfg,
|
||||
"listener": buildTunnelListenerConfig(protocol),
|
||||
}
|
||||
if chainNode.ChainType == 2 {
|
||||
service["handler"].(map[string]interface{})["chain"] = fmt.Sprintf("chains_%d", tunnelID)
|
||||
@@ -3567,10 +3986,67 @@ func nodeDisplayName(node *nodeRecord) string {
|
||||
return fmt.Sprintf("node_%d", node.ID)
|
||||
}
|
||||
|
||||
func isTCPTunnelProtocol(protocol string) bool {
|
||||
p := strings.ToLower(strings.TrimSpace(defaultString(protocol, "tls")))
|
||||
return p == "tls" || p == "mtls" || p == "mtcp"
|
||||
}
|
||||
|
||||
func isKCPTunnelProtocol(protocol string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(protocol), "kcp")
|
||||
}
|
||||
|
||||
func isTLSTunnelProtocol(protocol string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(defaultString(protocol, "tls")), "tls")
|
||||
}
|
||||
|
||||
func buildTunnelDialerConfig(protocol string) map[string]interface{} {
|
||||
dialer := map[string]interface{}{
|
||||
"type": protocol,
|
||||
}
|
||||
if isKCPTunnelProtocol(protocol) {
|
||||
dialer["metadata"] = map[string]interface{}{
|
||||
"kcp.keepalive": 10,
|
||||
"kcp.tcp": false,
|
||||
"kcp.mode": "fast3",
|
||||
"kcp.sndwnd": 4096,
|
||||
"kcp.rcvwnd": 4096,
|
||||
"kcp.mtu": 1350,
|
||||
"kcp.sockbuf": 4194304,
|
||||
"kcp.smuxbuf": 4194304,
|
||||
"kcp.streambuf": 2097152,
|
||||
"kcp.datashard": 10,
|
||||
"kcp.parityshard": 3,
|
||||
"kcp.nocomp": true,
|
||||
"kcp.nc": 1,
|
||||
}
|
||||
}
|
||||
return dialer
|
||||
}
|
||||
|
||||
func buildTunnelListenerConfig(protocol string) map[string]interface{} {
|
||||
listener := map[string]interface{}{
|
||||
"type": protocol,
|
||||
}
|
||||
if isKCPTunnelProtocol(protocol) {
|
||||
listener["metadata"] = map[string]interface{}{
|
||||
"kcp.keepalive": 10,
|
||||
"kcp.tcp": false,
|
||||
"kcp.mode": "fast3",
|
||||
"kcp.sndwnd": 4096,
|
||||
"kcp.rcvwnd": 4096,
|
||||
"kcp.mtu": 1350,
|
||||
"kcp.sockbuf": 4194304,
|
||||
"kcp.smuxbuf": 4194304,
|
||||
"kcp.streambuf": 2097152,
|
||||
"kcp.datashard": 10,
|
||||
"kcp.parityshard": 3,
|
||||
"kcp.nocomp": true,
|
||||
"kcp.nc": 1,
|
||||
}
|
||||
}
|
||||
return listener
|
||||
}
|
||||
|
||||
func nodeSupportsV4(node *nodeRecord) bool {
|
||||
if node == nil {
|
||||
return false
|
||||
@@ -3931,7 +4407,7 @@ func (h *Handler) rollbackForwardMutation(oldForward *forwardRecord, oldPorts []
|
||||
h.repo.RollbackForwardFields(
|
||||
oldForward.ID, oldForward.UserID, oldForward.UserName, oldForward.Name,
|
||||
oldForward.TunnelID, oldForward.RemoteAddr, oldForward.Strategy, oldForward.Status,
|
||||
oldForward.SpeedID,
|
||||
oldForward.SpeedID, oldForward.MaxConn, oldForward.IPMaxConn, oldForward.IPSpeedID, oldForward.ProxyProtocol,
|
||||
time.Now().UnixMilli(),
|
||||
)
|
||||
|
||||
@@ -4095,6 +4571,26 @@ func (h *Handler) syncUserTunnelForwards(userID, tunnelID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) syncUserMaxConnForwards(userID int64) ([]string, error) {
|
||||
forwards, err := h.listActiveForwardsByUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
warnings := make([]string, 0)
|
||||
for i := range forwards {
|
||||
f := &forwards[i]
|
||||
if f.MaxConn > 0 {
|
||||
continue
|
||||
}
|
||||
syncWarnings, syncErr := h.syncForwardServicesWithWarnings(f, "UpdateService", true)
|
||||
warnings = append(warnings, syncWarnings...)
|
||||
if syncErr != nil {
|
||||
return warnings, syncErr
|
||||
}
|
||||
}
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
// cleanupForwardsForUserTunnel deletes all forwarding rules belonging to a
|
||||
// specific user+tunnel pair. It notifies nodes to remove the runtime services
|
||||
// first, then deletes the DB records. This is best-effort: individual failures
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DisableSafeRemoteAddrCheckForTesting allows bypassing the safety check during integration tests.
|
||||
var DisableSafeRemoteAddrCheckForTesting = false
|
||||
|
||||
// IsSafeRemoteAddr checks if a given address is safe to connect to (prevents SSRF/Open Proxy).
|
||||
// It resolves domains to IPs to prevent DNS rebinding attacks pointing to internal networks.
|
||||
// Supports multiple addresses separated by commas or newlines (one per line).
|
||||
func IsSafeRemoteAddr(addr string) error {
|
||||
if DisableSafeRemoteAddrCheckForTesting {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, part := range splitRemoteParts(addr) {
|
||||
if err := checkSingleRemoteAddr(part); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitRemoteParts splits a multi-address string by commas and newlines.
|
||||
func splitRemoteParts(addr string) []string {
|
||||
addr = strings.ReplaceAll(addr, "\n", ",")
|
||||
addr = strings.ReplaceAll(addr, "\r", ",")
|
||||
parts := strings.Split(addr, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// checkSingleRemoteAddr validates a single address.
|
||||
func checkSingleRemoteAddr(addr string) error {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "missing port in address") {
|
||||
host = addr
|
||||
} else {
|
||||
return fmt.Errorf("invalid address format: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not resolve address %q: %v", addr, err)
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
if ip.IsLoopback() || ip.IsPrivate() {
|
||||
return fmt.Errorf("address %q resolves to internal IP: %s", addr, ip.String())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsValidNodeAddress ensures the address is strictly a host or host:port.
|
||||
// It explicitly denies schemes (http://, https://), paths (/...), and query params (?).
|
||||
func IsValidNodeAddress(addr string) error {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if strings.Contains(addr, "://") {
|
||||
return fmt.Errorf("address must not contain scheme (e.g. http://)")
|
||||
}
|
||||
if strings.ContainsAny(addr, "/?") {
|
||||
return fmt.Errorf("address must not contain path or query parameters")
|
||||
}
|
||||
|
||||
_, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "missing port in address") {
|
||||
return fmt.Errorf("invalid address format")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func (h *Handler) storageSummary(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if h == nil || h.repo == nil {
|
||||
response.WriteJSON(w, response.Err(-2, "repository not initialized"))
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := h.repo.DatabaseStorageSummary()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(summary))
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
tunnelStrategyBest = "best"
|
||||
bestExitRuntimeStrategy = "fifo"
|
||||
bestExitPublicTargetHost = "www.bing.com"
|
||||
bestExitPublicTargetPort = 443
|
||||
bestExitLossPenaltyMsPerPercent = 100.0
|
||||
bestExitConfirmationRounds = 3
|
||||
bestExitSwitchCooldown = 30 * time.Second
|
||||
bestExitApplyRetryCooldown = bestExitSwitchCooldown
|
||||
bestExitMinLatencyAdvantageMs = 20.0
|
||||
bestExitMinScoreAdvantageRatio = 0.15
|
||||
)
|
||||
|
||||
type bestExitOwnerKey struct {
|
||||
TunnelID int64
|
||||
OwnerNodeID int64
|
||||
}
|
||||
|
||||
type bestExitCandidateScore struct {
|
||||
OwnerNodeID int64
|
||||
ExitNodeID int64
|
||||
ExitName string
|
||||
|
||||
OwnerToExitLatency float64
|
||||
ExitToBingLatency float64
|
||||
OwnerToExitLoss float64
|
||||
ExitToBingLoss float64
|
||||
TotalLatency float64
|
||||
TotalLoss float64
|
||||
Score float64
|
||||
Success bool
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
type bestExitSwitchDecision struct {
|
||||
Switch bool
|
||||
ExitNodeID int64
|
||||
Reason string
|
||||
Scores []bestExitCandidateScore
|
||||
}
|
||||
|
||||
type bestExitProbeFunc func(nodeID int64, ip string, port int, options diagnosisExecOptions) (latency float64, loss float64, err error)
|
||||
|
||||
type bestExitProbeResult struct {
|
||||
latency float64
|
||||
loss float64
|
||||
err error
|
||||
}
|
||||
|
||||
type bestExitDecision struct {
|
||||
AppliedExitNodeID int64
|
||||
PendingExitNodeID int64
|
||||
PendingCount int
|
||||
LastSwitchAt time.Time
|
||||
LastApplyFailureAt time.Time
|
||||
LastApplyFailureExitNodeID int64
|
||||
LastReason string
|
||||
Scores []bestExitCandidateScore
|
||||
}
|
||||
|
||||
type bestExitManager struct {
|
||||
mu sync.Mutex
|
||||
decisions map[bestExitOwnerKey]*bestExitDecision
|
||||
}
|
||||
|
||||
func newBestExitManager() *bestExitManager {
|
||||
return &bestExitManager{decisions: make(map[bestExitOwnerKey]*bestExitDecision)}
|
||||
}
|
||||
|
||||
func isBestTunnelStrategy(strategy string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(strategy), tunnelStrategyBest)
|
||||
}
|
||||
|
||||
func runtimeTunnelStrategy(strategy string) string {
|
||||
if isBestTunnelStrategy(strategy) {
|
||||
return bestExitRuntimeStrategy
|
||||
}
|
||||
return strategy
|
||||
}
|
||||
|
||||
func scoreBestExitCandidate(ownerNodeID int64, exit chainNodeRecord, ownerLatency, ownerLoss, publicLatency, publicLoss float64) bestExitCandidateScore {
|
||||
totalLatency := ownerLatency + publicLatency
|
||||
totalLoss := combineLossPercent(ownerLoss, publicLoss)
|
||||
return bestExitCandidateScore{
|
||||
OwnerNodeID: ownerNodeID,
|
||||
ExitNodeID: exit.NodeID,
|
||||
ExitName: exit.NodeName,
|
||||
OwnerToExitLatency: ownerLatency,
|
||||
ExitToBingLatency: publicLatency,
|
||||
OwnerToExitLoss: ownerLoss,
|
||||
ExitToBingLoss: publicLoss,
|
||||
TotalLatency: totalLatency,
|
||||
TotalLoss: totalLoss,
|
||||
Score: totalLatency + totalLoss*bestExitLossPenaltyMsPerPercent,
|
||||
Success: true,
|
||||
}
|
||||
}
|
||||
|
||||
func failedBestExitCandidate(ownerNodeID int64, exit chainNodeRecord, message string) bestExitCandidateScore {
|
||||
return bestExitCandidateScore{
|
||||
OwnerNodeID: ownerNodeID,
|
||||
ExitNodeID: exit.NodeID,
|
||||
ExitName: exit.NodeName,
|
||||
Success: false,
|
||||
ErrorMessage: message,
|
||||
}
|
||||
}
|
||||
|
||||
func combineLossPercent(a, b float64) float64 {
|
||||
a = clampPercent(a)
|
||||
b = clampPercent(b)
|
||||
return (1 - (1-a/100.0)*(1-b/100.0)) * 100.0
|
||||
}
|
||||
|
||||
func clampPercent(v float64) float64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 100 {
|
||||
return 100
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func sortBestExitScores(scores []bestExitCandidateScore) {
|
||||
sort.SliceStable(scores, func(i, j int) bool {
|
||||
return bestExitScoreLess(scores[i], scores[j])
|
||||
})
|
||||
}
|
||||
|
||||
func evaluateBestExitOwner(owner chainNodeRecord, exits []chainNodeRecord, nodes map[int64]*nodeRecord, ipPreference string, options diagnosisExecOptions, ping bestExitProbeFunc) []bestExitCandidateScore {
|
||||
scores := make([]bestExitCandidateScore, 0, len(exits))
|
||||
if owner.NodeID <= 0 || len(exits) == 0 || ping == nil {
|
||||
return scores
|
||||
}
|
||||
ownerNode := nodes[owner.NodeID]
|
||||
for _, exit := range exits {
|
||||
exitNode := nodes[exit.NodeID]
|
||||
if exitNode == nil {
|
||||
scores = append(scores, failedBestExitCandidate(owner.NodeID, exit, "exit node unavailable"))
|
||||
continue
|
||||
}
|
||||
targetIP, targetPort, resolveErr := resolveBestExitProbeTarget(ownerNode, exitNode, exit.Port, ipPreference, exit.ConnectIP)
|
||||
if resolveErr != nil {
|
||||
scores = append(scores, failedBestExitCandidate(owner.NodeID, exit, resolveErr.Error()))
|
||||
continue
|
||||
}
|
||||
ownerLatency, ownerLoss, ownerErr := ping(owner.NodeID, targetIP, targetPort, options)
|
||||
if ownerErr != nil {
|
||||
scores = append(scores, failedBestExitCandidate(owner.NodeID, exit, ownerErr.Error()))
|
||||
continue
|
||||
}
|
||||
publicLatency, publicLoss, publicErr := ping(exit.NodeID, bestExitPublicTargetHost, bestExitPublicTargetPort, options)
|
||||
if publicErr != nil {
|
||||
scores = append(scores, failedBestExitCandidate(owner.NodeID, exit, publicErr.Error()))
|
||||
continue
|
||||
}
|
||||
scores = append(scores, scoreBestExitCandidate(owner.NodeID, exit, ownerLatency, ownerLoss, publicLatency, publicLoss))
|
||||
}
|
||||
sortBestExitScores(scores)
|
||||
return scores
|
||||
}
|
||||
|
||||
func resolveBestExitProbeTarget(fromNode, targetNode *nodeRecord, preferredPort int, ipPreference string, connectIP string) (string, int, error) {
|
||||
if targetNode == nil {
|
||||
return "", 0, errors.New("目标节点不存在")
|
||||
}
|
||||
host, err := selectTunnelDialHost(fromNode, targetNode, ipPreference, connectIP)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if strings.TrimSpace(host) == "" {
|
||||
return "", 0, errors.New("目标节点地址为空")
|
||||
}
|
||||
port := preferredPort
|
||||
if port <= 0 {
|
||||
port = firstPortFromRange(targetNode.PortRange)
|
||||
}
|
||||
if port <= 0 {
|
||||
port = 443
|
||||
}
|
||||
return host, port, nil
|
||||
}
|
||||
|
||||
func newBestExitRoundPinger(base bestExitProbeFunc) bestExitProbeFunc {
|
||||
cache := make(map[int64]bestExitProbeResult)
|
||||
return func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
|
||||
if ip == bestExitPublicTargetHost && port == bestExitPublicTargetPort {
|
||||
if cached, ok := cache[nodeID]; ok {
|
||||
return cached.latency, cached.loss, cached.err
|
||||
}
|
||||
lat, loss, err := base(nodeID, ip, port, options)
|
||||
cache[nodeID] = bestExitProbeResult{latency: lat, loss: loss, err: err}
|
||||
return lat, loss, err
|
||||
}
|
||||
return base(nodeID, ip, port, options)
|
||||
}
|
||||
}
|
||||
|
||||
func bestExitChainOwners(inNodes []chainNodeRecord, chainHops [][]chainNodeRecord) []chainNodeRecord {
|
||||
if len(chainHops) == 0 {
|
||||
return inNodes
|
||||
}
|
||||
return chainHops[len(chainHops)-1]
|
||||
}
|
||||
|
||||
func chainRecordsToRuntimeTargets(rows []chainNodeRecord) []tunnelRuntimeNode {
|
||||
out := make([]tunnelRuntimeNode, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, tunnelRuntimeNode{
|
||||
NodeID: row.NodeID,
|
||||
Protocol: row.Protocol,
|
||||
Strategy: row.Strategy,
|
||||
Inx: int(row.Inx),
|
||||
ChainType: row.ChainType,
|
||||
Port: row.Port,
|
||||
ConnectIP: row.ConnectIP,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func orderRuntimeTargetsByNodeID(targets []tunnelRuntimeNode, orderedIDs []int64) []tunnelRuntimeNode {
|
||||
out := append([]tunnelRuntimeNode(nil), targets...)
|
||||
if len(out) <= 1 || len(orderedIDs) == 0 {
|
||||
return out
|
||||
}
|
||||
positions := make(map[int64]int, len(orderedIDs))
|
||||
for i, id := range orderedIDs {
|
||||
if _, ok := positions[id]; !ok {
|
||||
positions[id] = i
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
pi, iok := positions[out[i].NodeID]
|
||||
pj, jok := positions[out[j].NodeID]
|
||||
if iok != jok {
|
||||
return iok
|
||||
}
|
||||
if iok && jok && pi != pj {
|
||||
return pi < pj
|
||||
}
|
||||
return false
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneBestExitScores(scores []bestExitCandidateScore) []bestExitCandidateScore {
|
||||
return append([]bestExitCandidateScore(nil), scores...)
|
||||
}
|
||||
|
||||
func bestExitDecisionResult(switchNow bool, exitNodeID int64, reason string, scores []bestExitCandidateScore) bestExitSwitchDecision {
|
||||
return bestExitSwitchDecision{Switch: switchNow, ExitNodeID: exitNodeID, Reason: reason, Scores: cloneBestExitScores(scores)}
|
||||
}
|
||||
|
||||
func bestExitScoreLess(a, b bestExitCandidateScore) bool {
|
||||
if a.Success != b.Success {
|
||||
return a.Success
|
||||
}
|
||||
if !a.Success && !b.Success {
|
||||
return a.ExitNodeID < b.ExitNodeID
|
||||
}
|
||||
if a.Score != b.Score {
|
||||
return a.Score < b.Score
|
||||
}
|
||||
return a.ExitNodeID < b.ExitNodeID
|
||||
}
|
||||
|
||||
func bestExitHasMinimumAdvantage(candidate, current bestExitCandidateScore) bool {
|
||||
if !candidate.Success {
|
||||
return false
|
||||
}
|
||||
if !current.Success {
|
||||
return true
|
||||
}
|
||||
improvement := current.Score - candidate.Score
|
||||
threshold := current.Score * bestExitMinScoreAdvantageRatio
|
||||
if threshold < bestExitMinLatencyAdvantageMs {
|
||||
threshold = bestExitMinLatencyAdvantageMs
|
||||
}
|
||||
return improvement >= threshold
|
||||
}
|
||||
|
||||
func (m *bestExitManager) setApplied(key bestExitOwnerKey, exitNodeID int64, at time.Time) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
d := m.decisionLocked(key)
|
||||
d.AppliedExitNodeID = exitNodeID
|
||||
d.PendingExitNodeID = 0
|
||||
d.PendingCount = 0
|
||||
d.LastApplyFailureAt = time.Time{}
|
||||
d.LastApplyFailureExitNodeID = 0
|
||||
d.LastSwitchAt = at
|
||||
}
|
||||
|
||||
func (m *bestExitManager) recordApplyFailure(key bestExitOwnerKey, exitNodeID int64, at time.Time) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
d := m.decisionLocked(key)
|
||||
d.LastApplyFailureAt = at
|
||||
d.LastApplyFailureExitNodeID = exitNodeID
|
||||
d.LastReason = "apply retry cooldown"
|
||||
}
|
||||
|
||||
func (m *bestExitManager) ensureApplied(key bestExitOwnerKey, exitNodeID int64, at time.Time) {
|
||||
if m == nil || exitNodeID <= 0 {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
d := m.decisionLocked(key)
|
||||
if d.AppliedExitNodeID == 0 {
|
||||
d.AppliedExitNodeID = exitNodeID
|
||||
d.LastSwitchAt = at
|
||||
}
|
||||
}
|
||||
|
||||
func (m *bestExitManager) observeScores(key bestExitOwnerKey, scores []bestExitCandidateScore, now time.Time) bestExitSwitchDecision {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
ordered := append([]bestExitCandidateScore(nil), scores...)
|
||||
sortBestExitScores(ordered)
|
||||
d := m.decisionLocked(key)
|
||||
d.Scores = cloneBestExitScores(ordered)
|
||||
|
||||
if len(ordered) == 0 || !ordered[0].Success {
|
||||
d.LastReason = "all exits failed"
|
||||
return bestExitDecisionResult(false, 0, d.LastReason, ordered)
|
||||
}
|
||||
|
||||
candidate := ordered[0]
|
||||
if d.AppliedExitNodeID == 0 {
|
||||
d.AppliedExitNodeID = candidate.ExitNodeID
|
||||
d.LastSwitchAt = now
|
||||
d.LastReason = "initial best exit"
|
||||
return bestExitDecisionResult(false, 0, d.LastReason, ordered)
|
||||
}
|
||||
if candidate.ExitNodeID == d.AppliedExitNodeID {
|
||||
d.PendingExitNodeID = 0
|
||||
d.PendingCount = 0
|
||||
d.LastApplyFailureAt = time.Time{}
|
||||
d.LastApplyFailureExitNodeID = 0
|
||||
d.LastReason = "current exit remains best"
|
||||
return bestExitDecisionResult(false, 0, d.LastReason, ordered)
|
||||
}
|
||||
if candidate.ExitNodeID == d.LastApplyFailureExitNodeID && !d.LastApplyFailureAt.IsZero() && now.Sub(d.LastApplyFailureAt) < bestExitApplyRetryCooldown {
|
||||
d.LastReason = "apply retry cooldown"
|
||||
return bestExitDecisionResult(false, 0, d.LastReason, ordered)
|
||||
}
|
||||
if now.Sub(d.LastSwitchAt) < bestExitSwitchCooldown {
|
||||
d.LastReason = "cooldown"
|
||||
return bestExitDecisionResult(false, 0, d.LastReason, ordered)
|
||||
}
|
||||
|
||||
current := findBestExitScore(ordered, d.AppliedExitNodeID)
|
||||
if !bestExitHasMinimumAdvantage(candidate, current) {
|
||||
d.PendingExitNodeID = 0
|
||||
d.PendingCount = 0
|
||||
d.LastReason = "insufficient advantage"
|
||||
return bestExitDecisionResult(false, 0, d.LastReason, ordered)
|
||||
}
|
||||
|
||||
if d.PendingExitNodeID != candidate.ExitNodeID {
|
||||
d.PendingExitNodeID = candidate.ExitNodeID
|
||||
d.PendingCount = 1
|
||||
d.LastReason = "candidate pending confirmation"
|
||||
return bestExitDecisionResult(false, 0, d.LastReason, ordered)
|
||||
}
|
||||
d.PendingCount++
|
||||
if d.PendingCount < bestExitConfirmationRounds {
|
||||
d.LastReason = "candidate pending confirmation"
|
||||
return bestExitDecisionResult(false, 0, d.LastReason, ordered)
|
||||
}
|
||||
|
||||
d.LastReason = "switch confirmed"
|
||||
return bestExitDecisionResult(true, candidate.ExitNodeID, d.LastReason, ordered)
|
||||
}
|
||||
|
||||
func findBestExitScore(scores []bestExitCandidateScore, exitNodeID int64) bestExitCandidateScore {
|
||||
for _, score := range scores {
|
||||
if score.ExitNodeID == exitNodeID {
|
||||
return score
|
||||
}
|
||||
}
|
||||
return failedBestExitCandidate(0, chainNodeRecord{NodeID: exitNodeID}, "current exit has no successful score")
|
||||
}
|
||||
|
||||
func (m *bestExitManager) decisionLocked(key bestExitOwnerKey) *bestExitDecision {
|
||||
if d := m.decisions[key]; d != nil {
|
||||
return d
|
||||
}
|
||||
d := &bestExitDecision{}
|
||||
m.decisions[key] = d
|
||||
return d
|
||||
}
|
||||
|
||||
func (m *bestExitManager) orderTargets(key bestExitOwnerKey, targets []tunnelRuntimeNode) []tunnelRuntimeNode {
|
||||
out := append([]tunnelRuntimeNode(nil), targets...)
|
||||
if m == nil || len(out) <= 1 {
|
||||
return out
|
||||
}
|
||||
m.mu.Lock()
|
||||
applied := int64(0)
|
||||
if d := m.decisions[key]; d != nil {
|
||||
applied = d.AppliedExitNodeID
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if applied <= 0 {
|
||||
return out
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].NodeID == applied {
|
||||
return true
|
||||
}
|
||||
if out[j].NodeID == applied {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
bestExitDisplayStatusApplied = "applied"
|
||||
bestExitDisplayStatusWaiting = "waiting"
|
||||
bestExitDisplaySummaryMulti = "多个出口"
|
||||
bestExitDisplaySummaryWait = "等待探测"
|
||||
bestExitUnknownExitName = "未知出口"
|
||||
bestExitUnknownEntryName = "未知入口"
|
||||
bestExitUnknownChainName = "未知中转"
|
||||
)
|
||||
|
||||
type bestExitDecisionSnapshot struct {
|
||||
AppliedExitNodeID int64
|
||||
UpdatedAt int64
|
||||
Reason string
|
||||
Scores []bestExitCandidateScore
|
||||
}
|
||||
|
||||
type bestExitDisplayState struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Summary string `json:"summary"`
|
||||
Status string `json:"status"`
|
||||
UpdatedAt int64 `json:"updatedAt,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Items []bestExitDisplayItem `json:"items"`
|
||||
}
|
||||
|
||||
type bestExitDisplayItem struct {
|
||||
OwnerNodeID int64 `json:"ownerNodeId"`
|
||||
OwnerNodeName string `json:"ownerNodeName"`
|
||||
OwnerRole string `json:"ownerRole"`
|
||||
ExitNodeID int64 `json:"exitNodeId,omitempty"`
|
||||
ExitNodeName string `json:"exitNodeName"`
|
||||
UpdatedAt int64 `json:"updatedAt,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type bestExitNodeNameLookup func(nodeID int64) (string, bool)
|
||||
|
||||
func (m *bestExitManager) snapshot(key bestExitOwnerKey) (bestExitDecisionSnapshot, bool) {
|
||||
if m == nil {
|
||||
return bestExitDecisionSnapshot{}, false
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
d := m.decisions[key]
|
||||
if d == nil {
|
||||
return bestExitDecisionSnapshot{}, false
|
||||
}
|
||||
updatedAt := int64(0)
|
||||
if !d.LastSwitchAt.IsZero() {
|
||||
updatedAt = d.LastSwitchAt.UnixMilli()
|
||||
}
|
||||
return bestExitDecisionSnapshot{
|
||||
AppliedExitNodeID: d.AppliedExitNodeID,
|
||||
UpdatedAt: updatedAt,
|
||||
Reason: d.LastReason,
|
||||
Scores: cloneBestExitScores(d.Scores),
|
||||
}, true
|
||||
}
|
||||
|
||||
func (h *Handler) attachBestExitStates(items []map[string]interface{}) {
|
||||
if h == nil || len(items) == 0 {
|
||||
return
|
||||
}
|
||||
lookup := h.bestExitNodeNameLookup()
|
||||
for _, item := range items {
|
||||
state, ok := buildBestExitDisplayState(item, h.bestExit, lookup)
|
||||
if !ok {
|
||||
delete(item, "bestExitState")
|
||||
continue
|
||||
}
|
||||
item["bestExitState"] = state
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) bestExitNodeNameLookup() bestExitNodeNameLookup {
|
||||
cache := map[int64]string{}
|
||||
return func(nodeID int64) (string, bool) {
|
||||
if nodeID <= 0 || h == nil {
|
||||
return "", false
|
||||
}
|
||||
if name, ok := cache[nodeID]; ok {
|
||||
return name, name != ""
|
||||
}
|
||||
node, err := h.getNodeRecord(nodeID)
|
||||
if err != nil || node == nil {
|
||||
cache[nodeID] = ""
|
||||
return "", false
|
||||
}
|
||||
name := strings.TrimSpace(node.Name)
|
||||
cache[nodeID] = name
|
||||
return name, name != ""
|
||||
}
|
||||
}
|
||||
|
||||
func buildBestExitDisplayState(tunnel map[string]interface{}, manager *bestExitManager, lookup bestExitNodeNameLookup) (*bestExitDisplayState, bool) {
|
||||
if tunnel == nil {
|
||||
return nil, false
|
||||
}
|
||||
tunnelID := asInt64(tunnel["id"], 0)
|
||||
outNodes := bestExitDisplayMapSlice(tunnel["outNodeId"])
|
||||
if tunnelID <= 0 || len(outNodes) <= 1 {
|
||||
return nil, false
|
||||
}
|
||||
if !isBestTunnelStrategy(asString(outNodes[0]["strategy"])) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
owners, ownerRole := bestExitDisplayOwners(tunnel)
|
||||
state := &bestExitDisplayState{
|
||||
Enabled: true,
|
||||
Summary: bestExitDisplaySummaryWait,
|
||||
Status: bestExitDisplayStatusWaiting,
|
||||
Items: make([]bestExitDisplayItem, 0, len(owners)),
|
||||
}
|
||||
|
||||
exitsByID := map[int64]map[string]interface{}{}
|
||||
for _, exit := range outNodes {
|
||||
if id := asInt64(exit["nodeId"], 0); id > 0 {
|
||||
exitsByID[id] = exit
|
||||
}
|
||||
}
|
||||
appliedExitIDs := map[int64]string{}
|
||||
appliedCount := 0
|
||||
latestUpdatedAt := int64(0)
|
||||
latestReason := ""
|
||||
for _, owner := range owners {
|
||||
ownerNodeID := asInt64(owner["nodeId"], 0)
|
||||
if ownerNodeID <= 0 {
|
||||
continue
|
||||
}
|
||||
item := bestExitDisplayItem{
|
||||
OwnerNodeID: ownerNodeID,
|
||||
OwnerNodeName: bestExitDisplayNodeName(owner, ownerNodeID, lookup, bestExitUnknownOwnerName(ownerRole)),
|
||||
OwnerRole: ownerRole,
|
||||
ExitNodeName: bestExitDisplaySummaryWait,
|
||||
Reason: bestExitDisplayStatusWaiting,
|
||||
}
|
||||
if snapshot, ok := manager.snapshot(bestExitOwnerKey{TunnelID: tunnelID, OwnerNodeID: ownerNodeID}); ok && snapshot.AppliedExitNodeID > 0 {
|
||||
exit, ok := exitsByID[snapshot.AppliedExitNodeID]
|
||||
if !ok {
|
||||
state.Items = append(state.Items, item)
|
||||
continue
|
||||
}
|
||||
item.ExitNodeID = snapshot.AppliedExitNodeID
|
||||
item.ExitNodeName = bestExitDisplayNodeName(exit, snapshot.AppliedExitNodeID, lookup, bestExitUnknownExitName)
|
||||
item.UpdatedAt = snapshot.UpdatedAt
|
||||
item.Reason = snapshot.Reason
|
||||
appliedExitIDs[item.ExitNodeID] = item.ExitNodeName
|
||||
appliedCount++
|
||||
if snapshot.UpdatedAt > latestUpdatedAt {
|
||||
latestUpdatedAt = snapshot.UpdatedAt
|
||||
latestReason = snapshot.Reason
|
||||
}
|
||||
}
|
||||
state.Items = append(state.Items, item)
|
||||
}
|
||||
|
||||
if appliedCount == 0 {
|
||||
return state, true
|
||||
}
|
||||
if appliedCount < len(state.Items) {
|
||||
return state, true
|
||||
}
|
||||
state.Status = bestExitDisplayStatusApplied
|
||||
state.UpdatedAt = latestUpdatedAt
|
||||
state.Reason = latestReason
|
||||
if len(appliedExitIDs) == 1 {
|
||||
for _, name := range appliedExitIDs {
|
||||
state.Summary = name
|
||||
}
|
||||
} else {
|
||||
state.Summary = bestExitDisplaySummaryMulti
|
||||
}
|
||||
return state, true
|
||||
}
|
||||
|
||||
func bestExitDisplayOwners(tunnel map[string]interface{}) ([]map[string]interface{}, string) {
|
||||
chainGroups := bestExitDisplayChainGroups(tunnel["chainNodes"])
|
||||
if len(chainGroups) > 0 {
|
||||
return chainGroups[len(chainGroups)-1], "chain"
|
||||
}
|
||||
return bestExitDisplayMapSlice(tunnel["inNodeId"]), "entry"
|
||||
}
|
||||
|
||||
func bestExitDisplayMapSlice(v interface{}) []map[string]interface{} {
|
||||
switch arr := v.(type) {
|
||||
case []map[string]interface{}:
|
||||
return arr
|
||||
case []interface{}:
|
||||
out := make([]map[string]interface{}, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func bestExitDisplayChainGroups(v interface{}) [][]map[string]interface{} {
|
||||
switch groups := v.(type) {
|
||||
case [][]map[string]interface{}:
|
||||
return groups
|
||||
case []interface{}:
|
||||
out := make([][]map[string]interface{}, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
items := bestExitDisplayMapSlice(group)
|
||||
if len(items) > 0 {
|
||||
out = append(out, items)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func bestExitDisplayNodeName(source map[string]interface{}, nodeID int64, lookup bestExitNodeNameLookup, fallback string) string {
|
||||
if source != nil {
|
||||
for _, key := range []string{"nodeName", "name"} {
|
||||
if name := strings.TrimSpace(asString(source[key])); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
if lookup != nil {
|
||||
if name, ok := lookup(nodeID); ok && strings.TrimSpace(name) != "" {
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func bestExitUnknownOwnerName(role string) string {
|
||||
if role == "chain" {
|
||||
return bestExitUnknownChainName
|
||||
}
|
||||
return bestExitUnknownEntryName
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestBestExitDecisionSnapshotIsDefensiveCopy(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
key := bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}
|
||||
now := time.Unix(100, 0)
|
||||
score := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30, NodeName: "exit-a"}, 10, 0, 20, 0)
|
||||
|
||||
m.observeScores(key, []bestExitCandidateScore{score}, now)
|
||||
snapshot, ok := m.snapshot(key)
|
||||
if !ok {
|
||||
t.Fatalf("expected snapshot")
|
||||
}
|
||||
if snapshot.AppliedExitNodeID != 30 || snapshot.UpdatedAt != now.UnixMilli() {
|
||||
t.Fatalf("unexpected snapshot: %+v", snapshot)
|
||||
}
|
||||
if len(snapshot.Scores) != 1 {
|
||||
t.Fatalf("expected one score in snapshot, got %+v", snapshot.Scores)
|
||||
}
|
||||
snapshot.Scores[0].ExitNodeID = 99
|
||||
|
||||
again, ok := m.snapshot(key)
|
||||
if !ok {
|
||||
t.Fatalf("expected second snapshot")
|
||||
}
|
||||
if again.Scores[0].ExitNodeID != 30 {
|
||||
t.Fatalf("snapshot score mutation leaked into manager state: %+v", again.Scores)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBestExitDisplayStateForDirectMultiEntryOwners(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
now := time.Unix(100, 0)
|
||||
m.setApplied(bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}, 30, now)
|
||||
m.setApplied(bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 11}, 31, now.Add(time.Second))
|
||||
|
||||
tunnel := map[string]interface{}{
|
||||
"id": int64(77),
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(10)},
|
||||
{"nodeId": int64(11)},
|
||||
},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": tunnelStrategyBest},
|
||||
{"nodeId": int64(31), "strategy": tunnelStrategyBest},
|
||||
},
|
||||
"chainNodes": [][]map[string]interface{}{},
|
||||
}
|
||||
names := map[int64]string{10: "入口 A", 11: "入口 B", 30: "香港节点", 31: "日本节点"}
|
||||
|
||||
state, ok := buildBestExitDisplayState(tunnel, m, testBestExitNameLookup(names))
|
||||
if !ok {
|
||||
t.Fatalf("expected best exit state")
|
||||
}
|
||||
if !state.Enabled || state.Summary != "多个出口" || state.Status != "applied" {
|
||||
t.Fatalf("unexpected state summary: %+v", state)
|
||||
}
|
||||
if state.UpdatedAt != now.Add(time.Second).UnixMilli() {
|
||||
t.Fatalf("expected latest updatedAt, got %d", state.UpdatedAt)
|
||||
}
|
||||
if len(state.Items) != 2 {
|
||||
t.Fatalf("expected two owner items, got %+v", state.Items)
|
||||
}
|
||||
if state.Items[0].OwnerRole != "entry" || state.Items[0].OwnerNodeName != "入口 A" || state.Items[0].ExitNodeName != "香港节点" {
|
||||
t.Fatalf("unexpected first item: %+v", state.Items[0])
|
||||
}
|
||||
if state.Items[1].OwnerRole != "entry" || state.Items[1].OwnerNodeName != "入口 B" || state.Items[1].ExitNodeName != "日本节点" {
|
||||
t.Fatalf("unexpected second item: %+v", state.Items[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBestExitDisplayStateForFinalChainHopOwners(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
now := time.Unix(200, 0)
|
||||
m.setApplied(bestExitOwnerKey{TunnelID: 88, OwnerNodeID: 20}, 30, now)
|
||||
m.setApplied(bestExitOwnerKey{TunnelID: 88, OwnerNodeID: 21}, 30, now.Add(time.Second))
|
||||
|
||||
tunnel := map[string]interface{}{
|
||||
"id": int64(88),
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(10)},
|
||||
},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": tunnelStrategyBest},
|
||||
{"nodeId": int64(31), "strategy": tunnelStrategyBest},
|
||||
},
|
||||
"chainNodes": [][]map[string]interface{}{
|
||||
{{"nodeId": int64(15), "inx": int64(0)}},
|
||||
{{"nodeId": int64(20), "inx": int64(1)}, {"nodeId": int64(21), "inx": int64(1)}},
|
||||
},
|
||||
}
|
||||
names := map[int64]string{20: "中转 M1", 21: "中转 M2", 30: "香港节点", 31: "日本节点"}
|
||||
|
||||
state, ok := buildBestExitDisplayState(tunnel, m, testBestExitNameLookup(names))
|
||||
if !ok {
|
||||
t.Fatalf("expected best exit state")
|
||||
}
|
||||
if state.Summary != "香港节点" || state.Status != "applied" {
|
||||
t.Fatalf("expected single-exit summary, got %+v", state)
|
||||
}
|
||||
if len(state.Items) != 2 {
|
||||
t.Fatalf("expected two final-hop owner items, got %+v", state.Items)
|
||||
}
|
||||
if state.Items[0].OwnerRole != "chain" || state.Items[0].OwnerNodeName != "中转 M1" || state.Items[0].ExitNodeName != "香港节点" {
|
||||
t.Fatalf("unexpected first chain owner item: %+v", state.Items[0])
|
||||
}
|
||||
if state.Items[1].OwnerRole != "chain" || state.Items[1].OwnerNodeName != "中转 M2" || state.Items[1].ExitNodeName != "香港节点" {
|
||||
t.Fatalf("unexpected second chain owner item: %+v", state.Items[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBestExitDisplayStateWaitingWhenNoAppliedDecisionExists(t *testing.T) {
|
||||
tunnel := map[string]interface{}{
|
||||
"id": int64(77),
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(10)},
|
||||
},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": tunnelStrategyBest},
|
||||
{"nodeId": int64(31), "strategy": tunnelStrategyBest},
|
||||
},
|
||||
"chainNodes": [][]map[string]interface{}{},
|
||||
}
|
||||
names := map[int64]string{10: "入口 A", 30: "香港节点", 31: "日本节点"}
|
||||
|
||||
state, ok := buildBestExitDisplayState(tunnel, newBestExitManager(), testBestExitNameLookup(names))
|
||||
if !ok {
|
||||
t.Fatalf("expected waiting best exit state")
|
||||
}
|
||||
if state.Summary != "等待探测" || state.Status != "waiting" {
|
||||
t.Fatalf("expected waiting state, got %+v", state)
|
||||
}
|
||||
if len(state.Items) != 1 || state.Items[0].ExitNodeID != 0 || state.Items[0].ExitNodeName != "等待探测" {
|
||||
t.Fatalf("unexpected waiting item: %+v", state.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBestExitDisplayStateKeepsTopLevelWaitingWhenSomeOwnersPending(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
now := time.Unix(400, 0)
|
||||
m.setApplied(bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}, 30, now)
|
||||
|
||||
tunnel := map[string]interface{}{
|
||||
"id": int64(77),
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(10)},
|
||||
{"nodeId": int64(11)},
|
||||
},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": tunnelStrategyBest},
|
||||
{"nodeId": int64(31), "strategy": tunnelStrategyBest},
|
||||
},
|
||||
"chainNodes": [][]map[string]interface{}{},
|
||||
}
|
||||
names := map[int64]string{10: "入口 A", 11: "入口 B", 30: "香港节点", 31: "日本节点"}
|
||||
|
||||
state, ok := buildBestExitDisplayState(tunnel, m, testBestExitNameLookup(names))
|
||||
if !ok {
|
||||
t.Fatalf("expected best exit state")
|
||||
}
|
||||
if state.Status != bestExitDisplayStatusWaiting || state.Summary != bestExitDisplaySummaryWait {
|
||||
t.Fatalf("expected top-level waiting for partial owner state, got %+v", state)
|
||||
}
|
||||
if len(state.Items) != 2 {
|
||||
t.Fatalf("expected two owner items, got %+v", state.Items)
|
||||
}
|
||||
if state.Items[0].ExitNodeID != 30 || state.Items[0].ExitNodeName != "香港节点" {
|
||||
t.Fatalf("expected first owner applied details to remain visible, got %+v", state.Items[0])
|
||||
}
|
||||
if state.Items[1].ExitNodeID != 0 || state.Items[1].ExitNodeName != bestExitDisplaySummaryWait {
|
||||
t.Fatalf("expected second owner waiting details, got %+v", state.Items[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBestExitDisplayStateIgnoresAppliedExitRemovedFromTunnel(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
now := time.Unix(500, 0)
|
||||
m.setApplied(bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}, 99, now)
|
||||
|
||||
tunnel := map[string]interface{}{
|
||||
"id": int64(77),
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(10)},
|
||||
},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": tunnelStrategyBest},
|
||||
{"nodeId": int64(31), "strategy": tunnelStrategyBest},
|
||||
},
|
||||
"chainNodes": [][]map[string]interface{}{},
|
||||
}
|
||||
names := map[int64]string{10: "入口 A", 30: "香港节点", 31: "日本节点", 99: "已删除节点"}
|
||||
|
||||
state, ok := buildBestExitDisplayState(tunnel, m, testBestExitNameLookup(names))
|
||||
if !ok {
|
||||
t.Fatalf("expected best exit state")
|
||||
}
|
||||
if state.Status != bestExitDisplayStatusWaiting || state.Summary != bestExitDisplaySummaryWait {
|
||||
t.Fatalf("expected waiting state for stale applied exit, got %+v", state)
|
||||
}
|
||||
if len(state.Items) != 1 {
|
||||
t.Fatalf("expected one item, got %+v", state.Items)
|
||||
}
|
||||
if state.Items[0].ExitNodeID != 0 || state.Items[0].ExitNodeName != bestExitDisplaySummaryWait {
|
||||
t.Fatalf("expected stale exit to be ignored, got %+v", state.Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBestExitDisplayStateSkipsNonBestAndSingleExitTunnels(t *testing.T) {
|
||||
nonBest := map[string]interface{}{
|
||||
"id": int64(77),
|
||||
"inNodeId": []map[string]interface{}{{"nodeId": int64(10)}},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": "round"},
|
||||
{"nodeId": int64(31), "strategy": "round"},
|
||||
},
|
||||
}
|
||||
if state, ok := buildBestExitDisplayState(nonBest, newBestExitManager(), testBestExitNameLookup(nil)); ok || state != nil {
|
||||
t.Fatalf("expected non-best tunnel to skip state, got %+v", state)
|
||||
}
|
||||
|
||||
singleExit := map[string]interface{}{
|
||||
"id": int64(78),
|
||||
"inNodeId": []map[string]interface{}{{"nodeId": int64(10)}},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": int64(30), "strategy": tunnelStrategyBest},
|
||||
},
|
||||
}
|
||||
if state, ok := buildBestExitDisplayState(singleExit, newBestExitManager(), testBestExitNameLookup(nil)); ok || state != nil {
|
||||
t.Fatalf("expected single-exit tunnel to skip state, got %+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelListAttachesBestExitStateOnlyForEligibleTunnels(t *testing.T) {
|
||||
h := setupBestExitTunnelHandler(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
|
||||
res := httptest.NewRecorder()
|
||||
h.tunnelList(res, req)
|
||||
|
||||
var payload struct {
|
||||
Code int `json:"code"`
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
decodeBestExitTunnelResponse(t, res, &payload)
|
||||
if payload.Code != 0 {
|
||||
t.Fatalf("expected success response, got code %d", payload.Code)
|
||||
}
|
||||
|
||||
bestTunnel := findTunnelResponseItem(t, payload.Data, 77)
|
||||
if _, ok := bestTunnel["bestExitState"]; !ok {
|
||||
t.Fatalf("expected eligible best multi-exit tunnel to include bestExitState: %+v", bestTunnel)
|
||||
}
|
||||
|
||||
singleExitTunnel := findTunnelResponseItem(t, payload.Data, 78)
|
||||
if _, ok := singleExitTunnel["bestExitState"]; ok {
|
||||
t.Fatalf("expected single-exit tunnel to omit bestExitState: %+v", singleExitTunnel)
|
||||
}
|
||||
|
||||
nonBestTunnel := findTunnelResponseItem(t, payload.Data, 79)
|
||||
if _, ok := nonBestTunnel["bestExitState"]; ok {
|
||||
t.Fatalf("expected non-best tunnel to omit bestExitState: %+v", nonBestTunnel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelGetAttachesBestExitStateToSelectedTunnel(t *testing.T) {
|
||||
h := setupBestExitTunnelHandler(t)
|
||||
|
||||
body := bytes.NewReader([]byte(`{"id":77}`))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/get", body)
|
||||
res := httptest.NewRecorder()
|
||||
h.tunnelGet(res, req)
|
||||
|
||||
var payload struct {
|
||||
Code int `json:"code"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
decodeBestExitTunnelResponse(t, res, &payload)
|
||||
if payload.Code != 0 {
|
||||
t.Fatalf("expected success response, got code %d", payload.Code)
|
||||
}
|
||||
if _, ok := payload.Data["bestExitState"]; !ok {
|
||||
t.Fatalf("expected selected best multi-exit tunnel to include bestExitState: %+v", payload.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func setupBestExitTunnelHandler(t *testing.T) *Handler {
|
||||
t.Helper()
|
||||
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, "secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
insertNode := func(id int64, name string) {
|
||||
t.Helper()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(id, name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, id, name, name+"-secret", "10.0.0.1", "10.0.0.1", "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
insertNode(10, "entry-a")
|
||||
insertNode(30, "exit-a")
|
||||
insertNode(31, "exit-b")
|
||||
insertNode(32, "exit-c")
|
||||
|
||||
insertTunnel := func(id int64, name string) {
|
||||
t.Helper()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, inx, ip_preference)
|
||||
VALUES(?, ?, 1, 1, 'tls', 1, ?, ?, 1, ?, '')
|
||||
`, id, name, now, now, id).Error; err != nil {
|
||||
t.Fatalf("insert tunnel %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
insertTunnel(77, "best-multi")
|
||||
insertTunnel(78, "best-single")
|
||||
insertTunnel(79, "round-multi")
|
||||
|
||||
insertChain := func(tunnelID int64, chainType string, nodeID int64, strategy string, inx int64) {
|
||||
t.Helper()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, ?, ?, 30001, ?, ?, 'tls')
|
||||
`, tunnelID, chainType, nodeID, strategy, inx).Error; err != nil {
|
||||
t.Fatalf("insert chain tunnel %d/%s/%d: %v", tunnelID, chainType, nodeID, err)
|
||||
}
|
||||
}
|
||||
insertChain(77, "1", 10, "round", 1)
|
||||
insertChain(77, "3", 30, tunnelStrategyBest, 1)
|
||||
insertChain(77, "3", 31, tunnelStrategyBest, 2)
|
||||
insertChain(78, "1", 10, "round", 1)
|
||||
insertChain(78, "3", 30, tunnelStrategyBest, 1)
|
||||
insertChain(79, "1", 10, "round", 1)
|
||||
insertChain(79, "3", 31, "round", 1)
|
||||
insertChain(79, "3", 32, "round", 2)
|
||||
|
||||
h.bestExit.setApplied(bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}, 30, time.UnixMilli(now))
|
||||
return h
|
||||
}
|
||||
|
||||
func decodeBestExitTunnelResponse(t *testing.T, res *httptest.ResponseRecorder, v any) {
|
||||
t.Helper()
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected HTTP %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(v); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func findTunnelResponseItem(t *testing.T, items []map[string]any, id float64) map[string]any {
|
||||
t.Helper()
|
||||
for _, item := range items {
|
||||
if item["id"] == id {
|
||||
return item
|
||||
}
|
||||
}
|
||||
t.Fatalf("tunnel %.0f not found in response: %+v", id, items)
|
||||
return nil
|
||||
}
|
||||
|
||||
func testBestExitNameLookup(names map[int64]string) bestExitNodeNameLookup {
|
||||
return func(nodeID int64) (string, bool) {
|
||||
name := names[nodeID]
|
||||
return name, name != ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var errBestExitProbeForTest = errors.New("probe failed")
|
||||
|
||||
func TestBestExitScoreCombinesLatencyAndLoss(t *testing.T) {
|
||||
exit := chainNodeRecord{NodeID: 30, NodeName: "exit-a"}
|
||||
score := scoreBestExitCandidate(10, exit, 25, 2, 80, 3)
|
||||
|
||||
if !score.Success {
|
||||
t.Fatalf("expected successful score")
|
||||
}
|
||||
if score.OwnerNodeID != 10 || score.ExitNodeID != 30 {
|
||||
t.Fatalf("unexpected owner/exit ids: %+v", score)
|
||||
}
|
||||
if score.TotalLatency != 105 {
|
||||
t.Fatalf("expected total latency 105, got %v", score.TotalLatency)
|
||||
}
|
||||
if score.TotalLoss < 4.9 || score.TotalLoss > 5.0 {
|
||||
t.Fatalf("expected combined loss about 4.94, got %v", score.TotalLoss)
|
||||
}
|
||||
if score.Score < 599 || score.Score > 600 {
|
||||
t.Fatalf("expected score about 599, got %v", score.Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitScorePenalizesLoss(t *testing.T) {
|
||||
stable := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 80, 0, 80, 0)
|
||||
lowLatencyLossy := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 10, 5, 10, 5)
|
||||
|
||||
if !bestExitScoreLess(stable, lowLatencyLossy) {
|
||||
t.Fatalf("expected stable exit to beat low-latency lossy exit: stable=%+v lossy=%+v", stable, lowLatencyLossy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitFailedCandidateSortsLast(t *testing.T) {
|
||||
failed := failedBestExitCandidate(10, chainNodeRecord{NodeID: 30}, "dial timeout")
|
||||
good := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 100, 0, 100, 0)
|
||||
|
||||
scores := []bestExitCandidateScore{failed, good}
|
||||
sortBestExitScores(scores)
|
||||
|
||||
if scores[0].ExitNodeID != 31 || scores[1].ExitNodeID != 30 {
|
||||
t.Fatalf("expected good score first and failed score last, got %+v", scores)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitInitialObservationAppliesWithoutSwitch(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
|
||||
now := time.Unix(100, 0)
|
||||
candidate := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 40, 0, 60, 0)
|
||||
|
||||
decision := m.observeScores(key, []bestExitCandidateScore{candidate}, now)
|
||||
if decision.Switch {
|
||||
t.Fatalf("initial observation should not return switch: %+v", decision)
|
||||
}
|
||||
if m.decisions[key].AppliedExitNodeID != 31 {
|
||||
t.Fatalf("expected applied exit 31, got %+v", m.decisions[key])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitDecisionRequiresMinimumAdvantage(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
|
||||
now := time.Unix(100, 0)
|
||||
current := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 100, 0, 100, 0)
|
||||
candidate := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 90, 0, 90, 0)
|
||||
|
||||
m.setApplied(key, 30, now.Add(-time.Minute))
|
||||
for i := 0; i < bestExitConfirmationRounds+1; i++ {
|
||||
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(time.Duration(i)*time.Second))
|
||||
if decision.Switch {
|
||||
t.Fatalf("candidate below minimum advantage should not switch after repeated observations: %+v", decision)
|
||||
}
|
||||
}
|
||||
if m.decisions[key].AppliedExitNodeID != 30 {
|
||||
t.Fatalf("expected applied exit to remain 30, got %+v", m.decisions[key])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitDecisionSwitchesWithMinimumAdvantage(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
|
||||
now := time.Unix(100, 0)
|
||||
current := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 100, 0, 100, 0)
|
||||
candidate := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 40, 0, 60, 0)
|
||||
|
||||
m.setApplied(key, 30, now.Add(-time.Minute))
|
||||
for i := 0; i < bestExitConfirmationRounds-1; i++ {
|
||||
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(time.Duration(i)*time.Second))
|
||||
if decision.Switch {
|
||||
t.Fatalf("candidate should wait for confirmations before switching: %+v", decision)
|
||||
}
|
||||
}
|
||||
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add((bestExitConfirmationRounds-1)*time.Second))
|
||||
if !decision.Switch || decision.ExitNodeID != 31 {
|
||||
t.Fatalf("candidate with enough advantage should switch after confirmations: %+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitConfirmedSwitchDoesNotMarkAppliedUntilSetApplied(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
|
||||
now := time.Unix(100, 0)
|
||||
current := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 100, 0, 100, 0)
|
||||
candidate := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 40, 0, 60, 0)
|
||||
|
||||
m.setApplied(key, 30, now.Add(-time.Minute))
|
||||
for i := 0; i < bestExitConfirmationRounds-1; i++ {
|
||||
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(time.Duration(i)*time.Second))
|
||||
if decision.Switch {
|
||||
t.Fatalf("candidate should wait for confirmations before switching: %+v", decision)
|
||||
}
|
||||
}
|
||||
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add((bestExitConfirmationRounds-1)*time.Second))
|
||||
if !decision.Switch || decision.ExitNodeID != 31 {
|
||||
t.Fatalf("candidate with enough advantage should switch after confirmations: %+v", decision)
|
||||
}
|
||||
if m.decisions[key].AppliedExitNodeID != 30 {
|
||||
t.Fatalf("confirmed switch should not mark applied before runtime update: %+v", m.decisions[key])
|
||||
}
|
||||
|
||||
m.setApplied(key, decision.ExitNodeID, now.Add(time.Second))
|
||||
if m.decisions[key].AppliedExitNodeID != 31 {
|
||||
t.Fatalf("setApplied should commit confirmed switch: %+v", m.decisions[key])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitApplyFailureStartsRetryCooldownWithoutChangingAppliedExit(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
|
||||
now := time.Unix(100, 0)
|
||||
current := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 100, 0, 100, 0)
|
||||
candidate := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 40, 0, 60, 0)
|
||||
|
||||
m.setApplied(key, 30, now.Add(-time.Minute))
|
||||
for i := 0; i < bestExitConfirmationRounds-1; i++ {
|
||||
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(time.Duration(i)*time.Second))
|
||||
if decision.Switch {
|
||||
t.Fatalf("candidate should wait for confirmations before switching: %+v", decision)
|
||||
}
|
||||
}
|
||||
confirmed := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add((bestExitConfirmationRounds-1)*time.Second))
|
||||
if !confirmed.Switch || confirmed.ExitNodeID != 31 {
|
||||
t.Fatalf("expected confirmed switch before apply failure: %+v", confirmed)
|
||||
}
|
||||
|
||||
m.recordApplyFailure(key, confirmed.ExitNodeID, now.Add(bestExitConfirmationRounds*time.Second))
|
||||
if m.decisions[key].AppliedExitNodeID != 30 {
|
||||
t.Fatalf("apply failure should leave applied exit unchanged: %+v", m.decisions[key])
|
||||
}
|
||||
|
||||
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add((bestExitConfirmationRounds+1)*time.Second))
|
||||
if decision.Switch {
|
||||
t.Fatalf("apply retry cooldown should suppress immediate retry: %+v", decision)
|
||||
}
|
||||
if decision.Reason != "apply retry cooldown" {
|
||||
t.Fatalf("expected apply retry cooldown reason, got %q", decision.Reason)
|
||||
}
|
||||
|
||||
retry := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(bestExitConfirmationRounds*time.Second+bestExitApplyRetryCooldown))
|
||||
if !retry.Switch || retry.ExitNodeID != 31 {
|
||||
t.Fatalf("expected retry after apply cooldown: %+v", retry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitEnsureAppliedDoesNotOverrideExistingAppliedExit(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
|
||||
now := time.Unix(100, 0)
|
||||
|
||||
m.ensureApplied(key, 30, now)
|
||||
if m.decisions[key].AppliedExitNodeID != 30 {
|
||||
t.Fatalf("expected initial applied exit 30, got %+v", m.decisions[key])
|
||||
}
|
||||
if !m.decisions[key].LastSwitchAt.Equal(now) {
|
||||
t.Fatalf("expected initial applied timestamp, got %+v", m.decisions[key])
|
||||
}
|
||||
|
||||
m.ensureApplied(key, 31, now.Add(time.Minute))
|
||||
if m.decisions[key].AppliedExitNodeID != 30 {
|
||||
t.Fatalf("ensureApplied should not override existing applied exit: %+v", m.decisions[key])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitRoundPingerCachesPublicProbeOnly(t *testing.T) {
|
||||
publicCalls := 0
|
||||
ownerCalls := 0
|
||||
pinger := newBestExitRoundPinger(func(nodeID int64, ip string, port int, _ diagnosisExecOptions) (float64, float64, error) {
|
||||
if ip == bestExitPublicTargetHost && port == bestExitPublicTargetPort {
|
||||
publicCalls++
|
||||
return float64(nodeID), 0, nil
|
||||
}
|
||||
ownerCalls++
|
||||
return float64(ownerCalls), 0, nil
|
||||
})
|
||||
|
||||
if lat, _, err := pinger(30, bestExitPublicTargetHost, bestExitPublicTargetPort, diagnosisExecOptions{}); err != nil || lat != 30 {
|
||||
t.Fatalf("unexpected first public ping result lat=%v err=%v", lat, err)
|
||||
}
|
||||
if lat, _, err := pinger(30, bestExitPublicTargetHost, bestExitPublicTargetPort, diagnosisExecOptions{}); err != nil || lat != 30 {
|
||||
t.Fatalf("unexpected cached public ping result lat=%v err=%v", lat, err)
|
||||
}
|
||||
if _, _, err := pinger(31, bestExitPublicTargetHost, bestExitPublicTargetPort, diagnosisExecOptions{}); err != nil {
|
||||
t.Fatalf("unexpected second exit public ping err=%v", err)
|
||||
}
|
||||
if publicCalls != 2 {
|
||||
t.Fatalf("expected public probes cached per exit node, got %d calls", publicCalls)
|
||||
}
|
||||
|
||||
if _, _, err := pinger(10, "10.0.0.30", 30030, diagnosisExecOptions{}); err != nil {
|
||||
t.Fatalf("unexpected owner ping err=%v", err)
|
||||
}
|
||||
if _, _, err := pinger(10, "10.0.0.30", 30030, diagnosisExecOptions{}); err != nil {
|
||||
t.Fatalf("unexpected repeated owner ping err=%v", err)
|
||||
}
|
||||
if ownerCalls != 2 {
|
||||
t.Fatalf("expected owner-to-exit probes not cached, got %d calls", ownerCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitDecisionScoresAreDefensiveCopies(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
|
||||
now := time.Unix(100, 0)
|
||||
candidate := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 40, 0, 60, 0)
|
||||
current := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 100, 0, 100, 0)
|
||||
|
||||
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now)
|
||||
decision.Scores[0].ExitNodeID = 99
|
||||
|
||||
if m.decisions[key].Scores[0].ExitNodeID != 31 {
|
||||
t.Fatalf("decision scores mutation leaked into manager state: %+v", m.decisions[key].Scores)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitDecisionRequiresConfirmationsAndCooldown(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
|
||||
now := time.Unix(100, 0)
|
||||
current := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 100, 0, 100, 0)
|
||||
candidate := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 40, 0, 60, 0)
|
||||
|
||||
m.setApplied(key, 30, now.Add(-time.Minute))
|
||||
|
||||
if decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now); decision.Switch {
|
||||
t.Fatalf("first observation should not switch: %+v", decision)
|
||||
}
|
||||
if decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(time.Second)); decision.Switch {
|
||||
t.Fatalf("second observation should not switch: %+v", decision)
|
||||
}
|
||||
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(2*time.Second))
|
||||
if !decision.Switch || decision.ExitNodeID != 31 {
|
||||
t.Fatalf("third confirmed observation should switch to 31: %+v", decision)
|
||||
}
|
||||
|
||||
betterAgain := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 20, 0, 20, 0)
|
||||
if decision := m.observeScores(key, []bestExitCandidateScore{betterAgain, candidate}, now.Add(3*time.Second)); decision.Switch {
|
||||
t.Fatalf("cooldown should block immediate switch back: %+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestExitOrderingUsesAppliedDecision(t *testing.T) {
|
||||
m := newBestExitManager()
|
||||
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
|
||||
m.setApplied(key, 31, time.Unix(100, 0))
|
||||
targets := []tunnelRuntimeNode{
|
||||
{NodeID: 30, Strategy: tunnelStrategyBest},
|
||||
{NodeID: 31, Strategy: tunnelStrategyBest},
|
||||
{NodeID: 32, Strategy: tunnelStrategyBest},
|
||||
}
|
||||
|
||||
ordered := m.orderTargets(key, targets)
|
||||
if ordered[0].NodeID != 31 || ordered[1].NodeID != 30 || ordered[2].NodeID != 32 {
|
||||
t.Fatalf("unexpected order: %+v", ordered)
|
||||
}
|
||||
if targets[0].NodeID != 30 {
|
||||
t.Fatalf("orderTargets mutated input: %+v", targets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTunnelChainConfigMapsBestStrategyToFIFO(t *testing.T) {
|
||||
nodes := map[int64]*nodeRecord{
|
||||
10: {ID: 10, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
|
||||
30: {ID: 30, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30", TCPListenAddr: "[::]"},
|
||||
31: {ID: 31, ServerIP: "10.0.0.31", ServerIPv4: "10.0.0.31", TCPListenAddr: "[::]"},
|
||||
}
|
||||
targets := []tunnelRuntimeNode{
|
||||
{NodeID: 30, Port: 30030, Protocol: "tls", Strategy: tunnelStrategyBest, ChainType: 3},
|
||||
{NodeID: 31, Port: 30031, Protocol: "tls", Strategy: tunnelStrategyBest, ChainType: 3},
|
||||
}
|
||||
|
||||
chainData, err := buildTunnelChainConfig(77, 10, targets, nodes, "")
|
||||
if err != nil {
|
||||
t.Fatalf("build chain: %v", err)
|
||||
}
|
||||
hops := chainData["hops"].([]map[string]interface{})
|
||||
selector := hops[0]["selector"].(map[string]interface{})
|
||||
if selector["strategy"] != bestExitRuntimeStrategy {
|
||||
t.Fatalf("expected best to render as fifo, got %v", selector["strategy"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerOrdersBestExitTargetsForOwner(t *testing.T) {
|
||||
h := &Handler{bestExit: newBestExitManager()}
|
||||
key := bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}
|
||||
h.bestExit.setApplied(key, 31, time.Unix(100, 0))
|
||||
targets := []tunnelRuntimeNode{
|
||||
{NodeID: 30, Port: 30030, Strategy: tunnelStrategyBest},
|
||||
{NodeID: 31, Port: 30031, Strategy: tunnelStrategyBest},
|
||||
}
|
||||
|
||||
ordered := h.orderBestExitTargets(77, 10, targets)
|
||||
if ordered[0].NodeID != 31 || ordered[1].NodeID != 30 {
|
||||
t.Fatalf("unexpected ordered targets: %+v", ordered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeStrategyForTargetsMapsBestTargetStrategyToFIFO(t *testing.T) {
|
||||
owner := tunnelRuntimeNode{Strategy: "round"}
|
||||
targets := []tunnelRuntimeNode{{Strategy: tunnelStrategyBest}}
|
||||
|
||||
if got := runtimeStrategyForTargets(owner, targets); got != bestExitRuntimeStrategy {
|
||||
t.Fatalf("expected best target strategy to map to fifo, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeStrategyForTargetsPreservesNonBestTargetStrategy(t *testing.T) {
|
||||
owner := tunnelRuntimeNode{Strategy: tunnelStrategyBest}
|
||||
targets := []tunnelRuntimeNode{{Strategy: "round"}}
|
||||
|
||||
if got := runtimeStrategyForTargets(owner, targets); got != "round" {
|
||||
t.Fatalf("expected target strategy round to remain unchanged, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeStrategyForTargetsMapsBestOwnerStrategyWhenTargetsEmpty(t *testing.T) {
|
||||
owner := tunnelRuntimeNode{Strategy: tunnelStrategyBest}
|
||||
|
||||
if got := runtimeStrategyForTargets(owner, nil); got != bestExitRuntimeStrategy {
|
||||
t.Fatalf("expected best owner fallback strategy to map to fifo, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateBestExitOwnerScoresAllCandidates(t *testing.T) {
|
||||
owner := chainNodeRecord{NodeID: 10, NodeName: "entry"}
|
||||
exits := []chainNodeRecord{
|
||||
{NodeID: 30, NodeName: "exit-a", Port: 30030},
|
||||
{NodeID: 31, NodeName: "exit-b", Port: 30031},
|
||||
}
|
||||
nodes := map[int64]*nodeRecord{
|
||||
10: {ID: 10, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
|
||||
30: {ID: 30, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30", TCPListenAddr: "[::]"},
|
||||
31: {ID: 31, ServerIP: "10.0.0.31", ServerIPv4: "10.0.0.31", TCPListenAddr: "[::]"},
|
||||
}
|
||||
pinger := func(nodeID int64, ip string, port int, _ diagnosisExecOptions) (float64, float64, error) {
|
||||
switch {
|
||||
case nodeID == 10 && port == 30030:
|
||||
return 60, 0, nil
|
||||
case nodeID == 10 && port == 30031:
|
||||
return 20, 0, nil
|
||||
case nodeID == 30 && ip == bestExitPublicTargetHost:
|
||||
return 60, 0, nil
|
||||
case nodeID == 31 && ip == bestExitPublicTargetHost:
|
||||
return 20, 0, nil
|
||||
default:
|
||||
t.Fatalf("unexpected ping node=%d ip=%s port=%d", nodeID, ip, port)
|
||||
return 0, 100, nil
|
||||
}
|
||||
}
|
||||
|
||||
scores := evaluateBestExitOwner(owner, exits, nodes, "", diagnosisExecOptions{}, pinger)
|
||||
if len(scores) != 2 {
|
||||
t.Fatalf("expected two scores, got %+v", scores)
|
||||
}
|
||||
if scores[0].ExitNodeID != 31 {
|
||||
t.Fatalf("expected exit-b first, got %+v", scores)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateBestExitOwnerMarksCandidateFailedWhenOwnerToExitFails(t *testing.T) {
|
||||
owner := chainNodeRecord{NodeID: 10, NodeName: "entry"}
|
||||
exits := []chainNodeRecord{{NodeID: 30, NodeName: "exit-a", Port: 30030}}
|
||||
nodes := map[int64]*nodeRecord{
|
||||
10: {ID: 10, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
|
||||
30: {ID: 30, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30", TCPListenAddr: "[::]"},
|
||||
}
|
||||
pinger := func(nodeID int64, ip string, port int, _ diagnosisExecOptions) (float64, float64, error) {
|
||||
return 0, 100, errBestExitProbeForTest
|
||||
}
|
||||
|
||||
scores := evaluateBestExitOwner(owner, exits, nodes, "", diagnosisExecOptions{}, pinger)
|
||||
if len(scores) != 1 || scores[0].Success {
|
||||
t.Fatalf("expected failed candidate, got %+v", scores)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateBestExitOwnerMarksCandidateFailedWhenTargetResolutionFails(t *testing.T) {
|
||||
owner := chainNodeRecord{NodeID: 10, NodeName: "entry"}
|
||||
exits := []chainNodeRecord{{NodeID: 30, NodeName: "exit-v6", Port: 30030}}
|
||||
nodes := map[int64]*nodeRecord{
|
||||
10: {ID: 10, Name: "entry", ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
|
||||
30: {ID: 30, Name: "exit-v6", ServerIP: "2001:db8::30", ServerIPv6: "2001:db8::30", TCPListenAddr: "[::]"},
|
||||
}
|
||||
pinger := func(nodeID int64, ip string, port int, _ diagnosisExecOptions) (float64, float64, error) {
|
||||
t.Fatalf("ping should not be called when target resolution fails: node=%d ip=%s port=%d", nodeID, ip, port)
|
||||
return 0, 100, nil
|
||||
}
|
||||
|
||||
scores := evaluateBestExitOwner(owner, exits, nodes, "v4", diagnosisExecOptions{}, pinger)
|
||||
if len(scores) != 1 || scores[0].Success {
|
||||
t.Fatalf("expected failed candidate, got %+v", scores)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
type tunnelTrafficDelta struct {
|
||||
@@ -21,75 +22,42 @@ func unixMilliBucketMinute(nowMs int64) int64 {
|
||||
return nowMs - (nowMs % minuteMs)
|
||||
}
|
||||
|
||||
func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowItem, nowMs int64) {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
if nodeID <= 0 || len(items) == 0 {
|
||||
return
|
||||
func collectFlowUploadForwardIDs(items []flowItem) []int64 {
|
||||
ids := make([]int64, 0, len(items))
|
||||
seen := make(map[int64]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
forwardID, _, _, ok := parseFlowServiceIDs(strings.TrimSpace(item.N))
|
||||
if !ok || forwardID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[forwardID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[forwardID] = struct{}{}
|
||||
ids = append(ids, forwardID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (h *Handler) recordTunnelMetricsFromForwardBatch(nodeID int64, forwardDeltas map[int64]tunnelTrafficDelta, metas map[int64]repo.FlowUploadForwardMeta, nowMs int64) {
|
||||
if h == nil || h.repo == nil || nodeID <= 0 || len(forwardDeltas) == 0 {
|
||||
return
|
||||
}
|
||||
bucketTs := unixMilliBucketMinute(nowMs)
|
||||
if bucketTs <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
forwardDeltas := make(map[int64]tunnelTrafficDelta)
|
||||
var skippedParse, skippedZero int
|
||||
for _, item := range items {
|
||||
name := strings.TrimSpace(item.N)
|
||||
if name == "" || name == "web_api" {
|
||||
continue
|
||||
}
|
||||
forwardID, _, _, ok := parseFlowServiceIDs(name)
|
||||
if !ok {
|
||||
skippedParse++
|
||||
continue
|
||||
}
|
||||
if item.D == 0 && item.U == 0 {
|
||||
skippedZero++
|
||||
continue
|
||||
}
|
||||
d := forwardDeltas[forwardID]
|
||||
d.bytesIn += item.D
|
||||
d.bytesOut += item.U
|
||||
forwardDeltas[forwardID] = d
|
||||
}
|
||||
if len(forwardDeltas) == 0 {
|
||||
if len(items) > 0 {
|
||||
log.Printf("monitoring debug op=tunnel_metric.no_forward_deltas node_id=%d items=%d skipped_parse=%d skipped_zero=%d", nodeID, len(items), skippedParse, skippedZero)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
forwardIDs := make([]int64, 0, len(forwardDeltas))
|
||||
for id := range forwardDeltas {
|
||||
forwardIDs = append(forwardIDs, id)
|
||||
}
|
||||
|
||||
forwardTunnelMap, err := h.repo.MapForwardIDsToTunnelIDs(forwardIDs)
|
||||
if err != nil {
|
||||
log.Printf("monitoring write skipped op=tunnel_metric.map_forward_to_tunnel node_id=%d err=%v", nodeID, err)
|
||||
return
|
||||
}
|
||||
if len(forwardTunnelMap) == 0 {
|
||||
log.Printf("monitoring debug op=tunnel_metric.no_tunnel_map node_id=%d forward_ids=%v", nodeID, forwardIDs)
|
||||
return
|
||||
}
|
||||
|
||||
tunnelAgg := make(map[int64]tunnelTrafficDelta)
|
||||
for forwardID, delta := range forwardDeltas {
|
||||
tunnelID := forwardTunnelMap[forwardID]
|
||||
if tunnelID <= 0 {
|
||||
meta, ok := metas[forwardID]
|
||||
if !ok || meta.TunnelID <= 0 {
|
||||
continue
|
||||
}
|
||||
a := tunnelAgg[tunnelID]
|
||||
a.bytesIn += delta.bytesIn
|
||||
a.bytesOut += delta.bytesOut
|
||||
tunnelAgg[tunnelID] = a
|
||||
}
|
||||
if len(tunnelAgg) == 0 {
|
||||
return
|
||||
current := tunnelAgg[meta.TunnelID]
|
||||
current.bytesIn += delta.bytesIn
|
||||
current.bytesOut += delta.bytesOut
|
||||
tunnelAgg[meta.TunnelID] = current
|
||||
}
|
||||
|
||||
metrics := make([]*model.TunnelMetric, 0, len(tunnelAgg))
|
||||
@@ -98,14 +66,11 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
|
||||
continue
|
||||
}
|
||||
metrics = append(metrics, &model.TunnelMetric{
|
||||
TunnelID: tunnelID,
|
||||
NodeID: nodeID,
|
||||
Timestamp: bucketTs,
|
||||
BytesIn: delta.bytesIn,
|
||||
BytesOut: delta.bytesOut,
|
||||
Connections: 0,
|
||||
Errors: 0,
|
||||
AvgLatencyMs: 0,
|
||||
TunnelID: tunnelID,
|
||||
NodeID: nodeID,
|
||||
Timestamp: bucketTs,
|
||||
BytesIn: delta.bytesIn,
|
||||
BytesOut: delta.bytesOut,
|
||||
})
|
||||
}
|
||||
if len(metrics) == 0 {
|
||||
@@ -114,7 +79,7 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
|
||||
|
||||
if err := h.repo.UpsertTunnelMetricBuckets(metrics); err != nil {
|
||||
log.Printf("monitoring write failed op=tunnel_metric.upsert_buckets node_id=%d bucket_ts=%d count=%d err=%v", nodeID, bucketTs, len(metrics), err)
|
||||
} else {
|
||||
log.Printf("monitoring ok op=tunnel_metric.upsert_buckets node_id=%d bucket_ts=%d count=%d", nodeID, bucketTs, len(metrics))
|
||||
return
|
||||
}
|
||||
log.Printf("monitoring ok op=tunnel_metric.upsert_buckets node_id=%d bucket_ts=%d count=%d", nodeID, bucketTs, len(metrics))
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/monitoring"
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
@@ -15,7 +16,6 @@ const (
|
||||
tunnelQualityProbeInterval = 1 * time.Second
|
||||
tunnelQualityProbeTimeout = 8 * time.Second
|
||||
tunnelQualityPingTimeoutMs = 5000
|
||||
tunnelQualityRetention = 24 * time.Hour // keep 24h of history
|
||||
tunnelQualityPruneInterval = 10 * time.Minute
|
||||
tunnelQualityReportInterval = 30 * time.Second // DB save interval
|
||||
)
|
||||
@@ -128,12 +128,19 @@ func (p *tunnelQualityProber) isEnabled() bool {
|
||||
return p.handler.isTunnelQualityMonitoringEnabled()
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) retentionDays() int {
|
||||
if p == nil || p.handler == nil || p.handler.repo == nil {
|
||||
return monitoring.DefaultMonitorRetentionDays
|
||||
}
|
||||
cfg, err := p.handler.repo.GetConfigsByNames([]string{monitoring.ConfigMonitorRetentionDays})
|
||||
if err != nil {
|
||||
return monitoring.DefaultMonitorRetentionDays
|
||||
}
|
||||
return monitoring.MonitoringRetentionDaysFromConfigMap(cfg)
|
||||
}
|
||||
|
||||
// maybePrune deletes old quality rows periodically (mirrors PruneServiceMonitorResults).
|
||||
func (p *tunnelQualityProber) maybePrune() {
|
||||
if !p.isEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if p.lastPrune > 0 && now-p.lastPrune < int64(tunnelQualityPruneInterval/time.Millisecond) {
|
||||
return
|
||||
@@ -145,7 +152,7 @@ func (p *tunnelQualityProber) maybePrune() {
|
||||
return
|
||||
}
|
||||
|
||||
cutoff := now - int64(tunnelQualityRetention/time.Millisecond)
|
||||
cutoff := now - int64(time.Duration(p.retentionDays())*24*time.Hour/time.Millisecond)
|
||||
if err := h.repo.PruneTunnelQualityResults(cutoff); err != nil {
|
||||
log.Printf("tunnel_quality_prober: prune err=%v", err)
|
||||
}
|
||||
@@ -235,6 +242,7 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
|
||||
pingTimeoutMS: tunnelQualityPingTimeoutMs,
|
||||
timeoutMessage: "探测超时",
|
||||
}
|
||||
p.probeBestExitOwners(tunnelID, inNodes, midNodesGrouped, outNodes, ipPreference, options)
|
||||
|
||||
switch tunnel.Type {
|
||||
case 1:
|
||||
@@ -287,7 +295,7 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
|
||||
hops = append(hops, hop)
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
fromNode, _ := h.getNodeRecord(source.NodeID)
|
||||
targetIP, targetPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, target.Port, ipPreference, target.ConnectIP)
|
||||
if resolveErr != nil {
|
||||
@@ -368,6 +376,51 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
|
||||
p.storeResult(snap)
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) probeBestExitOwners(tunnelID int64, inNodes []chainNodeRecord, chainHops [][]chainNodeRecord, outNodes []chainNodeRecord, ipPreference string, options diagnosisExecOptions) {
|
||||
if p == nil || p.handler == nil || p.handler.bestExit == nil || len(outNodes) <= 1 {
|
||||
return
|
||||
}
|
||||
if !isBestTunnelStrategy(outNodes[0].Strategy) {
|
||||
return
|
||||
}
|
||||
owners := bestExitChainOwners(inNodes, chainHops)
|
||||
if len(owners) == 0 {
|
||||
return
|
||||
}
|
||||
nodeMap := make(map[int64]*nodeRecord, len(owners)+len(outNodes))
|
||||
for _, owner := range owners {
|
||||
if node, err := p.handler.getNodeRecord(owner.NodeID); err == nil && node != nil {
|
||||
nodeMap[owner.NodeID] = node
|
||||
}
|
||||
}
|
||||
for _, exit := range outNodes {
|
||||
if node, err := p.handler.getNodeRecord(exit.NodeID); err == nil && node != nil {
|
||||
nodeMap[exit.NodeID] = node
|
||||
}
|
||||
}
|
||||
// This best-exit decision cache is per decision round; the display-oriented
|
||||
// tunnel quality snapshot may still collect its own first-exit public probe.
|
||||
roundPinger := newBestExitRoundPinger(p.tcpPingNode)
|
||||
for _, owner := range owners {
|
||||
if nodeMap[owner.NodeID] == nil {
|
||||
continue
|
||||
}
|
||||
key := bestExitOwnerKey{TunnelID: tunnelID, OwnerNodeID: owner.NodeID}
|
||||
p.handler.bestExit.ensureApplied(key, outNodes[0].NodeID, time.Now())
|
||||
scores := evaluateBestExitOwner(owner, outNodes, nodeMap, ipPreference, options, roundPinger)
|
||||
decision := p.handler.bestExit.observeScores(key, scores, time.Now())
|
||||
if decision.Switch {
|
||||
now := time.Now()
|
||||
if err := p.handler.applyBestExitChainOrder(tunnelID, owner.NodeID, outNodes, decision.Scores, ipPreference); err != nil {
|
||||
log.Printf("best_exit: switch apply failed tunnel=%d owner=%d exit=%d err=%v", tunnelID, owner.NodeID, decision.ExitNodeID, err)
|
||||
p.handler.bestExit.recordApplyFailure(key, decision.ExitNodeID, now)
|
||||
continue
|
||||
}
|
||||
p.handler.bestExit.setApplied(key, decision.ExitNodeID, time.Now())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *tunnelQualityProber) tcpPingNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (latency float64, loss float64, err error) {
|
||||
h := p.handler
|
||||
if h == nil {
|
||||
|
||||
@@ -13,6 +13,13 @@ import (
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
// failedForward tracks a forward that failed redeployment, for retry.
|
||||
type failedForward struct {
|
||||
id int64
|
||||
forward *forwardRecord
|
||||
err error
|
||||
}
|
||||
|
||||
const (
|
||||
githubRepo = "Sagit-chu/flvx"
|
||||
githubAPIBase = "https://api.github.com"
|
||||
@@ -32,6 +39,8 @@ var (
|
||||
testKeywordPattern = regexp.MustCompile(`(?i)(alpha|beta|rc)`)
|
||||
)
|
||||
|
||||
const nodeOnlineRedeployCooldown = 30 * time.Second
|
||||
|
||||
type githubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
@@ -389,24 +398,123 @@ func (h *Handler) consumeNodePendingUpgradeRedeploy(nodeID int64) bool {
|
||||
}
|
||||
|
||||
func (h *Handler) onNodeOnline(nodeID int64) {
|
||||
if !h.consumeNodePendingUpgradeRedeploy(nodeID) {
|
||||
if !h.startNodeOnlineRedeploy(nodeID, time.Now()) {
|
||||
return
|
||||
}
|
||||
h.redeployNodeRuntimeAfterUpgrade(nodeID)
|
||||
defer h.finishNodeOnlineRedeploy(nodeID)
|
||||
|
||||
// Reconcile node runtime on the first reconnect, but suppress rapid flapping
|
||||
// so websocket churn does not trigger repeated full redeploy storms.
|
||||
if !h.redeployNodeRuntimeAfterUpgrade(nodeID) {
|
||||
h.markNodePendingUpgradeRedeploy(nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) {
|
||||
func (h *Handler) startNodeOnlineRedeploy(nodeID int64, now time.Time) bool {
|
||||
if h == nil || nodeID <= 0 {
|
||||
return false
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
|
||||
h.upgradeMu.Lock()
|
||||
defer h.upgradeMu.Unlock()
|
||||
if h.pendingUpgradeRedeploy == nil {
|
||||
h.pendingUpgradeRedeploy = make(map[int64]struct{})
|
||||
}
|
||||
if h.nodeOnlineRedeployAt == nil {
|
||||
h.nodeOnlineRedeployAt = make(map[int64]time.Time)
|
||||
}
|
||||
if h.nodeOnlineRedeployQueued == nil {
|
||||
h.nodeOnlineRedeployQueued = make(map[int64]struct{})
|
||||
}
|
||||
if h.nodeOnlineRedeploying == nil {
|
||||
h.nodeOnlineRedeploying = make(map[int64]struct{})
|
||||
}
|
||||
|
||||
_, pendingUpgrade := h.pendingUpgradeRedeploy[nodeID]
|
||||
lastRedeployAt := h.nodeOnlineRedeployAt[nodeID]
|
||||
_, inFlight := h.nodeOnlineRedeploying[nodeID]
|
||||
if fireAt, start := nextNodeOnlineRedeployFireAt(lastRedeployAt, now, pendingUpgrade, inFlight); !start {
|
||||
h.queueNodeOnlineRedeployLocked(nodeID, fireAt)
|
||||
return false
|
||||
}
|
||||
|
||||
delete(h.pendingUpgradeRedeploy, nodeID)
|
||||
h.nodeOnlineRedeployAt[nodeID] = now
|
||||
h.nodeOnlineRedeploying[nodeID] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func nextNodeOnlineRedeployFireAt(lastRedeployAt, now time.Time, pendingUpgrade bool, inFlight bool) (time.Time, bool) {
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
if inFlight {
|
||||
fireAt := now.Add(nodeOnlineRedeployCooldown)
|
||||
if !lastRedeployAt.IsZero() {
|
||||
cooldownAt := lastRedeployAt.Add(nodeOnlineRedeployCooldown)
|
||||
if cooldownAt.After(now) {
|
||||
fireAt = cooldownAt
|
||||
}
|
||||
}
|
||||
return fireAt, false
|
||||
}
|
||||
if !pendingUpgrade && !lastRedeployAt.IsZero() && now.Sub(lastRedeployAt) < nodeOnlineRedeployCooldown {
|
||||
return lastRedeployAt.Add(nodeOnlineRedeployCooldown), false
|
||||
}
|
||||
return time.Time{}, true
|
||||
}
|
||||
|
||||
func (h *Handler) queueNodeOnlineRedeployLocked(nodeID int64, fireAt time.Time) {
|
||||
if h == nil || nodeID <= 0 {
|
||||
return
|
||||
}
|
||||
if h.nodeOnlineRedeployQueued == nil {
|
||||
h.nodeOnlineRedeployQueued = make(map[int64]struct{})
|
||||
}
|
||||
if _, queued := h.nodeOnlineRedeployQueued[nodeID]; queued {
|
||||
return
|
||||
}
|
||||
if fireAt.IsZero() {
|
||||
fireAt = time.Now().Add(nodeOnlineRedeployCooldown)
|
||||
}
|
||||
delay := time.Until(fireAt)
|
||||
if delay < 0 {
|
||||
delay = 0
|
||||
}
|
||||
h.nodeOnlineRedeployQueued[nodeID] = struct{}{}
|
||||
time.AfterFunc(delay, func() {
|
||||
h.upgradeMu.Lock()
|
||||
delete(h.nodeOnlineRedeployQueued, nodeID)
|
||||
h.upgradeMu.Unlock()
|
||||
h.onNodeOnline(nodeID)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) finishNodeOnlineRedeploy(nodeID int64) {
|
||||
if h == nil || nodeID <= 0 {
|
||||
return
|
||||
}
|
||||
h.upgradeMu.Lock()
|
||||
delete(h.nodeOnlineRedeploying, nodeID)
|
||||
h.upgradeMu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) bool {
|
||||
tunnelIDs, err := h.repo.ListActiveTunnelIDsByNode(nodeID)
|
||||
if err != nil {
|
||||
fmt.Printf("post-upgrade redeploy: list tunnels for node %d failed: %v\n", nodeID, err)
|
||||
return
|
||||
return false
|
||||
}
|
||||
forwardIDs, err := h.repo.ListActiveForwardIDsByNode(nodeID)
|
||||
forwardIDs, err := h.repo.ListForwardIDsByNode(nodeID)
|
||||
if err != nil {
|
||||
fmt.Printf("post-upgrade redeploy: list forwards for node %d failed: %v\n", nodeID, err)
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// First pass: deploy everything
|
||||
tunnelFailed := make(map[int64]struct{})
|
||||
for _, tunnelID := range tunnelIDs {
|
||||
if err := h.redeployTunnelAndForwards(tunnelID); err != nil {
|
||||
@@ -415,6 +523,9 @@ func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) {
|
||||
}
|
||||
}
|
||||
|
||||
// Collect forwards that failed independently (not skipped due to tunnel failure)
|
||||
var failedForwards []failedForward
|
||||
|
||||
for _, forwardID := range forwardIDs {
|
||||
forward, getErr := h.getForwardRecord(forwardID)
|
||||
if getErr != nil || forward == nil {
|
||||
@@ -424,7 +535,87 @@ func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) {
|
||||
continue
|
||||
}
|
||||
if err := h.syncForwardServices(forward, "UpdateService", true); err != nil {
|
||||
failedForwards = append(failedForwards, failedForward{id: forwardID, forward: forward, err: err})
|
||||
fmt.Printf("post-upgrade redeploy: forward %d failed on node %d: %v\n", forwardID, nodeID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Retry failed items with exponential backoff (max 3 attempts)
|
||||
return h.retryFailedRedeploys(nodeID, tunnelFailed, failedForwards)
|
||||
}
|
||||
|
||||
// isRetryableError returns true if the error looks transient and worth retrying.
|
||||
func isRetryableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
// Skip non-retryable errors: not-found, already-exists, validation errors
|
||||
if strings.Contains(msg, "not found") || strings.Contains(msg, "不存在") {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在") {
|
||||
return false
|
||||
}
|
||||
// Everything else (timeout, connection lost, port in use, etc.) is retryable
|
||||
return true
|
||||
}
|
||||
|
||||
// retryFailedRedeploys retries failed tunnels and forwards with exponential backoff.
|
||||
func (h *Handler) retryFailedRedeploys(nodeID int64, tunnelFailed map[int64]struct{}, failedForwards []failedForward) bool {
|
||||
if len(tunnelFailed) == 0 && len(failedForwards) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
const maxRetries = 3
|
||||
baseDelay := time.Second
|
||||
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
delay := baseDelay * time.Duration(1<<uint(attempt-1)) // 1s, 2s, 4s
|
||||
time.Sleep(delay)
|
||||
|
||||
// Retry failed tunnels
|
||||
for tunnelID := range tunnelFailed {
|
||||
if err := h.redeployTunnelAndForwards(tunnelID); err == nil {
|
||||
delete(tunnelFailed, tunnelID)
|
||||
fmt.Printf("post-upgrade redeploy retry: tunnel %d succeeded on node %d (attempt %d)\n", tunnelID, nodeID, attempt)
|
||||
} else if !isRetryableError(err) {
|
||||
delete(tunnelFailed, tunnelID) // Non-retryable, don't retry again
|
||||
} else {
|
||||
fmt.Printf("post-upgrade redeploy retry: tunnel %d still failing on node %d (attempt %d): %v\n", tunnelID, nodeID, attempt, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Retry failed forwards
|
||||
var stillFailed []failedForward
|
||||
for _, ff := range failedForwards {
|
||||
if _, skipped := tunnelFailed[ff.forward.TunnelID]; skipped {
|
||||
stillFailed = append(stillFailed, ff) // Tunnel still failed, skip forward
|
||||
continue
|
||||
}
|
||||
if err := h.syncForwardServices(ff.forward, "UpdateService", true); err == nil {
|
||||
fmt.Printf("post-upgrade redeploy retry: forward %d succeeded on node %d (attempt %d)\n", ff.id, nodeID, attempt)
|
||||
} else if !isRetryableError(err) {
|
||||
// Non-retryable, drop it
|
||||
} else {
|
||||
stillFailed = append(stillFailed, ff)
|
||||
fmt.Printf("post-upgrade redeploy retry: forward %d still failing on node %d (attempt %d): %v\n", ff.id, nodeID, attempt, err)
|
||||
}
|
||||
}
|
||||
failedForwards = stillFailed
|
||||
|
||||
if len(tunnelFailed) == 0 && len(failedForwards) == 0 {
|
||||
fmt.Printf("post-upgrade redeploy retry: all items recovered on node %d\n", nodeID)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Final summary
|
||||
for tunnelID := range tunnelFailed {
|
||||
fmt.Printf("post-upgrade redeploy: tunnel %d permanently failed on node %d after retries\n", tunnelID, nodeID)
|
||||
}
|
||||
for _, ff := range failedForwards {
|
||||
fmt.Printf("post-upgrade redeploy: forward %d permanently failed on node %d after retries\n", ff.id, nodeID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStartNodeOnlineRedeploySkipsRecentReconnects(t *testing.T) {
|
||||
h := &Handler{
|
||||
pendingUpgradeRedeploy: map[int64]struct{}{},
|
||||
nodeOnlineRedeployAt: map[int64]time.Time{},
|
||||
nodeOnlineRedeployQueued: map[int64]struct{}{},
|
||||
nodeOnlineRedeploying: map[int64]struct{}{},
|
||||
}
|
||||
now := time.Unix(1_777_176_720, 0)
|
||||
|
||||
if !h.startNodeOnlineRedeploy(54, now) {
|
||||
t.Fatalf("expected first reconnect to redeploy")
|
||||
}
|
||||
h.finishNodeOnlineRedeploy(54)
|
||||
|
||||
if h.startNodeOnlineRedeploy(54, now.Add(5*time.Second)) {
|
||||
t.Fatalf("expected recent reconnect to skip redeploy")
|
||||
}
|
||||
if h.consumeNodePendingUpgradeRedeploy(54) {
|
||||
t.Fatalf("did not expect pending upgrade marker to be consumed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartNodeOnlineRedeployAllowsPendingUpgradeDuringCooldown(t *testing.T) {
|
||||
h := &Handler{
|
||||
pendingUpgradeRedeploy: map[int64]struct{}{},
|
||||
nodeOnlineRedeployAt: map[int64]time.Time{},
|
||||
nodeOnlineRedeployQueued: map[int64]struct{}{},
|
||||
nodeOnlineRedeploying: map[int64]struct{}{},
|
||||
}
|
||||
now := time.Unix(1_777_176_720, 0)
|
||||
|
||||
if !h.startNodeOnlineRedeploy(54, now) {
|
||||
t.Fatalf("expected first reconnect to redeploy")
|
||||
}
|
||||
h.finishNodeOnlineRedeploy(54)
|
||||
h.markNodePendingUpgradeRedeploy(54)
|
||||
|
||||
if !h.startNodeOnlineRedeploy(54, now.Add(5*time.Second)) {
|
||||
t.Fatalf("expected pending upgrade reconnect to bypass cooldown")
|
||||
}
|
||||
if h.consumeNodePendingUpgradeRedeploy(54) {
|
||||
t.Fatalf("expected pending upgrade marker to be consumed during redeploy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartNodeOnlineRedeployQueuesCooldownReconnect(t *testing.T) {
|
||||
h := &Handler{
|
||||
pendingUpgradeRedeploy: map[int64]struct{}{},
|
||||
nodeOnlineRedeployAt: map[int64]time.Time{},
|
||||
nodeOnlineRedeployQueued: map[int64]struct{}{},
|
||||
nodeOnlineRedeploying: map[int64]struct{}{},
|
||||
}
|
||||
now := time.Unix(1_777_176_720, 0)
|
||||
|
||||
if !h.startNodeOnlineRedeploy(54, now) {
|
||||
t.Fatalf("expected first reconnect to redeploy")
|
||||
}
|
||||
h.finishNodeOnlineRedeploy(54)
|
||||
|
||||
if h.startNodeOnlineRedeploy(54, now.Add(5*time.Second)) {
|
||||
t.Fatalf("expected cooldown reconnect to skip immediate redeploy")
|
||||
}
|
||||
if _, queued := h.nodeOnlineRedeployQueued[54]; !queued {
|
||||
t.Fatalf("expected cooldown reconnect to queue a follow-up redeploy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartNodeOnlineRedeployKeepsPendingUpgradeWhileInFlight(t *testing.T) {
|
||||
h := &Handler{
|
||||
pendingUpgradeRedeploy: map[int64]struct{}{},
|
||||
nodeOnlineRedeployAt: map[int64]time.Time{},
|
||||
nodeOnlineRedeployQueued: map[int64]struct{}{},
|
||||
nodeOnlineRedeploying: map[int64]struct{}{},
|
||||
}
|
||||
now := time.Unix(1_777_176_720, 0)
|
||||
|
||||
if !h.startNodeOnlineRedeploy(54, now) {
|
||||
t.Fatalf("expected first reconnect to redeploy")
|
||||
}
|
||||
h.markNodePendingUpgradeRedeploy(54)
|
||||
|
||||
if h.startNodeOnlineRedeploy(54, now.Add(time.Second)) {
|
||||
t.Fatalf("expected in-flight redeploy to suppress parallel restart")
|
||||
}
|
||||
if !h.consumeNodePendingUpgradeRedeploy(54) {
|
||||
t.Fatalf("expected pending upgrade marker to remain for the next retry")
|
||||
}
|
||||
h.finishNodeOnlineRedeploy(54)
|
||||
}
|
||||
|
||||
func TestNextNodeOnlineRedeployFireAtDefersExpiredInFlightReconnect(t *testing.T) {
|
||||
now := time.Unix(1_777_176_720, 0)
|
||||
last := now.Add(-nodeOnlineRedeployCooldown - 5*time.Second)
|
||||
|
||||
fireAt, start := nextNodeOnlineRedeployFireAt(last, now, false, true)
|
||||
if start {
|
||||
t.Fatalf("expected in-flight reconnect to queue instead of starting immediately")
|
||||
}
|
||||
|
||||
want := now.Add(nodeOnlineRedeployCooldown)
|
||||
if !fireAt.Equal(want) {
|
||||
t.Fatalf("expected queued reconnect at %s, got %s", want, fireAt)
|
||||
}
|
||||
}
|
||||
@@ -105,11 +105,15 @@ func requiresAdmin(path string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/v1/system/") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/v1/group/") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/v1/federation/share/") {
|
||||
if strings.HasPrefix(path, "/api/v1/federation/share/") || strings.HasPrefix(path, "/api/v1/federation/node/") {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/monitoring"
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
@@ -31,7 +32,6 @@ type IngestionService struct {
|
||||
nodeBuffer []*model.NodeMetric
|
||||
nodeBufferMu sync.Mutex
|
||||
flushInterval time.Duration
|
||||
retentionDays int
|
||||
}
|
||||
|
||||
func NewIngestionService(repo *repo.Repository) *IngestionService {
|
||||
@@ -39,7 +39,6 @@ func NewIngestionService(repo *repo.Repository) *IngestionService {
|
||||
repo: repo,
|
||||
nodeBuffer: make([]*model.NodeMetric, 0, 500),
|
||||
flushInterval: 30 * time.Second,
|
||||
retentionDays: 7,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +110,22 @@ func (s *IngestionService) flushNodeMetrics() {
|
||||
}
|
||||
|
||||
func (s *IngestionService) pruneMetrics() {
|
||||
cutoff := time.Now().Add(-time.Duration(s.retentionDays) * 24 * time.Hour).UnixMilli()
|
||||
s.pruneMetricsAt(time.Now())
|
||||
}
|
||||
|
||||
func (s *IngestionService) retentionDaysFromConfig() int {
|
||||
if s == nil || s.repo == nil {
|
||||
return monitoring.DefaultMonitorRetentionDays
|
||||
}
|
||||
cfg, err := s.repo.GetConfigsByNames([]string{monitoring.ConfigMonitorRetentionDays})
|
||||
if err != nil {
|
||||
return monitoring.DefaultMonitorRetentionDays
|
||||
}
|
||||
return monitoring.MonitoringRetentionDaysFromConfigMap(cfg)
|
||||
}
|
||||
|
||||
func (s *IngestionService) pruneMetricsAt(now time.Time) {
|
||||
cutoff := now.Add(-time.Duration(s.retentionDaysFromConfig()) * 24 * time.Hour).UnixMilli()
|
||||
if s.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
@@ -215,7 +216,6 @@ func TestPruneMetrics(t *testing.T) {
|
||||
defer r.Close()
|
||||
|
||||
svc := NewIngestionService(r)
|
||||
svc.retentionDays = 1
|
||||
|
||||
info := SystemInfo{CPUUsage: 50.0, MemoryUsage: 60.0, DiskUsage: 30.0}
|
||||
|
||||
@@ -233,6 +233,39 @@ func TestPruneMetrics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneMetricsUsesConfiguredRetentionDays(t *testing.T) {
|
||||
r, err := repo.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.UpsertConfig("monitor_retention_days", "2", now); err != nil {
|
||||
t.Fatalf("upsert retention config: %v", err)
|
||||
}
|
||||
|
||||
oldMetric := &model.NodeMetric{NodeID: 1, Timestamp: now - int64(3*24*time.Hour/time.Millisecond), CPUUsage: 10}
|
||||
newMetric := &model.NodeMetric{NodeID: 1, Timestamp: now - int64(1*24*time.Hour/time.Millisecond), CPUUsage: 20}
|
||||
if err := r.InsertNodeMetric(oldMetric); err != nil {
|
||||
t.Fatalf("insert old metric: %v", err)
|
||||
}
|
||||
if err := r.InsertNodeMetric(newMetric); err != nil {
|
||||
t.Fatalf("insert new metric: %v", err)
|
||||
}
|
||||
|
||||
svc := NewIngestionService(r)
|
||||
svc.pruneMetricsAt(time.UnixMilli(now))
|
||||
|
||||
metrics, err := r.GetNodeMetrics(1, now-int64(4*24*time.Hour/time.Millisecond), now+1000)
|
||||
if err != nil {
|
||||
t.Fatalf("get node metrics: %v", err)
|
||||
}
|
||||
if len(metrics) != 1 || metrics[0].CPUUsage != 20 {
|
||||
t.Fatalf("expected only newer metric to remain, got %#v", metrics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleNodes(t *testing.T) {
|
||||
r, err := repo.Open(":memory:")
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package monitoring
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ConfigMonitorRetentionDays = "monitor_retention_days"
|
||||
DefaultMonitorRetentionDays = 7
|
||||
MinMonitorRetentionDays = 1
|
||||
MaxMonitorRetentionDays = 3650
|
||||
)
|
||||
|
||||
func MonitoringRetentionDaysFromConfigMap(cfg map[string]string) int {
|
||||
if cfg == nil {
|
||||
return DefaultMonitorRetentionDays
|
||||
}
|
||||
days, err := parseMonitoringRetentionDays(cfg[ConfigMonitorRetentionDays])
|
||||
if err != nil {
|
||||
return DefaultMonitorRetentionDays
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
func NormalizeMonitoringRetentionDays(value string) (string, error) {
|
||||
days, err := parseMonitoringRetentionDays(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strconv.Itoa(days), nil
|
||||
}
|
||||
|
||||
func parseMonitoringRetentionDays(value string) (int, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return 0, fmt.Errorf("监控数据保留天数不能为空")
|
||||
}
|
||||
days, err := strconv.Atoi(trimmed)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("监控数据保留天数必须是整数")
|
||||
}
|
||||
if days < MinMonitorRetentionDays || days > MaxMonitorRetentionDays {
|
||||
return 0, fmt.Errorf("监控数据保留天数必须在 %d 到 %d 之间", MinMonitorRetentionDays, MaxMonitorRetentionDays)
|
||||
}
|
||||
return days, nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package monitoring
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMonitoringRetentionDaysFromConfigMap(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
want int
|
||||
}{
|
||||
{"missing uses default", nil, 7},
|
||||
{"valid custom", map[string]string{ConfigMonitorRetentionDays: "3"}, 3},
|
||||
{"trimmed custom", map[string]string{ConfigMonitorRetentionDays: " 30 "}, 30},
|
||||
{"invalid uses default", map[string]string{ConfigMonitorRetentionDays: "abc"}, 7},
|
||||
{"too small uses default", map[string]string{ConfigMonitorRetentionDays: "0"}, 7},
|
||||
{"too large uses default", map[string]string{ConfigMonitorRetentionDays: "3651"}, 7},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := MonitoringRetentionDaysFromConfigMap(tc.cfg); got != tc.want {
|
||||
t.Fatalf("expected %d, got %d", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMonitoringRetentionDays(t *testing.T) {
|
||||
for _, value := range []string{"1", "7", "3650", " 30 "} {
|
||||
if got, err := NormalizeMonitoringRetentionDays(value); err != nil || got == "" {
|
||||
t.Fatalf("expected %q valid, got value=%q err=%v", value, got, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, value := range []string{"", "0", "-1", "3651", "abc", "1.5"} {
|
||||
if got, err := NormalizeMonitoringRetentionDays(value); err == nil {
|
||||
t.Fatalf("expected %q invalid, got value=%q", value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,26 +23,31 @@ type User struct {
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
||||
Status int `gorm:"not null"`
|
||||
MaxConn int `gorm:"column:max_conn;not null;default:0"`
|
||||
}
|
||||
|
||||
func (User) TableName() string { return "user" }
|
||||
|
||||
// Forward maps to the "forward" table.
|
||||
type Forward struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
UserID int64 `gorm:"column:user_id;not null"`
|
||||
UserName string `gorm:"column:user_name;type:varchar(100);not null"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null"`
|
||||
RemoteAddr string `gorm:"column:remote_addr;type:text;not null"`
|
||||
Strategy string `gorm:"type:varchar(100);not null;default:'fifo'"`
|
||||
InFlow int64 `gorm:"not null;default:0"`
|
||||
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
Status int `gorm:"not null"`
|
||||
Inx int `gorm:"not null;default:0"`
|
||||
SpeedID sql.NullInt64 `gorm:"column:speed_id"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
UserID int64 `gorm:"column:user_id;not null"`
|
||||
UserName string `gorm:"column:user_name;type:varchar(100);not null"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null"`
|
||||
RemoteAddr string `gorm:"column:remote_addr;type:text;not null"`
|
||||
Strategy string `gorm:"type:varchar(100);not null;default:'fifo'"`
|
||||
InFlow int64 `gorm:"not null;default:0"`
|
||||
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
Status int `gorm:"not null"`
|
||||
Inx int `gorm:"not null;default:0"`
|
||||
SpeedID sql.NullInt64 `gorm:"column:speed_id"`
|
||||
MaxConn int `gorm:"column:max_conn;not null;default:0"`
|
||||
IPMaxConn int `gorm:"column:ip_max_conn;not null;default:0"`
|
||||
IPSpeedID sql.NullInt64 `gorm:"column:ip_speed_id"`
|
||||
ProxyProtocol int `gorm:"column:proxy_protocol;not null;default:0"`
|
||||
}
|
||||
|
||||
func (Forward) TableName() string { return "forward" }
|
||||
@@ -425,21 +430,24 @@ type ChainTunnelBackup struct {
|
||||
}
|
||||
|
||||
type ForwardBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
Name string `json:"name"`
|
||||
TunnelID int64 `json:"tunnelId"`
|
||||
RemoteAddr string `json:"remoteAddr"`
|
||||
Strategy string `json:"strategy"`
|
||||
InFlow int64 `json:"inFlow"`
|
||||
OutFlow int64 `json:"outFlow"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
Status int `json:"status"`
|
||||
Inx int `json:"inx"`
|
||||
SpeedID *int64 `json:"speedId,omitempty"`
|
||||
ForwardPorts *[]ForwardPortBackup `json:"forwardPorts,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
Name string `json:"name"`
|
||||
TunnelID int64 `json:"tunnelId"`
|
||||
RemoteAddr string `json:"remoteAddr"`
|
||||
Strategy string `json:"strategy"`
|
||||
InFlow int64 `json:"inFlow"`
|
||||
OutFlow int64 `json:"outFlow"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
Status int `json:"status"`
|
||||
Inx int `json:"inx"`
|
||||
SpeedID *int64 `json:"speedId,omitempty"`
|
||||
IPMaxConn int `json:"ipMaxConn,omitempty"`
|
||||
IPSpeedID *int64 `json:"ipSpeedId,omitempty"`
|
||||
ForwardPorts *[]ForwardPortBackup `json:"forwardPorts,omitempty"`
|
||||
ProxyProtocol int `json:"proxyProtocol"`
|
||||
}
|
||||
|
||||
type ForwardPortBackup struct {
|
||||
@@ -528,15 +536,19 @@ type ImportResult struct {
|
||||
|
||||
// ForwardRecord is a minimal forward view used by control plane and flow policy.
|
||||
type ForwardRecord struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
UserName string
|
||||
Name string
|
||||
TunnelID int64
|
||||
RemoteAddr string
|
||||
Strategy string
|
||||
Status int
|
||||
SpeedID sql.NullInt64
|
||||
ID int64
|
||||
UserID int64
|
||||
UserName string
|
||||
Name string
|
||||
TunnelID int64
|
||||
RemoteAddr string
|
||||
Strategy string
|
||||
Status int
|
||||
SpeedID sql.NullInt64
|
||||
MaxConn int
|
||||
IPMaxConn int
|
||||
IPSpeedID sql.NullInt64
|
||||
ProxyProtocol int
|
||||
}
|
||||
|
||||
// TunnelRecord is a minimal tunnel view used by control plane.
|
||||
@@ -546,6 +558,7 @@ type TunnelRecord struct {
|
||||
Status int
|
||||
Flow int64
|
||||
TrafficRatio float64
|
||||
Protocol string
|
||||
}
|
||||
|
||||
type UserQuotaView struct {
|
||||
|
||||
@@ -22,6 +22,13 @@ import (
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPostgresMaxOpenConns = 32
|
||||
defaultPostgresMaxIdleConns = 8
|
||||
defaultPostgresConnMaxIdle = 5 * time.Minute
|
||||
defaultPostgresConnMaxLife = 30 * time.Minute
|
||||
)
|
||||
|
||||
// ─── Type aliases for backward compatibility ─────────────────────────
|
||||
// Handlers still reference repo.User, repo.BackupData, etc.
|
||||
|
||||
@@ -58,7 +65,16 @@ type TunnelQuality = model.TunnelQuality
|
||||
// ─── Repository ──────────────────────────────────────────────────────
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
dbPath string
|
||||
}
|
||||
|
||||
type FlowUploadCounterDelta struct {
|
||||
ForwardID int64
|
||||
UserID int64
|
||||
UserTunnelID int64
|
||||
InFlow int64
|
||||
OutFlow int64
|
||||
}
|
||||
|
||||
func (r *Repository) DB() *gorm.DB {
|
||||
@@ -68,6 +84,79 @@ func (r *Repository) DB() *gorm.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
func sortedFlowUploadTargetIDs(totals map[int64][2]int64) []int64 {
|
||||
ids := make([]int64, 0, len(totals))
|
||||
for id := range totals {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
return ids
|
||||
}
|
||||
|
||||
func (r *Repository) ApplyFlowUploadDeltasBatch(deltas []FlowUploadCounterDelta) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if len(deltas) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
forwardTotals := make(map[int64][2]int64, len(deltas))
|
||||
userTotals := make(map[int64][2]int64, len(deltas))
|
||||
userTunnelTotals := make(map[int64][2]int64, len(deltas))
|
||||
for _, delta := range deltas {
|
||||
if delta.ForwardID > 0 {
|
||||
current := forwardTotals[delta.ForwardID]
|
||||
current[0] += delta.InFlow
|
||||
current[1] += delta.OutFlow
|
||||
forwardTotals[delta.ForwardID] = current
|
||||
}
|
||||
if delta.UserID > 0 {
|
||||
current := userTotals[delta.UserID]
|
||||
current[0] += delta.InFlow
|
||||
current[1] += delta.OutFlow
|
||||
userTotals[delta.UserID] = current
|
||||
}
|
||||
if delta.UserTunnelID > 0 {
|
||||
current := userTunnelTotals[delta.UserTunnelID]
|
||||
current[0] += delta.InFlow
|
||||
current[1] += delta.OutFlow
|
||||
userTunnelTotals[delta.UserTunnelID] = current
|
||||
}
|
||||
}
|
||||
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, forwardID := range sortedFlowUploadTargetIDs(forwardTotals) {
|
||||
total := forwardTotals[forwardID]
|
||||
if err := tx.Model(&model.Forward{}).Where("id = ?", forwardID).UpdateColumns(map[string]interface{}{
|
||||
"in_flow": gorm.Expr("in_flow + ?", total[0]),
|
||||
"out_flow": gorm.Expr("out_flow + ?", total[1]),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, userID := range sortedFlowUploadTargetIDs(userTotals) {
|
||||
total := userTotals[userID]
|
||||
if err := tx.Model(&model.User{}).Where("id = ?", userID).UpdateColumns(map[string]interface{}{
|
||||
"in_flow": gorm.Expr("in_flow + ?", total[0]),
|
||||
"out_flow": gorm.Expr("out_flow + ?", total[1]),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, userTunnelID := range sortedFlowUploadTargetIDs(userTunnelTotals) {
|
||||
total := userTunnelTotals[userTunnelID]
|
||||
if err := tx.Model(&model.UserTunnel{}).Where("id = ?", userTunnelID).UpdateColumns(map[string]interface{}{
|
||||
"in_flow": gorm.Expr("in_flow + ?", total[0]),
|
||||
"out_flow": gorm.Expr("out_flow + ?", total[1]),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Open / Close ────────────────────────────────────────────────────
|
||||
|
||||
func Open(path string) (*Repository, error) {
|
||||
@@ -110,7 +199,7 @@ func Open(path string) (*Repository, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Repository{db: db}, nil
|
||||
return &Repository{db: db, dbPath: path}, nil
|
||||
}
|
||||
|
||||
func OpenPostgres(dsn string) (*Repository, error) {
|
||||
@@ -129,6 +218,7 @@ func OpenPostgres(dsn string) (*Repository, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configurePostgresPool(sqlDB)
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, err
|
||||
@@ -154,6 +244,16 @@ func OpenPostgres(dsn string) (*Repository, error) {
|
||||
return &Repository{db: db}, nil
|
||||
}
|
||||
|
||||
func configurePostgresPool(sqlDB *sql.DB) {
|
||||
if sqlDB == nil {
|
||||
return
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(defaultPostgresMaxOpenConns)
|
||||
sqlDB.SetMaxIdleConns(defaultPostgresMaxIdleConns)
|
||||
sqlDB.SetConnMaxIdleTime(defaultPostgresConnMaxIdle)
|
||||
sqlDB.SetConnMaxLifetime(defaultPostgresConnMaxLife)
|
||||
}
|
||||
|
||||
func (r *Repository) Close() error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
@@ -295,6 +395,17 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error {
|
||||
}
|
||||
}
|
||||
|
||||
if m.HasTable(&model.Forward{}) {
|
||||
for _, field := range []string{"ProxyProtocol"} {
|
||||
if m.HasColumn(&model.Forward{}, field) {
|
||||
continue
|
||||
}
|
||||
if err := m.AddColumn(&model.Forward{}, field); err != nil {
|
||||
return fmt.Errorf("add forward.%s: %w", field, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -679,11 +790,11 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
|
||||
"version": nullableString(n.Version),
|
||||
"http": n.HTTP, "tls": n.TLS, "socks": n.Socks,
|
||||
"status": n.Status, "isRemote": n.IsRemote,
|
||||
"remoteUrl": nullableString(n.RemoteURL),
|
||||
"remoteToken": nullableString(n.RemoteToken),
|
||||
"remoteConfig": nullableString(n.RemoteConfig),
|
||||
"expiryReminderDismissed": n.ExpiryReminderDismissed,
|
||||
"interfaceName": nullableString(n.InterfaceName),
|
||||
"remoteUrl": nullableString(n.RemoteURL),
|
||||
"remoteToken": nullableString(n.RemoteToken),
|
||||
"remoteConfig": nullableString(n.RemoteConfig),
|
||||
"expiryReminderDismissed": n.ExpiryReminderDismissed,
|
||||
"interfaceName": nullableString(n.InterfaceName),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
@@ -714,6 +825,7 @@ func (r *Repository) ListUsers() ([]map[string]interface{}, error) {
|
||||
"flowResetTime": u.FlowResetTime, "createdTime": u.CreatedTime,
|
||||
"updatedTime": nullableInt64(u.UpdatedTime),
|
||||
"inFlow": u.InFlow, "outFlow": u.OutFlow,
|
||||
"maxConn": u.MaxConn,
|
||||
}
|
||||
if quota := quotaMap[u.ID]; quota != nil {
|
||||
item["dailyQuotaGB"] = quota.DailyLimitGB
|
||||
@@ -754,27 +866,33 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
|
||||
}
|
||||
|
||||
type fwdRow struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
UserName string
|
||||
Name string
|
||||
TunnelID int64
|
||||
TunnelName string
|
||||
TrafficRatio float64
|
||||
RemoteAddr string
|
||||
Strategy string
|
||||
InFlow int64
|
||||
OutFlow int64
|
||||
CreatedTime int64
|
||||
Status int
|
||||
Inx int
|
||||
SpeedID sql.NullInt64
|
||||
ID int64
|
||||
UserID int64
|
||||
UserName string
|
||||
Name string
|
||||
TunnelID int64
|
||||
TunnelName string
|
||||
TrafficRatio float64
|
||||
RemoteAddr string
|
||||
Strategy string
|
||||
InFlow int64
|
||||
OutFlow int64
|
||||
CreatedTime int64
|
||||
Status int
|
||||
Inx int
|
||||
SpeedID sql.NullInt64
|
||||
MaxConn int
|
||||
IPMaxConn int
|
||||
IPSpeedID sql.NullInt64
|
||||
IPSpeedLimitName string
|
||||
ProxyProtocol int
|
||||
}
|
||||
|
||||
var rows []fwdRow
|
||||
err := r.db.Model(&model.Forward{}).
|
||||
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id").
|
||||
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id, forward.max_conn, forward.ip_max_conn, forward.ip_speed_id, COALESCE(ip_speed_limit.name, '') AS ip_speed_limit_name, forward.proxy_protocol").
|
||||
Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
|
||||
Joins("LEFT JOIN speed_limit AS ip_speed_limit ON ip_speed_limit.id = forward.ip_speed_id").
|
||||
Order("forward.inx ASC, forward.id ASC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
@@ -795,10 +913,19 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
|
||||
"remoteAddr": row.RemoteAddr, "strategy": row.Strategy,
|
||||
"inFlow": row.InFlow, "outFlow": row.OutFlow,
|
||||
"createdTime": row.CreatedTime, "status": row.Status, "inx": int64(row.Inx),
|
||||
"maxConn": row.MaxConn,
|
||||
"ipMaxConn": row.IPMaxConn,
|
||||
"proxyProtocol": row.ProxyProtocol,
|
||||
}
|
||||
if row.SpeedID.Valid {
|
||||
item["speedId"] = row.SpeedID.Int64
|
||||
}
|
||||
if row.IPSpeedID.Valid {
|
||||
item["ipSpeedId"] = row.IPSpeedID.Int64
|
||||
}
|
||||
if strings.TrimSpace(row.IPSpeedLimitName) != "" {
|
||||
item["ipSpeedLimitName"] = row.IPSpeedLimitName
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
@@ -1987,6 +2114,16 @@ func (r *Repository) exportForwards() ([]model.ForwardBackup, error) {
|
||||
TunnelID: f.TunnelID, RemoteAddr: f.RemoteAddr, Strategy: f.Strategy,
|
||||
InFlow: f.InFlow, OutFlow: f.OutFlow, CreatedTime: f.CreatedTime,
|
||||
UpdatedTime: f.UpdatedTime, Status: f.Status, Inx: f.Inx,
|
||||
IPMaxConn: f.IPMaxConn,
|
||||
ProxyProtocol: f.ProxyProtocol,
|
||||
}
|
||||
if f.SpeedID.Valid {
|
||||
v := f.SpeedID.Int64
|
||||
b.SpeedID = &v
|
||||
}
|
||||
if f.IPSpeedID.Valid {
|
||||
v := f.IPSpeedID.Int64
|
||||
b.IPSpeedID = &v
|
||||
}
|
||||
ports, err := r.exportForwardPorts(f.ID)
|
||||
if err != nil {
|
||||
@@ -2367,29 +2504,40 @@ func importTunnels(tx *gorm.DB, tunnels []model.TunnelBackup, now int64) (int, e
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func nullableBackupInt64(v *int64) int64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
func importForwards(tx *gorm.DB, forwards []model.ForwardBackup, now int64) (int, error) {
|
||||
count := 0
|
||||
for _, f := range forwards {
|
||||
item := model.Forward{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
InFlow: f.InFlow,
|
||||
OutFlow: f.OutFlow,
|
||||
CreatedTime: f.CreatedTime,
|
||||
UpdatedTime: now,
|
||||
Status: f.Status,
|
||||
Inx: f.Inx,
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
InFlow: f.InFlow,
|
||||
OutFlow: f.OutFlow,
|
||||
CreatedTime: f.CreatedTime,
|
||||
UpdatedTime: now,
|
||||
Status: f.Status,
|
||||
Inx: f.Inx,
|
||||
SpeedID: sql.NullInt64{Int64: nullableBackupInt64(f.SpeedID), Valid: f.SpeedID != nil && *f.SpeedID > 0},
|
||||
IPMaxConn: f.IPMaxConn,
|
||||
IPSpeedID: sql.NullInt64{Int64: nullableBackupInt64(f.IPSpeedID), Valid: f.IPSpeedID != nil && *f.IPSpeedID > 0},
|
||||
ProxyProtocol: f.ProxyProtocol,
|
||||
}
|
||||
err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"user_id", "user_name", "name", "tunnel_id", "remote_addr", "strategy",
|
||||
"in_flow", "out_flow", "updated_time", "status", "inx",
|
||||
"in_flow", "out_flow", "updated_time", "status", "inx", "speed_id", "ip_max_conn", "ip_speed_id", "proxy_protocol",
|
||||
}),
|
||||
}).Create(&item).Error
|
||||
if err != nil {
|
||||
@@ -3337,7 +3485,7 @@ func (r *Repository) GetNodeMetrics(nodeID int64, startMs, endMs int64) ([]model
|
||||
|
||||
rangeMs := endMs - startMs
|
||||
const maxRawRangeMs = int64(60 * 60 * 1000) // 1 hour — return raw data for short ranges
|
||||
const targetPoints = 500 // target number of chart points for downsampled data
|
||||
const targetPoints = 500 // target number of chart points for downsampled data
|
||||
|
||||
// For short ranges, return raw data (full resolution).
|
||||
if rangeMs <= maxRawRangeMs {
|
||||
|
||||
@@ -45,15 +45,19 @@ func (r *Repository) ListForwardsByTunnelTx(tx *gorm.DB, tunnelID int64) ([]mode
|
||||
rows := make([]model.ForwardRecord, 0, len(forwards))
|
||||
for _, f := range forwards {
|
||||
rows = append(rows, model.ForwardRecord{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
MaxConn: f.MaxConn,
|
||||
IPMaxConn: f.IPMaxConn,
|
||||
IPSpeedID: f.IPSpeedID,
|
||||
ProxyProtocol: f.ProxyProtocol,
|
||||
})
|
||||
}
|
||||
for i := range rows {
|
||||
@@ -64,7 +68,6 @@ func (r *Repository) ListForwardsByTunnelTx(tx *gorm.DB, tunnelID int64) ([]mode
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
|
||||
func (r *Repository) ListActiveTunnelIDsByNode(nodeID int64) ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
@@ -99,6 +102,22 @@ func (r *Repository) ListActiveForwardIDsByNode(nodeID int64) ([]int64, error) {
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListForwardIDsByNode(nodeID int64) ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.ForwardPort{}).
|
||||
Where("forward_port.node_id = ?", nodeID).
|
||||
Select("DISTINCT forward_port.forward_id").
|
||||
Order("forward_port.forward_id ASC").
|
||||
Pluck("forward_port.forward_id", &ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
@@ -126,7 +145,6 @@ func (r *Repository) ListForwardPortsTx(tx *gorm.DB, forwardID int64) ([]model.F
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
|
||||
func (r *Repository) HasOtherForwardOnNodePort(nodeID int64, port int, currentForwardID int64) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
@@ -153,7 +171,6 @@ func (r *Repository) HasOtherForwardOnNodePortTx(tx *gorm.DB, nodeID int64, port
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
|
||||
func (r *Repository) GetTunnelOutProtocol(tunnelID int64) (string, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return "", errors.New("repository not initialized")
|
||||
|
||||
@@ -9,6 +9,90 @@ import (
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
type FlowUploadForwardMeta struct {
|
||||
ForwardID int64
|
||||
TunnelID int64
|
||||
TrafficRatio float64
|
||||
TunnelFlow int64
|
||||
}
|
||||
|
||||
const flowUploadForwardMetaChunkSize = 500
|
||||
|
||||
func chunkFlowUploadForwardIDs(ids []int64) [][]int64 {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
chunks := make([][]int64, 0, (len(ids)+flowUploadForwardMetaChunkSize-1)/flowUploadForwardMetaChunkSize)
|
||||
for start := 0; start < len(ids); start += flowUploadForwardMetaChunkSize {
|
||||
end := start + flowUploadForwardMetaChunkSize
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
chunks = append(chunks, ids[start:end])
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func (r *Repository) GetFlowUploadForwardMetas(forwardIDs []int64) (map[int64]FlowUploadForwardMeta, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
if len(forwardIDs) == 0 {
|
||||
return map[int64]FlowUploadForwardMeta{}, nil
|
||||
}
|
||||
|
||||
ids := make([]int64, 0, len(forwardIDs))
|
||||
seen := make(map[int64]struct{}, len(forwardIDs))
|
||||
for _, id := range forwardIDs {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return map[int64]FlowUploadForwardMeta{}, nil
|
||||
}
|
||||
|
||||
type row struct {
|
||||
ForwardID int64 `gorm:"column:forward_id"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id"`
|
||||
TrafficRatio float64 `gorm:"column:traffic_ratio"`
|
||||
TunnelFlow int64 `gorm:"column:tunnel_flow"`
|
||||
}
|
||||
|
||||
out := make(map[int64]FlowUploadForwardMeta, len(ids))
|
||||
for _, chunk := range chunkFlowUploadForwardIDs(ids) {
|
||||
var rows []row
|
||||
err := r.db.Table("forward AS f").
|
||||
Select("f.id AS forward_id, f.tunnel_id AS tunnel_id, t.traffic_ratio AS traffic_ratio, t.flow AS tunnel_flow").
|
||||
Joins("LEFT JOIN tunnel t ON t.id = f.tunnel_id").
|
||||
Where("f.id IN ?", chunk).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.TunnelFlow <= 0 {
|
||||
row.TunnelFlow = 1
|
||||
}
|
||||
if row.TrafficRatio <= 0 {
|
||||
row.TrafficRatio = 1
|
||||
}
|
||||
out[row.ForwardID] = FlowUploadForwardMeta{
|
||||
ForwardID: row.ForwardID,
|
||||
TunnelID: row.TunnelID,
|
||||
TrafficRatio: row.TrafficRatio,
|
||||
TunnelFlow: row.TunnelFlow,
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateForwardStatus(forwardID int64, status int, now int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
@@ -30,15 +114,19 @@ func (r *Repository) ListActiveForwardsByUser(userID int64) ([]model.ForwardReco
|
||||
rows := make([]model.ForwardRecord, 0, len(forwards))
|
||||
for _, f := range forwards {
|
||||
rows = append(rows, model.ForwardRecord{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
MaxConn: f.MaxConn,
|
||||
IPMaxConn: f.IPMaxConn,
|
||||
IPSpeedID: f.IPSpeedID,
|
||||
ProxyProtocol: f.ProxyProtocol,
|
||||
})
|
||||
}
|
||||
for i := range rows {
|
||||
@@ -61,15 +149,19 @@ func (r *Repository) ListActiveForwardsByUserTunnel(userID, tunnelID int64) ([]m
|
||||
rows := make([]model.ForwardRecord, 0, len(forwards))
|
||||
for _, f := range forwards {
|
||||
rows = append(rows, model.ForwardRecord{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
MaxConn: f.MaxConn,
|
||||
IPMaxConn: f.IPMaxConn,
|
||||
IPSpeedID: f.IPSpeedID,
|
||||
ProxyProtocol: f.ProxyProtocol,
|
||||
})
|
||||
}
|
||||
for i := range rows {
|
||||
@@ -92,15 +184,19 @@ func (r *Repository) ListForwardsByUserAndTunnel(userID, tunnelID int64) ([]mode
|
||||
rows := make([]model.ForwardRecord, 0, len(forwards))
|
||||
for _, f := range forwards {
|
||||
rows = append(rows, model.ForwardRecord{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
MaxConn: f.MaxConn,
|
||||
IPMaxConn: f.IPMaxConn,
|
||||
IPSpeedID: f.IPSpeedID,
|
||||
ProxyProtocol: f.ProxyProtocol,
|
||||
})
|
||||
}
|
||||
for i := range rows {
|
||||
@@ -124,15 +220,19 @@ func (r *Repository) GetForwardRecord(forwardID int64) (*model.ForwardRecord, er
|
||||
return nil, err
|
||||
}
|
||||
fr := model.ForwardRecord{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
MaxConn: f.MaxConn,
|
||||
IPMaxConn: f.IPMaxConn,
|
||||
IPSpeedID: f.IPSpeedID,
|
||||
ProxyProtocol: f.ProxyProtocol,
|
||||
}
|
||||
if strings.TrimSpace(fr.Strategy) == "" {
|
||||
fr.Strategy = "fifo"
|
||||
@@ -158,6 +258,7 @@ func (r *Repository) GetTunnelRecord(tunnelID int64) (*model.TunnelRecord, error
|
||||
Status: t.Status,
|
||||
Flow: t.Flow,
|
||||
TrafficRatio: t.TrafficRatio,
|
||||
Protocol: t.Protocol,
|
||||
}
|
||||
if tr.Flow <= 0 {
|
||||
tr.Flow = 1
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestChunkFlowUploadForwardIDs(t *testing.T) {
|
||||
ids := make([]int64, 0, 1001)
|
||||
for i := int64(1); i <= 1001; i++ {
|
||||
ids = append(ids, i)
|
||||
}
|
||||
|
||||
chunks := chunkFlowUploadForwardIDs(ids)
|
||||
if len(chunks) != 3 {
|
||||
t.Fatalf("expected 3 chunks, got %d", len(chunks))
|
||||
}
|
||||
if len(chunks[0]) != 500 || len(chunks[1]) != 500 || len(chunks[2]) != 1 {
|
||||
t.Fatalf("unexpected chunk sizes: %d, %d, %d", len(chunks[0]), len(chunks[1]), len(chunks[2]))
|
||||
}
|
||||
if chunks[0][0] != 1 || chunks[1][0] != 501 || chunks[2][0] != 1001 {
|
||||
t.Fatalf("unexpected chunk boundaries: %#v %#v %#v", chunks[0][:1], chunks[1][:1], chunks[2][:1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortedFlowUploadTargetIDs(t *testing.T) {
|
||||
totals := map[int64][2]int64{
|
||||
9: {1, 1},
|
||||
2: {1, 1},
|
||||
7: {1, 1},
|
||||
}
|
||||
|
||||
got := sortedFlowUploadTargetIDs(totals)
|
||||
want := []int64{2, 7, 9}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("expected sorted ids %v, got %v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFlowUploadForwardMetasAndApplyFlowUploadDeltasBatch(t *testing.T) {
|
||||
r, err := Open(filepath.Join(t.TempDir(), "flow-batch.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) VALUES(2, 'u2', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(1, 't1', 2.0, 1, 'tls', 3, ?, ?, 1, NULL, 0)`, 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, 99999, 99999, 0, 0, 1, 2727251700000, 1)`).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:80', 'fifo', 0, 0, ?, ?, 1, 0)`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
|
||||
metas, err := r.GetFlowUploadForwardMetas([]int64{20, 99})
|
||||
if err != nil {
|
||||
t.Fatalf("get metas: %v", err)
|
||||
}
|
||||
if metas[20].TunnelID != 1 || metas[20].TrafficRatio != 2 || metas[20].TunnelFlow != 3 {
|
||||
t.Fatalf("unexpected meta for forward 20: %#v", metas[20])
|
||||
}
|
||||
if _, ok := metas[99]; ok {
|
||||
t.Fatalf("did not expect meta for missing forward 99")
|
||||
}
|
||||
|
||||
err = r.ApplyFlowUploadDeltasBatch([]FlowUploadCounterDelta{{ForwardID: 20, UserID: 2, UserTunnelID: 10, InFlow: 480, OutFlow: 660}})
|
||||
if err != nil {
|
||||
t.Fatalf("apply flow batch: %v", err)
|
||||
}
|
||||
if got := mustFlowBatchCount(t, r, `SELECT in_flow FROM forward WHERE id = 20`); got != 480 {
|
||||
t.Fatalf("expected forward in_flow=480, got %d", got)
|
||||
}
|
||||
if got := mustFlowBatchCount(t, r, `SELECT out_flow FROM user WHERE id = 2`); got != 660 {
|
||||
t.Fatalf("expected user out_flow=660, got %d", got)
|
||||
}
|
||||
if got := mustFlowBatchCount(t, r, `SELECT in_flow FROM user_tunnel WHERE id = 10`); got != 480 {
|
||||
t.Fatalf("expected user_tunnel in_flow=480, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFlowUploadForwardMetasKeepsForwardsWhenTunnelRowMissing(t *testing.T) {
|
||||
r, err := Open(filepath.Join(t.TempDir(), "flow-batch-missing-tunnel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
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(25, 2, 'u2', 'f25', 99, '1.1.1.1:80', 'fifo', 0, 0, ?, ?, 1, 0)`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
|
||||
metas, err := r.GetFlowUploadForwardMetas([]int64{25})
|
||||
if err != nil {
|
||||
t.Fatalf("get metas: %v", err)
|
||||
}
|
||||
meta, ok := metas[25]
|
||||
if !ok {
|
||||
t.Fatalf("expected metadata for forward with missing tunnel row")
|
||||
}
|
||||
if meta.ForwardID != 25 || meta.TunnelID != 99 || meta.TrafficRatio != 1 || meta.TunnelFlow != 1 {
|
||||
t.Fatalf("unexpected fallback meta: %#v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddUserQuotaUsageBatchReturnsNormalizedViews(t *testing.T) {
|
||||
r, err := Open(filepath.Join(t.TempDir(), "quota-batch.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now()
|
||||
nowMs := now.UnixMilli()
|
||||
if err := r.DB().Exec(`INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) VALUES(2, 'u2', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
views, err := r.AddUserQuotaUsageBatch(map[int64]int64{2: 1140}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("batch quota update: %v", err)
|
||||
}
|
||||
if views[2] == nil || views[2].DailyUsedBytes != 1140 || views[2].MonthlyUsedBytes != 1140 {
|
||||
t.Fatalf("unexpected quota view: %#v", views[2])
|
||||
}
|
||||
}
|
||||
|
||||
func mustFlowBatchCount(t *testing.T, r *Repository, query string, args ...interface{}) int64 {
|
||||
t.Helper()
|
||||
var value int64
|
||||
if err := r.DB().Raw(query, args...).Row().Scan(&value); err != nil {
|
||||
t.Fatalf("query %q failed: %v", query, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
func TestGetForwardRecordIncludesProxyProtocol(t *testing.T) {
|
||||
r, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Create(&model.Forward{
|
||||
UserID: 1,
|
||||
UserName: "admin",
|
||||
Name: "proxy-forward",
|
||||
TunnelID: 1,
|
||||
RemoteAddr: "1.1.1.1:443",
|
||||
Strategy: "fifo",
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
Status: 1,
|
||||
ProxyProtocol: 2,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create forward: %v", err)
|
||||
}
|
||||
|
||||
forwardID := mustRepoLastInsertID(t, r)
|
||||
record, err := r.GetForwardRecord(forwardID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetForwardRecord: %v", err)
|
||||
}
|
||||
if record == nil {
|
||||
t.Fatalf("expected forward record")
|
||||
}
|
||||
if record.ProxyProtocol != 2 {
|
||||
t.Fatalf("expected proxyProtocol 2, got %d", record.ProxyProtocol)
|
||||
}
|
||||
if record.MaxConn != 0 {
|
||||
t.Fatalf("expected default maxConn 0, got %d", record.MaxConn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListForwardsByTunnelIncludesProxyProtocol(t *testing.T) {
|
||||
r, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Create(&model.Forward{
|
||||
UserID: 1,
|
||||
UserName: "admin",
|
||||
Name: "proxy-forward",
|
||||
TunnelID: 7,
|
||||
RemoteAddr: "1.1.1.1:443",
|
||||
Strategy: "fifo",
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
Status: 1,
|
||||
ProxyProtocol: 2,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create forward: %v", err)
|
||||
}
|
||||
|
||||
records, err := r.ListForwardsByTunnel(7)
|
||||
if err != nil {
|
||||
t.Fatalf("ListForwardsByTunnel: %v", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("expected 1 forward record, got %d", len(records))
|
||||
}
|
||||
if records[0].ProxyProtocol != 2 {
|
||||
t.Fatalf("expected proxyProtocol 2, got %d", records[0].ProxyProtocol)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListForwardsByTunnelIncludesMaxConn(t *testing.T) {
|
||||
r, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Create(&model.Forward{
|
||||
UserID: 1,
|
||||
UserName: "admin",
|
||||
Name: "max-conn-forward",
|
||||
TunnelID: 8,
|
||||
RemoteAddr: "1.1.1.1:443",
|
||||
Strategy: "fifo",
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
Status: 1,
|
||||
MaxConn: 42,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create forward: %v", err)
|
||||
}
|
||||
|
||||
records, err := r.ListForwardsByTunnel(8)
|
||||
if err != nil {
|
||||
t.Fatalf("ListForwardsByTunnel: %v", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("expected 1 forward record, got %d", len(records))
|
||||
}
|
||||
if records[0].MaxConn != 42 {
|
||||
t.Fatalf("expected maxConn 42, got %d", records[0].MaxConn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListActiveForwardsByUserTunnelIncludesMaxConn(t *testing.T) {
|
||||
r, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Create(&model.Forward{
|
||||
UserID: 2,
|
||||
UserName: "user",
|
||||
Name: "active-max-conn-forward",
|
||||
TunnelID: 9,
|
||||
RemoteAddr: "1.1.1.1:443",
|
||||
Strategy: "fifo",
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
Status: 1,
|
||||
MaxConn: 55,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create forward: %v", err)
|
||||
}
|
||||
|
||||
records, err := r.ListActiveForwardsByUserTunnel(2, 9)
|
||||
if err != nil {
|
||||
t.Fatalf("ListActiveForwardsByUserTunnel: %v", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("expected 1 forward record, got %d", len(records))
|
||||
}
|
||||
if records[0].MaxConn != 55 {
|
||||
t.Fatalf("expected maxConn 55, got %d", records[0].MaxConn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardRepositoryPersistsPerIPLimits(t *testing.T) {
|
||||
r, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
forwardID, err := r.CreateForwardTx(1, "admin", "per-ip-forward", 2, "1.1.1.1:443", "fifo", now, 1, []int64{3}, 24000, "", nil, 0, 5, int64(21), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateForwardTx: %v", err)
|
||||
}
|
||||
record, err := r.GetForwardRecord(forwardID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetForwardRecord after create: %v", err)
|
||||
}
|
||||
if record.IPMaxConn != 5 {
|
||||
t.Fatalf("expected created ipMaxConn 5, got %d", record.IPMaxConn)
|
||||
}
|
||||
if !record.IPSpeedID.Valid || record.IPSpeedID.Int64 != 21 {
|
||||
t.Fatalf("expected created ipSpeedId 21, got %+v", record.IPSpeedID)
|
||||
}
|
||||
|
||||
if err := r.UpdateForward(forwardID, "per-ip-forward", 2, "2.2.2.2:443", "fifo", now+1, nil, 0, 9, int64(22), 0); err != nil {
|
||||
t.Fatalf("UpdateForward: %v", err)
|
||||
}
|
||||
record, err = r.GetForwardRecord(forwardID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetForwardRecord after update: %v", err)
|
||||
}
|
||||
if record.IPMaxConn != 9 {
|
||||
t.Fatalf("expected updated ipMaxConn 9, got %d", record.IPMaxConn)
|
||||
}
|
||||
if !record.IPSpeedID.Valid || record.IPSpeedID.Int64 != 22 {
|
||||
t.Fatalf("expected updated ipSpeedId 22, got %+v", record.IPSpeedID)
|
||||
}
|
||||
|
||||
if err := r.DB().Create(&model.Forward{
|
||||
UserID: 4,
|
||||
UserName: "user",
|
||||
Name: "listed-per-ip-forward",
|
||||
TunnelID: 8,
|
||||
RemoteAddr: "3.3.3.3:443",
|
||||
Strategy: "fifo",
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
Status: 1,
|
||||
IPMaxConn: 11,
|
||||
IPSpeedID: sql.NullInt64{Int64: 33, Valid: true},
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create listed forward: %v", err)
|
||||
}
|
||||
records, err := r.ListForwardsByTunnel(8)
|
||||
if err != nil {
|
||||
t.Fatalf("ListForwardsByTunnel: %v", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("expected 1 listed record, got %d", len(records))
|
||||
}
|
||||
if records[0].IPMaxConn != 11 || !records[0].IPSpeedID.Valid || records[0].IPSpeedID.Int64 != 33 {
|
||||
t.Fatalf("expected listed per-IP limits 11/33, got ipMaxConn=%d ipSpeedId=%+v", records[0].IPMaxConn, records[0].IPSpeedID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackForwardFieldsRestoresPerIPLimits(t *testing.T) {
|
||||
r, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
forwardID, err := r.CreateForwardTx(1, "admin", "rollback-per-ip-forward", 2, "1.1.1.1:443", "fifo", now, 1, nil, 0, "", nil, 7, 5, int64(21), 2)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateForwardTx: %v", err)
|
||||
}
|
||||
if err := r.UpdateForward(forwardID, "rollback-per-ip-forward", 2, "2.2.2.2:443", "fifo", now+1, nil, 0, 0, nil, 0); err != nil {
|
||||
t.Fatalf("UpdateForward: %v", err)
|
||||
}
|
||||
|
||||
r.RollbackForwardFields(forwardID, 1, "admin", "rollback-per-ip-forward", 2, "1.1.1.1:443", "fifo", 1, nil, 7, 5, int64(21), 2, now+2)
|
||||
|
||||
record, err := r.GetForwardRecord(forwardID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetForwardRecord: %v", err)
|
||||
}
|
||||
if record.IPMaxConn != 5 {
|
||||
t.Fatalf("expected rollback ipMaxConn 5, got %d", record.IPMaxConn)
|
||||
}
|
||||
if !record.IPSpeedID.Valid || record.IPSpeedID.Int64 != 21 {
|
||||
t.Fatalf("expected rollback ipSpeedId 21, got %+v", record.IPSpeedID)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRepoLastInsertID(t *testing.T, r *Repository) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
if err := r.DB().Raw("SELECT last_insert_rowid()").Row().Scan(&id); err != nil {
|
||||
t.Fatalf("last_insert_rowid: %v", err)
|
||||
}
|
||||
if id <= 0 {
|
||||
t.Fatalf("invalid last_insert_rowid %d", id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func (r *Repository) UserExistsExcluding(username string, excludeID int64) (bool
|
||||
return cnt > 0, err
|
||||
}
|
||||
|
||||
func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, flow, flowResetTime int64, num, status int, now int64) (int64, error) {
|
||||
func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, flow, flowResetTime int64, num, status, maxConn int, now int64) (int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return 0, errors.New("repository not initialized")
|
||||
}
|
||||
@@ -51,6 +51,7 @@ func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, f
|
||||
OutFlow: 0,
|
||||
FlowResetTime: flowResetTime,
|
||||
Num: num,
|
||||
MaxConn: maxConn,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||
Status: status,
|
||||
@@ -73,7 +74,7 @@ func (r *Repository) GetUserRoleID(userID int64) (int, error) {
|
||||
return user.RoleID, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateUserWithPassword(id int64, username, pwdHash string, flow int64, num int, expTime, flowResetTime int64, status int, now int64) error {
|
||||
func (r *Repository) UpdateUserWithPassword(id int64, username, pwdHash string, flow int64, num int, expTime, flowResetTime int64, status, maxConn int, now int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
@@ -87,11 +88,12 @@ func (r *Repository) UpdateUserWithPassword(id int64, username, pwdHash string,
|
||||
"exp_time": expTime,
|
||||
"flow_reset_time": flowResetTime,
|
||||
"status": status,
|
||||
"max_conn": maxConn,
|
||||
"updated_time": sql.NullInt64{Int64: now, Valid: true},
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateUserWithoutPassword(id int64, username string, flow int64, num int, expTime, flowResetTime int64, status int, now int64) error {
|
||||
func (r *Repository) UpdateUserWithoutPassword(id int64, username string, flow int64, num int, expTime, flowResetTime int64, status, maxConn int, now int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
@@ -104,6 +106,7 @@ func (r *Repository) UpdateUserWithoutPassword(id int64, username string, flow i
|
||||
"exp_time": expTime,
|
||||
"flow_reset_time": flowResetTime,
|
||||
"status": status,
|
||||
"max_conn": maxConn,
|
||||
"updated_time": sql.NullInt64{Int64: now, Valid: true},
|
||||
}).Error
|
||||
}
|
||||
@@ -394,7 +397,7 @@ func (r *Repository) UpdateTunnelOrder(tunnelID int64, inx int, now int64) {
|
||||
Updates(map[string]interface{}{"inx": inx, "updated_time": now}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateTunnelTx(tx *gorm.DB, tunnelID int64, name string, typeVal int, flow int64, trafficRatio float64, status int, inIP, ipPreference string, now int64) error {
|
||||
func (r *Repository) UpdateTunnelTx(tx *gorm.DB, tunnelID int64, name string, typeVal int, flow int64, trafficRatio float64, status int, inIP, ipPreference string, protocol string, now int64) error {
|
||||
if tx == nil {
|
||||
return errors.New("database unavailable")
|
||||
}
|
||||
@@ -408,6 +411,7 @@ func (r *Repository) UpdateTunnelTx(tx *gorm.DB, tunnelID int64, name string, ty
|
||||
"status": status,
|
||||
"in_ip": nullStringFromInterface(inIP),
|
||||
"ip_preference": ipPreference,
|
||||
"protocol": protocol,
|
||||
"updated_time": now,
|
||||
}).Error
|
||||
}
|
||||
@@ -691,19 +695,23 @@ func (r *Repository) GetMinForwardPort(forwardID int64) sql.NullInt64 {
|
||||
return p
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateForward(id int64, name string, tunnelID int64, remoteAddr, strategy string, now int64, speedID interface{}) error {
|
||||
func (r *Repository) UpdateForward(id int64, name string, tunnelID int64, remoteAddr, strategy string, now int64, speedID interface{}, maxConn int, ipMaxConn int, ipSpeedID interface{}, proxyProtocol int) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
return r.db.Model(&model.Forward{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"name": name,
|
||||
"tunnel_id": tunnelID,
|
||||
"remote_addr": remoteAddr,
|
||||
"strategy": strategy,
|
||||
"speed_id": nullInt64FromInterface(speedID),
|
||||
"updated_time": now,
|
||||
"name": name,
|
||||
"tunnel_id": tunnelID,
|
||||
"remote_addr": remoteAddr,
|
||||
"strategy": strategy,
|
||||
"speed_id": nullInt64FromInterface(speedID),
|
||||
"max_conn": maxConn,
|
||||
"ip_max_conn": ipMaxConn,
|
||||
"ip_speed_id": nullInt64FromInterface(ipSpeedID),
|
||||
"proxy_protocol": proxyProtocol,
|
||||
"updated_time": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -777,22 +785,26 @@ func (r *Repository) UpdateForwardPortBindIP(forwardID, nodeID int64, port int,
|
||||
Update("in_ip", sql.NullString{String: inIP, Valid: strings.TrimSpace(inIP) != ""}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) RollbackForwardFields(id, userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, status int, speedID interface{}, now int64) {
|
||||
func (r *Repository) RollbackForwardFields(id, userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, status int, speedID interface{}, maxConn int, ipMaxConn int, ipSpeedID interface{}, proxyProtocol int, now int64) {
|
||||
if r == nil || r.db == nil {
|
||||
return
|
||||
}
|
||||
_ = r.db.Model(&model.Forward{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"user_name": userName,
|
||||
"name": name,
|
||||
"tunnel_id": tunnelID,
|
||||
"remote_addr": remoteAddr,
|
||||
"strategy": strategy,
|
||||
"status": status,
|
||||
"speed_id": nullInt64FromInterface(speedID),
|
||||
"updated_time": now,
|
||||
"user_id": userID,
|
||||
"user_name": userName,
|
||||
"name": name,
|
||||
"tunnel_id": tunnelID,
|
||||
"remote_addr": remoteAddr,
|
||||
"strategy": strategy,
|
||||
"status": status,
|
||||
"speed_id": nullInt64FromInterface(speedID),
|
||||
"max_conn": maxConn,
|
||||
"ip_max_conn": ipMaxConn,
|
||||
"ip_speed_id": nullInt64FromInterface(ipSpeedID),
|
||||
"proxy_protocol": proxyProtocol,
|
||||
"updated_time": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -1252,26 +1264,30 @@ func (r *Repository) EnsureUserTunnelGrant(userID, tunnelID int64) (int64, bool,
|
||||
return ut.ID, true, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, now int64, inx int, entryNodeIDs []int64, port int, inIp string, speedID interface{}) (int64, error) {
|
||||
func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, now int64, inx int, entryNodeIDs []int64, port int, inIp string, speedID interface{}, maxConn int, ipMaxConn int, ipSpeedID interface{}, proxyProtocol int) (int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return 0, errors.New("repository not initialized")
|
||||
}
|
||||
var forwardID int64
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
fwd := model.Forward{
|
||||
UserID: userID,
|
||||
UserName: userName,
|
||||
Name: name,
|
||||
TunnelID: tunnelID,
|
||||
RemoteAddr: remoteAddr,
|
||||
Strategy: strategy,
|
||||
InFlow: 0,
|
||||
OutFlow: 0,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
Status: 1,
|
||||
Inx: inx,
|
||||
SpeedID: nullInt64FromInterface(speedID),
|
||||
UserID: userID,
|
||||
UserName: userName,
|
||||
Name: name,
|
||||
TunnelID: tunnelID,
|
||||
RemoteAddr: remoteAddr,
|
||||
Strategy: strategy,
|
||||
InFlow: 0,
|
||||
OutFlow: 0,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
Status: 1,
|
||||
Inx: inx,
|
||||
MaxConn: maxConn,
|
||||
SpeedID: nullInt64FromInterface(speedID),
|
||||
IPMaxConn: ipMaxConn,
|
||||
IPSpeedID: nullInt64FromInterface(ipSpeedID),
|
||||
ProxyProtocol: proxyProtocol,
|
||||
}
|
||||
if err := tx.Create(&fwd).Error; err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
gsqlite "github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestConfigurePostgresPoolSetsMaxOpenConnections(t *testing.T) {
|
||||
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("db handle: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = sqlDB.Close()
|
||||
})
|
||||
|
||||
configurePostgresPool(sqlDB)
|
||||
|
||||
if got := sqlDB.Stats().MaxOpenConnections; got != defaultPostgresMaxOpenConns {
|
||||
t.Fatalf("expected max open conns %d, got %d", defaultPostgresMaxOpenConns, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type DatabaseStorageSummary struct {
|
||||
DBType string `json:"dbType"`
|
||||
DatabaseSizeBytes int64 `json:"databaseSizeBytes"`
|
||||
DatabaseSizeText string `json:"databaseSizeText"`
|
||||
}
|
||||
|
||||
func (r *Repository) DatabaseStorageSummary() (DatabaseStorageSummary, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return DatabaseStorageSummary{}, errors.New("repository not initialized")
|
||||
}
|
||||
|
||||
switch r.db.Dialector.Name() {
|
||||
case "sqlite":
|
||||
size, err := sqliteDatabaseFileSize(r.dbPath)
|
||||
if err != nil {
|
||||
return DatabaseStorageSummary{}, err
|
||||
}
|
||||
return DatabaseStorageSummary{DBType: "sqlite", DatabaseSizeBytes: size, DatabaseSizeText: formatDatabaseSize(size)}, nil
|
||||
case "postgres":
|
||||
var size int64
|
||||
if err := r.db.Raw("SELECT pg_database_size(current_database())").Scan(&size).Error; err != nil {
|
||||
return DatabaseStorageSummary{}, err
|
||||
}
|
||||
return DatabaseStorageSummary{DBType: "postgres", DatabaseSizeBytes: size, DatabaseSizeText: formatDatabaseSize(size)}, nil
|
||||
default:
|
||||
return DatabaseStorageSummary{}, fmt.Errorf("unsupported database dialect %q", r.db.Dialector.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func sqliteDatabaseFileSize(path string) (int64, error) {
|
||||
if path == "" || path == ":memory:" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var total int64
|
||||
for _, candidate := range []string{path, path + "-wal", path + "-shm"} {
|
||||
info, err := os.Stat(candidate)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
total += info.Size()
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func formatDatabaseSize(bytes int64) string {
|
||||
if bytes < 1024 {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
units := []string{"KB", "MB", "GB", "TB"}
|
||||
value := float64(bytes) / 1024
|
||||
for _, unit := range units {
|
||||
if value < 1024 || unit == "TB" {
|
||||
return fmt.Sprintf("%.1f %s", value, unit)
|
||||
}
|
||||
value /= 1024
|
||||
}
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
func TestDatabaseStorageSummarySQLiteIncludesSize(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "storage.db")
|
||||
r, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
if err := r.InsertNodeMetric(&model.NodeMetric{NodeID: 1, Timestamp: 123, CPUUsage: 1}); err != nil {
|
||||
t.Fatalf("insert metric: %v", err)
|
||||
}
|
||||
|
||||
summary, err := r.DatabaseStorageSummary()
|
||||
if err != nil {
|
||||
t.Fatalf("storage summary: %v", err)
|
||||
}
|
||||
if summary.DBType != "sqlite" {
|
||||
t.Fatalf("expected sqlite db type, got %q", summary.DBType)
|
||||
}
|
||||
if summary.DatabaseSizeBytes <= 0 {
|
||||
t.Fatalf("expected database size > 0, got %d", summary.DatabaseSizeBytes)
|
||||
}
|
||||
if summary.DatabaseSizeText == "" {
|
||||
t.Fatalf("expected formatted size")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDatabaseSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
bytes int64
|
||||
want string
|
||||
}{
|
||||
{bytes: 0, want: "0 B"},
|
||||
{bytes: 512, want: "512 B"},
|
||||
{bytes: 1024, want: "1.0 KB"},
|
||||
{bytes: 1024 * 1024, want: "1.0 MB"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := formatDatabaseSize(tc.bytes); got != tc.want {
|
||||
t.Fatalf("formatDatabaseSize(%d) = %q, want %q", tc.bytes, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package repo
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -255,6 +256,54 @@ func (r *Repository) AddUserQuotaUsage(userID int64, usedBytes int64, now time.T
|
||||
return normalizeUserQuotaView(result, now), nil
|
||||
}
|
||||
|
||||
func (r *Repository) AddUserQuotaUsageBatch(usages map[int64]int64, now time.Time) (map[int64]*model.UserQuotaView, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
if len(usages) == 0 {
|
||||
return map[int64]*model.UserQuotaView{}, nil
|
||||
}
|
||||
|
||||
result := make(map[int64]*model.UserQuotaView, len(usages))
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
userIDs := make([]int64, 0, len(usages))
|
||||
for userID := range usages {
|
||||
if userID > 0 {
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
}
|
||||
sort.Slice(userIDs, func(i, j int) bool { return userIDs[i] < userIDs[j] })
|
||||
|
||||
for _, userID := range userIDs {
|
||||
q, err := r.loadOrCreateUserQuotaTx(tx, userID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyUserQuotaWindowRoll(q, now)
|
||||
if usages[userID] > 0 {
|
||||
q.DailyUsedBytes += usages[userID]
|
||||
q.MonthlyUsedBytes += usages[userID]
|
||||
}
|
||||
q.UpdatedTime = now.UnixMilli()
|
||||
if err := tx.Model(&model.UserQuota{}).Where("user_id = ?", userID).Updates(map[string]interface{}{
|
||||
"daily_used_bytes": q.DailyUsedBytes,
|
||||
"monthly_used_bytes": q.MonthlyUsedBytes,
|
||||
"day_key": q.DayKey,
|
||||
"month_key": q.MonthKey,
|
||||
"updated_time": q.UpdatedTime,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result[userID] = normalizeUserQuotaView(cloneUserQuotaView(*q), now)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) MarkUserQuotaDisabled(userID int64, pausedForwardIDs []int64, now int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import re
|
||||
|
||||
with open('internal/http/handler/control_plane.go', 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# 1. Update ensureLimiterOnNode and add ensureConnLimiterOnNode
|
||||
ensure_conn_limiter = """
|
||||
func (h *Handler) ensureConnLimiterOnNode(nodeID int64, limiterName string, maxConn int) error {
|
||||
\tlimitStr := fmt.Sprintf("$ %d", maxConn)
|
||||
\t
|
||||
\tpayload := map[string]interface{}{
|
||||
\t\t"name": limiterName,
|
||||
\t\t"limits": []string{limitStr},
|
||||
\t}
|
||||
\t
|
||||
\tif _, err := h.sendNodeCommand(nodeID, "AddCLimiters", payload, false, false); err != nil {
|
||||
\t\tif !isAlreadyExistsMessage(err.Error()) {
|
||||
\t\t\treturn fmt.Errorf("连接限制器下发失败: %w", err)
|
||||
\t\t}
|
||||
\t\tupdatePayload := map[string]interface{}{
|
||||
\t\t\t"limiter": limiterName,
|
||||
\t\t\t"data": payload,
|
||||
\t\t}
|
||||
\t\tif _, updateErr := h.sendNodeCommand(nodeID, "UpdateCLimiters", updatePayload, false, false); updateErr != nil {
|
||||
\t\t\treturn fmt.Errorf("连接限制器更新失败: %w", updateErr)
|
||||
\t\t}
|
||||
\t}
|
||||
\treturn nil
|
||||
}
|
||||
"""
|
||||
|
||||
content = content.replace('func (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int) error {\n\tif err := h.upsertLimiterOnNode(nodeID, limiterID, speed); err != nil {\n\t\treturn fmt.Errorf("限速规则下发失败: %w", err)\n\t}\n\n\treturn nil\n}',
|
||||
'func (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int) error {\n\tif err := h.upsertLimiterOnNode(nodeID, limiterID, speed); err != nil {\n\t\treturn fmt.Errorf("限速规则下发失败: %w", err)\n\t}\n\n\treturn nil\n}\n' + ensure_conn_limiter)
|
||||
|
||||
|
||||
# 2. Update buildForwardServiceConfigs declaration
|
||||
content = content.replace(
|
||||
'func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64) []map[string]interface{} {',
|
||||
'func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, cLimiterName string) []map[string]interface{} {'
|
||||
)
|
||||
|
||||
|
||||
# 3. Inject climiter into generated service
|
||||
service_map_end = """ }
|
||||
if protocol == "udp" {"""
|
||||
service_map_end_new = """ }
|
||||
if cLimiterName != "" {
|
||||
service["climiter"] = cLimiterName
|
||||
}
|
||||
if protocol == "udp" {"""
|
||||
content = content.replace(service_map_end, service_map_end_new)
|
||||
|
||||
|
||||
# 4. Update syncForwardServicesWithWarnings
|
||||
# Find user tunnel resolution
|
||||
resolution = """ serviceBase := buildForwardServiceBaseWithResolvedUserTunnel(forward.ID, forward.UserID, userTunnelID)
|
||||
|
||||
for _, fp := range ports {"""
|
||||
|
||||
resolution_new = """ serviceBase := buildForwardServiceBaseWithResolvedUserTunnel(forward.ID, forward.UserID, userTunnelID)
|
||||
|
||||
user, err := h.repo.GetUserByID(forward.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cLimiterName string
|
||||
var maxConnToSet int
|
||||
|
||||
if forward.MaxConn > 0 {
|
||||
maxConnToSet = forward.MaxConn
|
||||
cLimiterName = fmt.Sprintf("rule_conn_limit_%d", forward.ID)
|
||||
} else if user != nil && user.MaxConn > 0 {
|
||||
maxConnToSet = user.MaxConn
|
||||
cLimiterName = fmt.Sprintf("user_conn_limit_%d", user.ID)
|
||||
}
|
||||
|
||||
for _, fp := range ports {"""
|
||||
content = content.replace(resolution, resolution_new)
|
||||
|
||||
# Inject ensureConnLimiterOnNode inside loop
|
||||
loop_inner = """ if limiterID != nil && speed != nil {
|
||||
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {
|
||||
// If the limiter push fails because the node is offline, skip it with a warning
|
||||
if isNodeOfflineOrTimeoutError(err) {
|
||||
node, _ := h.getNodeRecord(fp.NodeID)
|
||||
nodeName := fmt.Sprintf("%d", fp.NodeID)
|
||||
if node != nil && strings.TrimSpace(node.Name) != "" {
|
||||
nodeName = strings.TrimSpace(node.Name)
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("节点 %s 不在线,已跳过下发", nodeName))
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
node, err := h.getNodeRecord(fp.NodeID)"""
|
||||
|
||||
loop_inner_new = """ if limiterID != nil && speed != nil {
|
||||
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {
|
||||
// If the limiter push fails because the node is offline, skip it with a warning
|
||||
if isNodeOfflineOrTimeoutError(err) {
|
||||
node, _ := h.getNodeRecord(fp.NodeID)
|
||||
nodeName := fmt.Sprintf("%d", fp.NodeID)
|
||||
if node != nil && strings.TrimSpace(node.Name) != "" {
|
||||
nodeName = strings.TrimSpace(node.Name)
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("节点 %s 不在线,已跳过下发", nodeName))
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if cLimiterName != "" {
|
||||
if err := h.ensureConnLimiterOnNode(fp.NodeID, cLimiterName, maxConnToSet); err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("节点 %d 连接限制器下发失败: %v", fp.NodeID, err))
|
||||
}
|
||||
}
|
||||
|
||||
node, err := h.getNodeRecord(fp.NodeID)"""
|
||||
content = content.replace(loop_inner, loop_inner_new)
|
||||
|
||||
# Update buildForwardServiceConfigs call in syncForwardServicesWithWarnings
|
||||
content = content.replace(
|
||||
'services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID)',
|
||||
'services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, cLimiterName)'
|
||||
)
|
||||
|
||||
# Update fallbackForwardPortToDefaultBind call
|
||||
content = content.replace(
|
||||
'warning, err = h.fallbackForwardPortToDefaultBind(forward, tunnel, node, fp, serviceBase, limiterID)',
|
||||
'warning, err = h.fallbackForwardPortToDefaultBind(forward, tunnel, node, fp, serviceBase, limiterID, cLimiterName)'
|
||||
)
|
||||
|
||||
# 5. Update fallbackForwardPortToDefaultBind declaration and logic
|
||||
content = content.replace(
|
||||
'func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, fp forwardPortRecord, serviceBase string, limiterID *int64) (string, error) {',
|
||||
'func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, fp forwardPortRecord, serviceBase string, limiterID *int64, cLimiterName string) (string, error) {'
|
||||
)
|
||||
|
||||
content = content.replace(
|
||||
'defaultServices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, "", limiterID)',
|
||||
'defaultServices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, "", limiterID, cLimiterName)'
|
||||
)
|
||||
|
||||
with open('internal/http/handler/control_plane.go', 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
# Update control_plane_test.go
|
||||
with open('internal/http/handler/control_plane_test.go', 'r') as f:
|
||||
test_content = f.read()
|
||||
|
||||
test_content = re.sub(
|
||||
r'buildForwardServiceConfigs\((.*?),(.*?),(.*?),(.*?),(.*?),(.*?),(.*?)\)',
|
||||
r'buildForwardServiceConfigs(\1,\2,\3,\4,\5,\6,\7, "")',
|
||||
test_content
|
||||
)
|
||||
|
||||
with open('internal/http/handler/control_plane_test.go', 'w') as f:
|
||||
f.write(test_content)
|
||||
@@ -6,9 +6,14 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/http/handler"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func init() {
|
||||
handler.DisableSafeRemoteAddrCheckForTesting = true
|
||||
}
|
||||
|
||||
func mustLastInsertID(t *testing.T, r *repo.Repository, label string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
func TestFlowUploadAggregatesRepeatedItemsAndDisablesQuotaImmediately(t *testing.T) {
|
||||
secret := "monitoring-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now()
|
||||
nowMs := now.UnixMilli()
|
||||
dayKey := int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
|
||||
monthKey := int64(now.Year()*100 + int(now.Month()))
|
||||
const bytesPerGB = int64(1024 * 1024 * 1024)
|
||||
|
||||
node := &model.Node{Name: "node-1", Secret: "node-secret", ServerIP: "127.0.0.1", Port: "10000-10010", TCPListenAddr: "[::]", UDPListenAddr: "[::]", CreatedTime: nowMs, Status: 1}
|
||||
if err := repo.DB().Create(node).Error; err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) VALUES(2, 'flow_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
tunnel := &model.Tunnel{Name: "tunnel-1", TrafficRatio: 1.0, Type: 1, Protocol: "tls", Flow: 1, CreatedTime: nowMs, UpdatedTime: nowMs, Status: 1}
|
||||
if err := repo.DB().Create(tunnel).Error; err != nil {
|
||||
t.Fatalf("seed tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(10, 2, ?, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)`, tunnel.ID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
forward := &model.Forward{ID: 20, UserID: 2, UserName: "flow_user", Name: "forward-20", TunnelID: tunnel.ID, RemoteAddr: "1.1.1.1:80", Strategy: "fifo", CreatedTime: nowMs, UpdatedTime: nowMs, Status: 1}
|
||||
if err := repo.DB().Create(forward).Error; err != nil {
|
||||
t.Fatalf("seed forward: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`INSERT INTO user_quota(user_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time) VALUES(2, 1, 0, ?, ?, ?, ?, 0, 0, '', ?, ?)`, bytesPerGB-100, bytesPerGB-100, dayKey, monthKey, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user_quota: %v", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal([]map[string]interface{}{
|
||||
{"n": "20_2_10", "u": 70, "d": 50},
|
||||
{"n": "20_2_10_tcp", "u": 40, "d": 30},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/flow/upload?secret="+node.Secret, bytes.NewReader(body))
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", res.Code)
|
||||
}
|
||||
if got := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = 20`); got != 0 {
|
||||
t.Fatalf("expected forward paused immediately, got status=%d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, repo, `SELECT disabled_by_quota FROM user_quota WHERE user_id = 2`); got != 1 {
|
||||
t.Fatalf("expected quota disabled flag=1, got %d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, repo, `SELECT in_flow FROM forward WHERE id = 20`); got != 80 {
|
||||
t.Fatalf("expected forward in_flow=80, got %d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, repo, `SELECT out_flow FROM forward WHERE id = 20`); got != 110 {
|
||||
t.Fatalf("expected forward out_flow=110, got %d", got)
|
||||
}
|
||||
metrics, err := repo.GetTunnelMetrics(tunnel.ID, 0, nowMs+60_000)
|
||||
if err != nil {
|
||||
t.Fatalf("get tunnel metrics: %v", err)
|
||||
}
|
||||
if len(metrics) != 1 || metrics[0].BytesIn != 80 || metrics[0].BytesOut != 110 {
|
||||
t.Fatalf("expected one aggregated metric row, got %#v", metrics)
|
||||
}
|
||||
|
||||
body, err = json.Marshal([]map[string]interface{}{
|
||||
{"n": "20_2_10", "u": 10, "d": 20},
|
||||
{"n": "20_2_10", "u": 10, "d": 20},
|
||||
{"n": "20_2_10_tcp", "u": 10, "d": 20},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodPost, "/flow/upload?secret="+node.Secret, bytes.NewReader(body))
|
||||
res = httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected second request status 200, got %d", res.Code)
|
||||
}
|
||||
if got := mustQueryInt(t, repo, `SELECT in_flow FROM forward WHERE id = 20`); got != 140 {
|
||||
t.Fatalf("expected forward in_flow=140 after second request, got %d", got)
|
||||
}
|
||||
if got := mustQueryInt(t, repo, `SELECT out_flow FROM forward WHERE id = 20`); got != 140 {
|
||||
t.Fatalf("expected forward out_flow=140 after second request, got %d", got)
|
||||
}
|
||||
metrics, err = repo.GetTunnelMetrics(tunnel.ID, 0, nowMs+60_000)
|
||||
if err != nil {
|
||||
t.Fatalf("get tunnel metrics after second request: %v", err)
|
||||
}
|
||||
if len(metrics) != 1 || metrics[0].BytesIn != 140 || metrics[0].BytesOut != 140 {
|
||||
t.Fatalf("expected one aggregated metric row after second request, got %#v", metrics)
|
||||
}
|
||||
}
|
||||
@@ -1085,6 +1085,179 @@ func jsonNumber(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
|
||||
func TestForwardIPSpeedLimitPermission(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'normal_user', 'pwd', 1, ?, 99999, 0, 0, 1, 10, ?, ?, 1)
|
||||
`, now+86400000, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(12, 'ip-speed-permission-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(id, name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(20, 'ip-speed-permission-node', 'ip-speed-permission-secret', '10.22.0.1', '10.22.0.1', '', '32200-32210', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(12, 1, 20, 32201, 'round', 1, 'tls')
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO speed_limit(id, name, speed, created_time, status)
|
||||
VALUES(9, 'per-ip-10m', 10, ?, 1)
|
||||
`, now).Error; err != nil {
|
||||
t.Fatalf("insert speed limit: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(2, 12, 10, 99999, 0, 0, 1, ?, 1)
|
||||
`, now+86400000).Error; err != nil {
|
||||
t.Fatalf("insert user tunnel: %v", err)
|
||||
}
|
||||
|
||||
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate user token: %v", err)
|
||||
}
|
||||
body, err := json.Marshal(map[string]interface{}{
|
||||
"name": "blocked-ip-speed",
|
||||
"tunnelId": 12,
|
||||
"remoteAddr": "1.1.1.1:443",
|
||||
"strategy": "fifo",
|
||||
"ipSpeedId": 9,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCodeMsg(t, res, -1, "普通用户无法设置每 IP 限速规则")
|
||||
}
|
||||
|
||||
func TestForwardIPSpeedLimitUpdatePermission(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'normal_user_ip_update', 'pwd', 1, ?, 99999, 0, 0, 1, 10, ?, ?, 1)
|
||||
`, now+86400000, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(13, 'ip-speed-update-permission-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(id, name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(21, 'ip-speed-update-permission-node', 'ip-speed-update-permission-secret', '10.22.0.2', '10.22.0.2', '', '32300-32310', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(13, 1, 21, 32301, 'round', 1, 'tls')
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO speed_limit(id, name, speed, created_time, status)
|
||||
VALUES(10, 'per-ip-10m-update', 10, ?, 1), (11, 'per-ip-20m-update', 20, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert speed limits: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(2, 13, 10, 99999, 0, 0, 1, ?, 1)
|
||||
`, now+86400000).Error; err != nil {
|
||||
t.Fatalf("insert user tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, ip_speed_id, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(30, 2, 'normal_user_ip_update', 'ip-speed-update-forward', 13, '1.1.1.1:443', 'fifo', 10, 0, 0, ?, ?, 1, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
|
||||
userToken, err := auth.GenerateToken(2, "normal_user_ip_update", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate user token: %v", err)
|
||||
}
|
||||
stopNode := startMockNodeSession(t, server.URL, "ip-speed-update-permission-secret")
|
||||
defer stopNode()
|
||||
|
||||
updateForward := func(t *testing.T, ipSpeedID interface{}) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
if err := repo.DB().Exec(`UPDATE forward SET ip_speed_id = 10 WHERE id = 30`).Error; err != nil {
|
||||
t.Fatalf("reset forward ip speed limit: %v", err)
|
||||
}
|
||||
body, err := json.Marshal(map[string]interface{}{
|
||||
"id": 30,
|
||||
"name": "ip-speed-update-forward",
|
||||
"tunnelId": 13,
|
||||
"remoteAddr": "1.1.1.1:443",
|
||||
"ipSpeedId": ipSpeedID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal update payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
return res
|
||||
}
|
||||
assertStoredIPSpeedID := func(t *testing.T, want int64) {
|
||||
t.Helper()
|
||||
var got sql.NullInt64
|
||||
if err := repo.DB().Raw(`SELECT ip_speed_id FROM forward WHERE id = 30`).Scan(&got).Error; err != nil {
|
||||
t.Fatalf("read forward ip_speed_id: %v", err)
|
||||
}
|
||||
if !got.Valid || got.Int64 != want {
|
||||
t.Fatalf("expected ip_speed_id %d, got valid=%v value=%d", want, got.Valid, got.Int64)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("non-admin cannot change existing ipSpeedId", func(t *testing.T) {
|
||||
res := updateForward(t, 11)
|
||||
assertCodeMsg(t, res, -1, "普通用户无法修改每 IP 限速规则")
|
||||
assertStoredIPSpeedID(t, 10)
|
||||
})
|
||||
|
||||
t.Run("non-admin cannot clear existing ipSpeedId", func(t *testing.T) {
|
||||
res := updateForward(t, nil)
|
||||
assertCodeMsg(t, res, -1, "普通用户无法修改每 IP 限速规则")
|
||||
assertStoredIPSpeedID(t, 10)
|
||||
})
|
||||
|
||||
t.Run("non-admin can keep existing ipSpeedId", func(t *testing.T) {
|
||||
res := updateForward(t, 10)
|
||||
assertCode(t, res, 0)
|
||||
assertStoredIPSpeedID(t, 10)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
|
||||
secret := "contract-jwt-secret-perm"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/handler"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func TestForwardLocalRemoteAddrToggleContracts(t *testing.T) {
|
||||
handler.DisableSafeRemoteAddrCheckForTesting = false
|
||||
t.Cleanup(func() {
|
||||
handler.DisableSafeRemoteAddrCheckForTesting = true
|
||||
})
|
||||
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'local_remote_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "local-remote-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "local-remote-tunnel")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "local-remote-entry", "local-remote-secret", "10.60.0.1", "10.60.0.1", "", "31000-31010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
entryNodeID := mustLastInsertID(t, repo, "local-remote-entry")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 31001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryNodeID).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(601, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
userToken, err := auth.GenerateToken(2, "local_remote_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate user token: %v", err)
|
||||
}
|
||||
|
||||
stopNode := startMockNodeSession(t, server.URL, "local-remote-secret")
|
||||
defer stopNode()
|
||||
waitNodeStatus(t, repo, entryNodeID, 1)
|
||||
|
||||
t.Run("local remote address is rejected on create when toggle is off", func(t *testing.T) {
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "deny-local-create",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "127.0.0.1:8080",
|
||||
"strategy": "fifo",
|
||||
}
|
||||
createBody, err := json.Marshal(createPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code == 0 {
|
||||
t.Fatalf("expected local remote address to be rejected when toggle is off")
|
||||
}
|
||||
if !strings.Contains(out.Msg, "internal IP") && !strings.Contains(out.Msg, "内部") {
|
||||
t.Fatalf("expected internal IP error, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("local remote address is allowed on create when toggle is on", func(t *testing.T) {
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO vite_config(name, value, time)
|
||||
VALUES(?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
|
||||
`, "allow_local_remote_addr", "true", time.Now().UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("enable allow_local_remote_addr: %v", err)
|
||||
}
|
||||
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "allow-local-create",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "127.0.0.1:8080",
|
||||
"strategy": "fifo",
|
||||
}
|
||||
createBody, err := json.Marshal(createPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
})
|
||||
|
||||
t.Run("local remote address is rejected on update when toggle is off", func(t *testing.T) {
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO vite_config(name, value, time)
|
||||
VALUES(?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
|
||||
`, "allow_local_remote_addr", "false", time.Now().UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("disable allow_local_remote_addr: %v", err)
|
||||
}
|
||||
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "safe-remote-before-update",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "8.8.8.8:53",
|
||||
"strategy": "fifo",
|
||||
}
|
||||
createBody, err := json.Marshal(createPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal safe create payload: %v", err)
|
||||
}
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||
createReq.Header.Set("Authorization", userToken)
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRes, createReq)
|
||||
assertCode(t, createRes, 0)
|
||||
|
||||
forwardID := mustLastInsertID(t, repo, "safe-remote-before-update")
|
||||
updatePayload := map[string]interface{}{
|
||||
"id": forwardID,
|
||||
"name": "safe-remote-before-update",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "127.0.0.1:8081",
|
||||
"strategy": "fifo",
|
||||
}
|
||||
updateBody, err := json.Marshal(updatePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal update payload: %v", err)
|
||||
}
|
||||
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
|
||||
updateReq.Header.Set("Authorization", userToken)
|
||||
updateReq.Header.Set("Content-Type", "application/json")
|
||||
updateRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(updateRes, updateReq)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(updateRes.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode update response: %v", err)
|
||||
}
|
||||
if out.Code == 0 {
|
||||
t.Fatalf("expected local remote address to be rejected on update when toggle is off")
|
||||
}
|
||||
if !strings.Contains(out.Msg, "internal IP") && !strings.Contains(out.Msg, "内部") {
|
||||
t.Fatalf("expected internal IP error on update, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("local remote address is allowed on update when toggle is on", func(t *testing.T) {
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO vite_config(name, value, time)
|
||||
VALUES(?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
|
||||
`, "allow_local_remote_addr", "true", time.Now().UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("enable allow_local_remote_addr: %v", err)
|
||||
}
|
||||
|
||||
var forwardID int64
|
||||
if err := repo.DB().Raw(`SELECT id FROM forward WHERE name = ? ORDER BY id DESC LIMIT 1`, "safe-remote-before-update").Row().Scan(&forwardID); err != nil {
|
||||
t.Fatalf("query forward id: %v", err)
|
||||
}
|
||||
|
||||
updatePayload := map[string]interface{}{
|
||||
"id": forwardID,
|
||||
"name": "safe-remote-before-update",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "127.0.0.1:8081",
|
||||
"strategy": "fifo",
|
||||
}
|
||||
updateBody, err := json.Marshal(updatePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal update payload: %v", err)
|
||||
}
|
||||
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
|
||||
updateReq.Header.Set("Authorization", userToken)
|
||||
updateReq.Header.Set("Content-Type", "application/json")
|
||||
updateRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(updateRes, updateReq)
|
||||
assertCode(t, updateRes, 0)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/security"
|
||||
)
|
||||
|
||||
func TestMaxConnLimit(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "max-conn-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
var tunnelID int64
|
||||
if err := r.DB().Raw("SELECT id FROM tunnel WHERE name = ?", "max-conn-tunnel").Scan(&tunnelID).Error; err != nil {
|
||||
t.Fatalf("get tunnel ID: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "max-conn-node", "max-conn-secret", "10.20.0.1", "10.20.0.1", "", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
var nodeID int64
|
||||
if err := r.DB().Raw("SELECT id FROM node WHERE name = ?", "max-conn-node").Scan(&nodeID).Error; err != nil {
|
||||
t.Fatalf("get node ID: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 32001, 'round', 1, 'tls')
|
||||
`, tunnelID, nodeID).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user_tunnel(user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(1, ?, 10, 99999, 0, 0, 1, ?, 1)
|
||||
`, tunnelID, now+365*24*3600*1000).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
var commandMu sync.Mutex
|
||||
receivedCommands := make([]string, 0)
|
||||
var addCLimitersData json.RawMessage
|
||||
var updateCLimitersData json.RawMessage
|
||||
|
||||
stopNode := startMockSessionForMaxConn(t, server.URL, "max-conn-secret", func(cmdType string, data json.RawMessage) (bool, string) {
|
||||
commandMu.Lock()
|
||||
defer commandMu.Unlock()
|
||||
receivedCommands = append(receivedCommands, cmdType)
|
||||
|
||||
if cmdType == "AddCLimiters" {
|
||||
addCLimitersData = append([]byte(nil), data...)
|
||||
return true, "already exists"
|
||||
}
|
||||
if cmdType == "UpdateCLimiters" {
|
||||
updateCLimitersData = append([]byte(nil), data...)
|
||||
}
|
||||
|
||||
return false, ""
|
||||
})
|
||||
defer stopNode()
|
||||
|
||||
waitNodeStatus(t, r, nodeID, 1)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"name": "max-conn-forward",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "1.1.1.1:443",
|
||||
"strategy": "fifo",
|
||||
"maxConn": 42,
|
||||
"ipMaxConn": 7,
|
||||
"proxyProtocol": 2,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected create success, got code=%d msg=%s", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
var forwardID int64
|
||||
if err := r.DB().Raw("SELECT id FROM forward WHERE name = ?", "max-conn-forward").Scan(&forwardID).Error; err != nil {
|
||||
t.Fatalf("get forward ID: %v", err)
|
||||
}
|
||||
|
||||
listOut := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
|
||||
if listOut.Code != 0 {
|
||||
t.Fatalf("expected /forward/list success, got code=%d msg=%s", listOut.Code, listOut.Msg)
|
||||
}
|
||||
|
||||
rows := mustContractSlice(t, listOut.Data, "forward list")
|
||||
var target map[string]interface{}
|
||||
for _, row := range rows {
|
||||
item, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected forward item to be object, got %T", row)
|
||||
}
|
||||
idVal, ok := item["id"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected forward id to be float64, got %T", item["id"])
|
||||
}
|
||||
if int64(idVal) == forwardID {
|
||||
target = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
t.Fatalf("forward %d not found in /forward/list response", forwardID)
|
||||
}
|
||||
|
||||
maxConnVal, ok := target["maxConn"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected maxConn to be float64, got %T (%v)", target["maxConn"], target["maxConn"])
|
||||
}
|
||||
if int(maxConnVal) != 42 {
|
||||
t.Fatalf("expected maxConn 42 in /forward/list, got %v", maxConnVal)
|
||||
}
|
||||
|
||||
proxyProtocolVal, ok := target["proxyProtocol"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected proxyProtocol to be float64, got %T (%v)", target["proxyProtocol"], target["proxyProtocol"])
|
||||
}
|
||||
if int(proxyProtocolVal) != 2 {
|
||||
t.Fatalf("expected proxyProtocol 2 in /forward/list, got %v", proxyProtocolVal)
|
||||
}
|
||||
|
||||
commandMu.Lock()
|
||||
defer commandMu.Unlock()
|
||||
|
||||
hasAdd := false
|
||||
hasUpdate := false
|
||||
for _, cmd := range receivedCommands {
|
||||
if cmd == "AddCLimiters" {
|
||||
hasAdd = true
|
||||
}
|
||||
if cmd == "UpdateCLimiters" {
|
||||
hasUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasAdd {
|
||||
t.Fatalf("expected AddCLimiters to be sent, but it was not. Received: %v", receivedCommands)
|
||||
}
|
||||
if !hasUpdate {
|
||||
t.Fatalf("expected UpdateCLimiters to be sent after AddCLimiters failed with already exists. Received: %v", receivedCommands)
|
||||
}
|
||||
|
||||
expectedName := fmt.Sprintf("rule_conn_limit_%d", forwardID)
|
||||
|
||||
// verify payload for AddCLimiters
|
||||
var addData map[string]interface{}
|
||||
if err := json.Unmarshal(addCLimitersData, &addData); err != nil {
|
||||
t.Fatalf("unmarshal AddCLimiters data: %v", err)
|
||||
}
|
||||
if addData["name"] != expectedName {
|
||||
t.Fatalf("expected limiter name %s, got %v", expectedName, addData["name"])
|
||||
}
|
||||
if limits, ok := addData["limits"].([]interface{}); ok {
|
||||
if len(limits) != 2 || limits[0] != "$ 42" || limits[1] != "$$ 7" {
|
||||
t.Fatalf("expected limits to contain '$ 42' and '$$ 7', got %v", limits)
|
||||
}
|
||||
} else {
|
||||
t.Fatalf("invalid limits type in AddCLimiters data: %v", addData)
|
||||
}
|
||||
|
||||
// verify payload for UpdateCLimiters
|
||||
var updateData map[string]interface{}
|
||||
if err := json.Unmarshal(updateCLimitersData, &updateData); err != nil {
|
||||
t.Fatalf("unmarshal UpdateCLimiters data: %v", err)
|
||||
}
|
||||
if updateData["limiter"] != expectedName {
|
||||
t.Fatalf("expected update limiter name %s, got %v", expectedName, updateData["limiter"])
|
||||
}
|
||||
nestedData, ok := updateData["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected nested 'data' in UpdateCLimiters, got %v", updateData)
|
||||
}
|
||||
if nestedData["name"] != expectedName {
|
||||
t.Fatalf("expected nested name %s, got %v", expectedName, nestedData["name"])
|
||||
}
|
||||
if nestedLimits, ok := nestedData["limits"].([]interface{}); ok {
|
||||
if len(nestedLimits) != 2 || nestedLimits[0] != "$ 42" || nestedLimits[1] != "$$ 7" {
|
||||
t.Fatalf("expected nested limits to contain '$ 42' and '$$ 7', got %v", nestedLimits)
|
||||
}
|
||||
} else {
|
||||
t.Fatalf("invalid limits type in UpdateCLimiters nested data: %v", nestedData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserMaxConnUpdateResyncsExistingForwards(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, max_conn, created_time, updated_time, status)
|
||||
VALUES(2, 'limited_user', 'pwd', 1, ?, 99999, 0, 0, 1, 10, 0, ?, ?, 1)
|
||||
`, now+365*24*3600*1000, now, now).Error; err != nil {
|
||||
t.Fatalf("insert 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(10, 'user-max-conn-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(id, name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(20, 'user-max-conn-node', 'user-max-conn-secret', '10.21.0.1', '10.21.0.1', '', '32100-32110', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(10, 1, 20, 32101, 'round', 1, 'tls')
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(30, 2, 10, 10, 99999, 0, 0, 1, ?, 1)
|
||||
`, now+365*24*3600*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, max_conn)
|
||||
VALUES(40, 2, 'limited_user', 'user-max-conn-forward', 10, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward_port(forward_id, node_id, port, in_ip)
|
||||
VALUES(40, 20, 32105, '')
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
var commandMu sync.Mutex
|
||||
receivedCommands := make([]string, 0)
|
||||
var addCLimitersData json.RawMessage
|
||||
var updateServiceData json.RawMessage
|
||||
|
||||
stopNode := startMockSessionForMaxConn(t, server.URL, "user-max-conn-secret", func(cmdType string, data json.RawMessage) (bool, string) {
|
||||
commandMu.Lock()
|
||||
defer commandMu.Unlock()
|
||||
receivedCommands = append(receivedCommands, cmdType)
|
||||
if cmdType == "AddCLimiters" {
|
||||
addCLimitersData = append([]byte(nil), data...)
|
||||
}
|
||||
if cmdType == "UpdateService" {
|
||||
updateServiceData = append([]byte(nil), data...)
|
||||
}
|
||||
return false, ""
|
||||
})
|
||||
defer stopNode()
|
||||
|
||||
waitNodeStatus(t, r, 20, 1)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"id": 2,
|
||||
"user": "limited_user",
|
||||
"flow": 99999,
|
||||
"num": 10,
|
||||
"expTime": now + 365*24*3600*1000,
|
||||
"flowResetTime": 1,
|
||||
"status": 1,
|
||||
"maxConn": 37,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/update", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected user update success, got code=%d msg=%s", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
commandMu.Lock()
|
||||
defer commandMu.Unlock()
|
||||
if addCLimitersData == nil {
|
||||
t.Fatalf("expected AddCLimiters after user maxConn update. Received: %v", receivedCommands)
|
||||
}
|
||||
if updateServiceData == nil {
|
||||
t.Fatalf("expected UpdateService after user maxConn update. Received: %v", receivedCommands)
|
||||
}
|
||||
|
||||
var addData map[string]interface{}
|
||||
if err := json.Unmarshal(addCLimitersData, &addData); err != nil {
|
||||
t.Fatalf("unmarshal AddCLimiters data: %v", err)
|
||||
}
|
||||
if addData["name"] != "user_conn_limit_2" {
|
||||
t.Fatalf("expected limiter name user_conn_limit_2, got %v", addData["name"])
|
||||
}
|
||||
limits, ok := addData["limits"].([]interface{})
|
||||
if !ok || len(limits) != 1 || limits[0] != "$ 37" {
|
||||
t.Fatalf("expected limits to contain '$ 37', got %v", addData["limits"])
|
||||
}
|
||||
|
||||
var services []map[string]interface{}
|
||||
if err := json.Unmarshal(updateServiceData, &services); err != nil {
|
||||
t.Fatalf("unmarshal UpdateService data: %v", err)
|
||||
}
|
||||
if len(services) != 2 {
|
||||
t.Fatalf("expected 2 services, got %d", len(services))
|
||||
}
|
||||
for _, service := range services {
|
||||
if service["climiter"] != "user_conn_limit_2" {
|
||||
t.Fatalf("expected service climiter user_conn_limit_2, got %v", service["climiter"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserMaxConnWithPerIPRuleSplitsRuntimeLimiters(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
userToken, err := auth.GenerateToken(3, "per_ip_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate user token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, max_conn, created_time, updated_time, status)
|
||||
VALUES(3, 'per_ip_user', 'pwd', 1, ?, 99999, 0, 0, 1, 10, 37, ?, ?, 1)
|
||||
`, now+365*24*3600*1000, now, now).Error; err != nil {
|
||||
t.Fatalf("insert 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(11, 'user-per-ip-conn-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(id, name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(21, 'user-per-ip-conn-node', 'user-per-ip-conn-secret', '10.23.0.1', '10.23.0.1', '', '32300-32310', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(11, 1, 21, 32301, 'round', 1, 'tls')
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(31, 3, 11, 10, 99999, 0, 0, 1, ?, 1)
|
||||
`, now+365*24*3600*1000).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
var commandMu sync.Mutex
|
||||
receivedCommands := make([]string, 0)
|
||||
addCLimitersData := make([]json.RawMessage, 0)
|
||||
var updateServiceData json.RawMessage
|
||||
|
||||
stopNode := startMockSessionForMaxConn(t, server.URL, "user-per-ip-conn-secret", func(cmdType string, data json.RawMessage) (bool, string) {
|
||||
commandMu.Lock()
|
||||
defer commandMu.Unlock()
|
||||
receivedCommands = append(receivedCommands, cmdType)
|
||||
if cmdType == "AddCLimiters" {
|
||||
addCLimitersData = append(addCLimitersData, append([]byte(nil), data...))
|
||||
}
|
||||
if cmdType == "UpdateService" {
|
||||
updateServiceData = append([]byte(nil), data...)
|
||||
}
|
||||
return false, ""
|
||||
})
|
||||
defer stopNode()
|
||||
|
||||
waitNodeStatus(t, r, 21, 1)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"name": "user-per-ip-conn-forward",
|
||||
"tunnelId": int64(11),
|
||||
"remoteAddr": "1.1.1.1:443",
|
||||
"strategy": "fifo",
|
||||
"ipMaxConn": 7,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected create success, got code=%d msg=%s", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
var forwardID int64
|
||||
if err := r.DB().Raw("SELECT id FROM forward WHERE name = ?", "user-per-ip-conn-forward").Scan(&forwardID).Error; err != nil {
|
||||
t.Fatalf("get forward ID: %v", err)
|
||||
}
|
||||
expectedRuleName := fmt.Sprintf("rule_conn_limit_%d", forwardID)
|
||||
|
||||
commandMu.Lock()
|
||||
defer commandMu.Unlock()
|
||||
if len(addCLimitersData) != 2 {
|
||||
t.Fatalf("expected two AddCLimiters commands. Received: %v", receivedCommands)
|
||||
}
|
||||
if updateServiceData == nil {
|
||||
t.Fatalf("expected UpdateService. Received: %v", receivedCommands)
|
||||
}
|
||||
|
||||
gotLimits := make(map[string][]string)
|
||||
for _, raw := range addCLimitersData {
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
t.Fatalf("unmarshal AddCLimiters data: %v", err)
|
||||
}
|
||||
limits, ok := data["limits"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected limits array, got %T", data["limits"])
|
||||
}
|
||||
for _, limit := range limits {
|
||||
gotLimits[fmt.Sprint(data["name"])] = append(gotLimits[fmt.Sprint(data["name"])], fmt.Sprint(limit))
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(gotLimits["user_conn_limit_3"], []string{"$ 37"}) {
|
||||
t.Fatalf("expected user max limiter payload, got %v", gotLimits["user_conn_limit_3"])
|
||||
}
|
||||
if !reflect.DeepEqual(gotLimits[expectedRuleName], []string{"$$ 7"}) {
|
||||
t.Fatalf("expected rule per-IP limiter payload, got %v", gotLimits[expectedRuleName])
|
||||
}
|
||||
|
||||
var services []map[string]interface{}
|
||||
if err := json.Unmarshal(updateServiceData, &services); err != nil {
|
||||
t.Fatalf("unmarshal UpdateService data: %v", err)
|
||||
}
|
||||
expectedCLimiter := "user_conn_limit_3," + expectedRuleName
|
||||
for _, service := range services {
|
||||
if service["climiter"] != expectedCLimiter {
|
||||
t.Fatalf("expected service climiter %s, got %v", expectedCLimiter, service["climiter"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startMockSessionForMaxConn(t *testing.T, baseURL string, nodeSecret string, onCommand func(cmdType string, data json.RawMessage) (bool, string)) func() {
|
||||
t.Helper()
|
||||
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse provider url: %v", err)
|
||||
}
|
||||
if strings.EqualFold(u.Scheme, "https") {
|
||||
u.Scheme = "wss"
|
||||
} else {
|
||||
u.Scheme = "ws"
|
||||
}
|
||||
u.Path = "/system-info"
|
||||
q := u.Query()
|
||||
q.Set("type", "1")
|
||||
q.Set("secret", nodeSecret)
|
||||
q.Set("version", "v1")
|
||||
q.Set("http", "1")
|
||||
q.Set("tls", "1")
|
||||
q.Set("socks", "1")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial mock node websocket: %v", err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
_, raw, readErr := conn.ReadMessage()
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
plain := raw
|
||||
var wrap struct {
|
||||
Encrypted bool `json:"encrypted"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrap); err == nil && wrap.Encrypted && strings.TrimSpace(wrap.Data) != "" {
|
||||
crypto, cryptoErr := security.NewAESCrypto(nodeSecret)
|
||||
if cryptoErr == nil {
|
||||
if dec, decErr := crypto.Decrypt(wrap.Data); decErr == nil {
|
||||
plain = []byte(dec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd struct {
|
||||
Type string `json:"type"`
|
||||
RequestID string `json:"requestId"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(plain, &cmd); err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(cmd.RequestID) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
shouldFail := false
|
||||
failMsg := ""
|
||||
if onCommand != nil {
|
||||
shouldFail, failMsg = onCommand(strings.TrimSpace(cmd.Type), cmd.Data)
|
||||
}
|
||||
|
||||
respType := fmt.Sprintf("%sResponse", cmd.Type)
|
||||
respPayload := map[string]interface{}{
|
||||
"type": respType,
|
||||
"success": !shouldFail,
|
||||
"message": "OK",
|
||||
"requestId": cmd.RequestID,
|
||||
}
|
||||
if shouldFail {
|
||||
if strings.TrimSpace(failMsg) == "" {
|
||||
failMsg = "mock command failed"
|
||||
}
|
||||
respPayload["message"] = failMsg
|
||||
}
|
||||
|
||||
respBytes, err := json.Marshal(respPayload)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = conn.WriteMessage(websocket.TextMessage, respBytes)
|
||||
}
|
||||
}()
|
||||
|
||||
var stopOnce sync.Once
|
||||
return func() {
|
||||
stopOnce.Do(func() {
|
||||
_ = conn.Close()
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -349,9 +349,9 @@ func TestBackupExportImportRestoreContracts(t *testing.T) {
|
||||
tunnelID := mustLastInsertID(t, r, "backup-forward-tunnel")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 1, "admin_user", "backup-forward", tunnelID, "127.0.0.1:9000", "fifo", 0, 0, now, now, 1, 88).Error; err != nil {
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx, proxy_protocol)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 1, "admin_user", "backup-forward", tunnelID, "127.0.0.1:9000", "fifo", 0, 0, now, now, 1, 88, 2).Error; err != nil {
|
||||
t.Fatalf("seed forward for backup: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, "backup-forward")
|
||||
@@ -412,6 +412,9 @@ func TestBackupExportImportRestoreContracts(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("expected forwardPorts for forward %d in payload", forwardID)
|
||||
}
|
||||
if proxyProtocol, ok := forwardMap["proxyProtocol"].(float64); !ok || int(proxyProtocol) != 2 {
|
||||
t.Fatalf("expected exported proxyProtocol 2 for forward %d, got %v", forwardID, forwardMap["proxyProtocol"])
|
||||
}
|
||||
for _, p := range portsRaw {
|
||||
portMap, ok := p.(map[string]interface{})
|
||||
if !ok {
|
||||
@@ -475,6 +478,14 @@ func TestBackupExportImportRestoreContracts(t *testing.T) {
|
||||
t.Fatalf("expected forward_port node=%d port=%d after import, got %v", nodeID, port, after)
|
||||
}
|
||||
}
|
||||
|
||||
var proxyProtocol int
|
||||
if err := r.DB().Raw(`SELECT proxy_protocol FROM forward WHERE id = ?`, forwardID).Row().Scan(&proxyProtocol); err != nil {
|
||||
t.Fatalf("query proxy_protocol after import: %v", err)
|
||||
}
|
||||
if proxyProtocol != 2 {
|
||||
t.Fatalf("expected proxy_protocol 2 after import, got %d", proxyProtocol)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("backup export tolerates nullable legacy tunnel chain fields", func(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func TestPerIPSpeedLimitRuntimePayload(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "per-ip-speed-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
var tunnelID int64
|
||||
if err := r.DB().Raw("SELECT id FROM tunnel WHERE name = ?", "per-ip-speed-tunnel").Scan(&tunnelID).Error; err != nil {
|
||||
t.Fatalf("get tunnel ID: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "per-ip-speed-node", "per-ip-speed-secret", "10.22.0.1", "10.22.0.1", "", "32200-32210", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
var nodeID int64
|
||||
if err := r.DB().Raw("SELECT id FROM node WHERE name = ?", "per-ip-speed-node").Scan(&nodeID).Error; err != nil {
|
||||
t.Fatalf("get node ID: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 32201, 'round', 1, 'tls')
|
||||
`, tunnelID, nodeID).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user_tunnel(user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(1, ?, 10, 99999, 0, 0, 1, ?, 1)
|
||||
`, tunnelID, now+365*24*3600*1000).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
totalSpeedID, err := r.CreateSpeedLimit("per-ip-total-speed", 80, now, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("create total speed limit: %v", err)
|
||||
}
|
||||
ipSpeedID, err := r.CreateSpeedLimit("per-ip-client-speed", 40, now, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("create per-ip speed limit: %v", err)
|
||||
}
|
||||
|
||||
var commandMu sync.Mutex
|
||||
receivedCommands := make([]string, 0)
|
||||
addLimitersData := make([]json.RawMessage, 0)
|
||||
var updateServiceData json.RawMessage
|
||||
|
||||
stopNode := startMockSessionForMaxConn(t, server.URL, "per-ip-speed-secret", func(cmdType string, data json.RawMessage) (bool, string) {
|
||||
commandMu.Lock()
|
||||
defer commandMu.Unlock()
|
||||
receivedCommands = append(receivedCommands, cmdType)
|
||||
if cmdType == "AddLimiters" {
|
||||
addLimitersData = append(addLimitersData, append([]byte(nil), data...))
|
||||
}
|
||||
if cmdType == "UpdateService" {
|
||||
updateServiceData = append([]byte(nil), data...)
|
||||
}
|
||||
return false, ""
|
||||
})
|
||||
defer stopNode()
|
||||
|
||||
waitNodeStatus(t, r, nodeID, 1)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"name": "per-ip-speed-forward",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "1.1.1.1:443",
|
||||
"strategy": "fifo",
|
||||
"speedId": totalSpeedID,
|
||||
"ipSpeedId": ipSpeedID,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected create success, got code=%d msg=%s", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
var forwardID int64
|
||||
if err := r.DB().Raw("SELECT id FROM forward WHERE name = ?", "per-ip-speed-forward").Scan(&forwardID).Error; err != nil {
|
||||
t.Fatalf("get forward ID: %v", err)
|
||||
}
|
||||
expectedName := fmt.Sprintf("rule_traffic_limit_%d", forwardID)
|
||||
expectedTotalName := fmt.Sprint(totalSpeedID)
|
||||
expectedRuleLimits := []string{"0.0.0.0/0 5.0MB 5.0MB", "::/0 5.0MB 5.0MB"}
|
||||
expectedTotalLimits := []string{"$ 10.0MB 10.0MB"}
|
||||
|
||||
commandMu.Lock()
|
||||
defer commandMu.Unlock()
|
||||
if len(addLimitersData) != 2 {
|
||||
t.Fatalf("expected AddLimiters to be sent. Received: %v", receivedCommands)
|
||||
}
|
||||
if updateServiceData == nil {
|
||||
t.Fatalf("expected UpdateService to be sent. Received: %v", receivedCommands)
|
||||
}
|
||||
|
||||
gotLimiterLimits := make(map[string][]string)
|
||||
for _, raw := range addLimitersData {
|
||||
var addData map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &addData); err != nil {
|
||||
t.Fatalf("unmarshal AddLimiters data: %v", err)
|
||||
}
|
||||
name := fmt.Sprint(addData["name"])
|
||||
limits, ok := addData["limits"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected limits array, got %T", addData["limits"])
|
||||
}
|
||||
gotLimits := make([]string, 0, len(limits))
|
||||
for _, limit := range limits {
|
||||
gotLimits = append(gotLimits, fmt.Sprint(limit))
|
||||
}
|
||||
gotLimiterLimits[name] = gotLimits
|
||||
}
|
||||
if !reflect.DeepEqual(gotLimiterLimits[expectedTotalName], expectedTotalLimits) {
|
||||
t.Fatalf("expected total limits %v, got %v", expectedTotalLimits, gotLimiterLimits[expectedTotalName])
|
||||
}
|
||||
if !reflect.DeepEqual(gotLimiterLimits[expectedName], expectedRuleLimits) {
|
||||
t.Fatalf("expected rule limits %v, got %v", expectedRuleLimits, gotLimiterLimits[expectedName])
|
||||
}
|
||||
|
||||
var services []map[string]interface{}
|
||||
if err := json.Unmarshal(updateServiceData, &services); err != nil {
|
||||
t.Fatalf("unmarshal UpdateService data: %v", err)
|
||||
}
|
||||
if len(services) == 0 {
|
||||
t.Fatalf("expected services in UpdateService")
|
||||
}
|
||||
for _, service := range services {
|
||||
expectedLimiter := expectedTotalName + "," + expectedName
|
||||
if service["limiter"] != expectedLimiter {
|
||||
t.Fatalf("expected service limiter %s, got %v", expectedLimiter, service["limiter"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func TestStorageSummaryRequiresAdminAndReturnsSize(t *testing.T) {
|
||||
secret := "storage-contract-secret"
|
||||
router, _ := setupContractRouter(t, secret)
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate user token: %v", err)
|
||||
}
|
||||
|
||||
userReq := httptest.NewRequest(http.MethodGet, "/api/v1/system/storage", nil)
|
||||
userReq.Header.Set("Authorization", userToken)
|
||||
userRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(userRes, userReq)
|
||||
|
||||
var denied response.R
|
||||
if err := json.NewDecoder(userRes.Body).Decode(&denied); err != nil {
|
||||
t.Fatalf("decode denied response: %v", err)
|
||||
}
|
||||
if denied.Code != 403 {
|
||||
t.Fatalf("expected 403 for non-admin, got %d", denied.Code)
|
||||
}
|
||||
|
||||
adminReq := httptest.NewRequest(http.MethodGet, "/api/v1/system/storage", nil)
|
||||
adminReq.Header.Set("Authorization", adminToken)
|
||||
adminRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(adminRes, adminReq)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(adminRes.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode admin response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0, got %d: %s", out.Code, out.Msg)
|
||||
}
|
||||
data, ok := out.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected object data, got %T", out.Data)
|
||||
}
|
||||
if data["dbType"] == "" {
|
||||
t.Fatalf("expected dbType")
|
||||
}
|
||||
if _, ok := data["databaseSizeBytes"].(float64); !ok {
|
||||
t.Fatalf("expected numeric databaseSizeBytes, got %T", data["databaseSizeBytes"])
|
||||
}
|
||||
if data["databaseSizeText"] == "" {
|
||||
t.Fatalf("expected databaseSizeText")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
)
|
||||
|
||||
func TestUserListReturnsMaxConn(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, max_conn, created_time, updated_time, status)
|
||||
VALUES(2, 'max_conn_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 10, 37, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
out := requestContractEnvelope(t, router, adminToken, "/api/v1/user/list", map[string]interface{}{})
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected /user/list success, got code=%d msg=%s", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
rows := mustContractSlice(t, out.Data, "user list")
|
||||
var target map[string]interface{}
|
||||
for _, row := range rows {
|
||||
item, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected user item to be object, got %T", row)
|
||||
}
|
||||
idVal, ok := item["id"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected user id to be float64, got %T", item["id"])
|
||||
}
|
||||
if int64(idVal) == 2 {
|
||||
target = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
t.Fatalf("user 2 not found in /user/list response")
|
||||
}
|
||||
|
||||
maxConnVal, ok := target["maxConn"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected maxConn to be float64, got %T (%v)", target["maxConn"], target["maxConn"])
|
||||
}
|
||||
if int(maxConnVal) != 37 {
|
||||
t.Fatalf("expected maxConn 37 in /user/list, got %v", maxConnVal)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
gost
|
||||
gost_*
|
||||
gost-*
|
||||
*.sha256
|
||||
*.exe
|
||||
+18
-34
@@ -1,46 +1,30 @@
|
||||
# GO-GOST SERVICE KNOWLEDGE BASE
|
||||
# go-gost
|
||||
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
Forwarding agent (forked GOST v3). Uses local `x/` module via `replace github.com/go-gost/x => ./x`.
|
||||
|
||||
## OVERVIEW
|
||||
Forwarding agent built on GOST v3 with a local fork of `github.com/go-gost/x` under `x/`.
|
||||
**Stack:** Go 1.23, github.com/go-gost/core v0.3.1, local `go-gost/x` module.
|
||||
## Structure
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
go-gost/
|
||||
├── main.go # Entry; reads panel config.json; starts svc.Run(program)
|
||||
├── config.go # Panel config.json loader (addr/secret + ports)
|
||||
├── program.go # GOST runtime: parse config, run/reload services
|
||||
├── x/ # Local fork of github.com/go-gost/x (has its own go.mod)
|
||||
└── go.mod # replace github.com/go-gost/x => ./x
|
||||
```
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `main.go` | Entry point, reads `config.json`, starts reporter + service |
|
||||
| `config.go` | Panel integration config loader (addr, secret, ports) |
|
||||
| `program.go` | GOST runtime: parse config, run/reload services (SIGHUP) |
|
||||
| `x/` | Local fork of `github.com/go-gost/x` (own `go.mod`) |
|
||||
|
||||
## 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 |
|
||||
| **Build** | `go-gost/Makefile` | Cross-compile targets for amd64/arm64 |
|
||||
## Conventions
|
||||
|
||||
## 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).
|
||||
- Two config files: panel integration uses `config.json`; forwarding uses GOST config (`gost.{json,yaml}`).
|
||||
- `x/` is the extension surface — add handlers/listeners/dialers there, not in vendored deps.
|
||||
- Agent→panel: 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/`.
|
||||
## Anti-patterns
|
||||
|
||||
- Don't edit `x/internal/util/grpc/proto/*.pb.go` (generated protobuf).
|
||||
|
||||
## Commands
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost
|
||||
go run .
|
||||
go test ./...
|
||||
go build .
|
||||
|
||||
+14
-28
@@ -1,8 +1,6 @@
|
||||
module github.com/go-gost/gost
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.23.4
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/go-gost/core v0.3.1
|
||||
@@ -16,7 +14,6 @@ require (
|
||||
github.com/alessio/shellescape v1.4.1 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bwmarrin/snowflake v0.3.0 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
@@ -25,8 +22,6 @@ require (
|
||||
github.com/coreos/go-iptables v0.7.0 // indirect
|
||||
github.com/danieljoos/wincred v1.2.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/expr-lang/expr v1.16.3 // indirect
|
||||
github.com/florianl/go-nfqueue v1.3.2-0.20231218173729-f2bdeb033acf // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/cors v1.7.2 // indirect
|
||||
@@ -47,17 +42,13 @@ require (
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/gopacket v1.1.20-0.20220810144506-32ee38206866 // indirect
|
||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/gravitational/trace v1.1.16-0.20220114165159-14a9a7dd6aaf // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jonboulle/clockwork v0.2.2 // indirect
|
||||
github.com/josharian/native v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/klauspost/reedsolomon v1.11.8 // indirect
|
||||
@@ -65,8 +56,6 @@ require (
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mdlayher/netlink v1.6.0 // indirect
|
||||
github.com/mdlayher/socket v0.1.1 // indirect
|
||||
github.com/miekg/dns v1.1.61 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
@@ -87,7 +76,7 @@ require (
|
||||
github.com/prometheus/common v0.48.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.48.2 // indirect
|
||||
github.com/quic-go/quic-go v0.49.1 // indirect
|
||||
github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 // indirect
|
||||
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
|
||||
github.com/rs/xid v1.3.0 // indirect
|
||||
@@ -102,7 +91,6 @@ require (
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/cobra v1.8.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/spf13/viper v1.19.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
@@ -124,27 +112,25 @@ require (
|
||||
github.com/zalando/go-keyring v0.2.4 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.26.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.38.0 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20241210194714-1829a127f884 // indirect
|
||||
golang.org/x/mod v0.22.0 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sync v0.14.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/term v0.32.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
golang.org/x/tools v0.28.0 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/term v0.42.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||
google.golang.org/grpc v1.67.1 // indirect
|
||||
google.golang.org/protobuf v1.35.1 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace github.com/go-gost/x => ./x
|
||||
|
||||
|
||||
+48
-64
@@ -10,8 +10,6 @@ github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl
|
||||
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
|
||||
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
@@ -27,7 +25,6 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/coreos/go-iptables v0.7.0 h1:XWM3V+MPRr5/q51NuWSgU0fqMad64Zyxs8ZUoMsamr8=
|
||||
github.com/coreos/go-iptables v0.7.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE=
|
||||
github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -40,10 +37,6 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/expr-lang/expr v1.16.3 h1:NLldf786GffptcXNxxJx5dQ+FzeWDKChBDqOOwyK8to=
|
||||
github.com/expr-lang/expr v1.16.3/go.mod h1:uCkhfG+x7fcZ5A5sXHKuQ07jGZRl6J0FCAaf2k4PtVQ=
|
||||
github.com/florianl/go-nfqueue v1.3.2-0.20231218173729-f2bdeb033acf h1:NqGS3vTHzVENbIfd87cXZwdpO6MB2R1PjHMJLi4Z3ow=
|
||||
github.com/florianl/go-nfqueue v1.3.2-0.20231218173729-f2bdeb033acf/go.mod h1:eSnAor2YCfMCVYrVNEhkLGN/r1L+J4uDjc0EUy0tfq4=
|
||||
github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk=
|
||||
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
@@ -70,8 +63,10 @@ github.com/go-gost/relay v0.5.0 h1:JG1tgy/KWiVXS0ukuVXvbM0kbYuJTWxYpJ5JwzsCf/c=
|
||||
github.com/go-gost/relay v0.5.0/go.mod h1:lcX+23LCQ3khIeASBo+tJ/WbwXFO32/N5YN6ucuYTG8=
|
||||
github.com/go-gost/tls-dissector v0.1.1 h1:2zUOTPzCQAUQ54Rpy0UEi3JPMQSYsIFSeFeKrzmkCoU=
|
||||
github.com/go-gost/tls-dissector v0.1.1/go.mod h1:/9QfdewqmHdaE362Hv5nDaSWLx3pCmtD870d6GaquXs=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
@@ -103,6 +98,8 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4=
|
||||
@@ -112,9 +109,8 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gopacket v1.1.20-0.20220810144506-32ee38206866 h1:NaJi58bCZZh0jjPw78EqDZekPEfhlzYE01C5R+zh1tE=
|
||||
github.com/google/gopacket v1.1.20-0.20220810144506-32ee38206866/go.mod h1:riddUzxTSBpJXk3qBHtYr4qOhFhT6k/1c0E3qkQjQpA=
|
||||
@@ -126,16 +122,10 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gravitational/trace v1.1.16-0.20220114165159-14a9a7dd6aaf h1:C1GPyPJrOlJlIrcaBBiBpDsqZena2Ks8spa5xZqr1XQ=
|
||||
github.com/gravitational/trace v1.1.16-0.20220114165159-14a9a7dd6aaf/go.mod h1:zXqxTI6jXDdKnlf8s+nT+3c8LrwUEy3yNpO4XJL90lA=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
|
||||
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
|
||||
github.com/josharian/native v1.0.0 h1:Ts/E8zCSEsG17dUqv7joXJFybuMLjQfWE04tsBODTxk=
|
||||
github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/judwhite/go-svc v1.2.1 h1:a7fsJzYUa33sfDJRF2N/WXhA+LonCEEY8BJb1tuS5tA=
|
||||
@@ -158,10 +148,6 @@ github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0V
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mdlayher/netlink v1.6.0 h1:rOHX5yl7qnlpiVkFWoqccueppMtXzeziFjWAjLg6sz0=
|
||||
github.com/mdlayher/netlink v1.6.0/go.mod h1:0o3PlBmGst1xve7wQ7j/hwpNaFaH4qCRyWCdcZk8/vA=
|
||||
github.com/mdlayher/socket v0.1.1 h1:q3uOGirUPfAV2MUoaC7BavjQ154J7+JOkTWyiV+intI=
|
||||
github.com/mdlayher/socket v0.1.1/go.mod h1:mYV5YIZAfHh4dzDVzI8x8tWLWCliuX8Mon5Awbj+qDs=
|
||||
github.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs=
|
||||
github.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
@@ -213,8 +199,8 @@ github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.48.2 h1:wsKXZPeGWpMpCGSWqOcqpW2wZYic/8T3aqiOID0/KWE=
|
||||
github.com/quic-go/quic-go v0.48.2/go.mod h1:yBgs3rWBOADpga7F+jJsb6Ybg1LSYiQvwWlLX+/6HMs=
|
||||
github.com/quic-go/quic-go v0.49.1 h1:e5JXpUyF0f2uFjckQzD8jTghZrOUK1xxDqqZhlwixo0=
|
||||
github.com/quic-go/quic-go v0.49.1/go.mod h1:s2wDnmCdooUQBmQfpUSTCYBl1/D4FcqbULMMkASvR6s=
|
||||
github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 h1:4WFk6u3sOT6pLa1kQ50ZVdm8BQFgJNA117cepZxtLIg=
|
||||
github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66/go.mod h1:Vp72IJajgeOL6ddqrAhmp7IM9zbTcgkQxD/YdxrVwMw=
|
||||
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg=
|
||||
@@ -223,7 +209,6 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/rs/xid v1.3.0 h1:6NjYksEUlhurdVehpc7S7dk6DAmcKv8V9gG0FsVN2U4=
|
||||
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
@@ -249,8 +234,6 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
||||
@@ -312,14 +295,22 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zalando/go-keyring v0.2.4 h1:wi2xxTqdiwMKbM6TWwi+uJCG/Tum2UV0jqaQhCa9/68=
|
||||
github.com/zalando/go-keyring v0.2.4/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
|
||||
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
|
||||
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
|
||||
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
@@ -331,8 +322,8 @@ golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20241210194714-1829a127f884 h1:Y/Mj/94zIQQGHVSv1tTtQBDaQaJe62U9bkDZKKyhPCU=
|
||||
golang.org/x/exp v0.0.0-20241210194714-1829a127f884/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
|
||||
@@ -342,8 +333,8 @@ golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHl
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
||||
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -353,21 +344,18 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
|
||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210928044308-7d9f5e0b762b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -380,11 +368,7 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -392,25 +376,24 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
@@ -419,37 +402,38 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
|
||||
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
|
||||
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
|
||||
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -116,6 +116,10 @@ func main() {
|
||||
|
||||
fmt.Printf("✅ 配置加载成功 - addr: %s\n", config.Addr)
|
||||
|
||||
// 设置运行时配置持久化路径
|
||||
socket.SetConfigPersistPath("gost.json")
|
||||
// 启用持久化将在 program.Start() 后开启,避免启动加载阶段触发冗余写入
|
||||
|
||||
log := xlogger.NewLogger()
|
||||
logger.SetDefault(log)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
metrics "github.com/go-gost/x/metrics/service"
|
||||
"github.com/go-gost/x/registry"
|
||||
xservice "github.com/go-gost/x/service"
|
||||
"github.com/go-gost/x/socket"
|
||||
"github.com/judwhite/go-svc"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -66,6 +67,10 @@ func (p *program) Start() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Enable config persistence after initial load so runtime mutations
|
||||
// (AddService, UpdateService, DeleteService, etc.) are saved to disk.
|
||||
socket.EnableConfigPersist()
|
||||
|
||||
if err := p.run(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -229,6 +234,13 @@ func (p *program) reloadConfig() error {
|
||||
if err := loader.Load(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
activeServices := make(map[string]struct{}, len(cfg.Services))
|
||||
for _, svc := range cfg.Services {
|
||||
if svc != nil {
|
||||
activeServices[svc.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
xservice.GetGlobalTrafficManager().RetainServices(activeServices)
|
||||
|
||||
if err := p.run(cfg); err != nil {
|
||||
return err
|
||||
|
||||
+26
-41
@@ -1,50 +1,35 @@
|
||||
# GO-GOST/X KNOWLEDGE BASE
|
||||
# go-gost/x
|
||||
|
||||
**Generated:** Fri Mar 20 2026
|
||||
**Commit:** f45f960
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.9-beta6
|
||||
Local fork of `github.com/go-gost/x`. Standalone Go module, used by `go-gost/` via `replace => ./x`.
|
||||
|
||||
## OVERVIEW
|
||||
Local fork of `github.com/go-gost/x` used by `go-gost/` via `replace github.com/go-gost/x => ./x`. Most protocol/runtime behavior changes happen here. 30+ top-level packages - framework-style layout.
|
||||
## Key packages
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
go-gost/x/
|
||||
├── 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/...)
|
||||
├── 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 (20 files)
|
||||
├── service/ # Service wrappers + reporting hooks
|
||||
├── socket/ # WebSocket reporter / panel integration (6 files)
|
||||
└── internal/ # Shared internals (grpc proto, net utils, sniffing, tls, ...)
|
||||
```
|
||||
| Dir | Role |
|
||||
|-----|------|
|
||||
| `handler/` | Protocol handlers (socks, http, tunnel, relay, ...) |
|
||||
| `listener/` | Inbound listeners (tcp, udp, tun, tap, redirect, ...) |
|
||||
| `dialer/` | Outbound dialers (tcp, tls, ws, quic, ...) |
|
||||
| `connector/` | Outbound connect implementations |
|
||||
| `service/` | Service wrappers + reporting hooks |
|
||||
| `socket/` | WebSocket reporter / panel integration |
|
||||
| `config/` | Config model + parsing/load/reload |
|
||||
| `registry/` | Component registries (`Register{Type}(name, creator)`) |
|
||||
| `api/` | Gin management API + embedded swagger docs |
|
||||
| `limiter/` | Traffic/rate/conn limiters |
|
||||
| `internal/` | 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 |
|
||||
| **Register new component** | `go-gost/x/registry/` | `Register{Type}(name, creator)` |
|
||||
## Conventions
|
||||
|
||||
## CONVENTIONS
|
||||
- `go-gost/x/` is a standalone Go module (`go-gost/x/go.mod`); run go tooling from this dir when debugging module resolution.
|
||||
- Generated gRPC/proto code lives under `go-gost/x/internal/util/grpc/proto/`.
|
||||
- Handlers/listeners/dialers follow consistent pattern: `{type}.go` + `metadata.go` per protocol.
|
||||
- OS-specific code uses `name_[os].go` suffix (e.g., `tun_linux.go`, `tun_darwin.go`).
|
||||
- Each protocol follows `{type}.go` + `metadata.go` pattern.
|
||||
- OS-specific code uses `name_[os].go` suffix (e.g. `tun_linux.go`).
|
||||
- Run Go tooling from this directory for module resolution issues.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- Do not edit generated files in `go-gost/x/internal/util/grpc/proto/` (`*.pb.go`, `*_grpc.pb.go`).
|
||||
## Anti-patterns
|
||||
|
||||
- Don't edit `internal/util/grpc/proto/*.pb.go` or `*_grpc.pb.go` (generated).
|
||||
|
||||
## Commands
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost/x
|
||||
go test ./...
|
||||
```
|
||||
```
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/go-gost/x/config/loader"
|
||||
"github.com/go-gost/x/config/parsing/parser"
|
||||
"github.com/go-gost/x/registry"
|
||||
xservice "github.com/go-gost/x/service"
|
||||
)
|
||||
|
||||
// swagger:parameters reloadConfigRequest
|
||||
@@ -42,6 +43,13 @@ func reloadConfig(ctx *gin.Context) {
|
||||
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, err.Error()))
|
||||
return
|
||||
}
|
||||
activeServices := make(map[string]struct{}, len(cfg.Services))
|
||||
for _, svc := range cfg.Services {
|
||||
if svc != nil {
|
||||
activeServices[svc.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
xservice.GetGlobalTrafficManager().RetainServices(activeServices)
|
||||
|
||||
for _, svc := range registry.ServiceRegistry().GetAll() {
|
||||
svc := svc
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
parser "github.com/go-gost/x/config/parsing/service"
|
||||
kill "github.com/go-gost/x/internal/util/port"
|
||||
"github.com/go-gost/x/registry"
|
||||
xservice "github.com/go-gost/x/service"
|
||||
)
|
||||
|
||||
// swagger:parameters createServiceRequest
|
||||
@@ -409,6 +410,7 @@ func deleteService(ctx *gin.Context) {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
xservice.GetGlobalTrafficManager().RemoveServices(name)
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Msg: "OK",
|
||||
@@ -484,6 +486,12 @@ func deleteServices(ctx *gin.Context) {
|
||||
return nil
|
||||
})
|
||||
|
||||
names := make([]string, 0, len(servicesToDelete))
|
||||
for _, std := range servicesToDelete {
|
||||
names = append(names, std.name)
|
||||
}
|
||||
xservice.GetGlobalTrafficManager().RemoveServices(names...)
|
||||
|
||||
ctx.JSON(http.StatusOK, Response{
|
||||
Msg: "OK",
|
||||
})
|
||||
|
||||
@@ -44,9 +44,14 @@ func Set(c *Config) {
|
||||
|
||||
func OnUpdate(f func(c *Config) error) error {
|
||||
globalMux.Lock()
|
||||
defer globalMux.Unlock()
|
||||
err := f(global)
|
||||
globalMux.Unlock()
|
||||
|
||||
return f(global)
|
||||
if err == nil {
|
||||
err = persist()
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
@@ -573,6 +578,7 @@ func (c *Config) Load() error {
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
SetPersistPath(v.ConfigFileUsed())
|
||||
|
||||
return v.Unmarshal(c)
|
||||
}
|
||||
@@ -590,6 +596,7 @@ func (c *Config) ReadFile(file string) error {
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
SetPersistPath(v.ConfigFileUsed())
|
||||
return v.Unmarshal(c)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
corelimiter "github.com/go-gost/core/limiter"
|
||||
connlimiter "github.com/go-gost/core/limiter/conn"
|
||||
trafficlimiter "github.com/go-gost/core/limiter/traffic"
|
||||
xtraffic "github.com/go-gost/x/limiter/traffic"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
func resolveTrafficLimiter(names string) trafficlimiter.TrafficLimiter {
|
||||
parts := splitLimiterNames(names)
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return resolveSingleTrafficLimiter(parts[0])
|
||||
}
|
||||
limiters := make([]trafficlimiter.TrafficLimiter, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if lim := resolveSingleTrafficLimiter(part); lim != nil {
|
||||
limiters = append(limiters, lim)
|
||||
}
|
||||
}
|
||||
if len(limiters) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(limiters) == 1 {
|
||||
return limiters[0]
|
||||
}
|
||||
return &compositeTrafficLimiter{limiters: limiters}
|
||||
}
|
||||
|
||||
func resolveSingleTrafficLimiter(name string) trafficlimiter.TrafficLimiter {
|
||||
lim := registry.TrafficLimiterRegistry().Get(name)
|
||||
if lim != nil {
|
||||
return lim
|
||||
}
|
||||
if val, err := strconv.Atoi(name); err == nil && val > 0 {
|
||||
return xtraffic.NewTrafficLimiter(
|
||||
xtraffic.LimitsOption(fmt.Sprintf("%s %dB %dB", xtraffic.ServiceLimitKey, val, val)),
|
||||
)
|
||||
}
|
||||
return xtraffic.NewTrafficLimiter(
|
||||
xtraffic.LimitsOption(fmt.Sprintf("%s %s %s", xtraffic.ServiceLimitKey, name, name)),
|
||||
)
|
||||
}
|
||||
|
||||
func resolveConnLimiter(names string) connlimiter.ConnLimiter {
|
||||
parts := splitLimiterNames(names)
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return registry.ConnLimiterRegistry().Get(parts[0])
|
||||
}
|
||||
limiters := make([]connlimiter.ConnLimiter, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if lim := registry.ConnLimiterRegistry().Get(part); lim != nil {
|
||||
limiters = append(limiters, lim)
|
||||
}
|
||||
}
|
||||
if len(limiters) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(limiters) == 1 {
|
||||
return limiters[0]
|
||||
}
|
||||
return &compositeConnLimiter{limiters: limiters}
|
||||
}
|
||||
|
||||
func splitLimiterNames(names string) []string {
|
||||
parts := strings.Split(names, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part = strings.TrimSpace(part); part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type compositeTrafficLimiter struct {
|
||||
limiters []trafficlimiter.TrafficLimiter
|
||||
}
|
||||
|
||||
func (l *compositeTrafficLimiter) In(ctx context.Context, key string, opts ...corelimiter.Option) trafficlimiter.Limiter {
|
||||
limiters := make([]trafficlimiter.Limiter, 0, len(l.limiters))
|
||||
for _, child := range l.limiters {
|
||||
if lim := child.In(ctx, key, opts...); lim != nil {
|
||||
limiters = append(limiters, lim)
|
||||
}
|
||||
}
|
||||
return newCompositeTrafficChildLimiter(limiters)
|
||||
}
|
||||
|
||||
func (l *compositeTrafficLimiter) Out(ctx context.Context, key string, opts ...corelimiter.Option) trafficlimiter.Limiter {
|
||||
limiters := make([]trafficlimiter.Limiter, 0, len(l.limiters))
|
||||
for _, child := range l.limiters {
|
||||
if lim := child.Out(ctx, key, opts...); lim != nil {
|
||||
limiters = append(limiters, lim)
|
||||
}
|
||||
}
|
||||
return newCompositeTrafficChildLimiter(limiters)
|
||||
}
|
||||
|
||||
type compositeTrafficChildLimiter struct {
|
||||
limiters []trafficlimiter.Limiter
|
||||
}
|
||||
|
||||
func newCompositeTrafficChildLimiter(limiters []trafficlimiter.Limiter) trafficlimiter.Limiter {
|
||||
if len(limiters) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(limiters) == 1 {
|
||||
return limiters[0]
|
||||
}
|
||||
sort.Slice(limiters, func(i, j int) bool {
|
||||
return limiters[i].Limit() < limiters[j].Limit()
|
||||
})
|
||||
return &compositeTrafficChildLimiter{limiters: limiters}
|
||||
}
|
||||
|
||||
func (l *compositeTrafficChildLimiter) Wait(ctx context.Context, n int) int {
|
||||
for _, lim := range l.limiters {
|
||||
if v := lim.Wait(ctx, n); v < n {
|
||||
n = v
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (l *compositeTrafficChildLimiter) Limit() int {
|
||||
if len(l.limiters) == 0 {
|
||||
return 0
|
||||
}
|
||||
return l.limiters[0].Limit()
|
||||
}
|
||||
|
||||
func (l *compositeTrafficChildLimiter) Set(n int) {}
|
||||
|
||||
type compositeConnLimiter struct {
|
||||
limiters []connlimiter.ConnLimiter
|
||||
}
|
||||
|
||||
func (l *compositeConnLimiter) Limiter(key string) connlimiter.Limiter {
|
||||
limiters := make([]connlimiter.Limiter, 0, len(l.limiters))
|
||||
for _, child := range l.limiters {
|
||||
if lim := child.Limiter(key); lim != nil {
|
||||
limiters = append(limiters, lim)
|
||||
}
|
||||
}
|
||||
return newCompositeConnChildLimiter(limiters)
|
||||
}
|
||||
|
||||
type compositeConnChildLimiter struct {
|
||||
limiters []connlimiter.Limiter
|
||||
}
|
||||
|
||||
func newCompositeConnChildLimiter(limiters []connlimiter.Limiter) connlimiter.Limiter {
|
||||
if len(limiters) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(limiters) == 1 {
|
||||
return limiters[0]
|
||||
}
|
||||
sort.Slice(limiters, func(i, j int) bool {
|
||||
return limiters[i].Limit() < limiters[j].Limit()
|
||||
})
|
||||
return &compositeConnChildLimiter{limiters: limiters}
|
||||
}
|
||||
|
||||
func (l *compositeConnChildLimiter) Allow(n int) (allowed bool) {
|
||||
var i int
|
||||
for i = range l.limiters {
|
||||
if allowed = l.limiters[i].Allow(n); !allowed {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed && i > 0 && n > 0 {
|
||||
for _, lim := range l.limiters[:i] {
|
||||
lim.Allow(-n)
|
||||
}
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
func (l *compositeConnChildLimiter) Limit() int {
|
||||
if len(l.limiters) == 0 {
|
||||
return 0
|
||||
}
|
||||
return l.limiters[0].Limit()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
corelimiter "github.com/go-gost/core/limiter"
|
||||
corelogger "github.com/go-gost/core/logger"
|
||||
xconn "github.com/go-gost/x/limiter/conn"
|
||||
xtraffic "github.com/go-gost/x/limiter/traffic"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
func TestResolveTrafficLimiterComposesCommaSeparatedNames(t *testing.T) {
|
||||
const totalName = "test_total_speed_composite"
|
||||
const ruleName = "test_rule_speed_composite"
|
||||
registry.TrafficLimiterRegistry().Unregister(totalName)
|
||||
registry.TrafficLimiterRegistry().Unregister(ruleName)
|
||||
defer registry.TrafficLimiterRegistry().Unregister(totalName)
|
||||
defer registry.TrafficLimiterRegistry().Unregister(ruleName)
|
||||
|
||||
logger := xlogger.NewLogger(xlogger.OutputOption(io.Discard), xlogger.LevelOption(corelogger.ErrorLevel))
|
||||
if err := registry.TrafficLimiterRegistry().Register(totalName, xtraffic.NewTrafficLimiter(xtraffic.LimitsOption("$ 10B 10B"), xtraffic.LoggerOption(logger))); err != nil {
|
||||
t.Fatalf("register total limiter: %v", err)
|
||||
}
|
||||
if err := registry.TrafficLimiterRegistry().Register(ruleName, xtraffic.NewTrafficLimiter(xtraffic.LimitsOption("0.0.0.0/0 3B 3B"), xtraffic.LoggerOption(logger))); err != nil {
|
||||
t.Fatalf("register rule limiter: %v", err)
|
||||
}
|
||||
|
||||
lim := resolveTrafficLimiter(totalName + "," + ruleName)
|
||||
if lim == nil {
|
||||
t.Fatalf("expected composite traffic limiter")
|
||||
}
|
||||
serviceLimiter := lim.In(context.Background(), "192.0.2.1:1000", corelimiter.ScopeOption(corelimiter.ScopeService))
|
||||
if serviceLimiter == nil || serviceLimiter.Limit() != 10 {
|
||||
t.Fatalf("expected service-scope total limiter 10, got %#v", serviceLimiter)
|
||||
}
|
||||
connLimiter := lim.In(context.Background(), "192.0.2.1:1000", corelimiter.ScopeOption(corelimiter.ScopeConn))
|
||||
if connLimiter == nil || connLimiter.Limit() != 3 {
|
||||
t.Fatalf("expected conn-scope per-IP limiter 3, got %#v", connLimiter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConnLimiterComposesCommaSeparatedNames(t *testing.T) {
|
||||
const totalName = "test_total_conn_composite"
|
||||
const ruleName = "test_rule_conn_composite"
|
||||
registry.ConnLimiterRegistry().Unregister(totalName)
|
||||
registry.ConnLimiterRegistry().Unregister(ruleName)
|
||||
defer registry.ConnLimiterRegistry().Unregister(totalName)
|
||||
defer registry.ConnLimiterRegistry().Unregister(ruleName)
|
||||
|
||||
logger := xlogger.NewLogger(xlogger.OutputOption(io.Discard), xlogger.LevelOption(corelogger.ErrorLevel))
|
||||
if err := registry.ConnLimiterRegistry().Register(totalName, xconn.NewConnLimiter(xconn.LimitsOption("$ 2"), xconn.LoggerOption(logger))); err != nil {
|
||||
t.Fatalf("register total conn limiter: %v", err)
|
||||
}
|
||||
if err := registry.ConnLimiterRegistry().Register(ruleName, xconn.NewConnLimiter(xconn.LimitsOption("$$ 1"), xconn.LoggerOption(logger))); err != nil {
|
||||
t.Fatalf("register rule conn limiter: %v", err)
|
||||
}
|
||||
|
||||
lim := resolveConnLimiter(totalName + "," + ruleName)
|
||||
if lim == nil {
|
||||
t.Fatalf("expected composite conn limiter")
|
||||
}
|
||||
clientLimiter := lim.Limiter("192.0.2.1")
|
||||
if clientLimiter == nil || clientLimiter.Limit() != 1 {
|
||||
t.Fatalf("expected composite client limiter with strictest limit 1, got %#v", clientLimiter)
|
||||
}
|
||||
if !clientLimiter.Allow(1) {
|
||||
t.Fatalf("expected first connection to be allowed")
|
||||
}
|
||||
if clientLimiter.Allow(1) {
|
||||
t.Fatalf("expected per-IP rule limiter to reject second connection")
|
||||
}
|
||||
if !lim.Limiter("192.0.2.2").Allow(1) {
|
||||
t.Fatalf("expected another client to share total limiter but have independent per-IP capacity")
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package service
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -31,7 +30,6 @@ import (
|
||||
logger_parser "github.com/go-gost/x/config/parsing/logger"
|
||||
selector_parser "github.com/go-gost/x/config/parsing/selector"
|
||||
tls_util "github.com/go-gost/x/internal/util/tls"
|
||||
xtraffic "github.com/go-gost/x/limiter/traffic"
|
||||
cache_limiter "github.com/go-gost/x/limiter/traffic/cache"
|
||||
"github.com/go-gost/x/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
@@ -135,7 +133,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
postDown = mdutil.GetStrings(md, parsing.MDKeyPostDown)
|
||||
ignoreChain = mdutil.GetBool(md, parsing.MDKeyIgnoreChain)
|
||||
|
||||
if md.IsExists(parsing.MDKeyEnableStats) {
|
||||
if md != nil && md.IsExists(parsing.MDKeyEnableStats) {
|
||||
enableStats = mdutil.GetBool(md, parsing.MDKeyEnableStats)
|
||||
}
|
||||
|
||||
@@ -157,7 +155,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
resetTraffic := true
|
||||
if cfg.Metadata != nil {
|
||||
md := metadata.NewMetadata(cfg.Metadata)
|
||||
if md.IsExists(parsing.MDKeyObserverResetTraffic) {
|
||||
if md != nil && md.IsExists(parsing.MDKeyObserverResetTraffic) {
|
||||
resetTraffic = mdutil.GetBool(md, parsing.MDKeyObserverResetTraffic)
|
||||
}
|
||||
}
|
||||
@@ -185,20 +183,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
|
||||
var trafficLimiter listener.Option
|
||||
if cfg.Limiter != "" {
|
||||
lim := registry.TrafficLimiterRegistry().Get(cfg.Limiter)
|
||||
if lim == nil {
|
||||
// Try to parse as simple number (bandwidth in bytes/sec)
|
||||
if val, err := strconv.Atoi(cfg.Limiter); err == nil && val > 0 {
|
||||
lim = xtraffic.NewTrafficLimiter(
|
||||
xtraffic.LimitsOption(fmt.Sprintf("%s %dB %dB", xtraffic.ServiceLimitKey, val, val)),
|
||||
)
|
||||
}
|
||||
if lim == nil {
|
||||
lim = xtraffic.NewTrafficLimiter(
|
||||
xtraffic.LimitsOption(fmt.Sprintf("%s %s %s", xtraffic.ServiceLimitKey, cfg.Limiter, cfg.Limiter)),
|
||||
)
|
||||
}
|
||||
}
|
||||
lim := resolveTrafficLimiter(cfg.Limiter)
|
||||
trafficLimiter = listener.TrafficLimiterOption(
|
||||
cache_limiter.NewCachedTrafficLimiter(
|
||||
lim,
|
||||
@@ -216,7 +201,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
listener.AuthOption(auth_parser.Info(cfg.Listener.Auth)),
|
||||
listener.TLSConfigOption(tlsConfig),
|
||||
listener.AdmissionOption(xadmission.AdmissionGroup(admissions...)),
|
||||
listener.ConnLimiterOption(registry.ConnLimiterRegistry().Get(cfg.CLimiter)),
|
||||
listener.ConnLimiterOption(resolveConnLimiter(cfg.CLimiter)),
|
||||
listener.ServiceOption(cfg.Name),
|
||||
listener.ProxyProtocolOption(ppv),
|
||||
listener.StatsOption(pStats),
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
persistPath string
|
||||
persistMu sync.Mutex
|
||||
persistEnable bool
|
||||
)
|
||||
|
||||
// SetPersistPath sets the file path where runtime config changes will be
|
||||
// automatically persisted. Call this once during agent startup before any
|
||||
// OnUpdate mutations occur.
|
||||
func SetPersistPath(path string) {
|
||||
persistMu.Lock()
|
||||
defer persistMu.Unlock()
|
||||
persistPath = path
|
||||
}
|
||||
|
||||
func PersistPath() string {
|
||||
persistMu.Lock()
|
||||
defer persistMu.Unlock()
|
||||
return persistPath
|
||||
}
|
||||
|
||||
// EnablePersist turns on automatic persistence. Call this after the initial
|
||||
// config has been loaded (e.g. after program.Start) so that startup loading
|
||||
// does not trigger redundant disk writes.
|
||||
func EnablePersist() {
|
||||
persistMu.Lock()
|
||||
defer persistMu.Unlock()
|
||||
persistEnable = true
|
||||
}
|
||||
|
||||
// persist writes the current global config to the configured file atomically.
|
||||
func persist() error {
|
||||
persistMu.Lock()
|
||||
path := persistPath
|
||||
enabled := persistEnable
|
||||
persistMu.Unlock()
|
||||
|
||||
if !enabled || path == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := Global()
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(cfg); err != nil {
|
||||
fmt.Printf("⚠️ config persist: marshal failed: %v\n", err)
|
||||
return fmt.Errorf("config persist: marshal failed: %w", err)
|
||||
}
|
||||
|
||||
// Atomic write: write to temp file then rename
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := os.CreateTemp(dir, ".gost-*.tmp")
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ config persist: create temp file failed: %v\n", err)
|
||||
return fmt.Errorf("config persist: create temp file failed: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
|
||||
if _, err := tmp.Write(buf.Bytes()); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
fmt.Printf("⚠️ config persist: write failed: %v\n", err)
|
||||
return fmt.Errorf("config persist: write failed: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpName)
|
||||
fmt.Printf("⚠️ config persist: close temp file failed: %v\n", err)
|
||||
return fmt.Errorf("config persist: close temp file failed: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
os.Remove(tmpName)
|
||||
fmt.Printf("⚠️ config persist: rename failed: %v\n", err)
|
||||
return fmt.Errorf("config persist: rename failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("💾 节点配置已持久化到 %s\n", path)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadFileSetsPersistPath(t *testing.T) {
|
||||
originalPath := persistPath
|
||||
originalEnabled := persistEnable
|
||||
persistPath = ""
|
||||
persistEnable = false
|
||||
t.Cleanup(func() {
|
||||
persistPath = originalPath
|
||||
persistEnable = originalEnabled
|
||||
})
|
||||
|
||||
dir := t.TempDir()
|
||||
configFile := filepath.Join(dir, "custom-gost.yaml")
|
||||
if err := os.WriteFile(configFile, []byte("services: []\n"), 0o644); err != nil {
|
||||
t.Fatalf("write config file: %v", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := cfg.ReadFile(configFile); err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if persistPath != configFile {
|
||||
t.Fatalf("expected persistPath %q, got %q", configFile, persistPath)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/go-gost/core/logger"
|
||||
md "github.com/go-gost/core/metadata"
|
||||
kcp_util "github.com/go-gost/x/internal/util/kcp"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
"github.com/go-gost/x/registry"
|
||||
"github.com/xtaci/kcp-go/v5"
|
||||
"github.com/xtaci/smux"
|
||||
@@ -48,6 +49,9 @@ func (d *kcpDialer) Init(md md.Metadata) (err error) {
|
||||
}
|
||||
|
||||
d.md.config.Init()
|
||||
if md != nil && md.IsExists("kcp.nc") {
|
||||
d.md.config.NoCongestion = mdutil.GetInt(md, "kcp.nc")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -40,21 +40,61 @@ func (d *kcpDialer) parseMetadata(md mdata.Metadata) (err error) {
|
||||
d.md.config = cfg
|
||||
}
|
||||
if d.md.config == nil {
|
||||
d.md.config = kcp_util.DefaultConfig
|
||||
d.md.config = kcp_util.DefaultConfig.Clone()
|
||||
} else {
|
||||
d.md.config = d.md.config.Clone()
|
||||
}
|
||||
|
||||
if md != nil {
|
||||
if md.IsExists("kcp.tcp") {
|
||||
d.md.config.TCP = mdutil.GetBool(md, "kcp.tcp")
|
||||
}
|
||||
if md.IsExists("kcp.key") {
|
||||
d.md.config.Key = mdutil.GetString(md, "kcp.key")
|
||||
}
|
||||
if md.IsExists("kcp.crypt") {
|
||||
d.md.config.Crypt = mdutil.GetString(md, "kcp.crypt")
|
||||
}
|
||||
if md.IsExists("kcp.mode") {
|
||||
d.md.config.Mode = mdutil.GetString(md, "kcp.mode")
|
||||
}
|
||||
if md.IsExists("kcp.keepalive") {
|
||||
d.md.config.KeepAlive = mdutil.GetInt(md, "kcp.keepalive")
|
||||
}
|
||||
if md.IsExists("kcp.interval") {
|
||||
d.md.config.Interval = mdutil.GetInt(md, "kcp.interval")
|
||||
}
|
||||
if md.IsExists("kcp.mtu") {
|
||||
d.md.config.MTU = mdutil.GetInt(md, "kcp.mtu")
|
||||
}
|
||||
if md.IsExists("kcp.rcvwnd") {
|
||||
d.md.config.RcvWnd = mdutil.GetInt(md, "kcp.rcvwnd")
|
||||
}
|
||||
if md.IsExists("kcp.sndwnd") {
|
||||
d.md.config.SndWnd = mdutil.GetInt(md, "kcp.sndwnd")
|
||||
}
|
||||
if md.IsExists("kcp.sockbuf") {
|
||||
d.md.config.SockBuf = mdutil.GetInt(md, "kcp.sockbuf")
|
||||
}
|
||||
if md.IsExists("kcp.smuxver") {
|
||||
d.md.config.SmuxVer = mdutil.GetInt(md, "kcp.smuxver")
|
||||
}
|
||||
if md.IsExists("kcp.smuxbuf") {
|
||||
d.md.config.SmuxBuf = mdutil.GetInt(md, "kcp.smuxbuf")
|
||||
}
|
||||
if md.IsExists("kcp.streambuf") {
|
||||
d.md.config.StreamBuf = mdutil.GetInt(md, "kcp.streambuf")
|
||||
}
|
||||
if md.IsExists("kcp.nocomp") {
|
||||
d.md.config.NoComp = mdutil.GetBool(md, "kcp.nocomp")
|
||||
}
|
||||
if md.IsExists("kcp.datashard") {
|
||||
d.md.config.DataShard = mdutil.GetInt(md, "kcp.datashard")
|
||||
}
|
||||
if md.IsExists("kcp.parityshard") {
|
||||
d.md.config.ParityShard = mdutil.GetInt(md, "kcp.parityshard")
|
||||
}
|
||||
}
|
||||
d.md.config.TCP = mdutil.GetBool(md, "kcp.tcp", "tcp")
|
||||
d.md.config.Key = mdutil.GetString(md, "kcp.key")
|
||||
d.md.config.Crypt = mdutil.GetString(md, "kcp.crypt")
|
||||
d.md.config.Mode = mdutil.GetString(md, "kcp.mode")
|
||||
d.md.config.KeepAlive = mdutil.GetInt(md, "kcp.keepalive")
|
||||
d.md.config.Interval = mdutil.GetInt(md, "kcp.interval")
|
||||
d.md.config.MTU = mdutil.GetInt(md, "kcp.mtu")
|
||||
d.md.config.RcvWnd = mdutil.GetInt(md, "kcp.rcvwnd")
|
||||
d.md.config.SndWnd = mdutil.GetInt(md, "kcp.sndwnd")
|
||||
d.md.config.SmuxVer = mdutil.GetInt(md, "kcp.smuxver")
|
||||
d.md.config.SmuxBuf = mdutil.GetInt(md, "kcp.smuxbuf")
|
||||
d.md.config.StreamBuf = mdutil.GetInt(md, "kcp.streambuf")
|
||||
d.md.config.NoComp = mdutil.GetBool(md, "kcp.nocomp")
|
||||
|
||||
d.md.handshakeTimeout = mdutil.GetDuration(md, handshakeTimeout)
|
||||
return
|
||||
|
||||
+13
-15
@@ -1,8 +1,6 @@
|
||||
module github.com/go-gost/x
|
||||
|
||||
go 1.22.0
|
||||
|
||||
toolchain go1.23.4
|
||||
go 1.25.0
|
||||
|
||||
require github.com/shirou/gopsutil/v3 v3.24.5
|
||||
|
||||
@@ -28,7 +26,7 @@ require (
|
||||
github.com/pion/dtls/v2 v2.2.6
|
||||
github.com/pires/go-proxyproto v0.7.0
|
||||
github.com/prometheus/client_golang v1.19.1
|
||||
github.com/quic-go/quic-go v0.48.2
|
||||
github.com/quic-go/quic-go v0.49.1
|
||||
github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66
|
||||
github.com/rs/xid v1.3.0
|
||||
github.com/shadowsocks/go-shadowsocks2 v0.1.5
|
||||
@@ -45,15 +43,15 @@ require (
|
||||
github.com/xtaci/tcpraw v1.2.25
|
||||
github.com/yl2chen/cidranger v1.0.2
|
||||
github.com/zalando/go-keyring v0.2.4
|
||||
golang.org/x/crypto v0.31.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/exp v0.0.0-20241210194714-1829a127f884
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/sys v0.28.0
|
||||
golang.org/x/text v0.21.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/text v0.36.0
|
||||
golang.org/x/time v0.5.0
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
||||
google.golang.org/grpc v1.67.1
|
||||
google.golang.org/protobuf v1.35.1
|
||||
google.golang.org/grpc v1.80.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@@ -128,11 +126,11 @@ require (
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/mod v0.22.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/term v0.27.0 // indirect
|
||||
golang.org/x/tools v0.28.0 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/term v0.42.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
)
|
||||
|
||||
+46
-28
@@ -63,8 +63,10 @@ github.com/go-gost/relay v0.5.0 h1:JG1tgy/KWiVXS0ukuVXvbM0kbYuJTWxYpJ5JwzsCf/c=
|
||||
github.com/go-gost/relay v0.5.0/go.mod h1:lcX+23LCQ3khIeASBo+tJ/WbwXFO32/N5YN6ucuYTG8=
|
||||
github.com/go-gost/tls-dissector v0.1.1 h1:2zUOTPzCQAUQ54Rpy0UEi3JPMQSYsIFSeFeKrzmkCoU=
|
||||
github.com/go-gost/tls-dissector v0.1.1/go.mod h1:/9QfdewqmHdaE362Hv5nDaSWLx3pCmtD870d6GaquXs=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
@@ -96,6 +98,8 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4=
|
||||
@@ -105,8 +109,8 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
||||
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
||||
@@ -193,8 +197,8 @@ github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.48.2 h1:wsKXZPeGWpMpCGSWqOcqpW2wZYic/8T3aqiOID0/KWE=
|
||||
github.com/quic-go/quic-go v0.48.2/go.mod h1:yBgs3rWBOADpga7F+jJsb6Ybg1LSYiQvwWlLX+/6HMs=
|
||||
github.com/quic-go/quic-go v0.49.1 h1:e5JXpUyF0f2uFjckQzD8jTghZrOUK1xxDqqZhlwixo0=
|
||||
github.com/quic-go/quic-go v0.49.1/go.mod h1:s2wDnmCdooUQBmQfpUSTCYBl1/D4FcqbULMMkASvR6s=
|
||||
github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 h1:4WFk6u3sOT6pLa1kQ50ZVdm8BQFgJNA117cepZxtLIg=
|
||||
github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66/go.mod h1:Vp72IJajgeOL6ddqrAhmp7IM9zbTcgkQxD/YdxrVwMw=
|
||||
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg=
|
||||
@@ -286,6 +290,18 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zalando/go-keyring v0.2.4 h1:wi2xxTqdiwMKbM6TWwi+uJCG/Tum2UV0jqaQhCa9/68=
|
||||
github.com/zalando/go-keyring v0.2.4/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
@@ -301,8 +317,8 @@ golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20241210194714-1829a127f884 h1:Y/Mj/94zIQQGHVSv1tTtQBDaQaJe62U9bkDZKKyhPCU=
|
||||
golang.org/x/exp v0.0.0-20241210194714-1829a127f884/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
|
||||
@@ -312,8 +328,8 @@ golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHl
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
||||
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -326,15 +342,15 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -352,22 +368,22 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -378,8 +394,8 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
|
||||
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -387,27 +403,29 @@ golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeu
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
|
||||
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
|
||||
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/go-gost/core/recorder"
|
||||
ctxvalue "github.com/go-gost/x/ctx"
|
||||
xnet "github.com/go-gost/x/internal/net"
|
||||
"github.com/go-gost/x/internal/net/proxyproto"
|
||||
"github.com/go-gost/x/internal/util/forwarder"
|
||||
"github.com/go-gost/x/internal/util/sniffing"
|
||||
tls_util "github.com/go-gost/x/internal/util/tls"
|
||||
@@ -252,6 +253,8 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand
|
||||
}
|
||||
defer cc.Close()
|
||||
|
||||
cc = proxyproto.WrapClientConn(h.md.proxyProtocol, conn.RemoteAddr(), conn.LocalAddr(), cc)
|
||||
|
||||
if err := xnet.Transport(conn, cc); err != nil {
|
||||
if marker := target.Marker(); marker != nil {
|
||||
marker.Mark()
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
type metadata struct {
|
||||
readTimeout time.Duration
|
||||
proxyProtocol int
|
||||
httpKeepalive bool
|
||||
|
||||
sniffing bool
|
||||
@@ -38,6 +39,7 @@ func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) {
|
||||
if h.md.readTimeout <= 0 {
|
||||
h.md.readTimeout = 15 * time.Second
|
||||
}
|
||||
h.md.proxyProtocol = mdutil.GetInt(md, "proxyProtocol")
|
||||
|
||||
h.md.httpKeepalive = mdutil.GetBool(md, "http.keepalive")
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/handler"
|
||||
"github.com/go-gost/core/hop"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
xmd "github.com/go-gost/x/metadata"
|
||||
proxyproto "github.com/pires/go-proxyproto"
|
||||
)
|
||||
|
||||
type proxyProtocolTestHop struct {
|
||||
node *chain.Node
|
||||
}
|
||||
|
||||
func (h proxyProtocolTestHop) Select(context.Context, ...hop.SelectOption) *chain.Node {
|
||||
return h.node
|
||||
}
|
||||
|
||||
func (h proxyProtocolTestHop) Nodes() []*chain.Node {
|
||||
return []*chain.Node{h.node}
|
||||
}
|
||||
|
||||
type proxyProtocolTestRouter struct{}
|
||||
|
||||
func (r proxyProtocolTestRouter) Options() *chain.RouterOptions {
|
||||
return &chain.RouterOptions{}
|
||||
}
|
||||
|
||||
func (r proxyProtocolTestRouter) Dial(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, address)
|
||||
}
|
||||
|
||||
func (r proxyProtocolTestRouter) Bind(context.Context, string, string, ...chain.BindOption) (net.Listener, error) {
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
|
||||
func TestLocalForwardHandlerSendsProxyProtocolToTarget(t *testing.T) {
|
||||
targetListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen target: %v", err)
|
||||
}
|
||||
defer targetListener.Close()
|
||||
|
||||
entryListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen entry: %v", err)
|
||||
}
|
||||
defer entryListener.Close()
|
||||
|
||||
h := NewHandler(
|
||||
handler.RouterOption(proxyProtocolTestRouter{}),
|
||||
handler.LoggerOption(xlogger.Nop()),
|
||||
)
|
||||
forwarder := h.(handler.Forwarder)
|
||||
forwarder.Forward(proxyProtocolTestHop{node: chain.NewNode("target", targetListener.Addr().String())})
|
||||
if err := h.Init(xmd.NewMetadata(map[string]any{"proxyProtocol": 2})); err != nil {
|
||||
t.Fatalf("init handler: %v", err)
|
||||
}
|
||||
|
||||
handleErr := make(chan error, 1)
|
||||
acceptErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverConn, err := entryListener.Accept()
|
||||
if err != nil {
|
||||
acceptErr <- err
|
||||
return
|
||||
}
|
||||
handleErr <- h.Handle(context.Background(), serverConn)
|
||||
}()
|
||||
|
||||
clientConn, err := net.Dial("tcp", entryListener.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("dial entry: %v", err)
|
||||
}
|
||||
defer clientConn.Close()
|
||||
|
||||
targetConn, err := targetListener.Accept()
|
||||
if err != nil {
|
||||
t.Fatalf("accept target: %v", err)
|
||||
}
|
||||
defer targetConn.Close()
|
||||
if err := targetConn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
|
||||
t.Fatalf("set target deadline: %v", err)
|
||||
}
|
||||
|
||||
header, err := proxyproto.Read(bufio.NewReader(targetConn))
|
||||
if err != nil {
|
||||
t.Fatalf("read proxy protocol header: %v", err)
|
||||
}
|
||||
if header.Version != 2 {
|
||||
t.Fatalf("expected proxy protocol v2, got v%d", header.Version)
|
||||
}
|
||||
|
||||
_ = clientConn.Close()
|
||||
_ = targetConn.Close()
|
||||
select {
|
||||
case err := <-acceptErr:
|
||||
t.Fatalf("accept entry: %v", err)
|
||||
case <-handleErr:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("handler did not return after closing connections")
|
||||
}
|
||||
}
|
||||
@@ -227,6 +227,7 @@ func (c *conn) SetIdle(idle bool) {
|
||||
func (c *conn) WriteQueue(b []byte) error {
|
||||
select {
|
||||
case c.rc <- b:
|
||||
c.SetIdle(false)
|
||||
return nil
|
||||
|
||||
case <-c.closed:
|
||||
|
||||
@@ -40,7 +40,7 @@ var (
|
||||
SnmpLog: "",
|
||||
SnmpPeriod: 60,
|
||||
Signal: false,
|
||||
TCP: false,
|
||||
TCP: false, // default UDP mode (use tcpraw when true)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -89,6 +89,15 @@ func ParseFromFile(filename string) (*Config, error) {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// Clone returns a deep copy of the Config.
|
||||
func (c *Config) Clone() *Config {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *c
|
||||
return &clone
|
||||
}
|
||||
|
||||
// Init initializes the KCP config.
|
||||
func (c *Config) Init() {
|
||||
switch c.Mode {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package conn
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
corelogger "github.com/go-gost/core/logger"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
)
|
||||
|
||||
func TestIPLimitKeyCreatesIndependentLimiters(t *testing.T) {
|
||||
limiter := NewConnLimiter(
|
||||
LimitsOption("$$ 1"),
|
||||
LoggerOption(xlogger.NewLogger(xlogger.OutputOption(io.Discard), xlogger.LevelOption(corelogger.ErrorLevel))),
|
||||
)
|
||||
first := limiter.Limiter("192.0.2.1")
|
||||
second := limiter.Limiter("192.0.2.2")
|
||||
if first == nil || second == nil {
|
||||
t.Fatalf("expected non-nil per-IP limiters")
|
||||
}
|
||||
if !first.Allow(1) {
|
||||
t.Fatalf("expected first IP first connection to be allowed")
|
||||
}
|
||||
if first.Allow(1) {
|
||||
t.Fatalf("expected first IP second connection to be rejected")
|
||||
}
|
||||
if !second.Allow(1) {
|
||||
t.Fatalf("expected second IP first connection to be allowed independently")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package traffic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
corelogger "github.com/go-gost/core/logger"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
)
|
||||
|
||||
func TestCIDRLimitCreatesIndependentClientLimiters(t *testing.T) {
|
||||
limiter := NewTrafficLimiter(
|
||||
LimitsOption("0.0.0.0/0 2B 2B"),
|
||||
LoggerOption(xlogger.NewLogger(xlogger.OutputOption(io.Discard), xlogger.LevelOption(corelogger.ErrorLevel))),
|
||||
)
|
||||
first := limiter.In(context.Background(), "192.0.2.1:1000")
|
||||
second := limiter.In(context.Background(), "192.0.2.2:1000")
|
||||
if first == nil || second == nil {
|
||||
t.Fatalf("expected non-nil CIDR client limiters")
|
||||
}
|
||||
if first == second {
|
||||
t.Fatalf("expected different clients to receive independent limiter instances")
|
||||
}
|
||||
if first.Limit() != 2 || second.Limit() != 2 {
|
||||
t.Fatalf("expected both limits to be 2, got %d and %d", first.Limit(), second.Limit())
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
limiter_wrapper "github.com/go-gost/x/limiter/traffic/wrapper"
|
||||
metrics "github.com/go-gost/x/metrics/wrapper"
|
||||
stats "github.com/go-gost/x/observer/stats/wrapper"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
"github.com/go-gost/x/registry"
|
||||
"github.com/xtaci/kcp-go/v5"
|
||||
"github.com/xtaci/smux"
|
||||
@@ -53,6 +54,9 @@ func (l *kcpListener) Init(md md.Metadata) (err error) {
|
||||
|
||||
config := l.md.config
|
||||
config.Init()
|
||||
if md != nil && md.IsExists("kcp.nc") {
|
||||
config.NoCongestion = mdutil.GetInt(md, "kcp.nc")
|
||||
}
|
||||
|
||||
var conn net.PacketConn
|
||||
if config.TCP {
|
||||
|
||||
@@ -43,21 +43,61 @@ func (l *kcpListener) parseMetadata(md mdata.Metadata) (err error) {
|
||||
}
|
||||
|
||||
if l.md.config == nil {
|
||||
l.md.config = kcp_util.DefaultConfig
|
||||
l.md.config = kcp_util.DefaultConfig.Clone()
|
||||
} else {
|
||||
l.md.config = l.md.config.Clone()
|
||||
}
|
||||
|
||||
if md != nil {
|
||||
if md.IsExists("kcp.tcp") {
|
||||
l.md.config.TCP = mdutil.GetBool(md, "kcp.tcp")
|
||||
}
|
||||
if md.IsExists("kcp.key") {
|
||||
l.md.config.Key = mdutil.GetString(md, "kcp.key")
|
||||
}
|
||||
if md.IsExists("kcp.crypt") {
|
||||
l.md.config.Crypt = mdutil.GetString(md, "kcp.crypt")
|
||||
}
|
||||
if md.IsExists("kcp.mode") {
|
||||
l.md.config.Mode = mdutil.GetString(md, "kcp.mode")
|
||||
}
|
||||
if md.IsExists("kcp.keepalive") {
|
||||
l.md.config.KeepAlive = mdutil.GetInt(md, "kcp.keepalive")
|
||||
}
|
||||
if md.IsExists("kcp.interval") {
|
||||
l.md.config.Interval = mdutil.GetInt(md, "kcp.interval")
|
||||
}
|
||||
if md.IsExists("kcp.mtu") {
|
||||
l.md.config.MTU = mdutil.GetInt(md, "kcp.mtu")
|
||||
}
|
||||
if md.IsExists("kcp.rcvwnd") {
|
||||
l.md.config.RcvWnd = mdutil.GetInt(md, "kcp.rcvwnd")
|
||||
}
|
||||
if md.IsExists("kcp.sndwnd") {
|
||||
l.md.config.SndWnd = mdutil.GetInt(md, "kcp.sndwnd")
|
||||
}
|
||||
if md.IsExists("kcp.sockbuf") {
|
||||
l.md.config.SockBuf = mdutil.GetInt(md, "kcp.sockbuf")
|
||||
}
|
||||
if md.IsExists("kcp.smuxver") {
|
||||
l.md.config.SmuxVer = mdutil.GetInt(md, "kcp.smuxver")
|
||||
}
|
||||
if md.IsExists("kcp.smuxbuf") {
|
||||
l.md.config.SmuxBuf = mdutil.GetInt(md, "kcp.smuxbuf")
|
||||
}
|
||||
if md.IsExists("kcp.streambuf") {
|
||||
l.md.config.StreamBuf = mdutil.GetInt(md, "kcp.streambuf")
|
||||
}
|
||||
if md.IsExists("kcp.nocomp") {
|
||||
l.md.config.NoComp = mdutil.GetBool(md, "kcp.nocomp")
|
||||
}
|
||||
if md.IsExists("kcp.datashard") {
|
||||
l.md.config.DataShard = mdutil.GetInt(md, "kcp.datashard")
|
||||
}
|
||||
if md.IsExists("kcp.parityshard") {
|
||||
l.md.config.ParityShard = mdutil.GetInt(md, "kcp.parityshard")
|
||||
}
|
||||
}
|
||||
l.md.config.TCP = mdutil.GetBool(md, "kcp.tcp", "tcp")
|
||||
l.md.config.Key = mdutil.GetString(md, "kcp.key")
|
||||
l.md.config.Crypt = mdutil.GetString(md, "kcp.crypt")
|
||||
l.md.config.Mode = mdutil.GetString(md, "kcp.mode")
|
||||
l.md.config.KeepAlive = mdutil.GetInt(md, "kcp.keepalive")
|
||||
l.md.config.Interval = mdutil.GetInt(md, "kcp.interval")
|
||||
l.md.config.MTU = mdutil.GetInt(md, "kcp.mtu")
|
||||
l.md.config.RcvWnd = mdutil.GetInt(md, "kcp.rcvwnd")
|
||||
l.md.config.SndWnd = mdutil.GetInt(md, "kcp.sndwnd")
|
||||
l.md.config.SmuxVer = mdutil.GetInt(md, "kcp.smuxver")
|
||||
l.md.config.SmuxBuf = mdutil.GetInt(md, "kcp.smuxbuf")
|
||||
l.md.config.StreamBuf = mdutil.GetInt(md, "kcp.streambuf")
|
||||
l.md.config.NoComp = mdutil.GetBool(md, "kcp.nocomp")
|
||||
|
||||
l.md.backlog = mdutil.GetInt(md, backlog)
|
||||
if l.md.backlog <= 0 {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTTL = 5 * time.Second
|
||||
defaultTTL = 30 * time.Second
|
||||
defaultReadBufferSize = 8192
|
||||
defaultReadQueueSize = 1024
|
||||
defaultBacklog = 128
|
||||
|
||||
@@ -2,8 +2,11 @@ package udp
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/limiter"
|
||||
conn_limiter "github.com/go-gost/core/limiter/conn"
|
||||
"github.com/go-gost/core/listener"
|
||||
"github.com/go-gost/core/logger"
|
||||
md "github.com/go-gost/core/metadata"
|
||||
@@ -70,7 +73,7 @@ func (l *udpListener) Init(md md.Metadata) (err error) {
|
||||
limiter.NetworkOption(conn.LocalAddr().Network()),
|
||||
)
|
||||
|
||||
l.ln = udp.NewListener(conn, &udp.ListenConfig{
|
||||
ln := udp.NewListener(conn, &udp.ListenConfig{
|
||||
Backlog: l.md.backlog,
|
||||
ReadQueueSize: l.md.readQueueSize,
|
||||
ReadBufferSize: l.md.readBufferSize,
|
||||
@@ -78,11 +81,128 @@ func (l *udpListener) Init(md md.Metadata) (err error) {
|
||||
TTL: l.md.ttl,
|
||||
Logger: l.logger,
|
||||
})
|
||||
l.ln = ln
|
||||
return
|
||||
}
|
||||
|
||||
func (l *udpListener) Accept() (conn net.Conn, err error) {
|
||||
return l.ln.Accept()
|
||||
conn, err = l.ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if l.options.ConnLimiter != nil {
|
||||
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
|
||||
if lim := l.options.ConnLimiter.Limiter(host); lim != nil {
|
||||
if !lim.Allow(1) {
|
||||
_ = conn.Close()
|
||||
return newClosedConn(conn), nil
|
||||
}
|
||||
conn = wrapConnLimiter(lim, conn)
|
||||
}
|
||||
}
|
||||
|
||||
if pc, ok := conn.(net.PacketConn); ok {
|
||||
conn = limiter_wrapper.WrapUDPConn(
|
||||
pc,
|
||||
l.options.TrafficLimiter,
|
||||
conn.RemoteAddr().String(),
|
||||
limiter.ScopeOption(limiter.ScopeConn),
|
||||
limiter.ServiceOption(l.options.Service),
|
||||
limiter.NetworkOption(conn.LocalAddr().Network()),
|
||||
limiter.SrcOption(conn.RemoteAddr().String()),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type connLimiterConn struct {
|
||||
net.Conn
|
||||
net.PacketConn
|
||||
limiter conn_limiter.Limiter
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func wrapConnLimiter(limiter conn_limiter.Limiter, conn net.Conn) net.Conn {
|
||||
pc, ok := conn.(net.PacketConn)
|
||||
if !ok {
|
||||
return conn
|
||||
}
|
||||
return &connLimiterConn{
|
||||
Conn: conn,
|
||||
PacketConn: pc,
|
||||
limiter: limiter,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *connLimiterConn) Close() (err error) {
|
||||
c.once.Do(func() {
|
||||
c.limiter.Allow(-1)
|
||||
err = c.Conn.Close()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func (c *connLimiterConn) LocalAddr() net.Addr {
|
||||
return c.Conn.LocalAddr()
|
||||
}
|
||||
|
||||
func (c *connLimiterConn) SetDeadline(t time.Time) error {
|
||||
return c.Conn.SetDeadline(t)
|
||||
}
|
||||
|
||||
func (c *connLimiterConn) SetReadDeadline(t time.Time) error {
|
||||
return c.Conn.SetReadDeadline(t)
|
||||
}
|
||||
|
||||
func (c *connLimiterConn) SetWriteDeadline(t time.Time) error {
|
||||
return c.Conn.SetWriteDeadline(t)
|
||||
}
|
||||
|
||||
type closedConn struct {
|
||||
net.Conn
|
||||
net.PacketConn
|
||||
}
|
||||
|
||||
func newClosedConn(conn net.Conn) net.Conn {
|
||||
pc, _ := conn.(net.PacketConn)
|
||||
return closedConn{Conn: conn, PacketConn: pc}
|
||||
}
|
||||
|
||||
func (c closedConn) Read([]byte) (int, error) {
|
||||
return 0, net.ErrClosed
|
||||
}
|
||||
|
||||
func (c closedConn) Write([]byte) (int, error) {
|
||||
return 0, net.ErrClosed
|
||||
}
|
||||
|
||||
func (c closedConn) ReadFrom([]byte) (int, net.Addr, error) {
|
||||
return 0, nil, net.ErrClosed
|
||||
}
|
||||
|
||||
func (c closedConn) WriteTo([]byte, net.Addr) (int, error) {
|
||||
return 0, net.ErrClosed
|
||||
}
|
||||
|
||||
func (c closedConn) Close() error {
|
||||
return c.Conn.Close()
|
||||
}
|
||||
|
||||
func (c closedConn) LocalAddr() net.Addr {
|
||||
return c.Conn.LocalAddr()
|
||||
}
|
||||
|
||||
func (c closedConn) SetDeadline(t time.Time) error {
|
||||
return c.Conn.SetDeadline(t)
|
||||
}
|
||||
|
||||
func (c closedConn) SetReadDeadline(t time.Time) error {
|
||||
return c.Conn.SetReadDeadline(t)
|
||||
}
|
||||
|
||||
func (c closedConn) SetWriteDeadline(t time.Time) error {
|
||||
return c.Conn.SetWriteDeadline(t)
|
||||
}
|
||||
|
||||
func (l *udpListener) Addr() net.Addr {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package udp
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
corelistener "github.com/go-gost/core/listener"
|
||||
corelogger "github.com/go-gost/core/logger"
|
||||
xconn "github.com/go-gost/x/limiter/conn"
|
||||
xtraffic "github.com/go-gost/x/limiter/traffic"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
)
|
||||
|
||||
func TestAcceptWithLimitersPreservesPacketConn(t *testing.T) {
|
||||
ln := NewListener(
|
||||
corelistener.AddrOption("127.0.0.1:0"),
|
||||
corelistener.ConnLimiterOption(xconn.NewConnLimiter(
|
||||
xconn.LimitsOption("$$ 1"),
|
||||
xconn.LoggerOption(xlogger.NewLogger(xlogger.OutputOption(io.Discard), xlogger.LevelOption(corelogger.ErrorLevel))),
|
||||
)),
|
||||
corelistener.TrafficLimiterOption(xtraffic.NewTrafficLimiter(
|
||||
xtraffic.LimitsOption("$$ 1024B 1024B"),
|
||||
xtraffic.LoggerOption(xlogger.NewLogger(xlogger.OutputOption(io.Discard), xlogger.LevelOption(corelogger.ErrorLevel))),
|
||||
)),
|
||||
corelistener.LoggerOption(xlogger.NewLogger(xlogger.OutputOption(io.Discard), xlogger.LevelOption(corelogger.ErrorLevel))),
|
||||
)
|
||||
if err := ln.Init(nil); err != nil {
|
||||
t.Fatalf("init listener: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
client, err := net.Dial("udp", ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("dial udp listener: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
if _, err := client.Write([]byte("packet")); err != nil {
|
||||
t.Fatalf("write packet: %v", err)
|
||||
}
|
||||
|
||||
conn, err := acceptWithTimeout(t, ln, time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("accept conn: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
packetConn, ok := conn.(net.PacketConn)
|
||||
if !ok {
|
||||
t.Fatalf("expected accepted UDP conn with limiters to implement net.PacketConn, got %T", conn)
|
||||
}
|
||||
|
||||
buf := make([]byte, 16)
|
||||
n, addr, err := packetConn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("read packet: %v", err)
|
||||
}
|
||||
if string(buf[:n]) != "packet" {
|
||||
t.Fatalf("expected original datagram, got %q", string(buf[:n]))
|
||||
}
|
||||
if addr == nil || addr.String() != client.LocalAddr().String() {
|
||||
t.Fatalf("expected client addr %v, got %v", client.LocalAddr(), addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptAppliesConnLimiterAndReleasesOnClose(t *testing.T) {
|
||||
ln := NewListener(
|
||||
corelistener.AddrOption("127.0.0.1:0"),
|
||||
corelistener.ConnLimiterOption(xconn.NewConnLimiter(
|
||||
xconn.LimitsOption("$$ 1"),
|
||||
xconn.LoggerOption(xlogger.NewLogger(xlogger.OutputOption(io.Discard), xlogger.LevelOption(corelogger.ErrorLevel))),
|
||||
)),
|
||||
corelistener.LoggerOption(xlogger.NewLogger(xlogger.OutputOption(io.Discard), xlogger.LevelOption(corelogger.ErrorLevel))),
|
||||
)
|
||||
if err := ln.Init(nil); err != nil {
|
||||
t.Fatalf("init listener: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
addr := ln.Addr().String()
|
||||
client, err := net.Dial("udp", addr)
|
||||
if err != nil {
|
||||
t.Fatalf("dial udp listener: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
if _, err := client.Write([]byte("first")); err != nil {
|
||||
t.Fatalf("write first packet: %v", err)
|
||||
}
|
||||
first, err := acceptWithTimeout(t, ln, time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("accept first conn: %v", err)
|
||||
}
|
||||
|
||||
blockedClient, err := net.Dial("udp", addr)
|
||||
if err != nil {
|
||||
t.Fatalf("dial blocked udp client: %v", err)
|
||||
}
|
||||
defer blockedClient.Close()
|
||||
if _, err := blockedClient.Write([]byte("blocked")); err != nil {
|
||||
t.Fatalf("write blocked packet: %v", err)
|
||||
}
|
||||
blocked, err := acceptWithTimeout(t, ln, time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("expected blocked same-IP pseudo-connection to be returned closed: %v", err)
|
||||
}
|
||||
buf := make([]byte, 16)
|
||||
if _, err := blocked.Read(buf); err == nil {
|
||||
_ = blocked.Close()
|
||||
t.Fatalf("expected blocked same-IP pseudo-connection to be closed")
|
||||
}
|
||||
packetConn, ok := blocked.(net.PacketConn)
|
||||
if !ok {
|
||||
_ = blocked.Close()
|
||||
t.Fatalf("expected blocked same-IP pseudo-connection to preserve net.PacketConn, got %T", blocked)
|
||||
}
|
||||
if _, _, err := packetConn.ReadFrom(buf); err == nil {
|
||||
_ = blocked.Close()
|
||||
t.Fatalf("expected blocked same-IP packet connection to be closed")
|
||||
}
|
||||
if _, err := packetConn.WriteTo([]byte("blocked"), client.LocalAddr()); err == nil {
|
||||
_ = blocked.Close()
|
||||
t.Fatalf("expected blocked same-IP packet write to be closed")
|
||||
}
|
||||
_ = blocked.Close()
|
||||
_ = first.Close()
|
||||
|
||||
reopenedClient, err := net.Dial("udp", addr)
|
||||
if err != nil {
|
||||
t.Fatalf("dial reopened udp client: %v", err)
|
||||
}
|
||||
defer reopenedClient.Close()
|
||||
if _, err := reopenedClient.Write([]byte("after-close")); err != nil {
|
||||
t.Fatalf("write after close packet: %v", err)
|
||||
}
|
||||
reopened, err := acceptWithTimeout(t, ln, time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("expected same client to be accepted after close: %v", err)
|
||||
}
|
||||
_ = reopened.Close()
|
||||
}
|
||||
|
||||
func acceptWithTimeout(t *testing.T, ln corelistener.Listener, timeout time.Duration) (net.Conn, error) {
|
||||
t.Helper()
|
||||
type result struct {
|
||||
conn net.Conn
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
ch <- result{conn: conn, err: err}
|
||||
}()
|
||||
select {
|
||||
case res := <-ch:
|
||||
return res.conn, res.err
|
||||
case <-time.After(timeout):
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user