Compare commits

..

96 Commits

Author SHA1 Message Date
sagit 8339f52280 fix(postgres): encode relay tunnel chain roles as text (#579) 2026-11-11 18:01:04 +08:00
sagit 2e85234612 docs: add copyable agent installation prompt (#578) 2026-11-10 14:41:18 +08:00
sagit b26e9ee9e4 fix: align card handles and show live deployment progress (#577)
* fix: keep user and limit drag handles beside names

* fix: show live node and tunnel deployment progress in list view
2026-11-10 10:55:53 +08:00
sagit 577c189737 feat: show current owner names and persist user and limit order (#576)
Resolve current forwarding-rule owner names by user ID. Add administrator-managed persistent user and speed-limit ordering, backup compatibility, and conflict recovery. Repair PostgreSQL explicit-ID import sequences transactionally.

Closes #574
Closes #575
2026-11-10 09:13:05 +08:00
sagit 3209ff8a59 fix(sqlite): backfill tunnel relay authentication mode (#573)
* fix(sqlite): backfill tunnel relay authentication mode

* ci: cover SQLite upgrade migrations in backend checks
2026-11-09 20:46:52 +08:00
sagit 7e81e1c65c fix(panel): make self-upgrades durable and observable (#572)
Persist upgrade jobs and progress across backend restarts; preserve Compose identity and database mounts, isolate backups, and fail safely on uncertain recreation. Add frontend recovery handling and lifecycle/permission regressions.
2026-11-09 20:24:03 +08:00
sagit a784350eaf fix(monitor): unify realtime state and ignore stale requests (#571) 2026-11-09 18:21:18 +08:00
sagit 743bf6f278 fix(agent): verify upgrades and support OpenRC lifecycle (#570) 2026-11-09 08:59:25 +08:00
sagit fadc2b6c12 fix(relay): authenticate endpoints and fail closed on missing routes (#569)
* fix(relay): authenticate endpoints and fail closed on missing routes

* fix(tunnel): retain chains until receiver replacement is acknowledged

* docs: record final relay security review and validation
2026-11-08 23:46:18 +08:00
sagit 46bfb86c1b fix(user): revoke tunnel access when disabling users (#568)
Revoke tunnel grants and pause each forwarding entry without self-healing missing services. Continue healthy nodes and other forwards after failures, aggregate pending nodes for retry, and show persistent partial-disable feedback.

Related: #562
2026-11-01 18:03:07 +08:00
tsosunchia 3549fe5ad8 fix(udp): preserve wildcard reply sources and isolate destination sessions (#565)
* fix(udp): preserve local destinations and unicast reply sources on Linux

* fix(udp): select unicast sources for IPv6 multicast replies

* fix(udp): retain interface zones for link-local multicast sessions

* fix(udp): isolate interface-local multicast sessions

* fix(udp): include ingress interfaces in session identity

* fix(udp): restrict destination zones to IPv6

* ci(udp): follow rc5 patched Go toolchain requirement

---------

Co-authored-by: sagit <36596628+Sagit-chu@users.noreply.github.com>
2026-10-24 18:35:22 +08:00
Sebastian Luo dd3ed4e284 fix(frontend): keep node id when upgrading from list view (#567)
In list view, the per-node upgrade button set upgradeTargetNodeId and then
called openUpgradeModal("single") without an id, which resets the target to
null. handleConfirmUpgrade then matched neither branch, so "确认升级" sent no
request and the modal stayed open. Pass node.id the same way grid view does.

Co-authored-by: Sebastian Luo <sebastianluo@Sebastians-MacBook-Pro.local>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:18:20 +08:00
sagit c794586cc7 fix(security): require patched Go and Node.js runtimes (#566) 2026-10-22 11:15:28 +08:00
sagit d9303032ca feat: support username-free passkey login (#564) 2026-09-30 21:12:18 +08:00
sagit 6da8b2b6ec docs: clarify passkey setup after upgrades (#563) 2026-09-30 20:23:28 +08:00
sagit 861d61239a feat: support secure passkey login (#561)
Add WebAuthn passkey enrollment and login for issue #536, with trusted origin configuration, user verification, single-use challenges, and documented deployment and recovery behavior.
2026-09-30 19:23:10 +08:00
sagit c952d2fb3a fix: protect shared rules across delivery and recovery (#560) 2026-09-29 14:05:49 +08:00
sagit 129fa0aa4c fix(frontend): constrain service monitor records modal (#557) 2026-09-24 16:12:03 +08:00
sagit cf7246c71b feat: adaptive traffic units and precise MB–PB quotas (#556)
## Summary

- Format traffic amounts adaptively through PB across the user,
dashboard, forwarding, node, monitoring, and panel sharing views. Closes
#548.
- Let administrators choose MB, GB, TB, or PB when setting user, tunnel
permission, and panel sharing traffic limits. Closes #549.
- Persist exact MiB limits for users and tunnel permissions while
retaining the legacy GB field and existing data. Apply the precise limit
in forwarding policy checks and preserve it in backups.

## Verification

- `go test ./...` in `go-backend` (724 passed)
- `pnpm run build` in `vite-frontend`
- ESLint on changed frontend files
- `git diff --check`
2026-09-24 15:40:11 +08:00
sagit b4c2989285 Merge branch 'main' into codex/issues-548-549-traffic-units 2026-09-24 15:38:49 +08:00
sagit f2783713a5 feat: separate light and dark wallpapers (#555)
## Summary

- Add separate light and dark wallpapers with fallback to the existing
background setting, resolving #554.
- Make both wallpaper settings available to unauthenticated pages and
update the background when the theme changes.
- Include the pre-existing installer regression changes in this commit,
as requested.

## Verification

- `go test ./...` in `go-backend` (723 passed)
- `pnpm run build` in `vite-frontend`
- ESLint on changed frontend files
- `bash test-install-scripts-proxy.sh`
- `git diff --check`
2026-09-24 15:38:22 +08:00
sagitchu 5d60c4fbe1 feat: support adaptive traffic units and precise quotas 2026-09-24 15:33:53 +08:00
sagitchu 62c56e0a79 feat: add mode-specific wallpapers and installer regression coverage 2026-09-24 14:59:52 +08:00
sagit 2e845030de fix: build frontend assets on native platform (#553) 2026-09-01 16:27:00 +08:00
sagit 2269f2e2d5 fix: include pnpm config in frontend image build (#552) 2026-09-01 15:48:54 +08:00
sagit da7bef88f1 fix: harden panel updates and nftables deployment (#551)
Fix panel update deployment discovery and rollback safety, add nftables compatibility and atomic replacement, and restore reproducible frontend CI installs.
2026-09-01 15:37:33 +08:00
sagit f26014579b fix: stabilize federated agent runtime updates (#550) 2026-08-25 17:04:04 +08:00
sagit 5041c722c9 fix: harden license lifecycle (#547) 2026-08-13 09:25:05 +08:00
sagit c56798e991 fix: accept existing license machine binding (#546) 2026-08-12 14:12:46 +08:00
sagit 40e96f3592 fix: preserve active per-IP traffic limiters (#545) 2026-08-12 11:31:03 +08:00
sagit 0e24b53a5b fix: preserve license state across panel upgrades (#544) 2026-08-11 17:04:20 +08:00
sagit a820c49c94 fix(agent): harden Alpine OpenRC installation (#543)
Ensure Alpine installs use OpenRC without invoking systemd cleanup paths, and install missing download dependencies.
2026-08-08 11:45:39 +08:00
sagit 538e64ffc0 Fix modal scroll position jumps (#542)
Preserve page scroll positions while Radix modals acquire focus and forward the dialog overlay ref correctly.
2026-08-07 22:38:02 +08:00
sagit 0b23d6f7d7 fix(agent): retire replaced tunnel sessions (#541) 2026-08-07 14:35:30 +08:00
sagit 9e6f80019d feat(monitor): show backup nodes in topology (#538) 2026-08-03 14:14:01 +08:00
sagit a8fd01d4d8 fix(node): allow IPv6-only addresses (#537) 2026-08-03 11:05:04 +08:00
sagit cbe2fc492e feat(monitor): show backup tunnel latencies (#535)
Closes #508
2026-08-03 10:15:22 +08:00
sagit ae370382d3 feat(agent): support Alpine installation (#534)
Add Alpine bootstrap and OpenRC lifecycle support to the agent installer.

Closes #527
2026-07-31 17:02:52 +08:00
sagit e112d81697 fix: bound and configure tunnel quality probes (#533)
Closes #528 and #532.
2026-07-31 15:37:26 +08:00
sagit 11a27d3c67 feat: add per-rule traffic reset (#526)
Reset only the selected forwarding rule's displayed upload/download usage without affecting user totals, tunnel quotas, historical statistics, nftables baselines, or running services.

Closes #523
2026-07-10 17:05:20 +08:00
sagit 8e513a1bae Fix nftables node updates after panel restart (#525)
## Summary
- Skip agent protocol SetProtocol commands when updating nftables nodes.
- Preserve existing nftables SSH credentials when edit forms omit secret
fields.
- Add regression coverage for nftables updates and SSH config
persistence across repository reopen.

## Test Plan
- rtk go test ./...
2026-07-02 10:28:00 +08:00
sagitchu f98be845d3 fix: skip agent protocol updates for nftables nodes 2026-07-02 10:26:10 +08:00
sagit 0c2acfdd8a Fix nftables recovery and diagnostics (#524)
## Summary
- Reconcile nftables nodes when background jobs start so rules are
restored after server reboot.
- Collect nftables traffic immediately at startup and every 30 seconds
by default.
- Return nftables rule binding status in forward diagnostics and cover
it with regression tests.

## Test Plan
- `cd go-backend && go test ./...`
- `cd go-backend && make build`
2026-06-30 17:11:10 +08:00
sagitchu 777db8767f fix nftables recovery and diagnostics 2026-06-30 17:07:50 +08:00
sagit 82f6047506 fix(forward): split proxy protocol directions
Closes #520
2026-06-21 21:03:47 +08:00
sagitchu 3ce320da5a fix(nftables): harden traffic accounting edges 2026-06-07 11:55:59 +08:00
sagitchu 7ab0db29ae fix(nftables): clean counter state on forward delete 2026-06-07 11:55:59 +08:00
sagitchu 006ea97200 feat(nftables): ingest traffic counters 2026-06-07 11:55:59 +08:00
sagitchu e569aedd3e feat(nftables): calculate traffic deltas 2026-06-07 11:55:59 +08:00
sagitchu 35080aea2d feat(flow): expose forward owner metadata 2026-06-07 11:55:59 +08:00
sagitchu 85e588ffe9 feat(nftables): persist counter state 2026-06-07 11:55:59 +08:00
sagitchu 03524f4a65 feat(nftables): collect counters over ssh 2026-06-07 11:55:59 +08:00
sagitchu 9e69e020ab feat(nftables): parse traffic counters 2026-06-07 11:55:59 +08:00
sagitchu 14bbd3907d feat(nftables): render traffic counters 2026-06-07 11:55:59 +08:00
sagitchu ca8d8e92ba docs: plan nftables traffic stats 2026-06-07 11:55:59 +08:00
sagitchu 079474fa06 docs: design nftables traffic stats 2026-06-07 11:55:59 +08:00
sagit 6e249a54f4 feat: add nftables forwarding mode (#516)
Adds nftables forwarding support for nodes, including frontend mode
selection, backend rule rendering, SSH-based rule reconciliation, and
online-state handling for nftables nodes.\n\nVerification:\n-
go-backend: go test ./...\n- vite-frontend: pnpm run build
2026-06-01 19:56:44 +08:00
sagitchu fb798a4532 chore(frontend): refresh pnpm lockfile 2026-06-01 19:54:28 +08:00
sagitchu a599f383f5 feat: add nftables forwarding mode 2026-06-01 19:47:26 +08:00
sagitchu 6bfa7f0166 docs: design nftables forwarding 2026-05-30 22:14:38 +08:00
sagit 2d0c993c90 fix: allow admin access to sensitive configs (#511) 2026-05-17 23:09:31 +08:00
sagitchu 8b64542c94 fix(handler): allow admin access to sensitive configs 2026-05-17 23:07:37 +08:00
sagit 032b0f0cfd fix: serialize websocket writes in realtime server (#510) 2026-05-17 21:52:54 +08:00
sagitchu 7008717a49 fix(ws): serialize websocket writes in realtime server 2026-05-17 21:48:37 +08:00
sagit 8552a70355 fix: harden panel self-upgrade (#506)
This fixes panel self-upgrade and monitor realtime access for permitted
non-admin users.

Changes:
- Reuse GitHub proxy config for system upgrade release/API fetches.
- Make panel self-upgrade helper fail fast on compose command errors.
- Allow users with explicit monitor permission to connect to the
realtime websocket and receive broadcasts, not just admins.

Validation:
- cd go-backend && GOCACHE=/private/tmp/flvx-gocache go test
./internal/http/handler -count=1
- cd go-backend && go test ./internal/ws -count=1
2026-05-15 23:54:27 +08:00
sagitchu b2454e86c9 fix: allow monitor realtime websocket access 2026-05-15 23:37:08 +08:00
sagitchu 312c9a9c5c fix: harden panel self-upgrade 2026-05-15 23:16:14 +08:00
sagit e1324b8c8c fix: update dependabot vulnerabilities (#505)
* docs: add dependabot remediation design

* docs: add dependabot remediation plan

* fix: update backend pgx dependency

* fix: update frontend vulnerable dependencies

* fix: update gost quic dependencies

* fix: migrate gost dtls dependency

* fix: sync gost main dependencies

* fix: enable webtransport stream reset partial delivery

* fix: restore backend bcrypt dependency
2026-05-15 00:16:29 +08:00
sagit c034d0d41f fix: allow public config fallback for cached login (#504)
## Summary
- allow cached/old login clients to read public config keys through
`/api/v1/config/get` without a valid token
- keep sensitive config keys blocked from unauthenticated access
- stabilize the system upgrade fail-fast test by avoiding live
stable-release lookup and using POSIX shell syntax

## Test Plan
- `cd go-backend && go test ./...`
2026-05-14 18:46:43 +08:00
sagitchu fd3ecc38ef fix: allow public config fallback for cached login 2026-05-14 18:45:12 +08:00
sagit 2eee506716 fix: harden auth, config access, and backups (#503)
## Summary
- Migrate password storage to bcrypt with legacy MD5 verification-only
support and best-effort upgrade on successful auth.
- Revoke JWTs on auth-state changes, align WebSocket admin auth, and
split public config reads from protected config reads.
- Filter sensitive configs from backup export/import and update contract
coverage for the new security boundaries.

## Test Plan
- [x] `go test ./... -count=1`
- [x] `pnpm run lint`
- [x] `pnpm run build`
2026-05-14 11:21:45 +08:00
sagitchu abf13bdac9 fix: close remaining security remediation gaps 2026-05-14 11:10:06 +08:00
sagitchu f0facf6703 fix: block sensitive config writes 2026-05-14 10:37:29 +08:00
sagitchu 583a3834f9 fix: expire websocket admin sessions 2026-05-14 01:24:03 +08:00
sagitchu bd13477fa3 fix: stop caching sensitive configs in browser 2026-05-14 00:34:52 +08:00
sagitchu 106a30bf9d fix: tighten websocket auth and client cache handling 2026-05-14 00:24:56 +08:00
sagitchu 465815cf34 fix: harden auth, config access, and backups 2026-05-13 23:53:06 +08:00
sagitchu ec9fb77eb5 docs: add security remediation design spec 2026-05-13 14:17:51 +08:00
sagitchu 7d63dd4cc3 docs: add proxy protocol analysis and panel self-upgrade plans 2026-05-13 10:49:33 +08:00
sagit 4cfa6adee7 fix: Docker build pnpm/corepack compatibility (#501)
## Summary

- `node:20.19.0` + `corepack` + `pnpm@11` →
`ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING`
- `pnpm@11` blocks `@tailwindcss/oxide` build scripts by default
- Fix: `node:22-alpine` + `corepack prepare pnpm@10 --activate &&
corepack enable pnpm`

## Verification (all local)

| Check | Result |
|-------|--------|
| `docker build ./vite-frontend` | ✅ |
| `go test ./...` | ✅ 498 passed |
| `pnpm run build` | ✅ |
| `pnpm run lint` | ✅ |
2026-05-07 21:26:54 +08:00
sagitchu bc8f2ec8a1 fix: use corepack prepare pnpm@10 for Docker build compatibility
- node:22-alpine + corepack + pnpm@11 hits ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING
- corepack enable pnpm@10 is invalid syntax; use corepack prepare + enable
- pnpm@10 avoids the build script approval issue entirely
- Verified: docker build, pnpm build, pnpm lint, go test all pass locally
2026-05-07 21:24:47 +08:00
sagit 9a37c2f603 fix: pin pnpm to v10 in Dockerfile (#500)
Pin pnpm to v10 to avoid v11 build script issues in Docker build.
2026-05-07 21:12:57 +08:00
sagitchu ff6d46ddaf fix: pin pnpm to v10 in Dockerfile to avoid v11 build script issues
pnpm v11 blocks build scripts by default and the onlyBuiltDependencies
config is difficult to set in Docker build context. Pin to pnpm@10.
2026-05-07 21:10:41 +08:00
sagit 723534faea fix: use pnpm-workspace.yaml for onlyBuiltDependencies (#499)
Create pnpm-workspace.yaml inline in Dockerfile for pnpm v11 build
scripts.
2026-05-07 20:57:50 +08:00
sagitchu 73490a9be6 fix: use pnpm-workspace.yaml for onlyBuiltDependencies
Create pnpm-workspace.yaml inline in Dockerfile to allow
@tailwindcss/oxide build scripts in pnpm v11.
2026-05-07 20:55:42 +08:00
sagit 5a327459f7 fix: use .npmrc for pnpm onlyBuiltDependencies (#498)
Use .npmrc file for pnpm v11 build scripts approval.
2026-05-07 20:43:29 +08:00
sagitchu f307e7d5eb fix: use .npmrc for pnpm onlyBuiltDependencies config
pnpm config set doesn't support onlyBuiltDependencies in global config.
Use .npmrc file instead.
2026-05-07 20:41:05 +08:00
sagit fdd72979b6 fix: approve @tailwindcss/oxide build script for pnpm v11 (#497)
pnpm v11 blocks build scripts by default. Allow @tailwindcss/oxide via
onlyBuiltDependencies.
2026-05-07 20:26:24 +08:00
sagitchu ad33791a26 fix: approve @tailwindcss/oxide build script for pnpm v11
pnpm v11 blocks build scripts by default; explicitly allow
@tailwindcss/oxide via onlyBuiltDependencies config.
2026-05-07 20:24:17 +08:00
sagit 6320b1f0c1 fix: upgrade Node.js to 22-alpine for corepack/pnpm compat (#496)
## Summary

Node.js 20.19.0 + corepack + pnpm@11.0.8 hits
`ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING` during Docker build. Upgrade
builder image to `node:22-alpine` (LTS).

## Verification

Frontend build passes locally with `pnpm run build`.
2026-05-07 20:11:46 +08:00
sagitchu 91d79b6b3a fix: upgrade Node.js to 22-alpine for corepack/pnpm compatibility
node:20.19.0 + corepack + pnpm@11.0.8 hits ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING
2026-05-07 20:09:42 +08:00
sagit fc7df6bd64 fix: panel self-upgrade helper not recreating containers (#495)
## Summary

- **Helper container `docker compose up` 不会强制重建容器**:原脚本缺少
`--force-recreate`,Docker Compose
在检测不到配置变化时不会替换运行中的容器,导致拉取了新镜像但旧容器继续运行。新增 `--force-recreate
--remove-orphans` 确保容器被替换。
- **无错误日志**:helper 容器执行失败时没有任何可见反馈。新增 `upgrade.log` 写入部署目录,每一步操作和错误都有记录。
- **"立即升级"按钮永久禁用**:按钮 `isDisabled` 绑定了
`!canOpenSystemUpgradeModal`,该条件要求已完成检查更新且有可用更新,但页面本身有点击时自动检查的逻辑,导致按钮永远无法点击。改为
`!canTriggerSystemUpgrade` 允许自动检查流程触发。

## Verification

| Check | Result |
|-------|--------|
| `go test ./...` | ✅ 498 passed |
| `pnpm run build` | ✅ passed |
| `pnpm run lint` | ✅ passed |
2026-05-07 19:53:53 +08:00
sagitchu 25dfb84324 fix: panel self-upgrade helper not recreating containers
- Add --force-recreate --remove-orphans to docker compose up so helper
  actually replaces running containers with newly pulled images
- Add upgrade.log file for post-mortem debugging when helper fails
- Add pre-flight validation for docker-compose.yml and .env
- Fix "立即升级" button permanently disabled by relaxing the disabled
  condition so auto-check on click can fire
2026-05-07 19:51:18 +08:00
sagit 4ebd6703fe fix: harden proxy protocol rollout safety (#494) 2026-05-07 16:37:51 +08:00
sagit 1f53a39784 Update contact link from group to channel (#493) 2026-05-07 01:26:56 +00:00
sagit 5ebd4c2a91 feat: add panel self-upgrade workflow (#492) 2026-05-06 17:58:01 +08:00
317 changed files with 44908 additions and 3315 deletions
+27 -7
View File
@@ -7,6 +7,18 @@ on:
branches: ['**']
jobs:
install-scripts:
name: Test Installer Scripts (systemd and Alpine OpenRC)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run installer regression tests
run: bash test-install-scripts-proxy.sh
- name: Test Alpine bootstrap and OpenRC lifecycle
run: docker run --rm -v "$PWD:/workspace:ro" alpine:3.22 sh /workspace/test-install-scripts-alpine.sh
frontend:
name: Build Frontend
runs-on: ubuntu-latest
@@ -19,10 +31,10 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.19.0'
node-version-file: '.node-version'
- name: Install pnpm
run: npm install -g pnpm
run: npm install -g pnpm@10.28.1
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -42,7 +54,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
go-version-file: 'go-backend/go.mod'
cache-dependency-path: go-backend/go.sum
- name: Download dependencies
@@ -51,6 +63,14 @@ jobs:
- name: Build
run: go build -v ./...
- name: Test repository and SQLite upgrade migrations
run: go test ./internal/store/repo -count=1
- name: Test panel upgrade lifecycle and permissions
run: |
go test ./internal/panelupgrade -count=1
go test ./internal/http/handler ./tests/contract -run 'SystemUpgrade|SelectComposeAsset|DownloadReleaseAsset|UpgradeBackup|RestoreBackup' -count=1
backend-postgres-contract:
name: Go Backend PostgreSQL Contract
runs-on: ubuntu-latest
@@ -77,16 +97,16 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
go-version-file: 'go-backend/go.mod'
cache-dependency-path: go-backend/go.sum
- name: Download dependencies
run: go mod download
- name: Run PostgreSQL contract test
- name: Run PostgreSQL contract tests
env:
FLVX_POSTGRES_TEST_DSN: 'postgres://flux_test:flux_test_pass@127.0.0.1:5432/flux_test?sslmode=disable'
run: go test ./tests/contract -run TestPostgresNodeCreateRepairsMissingIDDefaultContract -count=1
run: go test ./tests/contract -run 'TestPostgres(NodeCreateRepairsMissingIDDefaultContract|NewRelayTunnelEmitsMatchingPrivateEndpointAuthentication)$' -count=1
agent:
name: Build Agent
@@ -100,7 +120,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
go-version-file: 'go-gost/go.mod'
cache-dependency-path: go-gost/go.sum
- name: Download dependencies
+6 -3
View File
@@ -79,7 +79,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
go-version-file: 'go-gost/go.mod'
- name: Cache Go modules
uses: actions/cache@v4
@@ -197,7 +197,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
go-version-file: 'go-backend/go.mod'
- name: Cache Go dependencies
uses: actions/cache@v4
@@ -229,6 +229,7 @@ jobs:
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg KEYGEN_ACCOUNT_ID=${{ secrets.KEYGEN_ACCOUNT_ID }} \
--push \
-t ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:latest \
-t ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:${VERSION} \
@@ -303,6 +304,9 @@ jobs:
sed -i "s|^PINNED_VERSION=\"\"|PINNED_VERSION=\"${VERSION}\"|" ./artifacts/install.sh
sed -i "s|^PINNED_VERSION=\"\"|PINNED_VERSION=\"${VERSION}\"|" ./artifacts/panel_install.sh
- name: Verify release installer on Alpine OpenRC
run: docker run --rm -v "$PWD:/workspace:ro" alpine:3.22 sh /workspace/test-install-scripts-alpine.sh /workspace/artifacts/install.sh
- name: Create Release
env:
GH_TOKEN: ${{ github.token }}
@@ -431,4 +435,3 @@ jobs:
gh release upload "${VERSION}" ./artifacts/gost-arm64.sha256 --clobber
echo "✅ GOST 二进制文件更新完成"
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version-file: '.node-version'
registry-url: 'https://registry.npmjs.org'
- name: Get version from tag
+87
View File
@@ -0,0 +1,87 @@
name: UDP Destination Regression
on:
push:
branches: ['main', 'fix/udp-wildcard-source', 'pr/udp-wildcard-source']
pull_request:
paths:
- 'go-gost/**'
- '.github/workflows/udp-destination.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: udp-destination-${{ github.ref }}
cancel-in-progress: true
jobs:
linux:
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: 'go-gost/go.mod'
cache-dependency-path: |
go-gost/go.sum
go-gost/x/go.sum
- name: Test agent module
working-directory: go-gost
run: go test ./...
- name: Test protocol module
working-directory: go-gost/x
run: go test ./...
- name: Check UDP races
working-directory: go-gost/x
run: go test -race ./internal/net/udp ./listener/udp
- name: Vet UDP implementation
working-directory: go-gost/x
run: go vet ./internal/net/udp ./listener/udp
- name: Build destination regression executable
working-directory: go-gost/x
run: |
go test -c -race -o "$RUNNER_TEMP/udp-destination.test" ./listener/udp
go test -c -race -o "$RUNNER_TEMP/udp-packet.test" ./internal/net/udp
- name: Test multiple destinations in isolated network namespace
run: |
sudo unshare --net -- sh -eu -c '
sysctl -qw net.ipv6.conf.all.disable_ipv6=0
ip link set lo up
ip addr add 198.18.0.1/32 dev lo
ip addr add 169.254.1.2/32 dev lo
ip -6 addr add fd00:1::1/128 dev lo nodad
ip -6 addr add fd00:2::1/128 dev lo nodad
ip -6 addr add fd00:3::1/128 dev lo nodad
for suffix in "" 2; do
ip link add "mc-tx$suffix" type veth peer name "mc-rx$suffix"
ip link set "mc-tx$suffix" addrgenmode none up
ip link set "mc-rx$suffix" addrgenmode none up
ip -6 addr add fe80::1/64 dev "mc-tx$suffix" nodad
ip -6 addr add fe80::2/64 dev "mc-rx$suffix" nodad
done
ip addr add 198.18.1.1/24 dev mc-tx
ip addr add 198.18.2.1/24 dev mc-tx2
export FLUX_TEST_IPV6_ADDRS=fd00:1::1,fd00:2::1,fd00:3::1
export FLUX_TEST_IPV4_LINK_LOCAL=169.254.1.2
export FLUX_TEST_MULTICAST_RX=mc-rx FLUX_TEST_MULTICAST_TX=mc-tx
export FLUX_TEST_MULTICAST_RX2=mc-rx2 FLUX_TEST_MULTICAST_TX2=mc-tx2
"$1" -test.v -test.timeout=60s -test.count=5
exec "$2" -test.v -test.timeout=60s -test.count=5
' sh "$RUNNER_TEMP/udp-destination.test" "$RUNNER_TEMP/udp-packet.test"
- name: Build Linux agent
working-directory: go-gost
env:
CGO_ENABLED: '0'
GOOS: linux
GOARCH: amd64
run: go build -trimpath -o "$RUNNER_TEMP/flux-agent" .
+1
View File
@@ -0,0 +1 @@
22.23.4
+2 -1
View File
@@ -40,7 +40,8 @@ FLVX — traffic forwarding panel: Go admin API + Vite/React UI + Go agent.
- **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.
- **Go versions**: all three modules require Go 1.26.10 or newer (CVE-2026-111943); CI reads the module's `go.mod`. Do not use Go 1.27.0–1.27.2, which also lack this fix.
- **Node.js versions**: `.node-version` pins 22.23.4 for CI; local builds also support patched 24.22.1+ and 26.12.1+ (CVE-2026-111497).
## Anti-patterns
+67 -1
View File
@@ -1,6 +1,6 @@
# FLVX
> **联系我们**: [Telegram群组](https://t.me/flvxpanel)
> **联系我们**: [Telegram群组](https://t.me/flvxchannel)
## 特性
@@ -17,8 +17,35 @@
- 支持隧道修改配置、转发修改隧道
## 构建环境
Go 后端和 agent 的最低编译版本为 **Go 1.26.10**;若使用 Go 1.27,须为 **1.27.3 或更新补丁版**。前端与 skill 发布使用 **Node.js 22.23.4**(见 `.node-version`),本地也支持 24.22.1+ 或 26.12.1+。前端使用 `pnpm@10.28.1`。
这些版本包含 2026 年 10 月的运行时安全修复。已有部署还需重新构建并更新后端镜像及所有 agent 二进制,详见 [安全排查与升级说明](docs/security/2026-10-runtime-updates.md)。
## 部署流程
---
### 让 AI Agent 帮你安装(复制即用)
将下面整段提示词复制给能执行终端命令的 AI Agent(如 Codex、Claude Code)。Agent 已运行在目标 Linux 服务器上时可直接安装;远程安装时,提供 SSH 主机别名或连接方式,使用已有 SSH 密钥登录即可,无需把私钥贴进对话。
```text
请帮我在目标 Linux 服务器上安装 FLVX 流量转发面板,直接执行安装并完成验证。
项目仓库:https://github.com/Sagit-chu/flvx
安装文档:https://github.com/Sagit-chu/flvx/blob/main/doc/install.md
1. 确认目标服务器:如果当前终端就在目标 Linux 服务器上,直接使用当前环境;否则使用我提供的 SSH 连接。缺少连接信息时,只询问必要信息,确认目标后再执行安装。
2. 先阅读项目 README、安装文档及将要执行的安装脚本,检查系统、架构、root/sudo 权限、Docker、Compose、端口占用和已有 FLVX 容器、数据卷及部署文件。已有部署时先确认是更新还是新装,保留原配置和数据。
3. 新安装默认使用仓库标记为 latest 的 Release、Docker Compose、SQLite,部署目录 /opt/flvx-panel,前端端口 6366、后端端口 6365。我已指定的版本、目录或端口优先;端口冲突时先与我确认替代端口。
4. 缺少依赖时按该发行版的官方方式安装 Docker Engine 和 Compose 插件。进入部署目录,从项目 Releases 下载所选版本的 panel_install.sh,先检查下载成功和脚本内容,再执行。使用交互式终端操作菜单,选择“安装面板”并按上述配置输入;以脚本实际提示为准。
5. 默认直连 GitHub,可设置 PROXY_ENABLED=false;连接失败时说明原因,再与我确认是否使用代理。脚本可能修改 Docker IPv6 配置并重启 Docker;若会影响现有服务,先说明影响并确认。保留现有防火墙和 SSH 规则,仅按需放行实际使用的面板端口;云安全组无法操作时列出需要我放行的端口。
6. 安装后检查 Compose 服务状态、后端健康检查、前端 HTTP 页面及后端 /flow/test 的响应,并从实际访问端验证连通性。失败时查看相关日志并修复后重试;受网络或权限限制无法完成的检查要明确说明,不能直接宣称安装成功。
7. 最后给我实际安装版本、部署目录、访问地址、验证结果,以及在该目录查看状态、查看日志和重启服务的命令。说明默认管理员账号和密码均为 admin_user,提醒首次登录立即修改密码;不要输出 .env 中的 JWT_SECRET、数据库密码等密钥。
```
需要安装转发节点时,告诉 Agent 按[节点端部署文档](doc/install.md#二节点端部署)操作,并提供节点服务器、面板后端地址及从面板获取的节点接入密钥;安装完成后以服务正常运行且面板显示节点在线为准。
### Docker Compose部署
#### 快速部署(安装最新版)
面板端:
@@ -71,6 +98,45 @@ docker compose up -d
3) 如果你想继续使用 SQLite,保留 `DB_TYPE=sqlite`(或不设置 `DB_TYPE`)即可。
#### 通行证密钥登录(可选)
通行证密钥登录默认关闭,需要管理员在部署环境中主动启用。它不依赖 Cloudflare Turnstile,但用户必须在验证码可用时先用密码登录并绑定密钥;未绑定的账号不能用此功能绕过验证码。原密码登录始终可用。
**新部署与旧版本升级:**新安装生成的 `.env` 默认没有 `FLVX_WEBAUTHN_ORIGIN`。升级到支持通行证密钥的版本也**不会自动启用**:安装脚本保留已有 `.env`,不会向其中添加该变量。如果手动升级时保留了旧版 `docker-compose.yml`,还需检查 `backend.environment` 是否包含下文的变量传递项。未设置该变量时,仅更新镜像或版本号后看不到入口是预期行为。
1. 在实际部署目录的 `.env` 中添加浏览器访问面板时的公开前端 origin,将示例域名换成自己的域名:
```dotenv
FLVX_WEBAUTHN_ORIGIN=https://panel.example.com
```
必须使用完整 HTTPS origin(协议、域名、必要时的端口),不能带路径、查询参数或尾部 `/`。仅本机开发可用 `http://localhost:3000` 或 `http://127.0.0.1:3000`。经反向代理访问时仍填浏览器地址栏中的**前端** origin,不填容器地址、后端监听地址或代理转发头;API 即使在另一个域名,也不改变此值。自行部署时将该变量传给 `paneld` 进程。
2. 确认实际使用的 `docker-compose.yml` 在 `backend` 服务的 `environment` 下有以下传递项;新版 Compose 模板已包含,旧模板若缺失需补上:
```yaml
FLVX_WEBAUTHN_ORIGIN: ${FLVX_WEBAUTHN_ORIGIN:-}
```
在该部署目录运行 `docker compose up -d --force-recreate backend`,使新环境变量进入后端容器。后端重建期间,面板 API 会短暂中断。容器恢复后重新加载前端页面;如果 PWA 提示“发现新版本”,选择“刷新”,也可以用新的无痕窗口核对是否仍加载旧页面。
3. 用户照常用密码登录,桌面端点右上角“个人资料”,手机端点底部“我的”(均进入 `/profile`);在页面顶部的“通行证密钥”卡片输入**当前密码**、可选名称,点击“绑定通行证密钥”并完成设备解锁。新绑定要求设备支持可发现凭据(resident key),不支持的设备会在绑定时失败。绑定或删除均需当前密码,且用户只能管理自己账号的密钥。
4. 以后在登录页直接点击“选择通行证密钥登录”,由设备选择账号并解锁,**无需先填写用户名**。账号被停用时密钥登录也会被拒绝。撤销密钥仍到个人资料页输入当前密码并删除。
旧版本绑定时只“优先”请求可发现凭据,现有密钥是否能直接选账号取决于当时的设备,不能按绑定版本统一判定或批量删除。若设备不显示某把旧密钥,请使用密码登录,在个人资料页删除该密钥并重新绑定;确认新密钥与密码恢复途径可用后再清理其他旧密钥。登录页不提供用户名密钥回退。
**入口未显示时,按顺序检查:**
1. 在部署目录运行 `docker compose exec backend printenv FLVX_WEBAUTHN_ORIGIN`,仅检查这一项是否为预期的公开前端 origin;不要贴出完整 `docker compose config` 或其他环境变量,以免泄露密码和密钥。若为空,检查 `.env` 和实际 Compose 模板的 `backend.environment`,然后按步骤 2 重建后端。
2. 向浏览器实际调用的后端地址发送公开状态请求,例如将以下示例地址替换为自己的 API 地址后运行 `curl -sS -X POST 'https://api.example.com/api/v1/user/passkey/status' -H 'Content-Type: application/json' -d '{}'`。应返回 `code: 0` 且 `data.enabled: true`;若为 `false`,后端尚未接受有效配置。此接口无需登录或提供密码。
3. 在访问面板的浏览器控制台检查 `window.isSecureContext` 为 `true`,且 `typeof window.PublicKeyCredential` 不是 `"undefined"`。普通公网 HTTP 页面或不支持 WebAuthn 的浏览器不会显示入口。确认后刷新页面,并查看 `/api/v1/user/passkey/status` 的实际网络响应。
4. 若状态已启用且浏览器满足条件,检查 `docker compose images frontend` 中的前端镜像版本,并按步骤 2 的 PWA 提示刷新或使用无痕窗口。登录页按钮与个人资料页卡片均不存在时,通常是旧前端页面或状态请求未成功;只有其中一个缺失时,记录页面地址和该请求的响应再排查。
**安全配置与恢复:**未设置或设置错误时,通行证密钥接口拒绝新的绑定和登录,登录页与个人资料页不显示对应入口;密码登录不受影响。不要把不可信的请求 `Host` / `X-Forwarded-Host` 当作可信 origin。域名变化会改变 RP ID,旧域名下绑定的密钥不能用于新域名;迁移时需保留原域名或可用的密码登录途径,再在新域名重新绑定。仅更改同一域名的端口也需同步更新此 origin。正在进行的密钥挑战只保存在后端内存中,重启后需重新开始;多副本部署需要共享会话方案,未实现前请保持单个后端实例。
建议保留至少一种可用的密码恢复途径。数据库级备份会保留密钥记录;当前面板的 JSON 导出不包含通行证密钥,使用 JSON 导入迁移后,用户需要通过密码登录并重新绑定。
#### 从 SQLite 迁移到 PostgreSQL
如果你是通过 `panel_install.sh` 安装面板,推荐直接使用脚本菜单一键迁移:
+10 -1
View File
@@ -64,6 +64,14 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_instal
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
```
Alpine Linux 最小化安装若未包含 `curl`,可使用系统自带的 `wget` 下载:
```bash
wget -O install.sh https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh && chmod +x install.sh && ./install.sh
```
脚本会在 Alpine 上自动安装 Bash、`curl` 和 CA 证书,并使用 OpenRC 注册、启动和管理 `flux_agent` 服务;其他受支持的 Linux 发行版继续使用 systemd。
**安装过程中会提示输入:**
- **服务器地址**: 面板端的通信地址(通常是 `http://<面板IP>:<后端端口>`,例如 `http://1.2.3.4:6365`)。
- **密钥**: 刚才在面板中获取的节点密钥。
@@ -77,7 +85,8 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -
### 3. 验证安装
安装完成后,服务会自动启动。
- 查看状态: `systemctl status flux_agent`
- systemd 查看状态: `systemctl status flux_agent`
- Alpine/OpenRC 查看状态: `rc-service flux_agent status`
- 回到面板 **节点管理** 页面,该节点状态应显示为 **在线**。
---
+6
View File
@@ -13,12 +13,18 @@ services:
DB_PATH: /app/data/gost.db
DATABASE_URL: ${DATABASE_URL:-}
JWT_SECRET: ${JWT_SECRET}
FLVX_WEBAUTHN_ORIGIN: ${FLVX_WEBAUTHN_ORIGIN:-}
SERVER_ADDR: :6365
TZ: Asia/Shanghai
FLUX_VERSION: ${FLUX_VERSION:-dev}
PANEL_DEPLOY_DIR: /opt/flvx-panel
PANEL_BACKEND_CONTAINER: flux-panel-backend
ports:
- "${BACKEND_PORT}:6365"
volumes:
- sqlite_data:/app/data
- /var/run/docker.sock:/var/run/docker.sock
- ./:/opt/flvx-panel
networks:
- gost-network
stop_grace_period: 30s
+6
View File
@@ -13,12 +13,18 @@ services:
DB_PATH: /app/data/gost.db
DATABASE_URL: ${DATABASE_URL:-}
JWT_SECRET: ${JWT_SECRET}
FLVX_WEBAUTHN_ORIGIN: ${FLVX_WEBAUTHN_ORIGIN:-}
SERVER_ADDR: :6365
TZ: Asia/Shanghai
FLUX_VERSION: ${FLUX_VERSION:-dev}
PANEL_DEPLOY_DIR: /opt/flvx-panel
PANEL_BACKEND_CONTAINER: flux-panel-backend
ports:
- "${BACKEND_PORT}:6365"
volumes:
- sqlite_data:/app/data
- /var/run/docker.sock:/var/run/docker.sock
- ./:/opt/flvx-panel
networks:
- gost-network
stop_grace_period: 30s
+178
View File
@@ -0,0 +1,178 @@
# Proxy Protocol 传输分析报告
**日期**: 2026-05-07
**测试环境**: 20.118.172.127 (Server 1) ↔ 108.181.90.137 (Server 2)
---
## 1. 代码流程分析
### 完整数据链路
```
前端 (proxyProtocol: 0|1|2)
→ 后端 handler mutations.go:1936
→ 数据库存储 forward.proxy_protocol (model.go:50)
→ 控制面 buildForwardServiceConfigs (control_plane.go:1791-1796)
→ handler metadata: {"proxyProtocol": 2}
→ Agent metadata 解析 (metadata.go:42)
→ handler.go:256 WrapClientConn()
→ conn.go:14 HeaderProxyFromAddrs(byte(ppv), src, dst)
→ conn.go:15 header.WriteTo(c)
→ 目标服务器收到 PROXY protocol header
```
### 关键代码
**写入 PROXY header** (`go-gost/x/internal/net/proxyproto/conn.go`):
```go
func WrapClientConn(ppv int, src, dst net.Addr, c net.Conn) net.Conn {
if ppv <= 0 {
return c
}
header := proxyproto.HeaderProxyFromrs(byte(ppv), src, dst)
header.WriteTo(c)
return c
}
```
**Handler 调用** (`go-gost/x/handler/forward/local/handler.go:256`):
```go
cc = proxyproto.WrapClientConn(h.md.proxyProtocol, conn.RemoteAddr(), conn.LocalAddr(), cc)
```
- `src` = `conn.RemoteAddr()` → 客户端真实 IP ✅
- `dst` = `conn.LocalAddr()` → agent 监听地址 ✅
- `ppv` = 1 或 2 → 版本号正确 ✅
---
## 2. 实际传输测试结果
### 测试方法
1. 在 Server 2 启动 Python TCP 监听器,解析 PROXY protocol header
2. 在 Server 1 用当前代码编译 gost,配置 `proxyProtocol: 2` 转发到 Server 2
3. 通过 `nc` 发送测试数据,验证 Server 2 是否收到正确的 PROXY header
### 测试结果
| 版本 | 状态 | 接收到的 Header |
|------|------|----------------|
| **PPv2** | ✅ 成功 | `PP2 family=1 alen=12 SRC=127.0.0.1:45410 DST=127.0.0.1:20001` |
| **PPv1** | ✅ 成功 | `PROXY TCP4 127.0.0.1 127.0.0.1 43816 20001` |
### 测试详情
**PPv2 原始数据**:
```
Got 28 bytes
PP2 family=1 alen=12
SRC=127.0.0.1:45410 DST=127.0.0.1:20001
```
**PPv1 原始数据** (hex):
```
50524f58592054435034203132372e302e302e31203132372e302e302e312034333831362032303030310d0a
```
解码: `PROXY TCP4 127.0.0.1 127.0.0.1 43816 20001`
---
## 3. 单元测试结果
```
go-gost/x/handler/forward/local/ → TestLocalForwardHandlerSendsProxyProtocolToTarget ✅
go-backend/internal/http/handler/ → TestBuildForwardServiceConfigsSendsProxyProtocolToForwardHandler ✅
go-backend/internal/store/repo/ → TestGetForwardRecordIncludesProxyProtocol ✅
```
全部通过 (3/3)。
---
## 4. 发现的问题
### 问题 1: `WriteTo` 错误未检查
**位置**: `go-gost/x/internal/net/proxyproto/conn.go:15`
```go
header.WriteTo(c) // 返回 (int64, error) 被忽略
```
**影响**: 如果写入失败(连接已断开、网络错误等),后续数据传输会在没有 PROXY header 的情况下继续,目标服务器可能解析出错。
**建议**:
```go
if _, err := header.WriteTo(c); err != nil {
return c // 或包装一个带错误的 conn
}
```
**严重程度**: 低(实际场景中,写入失败后 `Transport` 也会很快失败)
---
### 问题 2: 部署版本过旧
**服务器状态**:
| 服务器 | 组件 | 版本 | 状态 |
|--------|------|------|------|
| 20.118.172.127 | flux_agent | UPX 压缩,无法读取版本 | ✅ 运行中 |
| 20.118.172.127 | paneld | `/app/paneld` | ✅ 运行中 |
| 20.118.172.127 | /usr/local/bin/gost | v3.0.0 (go1.23.4) | 旧版,不支持 handler metadata 中的 proxyProtocol |
| 108.181.90.137 | flux_agent | 8.8MB | ✅ 运行中 |
**影响**: 旧版 gost 二进制不识别 handler metadata 中的 `proxyProtocol` 字段,PROXY protocol 功能在生产环境不可用。
**验证**: 用旧版 gost 测试时,目标服务器收到的原始数据为空,无 PROXY header。
---
### 问题 3: 数据库 Schema 缺失
**位置**: 20.118.172.127 的 `/app/data/gost.db`
**当前 forward 表 schema**:
```sql
CREATE TABLE `forward` (
`id` integer PRIMARY KEY AUTOINCREMENT,
`user_id` integer NOT NULL,
`user_name` varchar(100) NOT NULL,
`name` varchar(100) NOT NULL,
`tunnel_id` integer NOT NULL,
`remote_addr` text NOT NULL,
`strategy` varchar(100) NOT NULL DEFAULT "fifo",
`in_flow` integer NOT NULL DEFAULT 0,
`out_flow` integer NOT NULL DEFAULT 0,
`created_time` integer NOT NULL,
`updated_time` integer NOT NULL,
`status` integer NOT NULL,
`inx` integer NOT NULL DEFAULT 0,
`speed_id` integer
);
```
**缺失字段**:
- `proxy_protocol` — PROXY protocol 版本
- `max_conn` — 最大连接数
- `ip_max_conn` — 每 IP 最大连接数
- `ip_speed_id` — 每 IP 限速 ID
**影响**: 后端无法存储和读取 proxy_protocol 配置,前端设置不会生效。
---
## 5. 结论
| 维度 | 状态 | 说明 |
|------|------|------|
| **代码实现** | ✅ 正确 | 完整的写入链路,版本/地址正确 |
| **单元测试** | ✅ 通过 | 3/3 测试覆盖 handler、repo、控制面 |
| **实际传输 (新编译版)** | ✅ 成功 | PPv1 和 PPv2 均正确传输 |
| **实际传输 (部署版)** | ❌ 不工作 | 旧版不支持 handler metadata 中的 proxyProtocol |
| **数据库 Schema** | ❌ 缺字段 | 需要迁移添加 proxy_protocol 等列 |
**总结**: 代码实现正确,PROXY protocol 传输逻辑无误。但生产服务器运行的是旧版本,需要升级 backend 和 agent 才能启用此功能。
+37
View File
@@ -0,0 +1,37 @@
# 2026-10-22 运行时漏洞排查与升级
## 排查结论
| 漏洞 | 本项目涉及范围 | 修复措施 |
| --- | --- | --- |
| CVE-2026-109618、CVE-2026-109619(PHP RCE) | 仓库无 PHP 源码、Composer 依赖或 PHP 容器。项目后端为 Go,前端生产容器为 Nginx 静态服务。 | 本项目无 PHP 运行时需要更新;同机部署的其他 PHP 服务需单独排查。转发 PHP 网站的流量不会修复目标网站的 PHP。 |
| CVE-2026-111497(Node.js Brotli 越界写入) | 前端 Docker 构建使用 Node.js 22;CI 原使用已停止支持的 Node.js 20。Node.js 用于构建、开发及 skill 安装/发布,不是面板生产服务端。未发现项目源码直接调用 Brotli 解码,但构建依赖和下载工具仍运行于 Node.js。 | Docker、CI 和 `.node-version` 固定为 22.23.4;包声明仅接受已修复的 22.23.4+、24.22.1+、26.12.1+ 分支,前端启用 `engine-strict`。 |
| CVE-2026-111943(Go crypto/x509 中间证书信任) | 后端的版本检查、升级下载、授权验证等使用 HTTPS;agent 的更新下载、TLS 拨号和证书验证使用 Go 标准库。原 Go 1.25 工具链缺少此次拒绝信任修复。 | 三个 Go 模块最低版本升至 1.26.10,CI 从对应 `go.mod` 读取,后端 Docker 使用 1.26.10-bookworm。 |
Go 漏洞涉及一组特定中间证书,并非拒绝所有包含八层中间证书的证书链。官方修复在 `crypto/x509.Certificate.Verify` 中拒绝包含这些证书的链。Go 1.27.0–1.27.2 也未修复;手动选择 Go 1.27 时必须使用 1.27.3+。`go.mod` 的最低版本无法排除该较新分支里的旧补丁版本,因此发布必须使用仓库固定的工具链。
## 让已部署实例获得修复
1. 使用本次修改后的构建配置发布新版本,重新构建后端及前端镜像、Linux amd64/arm64 agent。Go 标准库编译进二进制,仅更新宿主机 Go 或 `ca-certificates` 不会替换已有二进制中的标准库。已有镜像标签也不会因源码更新自动修复。
2. 在面板实际部署目录,将 `.env` 中的 `FLUX_VERSION` 设置为包含本次修复的**已发布版本**,再执行 `docker compose pull backend frontend` 和 `docker compose up -d backend frontend`。如使用独立构建或私有仓库,应部署对应的新镜像。
3. 将所有节点的 agent 更新为同一安全版本并重启服务,包括离线节点。使用解压后的构建产物执行 `go version -m /path/to/gost` 或 `go version -m /path/to/paneld`,核对构建版本为 go1.26.10(或已修复的更新版本);UPX 压缩可能需要先对副本解压以读取构建信息。
4. 开发机和自托管构建机也需升级 Node.js。本次排查时本机为 v26.0.0,属于受影响版本;应升级至 26.12.1+,或切换到 `.node-version` 的 22.23.4。无需在运行静态前端的 Nginx 容器内安装 Node.js。
本次代码修改不表示远端部署已经更新;须完成新版本发布、镜像替换与节点升级后,才能确认线上获得修复。更新系统 CA 信任包可作为额外措施,但不能替代 Go 二进制重建。
## 本次验证
- Go 1.26.10 下,`go-backend`、`go-gost`、`go-gost/x` 各自的 `go test ./...` 全部通过。PostgreSQL 外部数据库契约测试仍需配置 `FLVX_POSTGRES_TEST_DSN` 后单独执行。
- Node.js 22.23.4、pnpm 10.28.1 下,前端 `pnpm install --frozen-lockfile` 与 `pnpm run build` 通过,无需更改依赖锁文件。
- 本机 Node.js 26.0.0 执行前端构建时,被 `engine-strict` 拒绝。
- 使用 `CGO_ENABLED=0` 交叉编译后端和 agent 的 Linux amd64/arm64 共四个二进制成功;`go version -m` 确认均为 go1.26.10。
- Docker Hub 上两个固定构建镜像标签均存在,且包含 Linux amd64/arm64。未执行 Docker 镜像构建或线上部署。
- 工作流 YAML、包 JSON 解析及 `git diff --check` 通过。
## 官方依据
- [PHP 官方安全公告](https://www.php.net/archive/2026.php#2026-10-21-4)
- [Node.js 2026-10-21 安全更新及修复版本](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases)
- [Go 官方问题 #82345](https://go.dev/issue/82345)
- [Go 漏洞库 GO-2026-6809](https://pkg.go.dev/vuln/GO-2026-6809)
- [Go 官方发布记录](https://go.dev/doc/devel/release)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,536 @@
# Dependabot Remediation 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:** Resolve the open Dependabot dependency alerts for `go-backend`, `vite-frontend`, `go-gost/x`, and `go-gost` without mixing in unrelated business security changes.
**Architecture:** Apply targeted dependency upgrades per module, verify each module before moving to the next, and keep commits scoped to one dependency group. `go-gost/x` is fixed before `go-gost` because the main agent module uses `replace github.com/go-gost/x => ./x`.
**Tech Stack:** Go modules, pnpm, Vite/Rolldown, GitHub CLI Dependabot alerts API.
---
## File Map
- Modify: `go-backend/go.mod`
Responsibility: update `github.com/jackc/pgx/v5` to the patched version.
- Modify: `go-backend/go.sum`
Responsibility: reflect Go module checksum changes from the pgx upgrade.
- Modify: `vite-frontend/package.json`
Responsibility: update direct vulnerable npm dependency versions and configure `pnpm.overrides`.
- Modify: `vite-frontend/pnpm-lock.yaml`
Responsibility: resolve vulnerable npm transitive dependencies to patched versions.
- Modify: `go-gost/x/go.mod`
Responsibility: update vulnerable Go dependencies used by the local `github.com/go-gost/x` module.
- Modify: `go-gost/x/go.sum`
Responsibility: reflect checksum changes for `go-gost/x`.
- Modify: `go-gost/x/dialer/dtls/dialer.go`
Responsibility: migrate DTLS import path from `github.com/pion/dtls/v2` to `github.com/pion/dtls/v3`.
- Modify: `go-gost/x/listener/dtls/listener.go`
Responsibility: migrate DTLS import path from `github.com/pion/dtls/v2` to `github.com/pion/dtls/v3`.
- Modify: `go-gost/go.mod`
Responsibility: sync vulnerable dependency versions for the main agent module while preserving local `replace github.com/go-gost/x => ./x`.
- Modify: `go-gost/go.sum`
Responsibility: reflect checksum changes for the main agent module.
## Task 1: Capture Baseline Alerts
**Files:**
- Read: GitHub Dependabot alerts API
- Read: `go-backend/go.mod`
- Read: `vite-frontend/package.json`
- Read: `go-gost/x/go.mod`
- Read: `go-gost/go.mod`
- [ ] **Step 1: Query current open Dependabot alerts**
Run:
```bash
gh api 'repos/Sagit-chu/flvx/dependabot/alerts?state=open&per_page=100' --paginate \
--jq '.[] | [.number,.security_advisory.severity,.dependency.package.ecosystem,.dependency.manifest_path,.dependency.package.name,.security_vulnerability.vulnerable_version_range,(.security_vulnerability.first_patched_version.identifier // "")] | @tsv'
```
Expected: output includes alerts for `github.com/jackc/pgx/v5`, `postcss`, `serialize-javascript`, `fast-uri`, `@babel/plugin-transform-modules-systemjs`, `github.com/sirupsen/logrus`, `github.com/quic-go/quic-go`, `github.com/quic-go/webtransport-go`, and `github.com/pion/dtls/v2`.
- [ ] **Step 2: Confirm starting versions in module manifests**
Run:
```bash
rg -n 'jackc/pgx|postcss|serialize-javascript|pion/dtls|quic-go|webtransport-go|sirupsen/logrus' \
go-backend/go.mod vite-frontend/package.json go-gost/x/go.mod go-gost/go.mod
```
Expected key lines:
```text
go-backend/go.mod: github.com/jackc/pgx/v5 v5.7.3
vite-frontend/package.json: "postcss": "8.5.6"
vite-frontend/package.json: "serialize-javascript": "7.0.3"
go-gost/x/go.mod: github.com/pion/dtls/v2 v2.2.6
go-gost/x/go.mod: github.com/quic-go/quic-go v0.49.1
go-gost/x/go.mod: github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66
go-gost/x/go.mod: github.com/sirupsen/logrus v1.8.1
```
- [ ] **Step 3: Confirm the DTLS advisory has no patched v2 release**
Run:
```bash
go list -m -versions github.com/pion/dtls/v2
gh api 'advisories/GHSA-9f3f-wv7r-qc8r' --jq '{summary, vulnerabilities}'
```
Expected:
```text
github.com/pion/dtls/v2 ... v2.2.12
```
Expected advisory facts:
```text
github.com/pion/dtls/v2 vulnerable range <= 2.2.12 has no first_patched_version.
github.com/pion/dtls/v3 patched versions include 3.0.11 and 3.1.1.
```
- [ ] **Step 4: Do not commit baseline capture**
Run:
```bash
git status --short
```
Expected: no files are changed by Task 1.
## Task 2: Fix go-backend pgx Alerts
**Files:**
- Modify: `go-backend/go.mod`
- Modify: `go-backend/go.sum`
- [ ] **Step 1: Upgrade pgx to the patched version**
Run:
```bash
(cd go-backend && go get github.com/jackc/pgx/v5@v5.9.2)
```
Expected: `go-backend/go.mod` changes `github.com/jackc/pgx/v5` from `v5.7.3` to `v5.9.2`, and `go-backend/go.sum` updates checksums.
- [ ] **Step 2: Tidy backend module**
Run:
```bash
(cd go-backend && go mod tidy)
```
Expected: command exits with code 0.
- [ ] **Step 3: Verify backend dependency version**
Run:
```bash
rg -n 'github.com/jackc/pgx/v5' go-backend/go.mod
```
Expected:
```text
go-backend/go.mod: github.com/jackc/pgx/v5 v5.9.2
```
- [ ] **Step 4: Run backend tests**
Run:
```bash
(cd go-backend && go test ./...)
```
Expected: all backend packages pass.
- [ ] **Step 5: Commit backend dependency fix**
Run:
```bash
git add go-backend/go.mod go-backend/go.sum
git commit -m "fix: update backend pgx dependency"
```
Expected: one commit containing only `go-backend/go.mod` and `go-backend/go.sum`.
## Task 3: Fix Frontend npm Alerts
**Files:**
- Modify: `vite-frontend/package.json`
- Modify: `vite-frontend/pnpm-lock.yaml`
- [ ] **Step 1: Update direct dependency and pnpm overrides in package.json**
Edit `vite-frontend/package.json` so the relevant entries are exactly:
```json
{
"devDependencies": {
"postcss": "8.5.10"
},
"pnpm": {
"overrides": {
"@babel/plugin-transform-modules-systemjs": "7.29.4",
"fast-uri": "3.1.2",
"serialize-javascript": "7.0.5"
}
}
}
```
Remove the existing top-level `"overrides"` block after adding `"pnpm.overrides"`. Keep all other existing dependencies and scripts unchanged.
- [ ] **Step 2: Regenerate pnpm lockfile**
Run:
```bash
(cd vite-frontend && pnpm install)
```
Expected: `vite-frontend/pnpm-lock.yaml` updates and install exits with code 0.
- [ ] **Step 3: Verify vulnerable npm versions are absent**
Run:
```bash
rg -n 'postcss@8\.5\.[0-9]:|"postcss":\s*"8\.5\.[0-9]"|serialize-javascript@[0-6]\.|serialize-javascript@7\.0\.[0-4]|fast-uri@3\.1\.[0-1]|plugin-transform-modules-systemjs@7\.29\.[0-3]' vite-frontend/pnpm-lock.yaml vite-frontend/package.json
```
Expected: no output.
- [ ] **Step 4: Verify patched npm versions are present**
Run:
```bash
rg -n 'postcss@8\.5\.10|serialize-javascript@7\.0\.5|fast-uri@3\.1\.2|plugin-transform-modules-systemjs@7\.29\.4' vite-frontend/pnpm-lock.yaml vite-frontend/package.json
```
Expected: output includes patched entries for `postcss@8.5.10`, `serialize-javascript@7.0.5`, `fast-uri@3.1.2`, and `@babel/plugin-transform-modules-systemjs@7.29.4`.
- [ ] **Step 5: Build frontend**
Run:
```bash
(cd vite-frontend && pnpm run build)
```
Expected: TypeScript and Rolldown/Vite build complete successfully.
- [ ] **Step 6: Commit frontend dependency fix**
Run:
```bash
git add vite-frontend/package.json vite-frontend/pnpm-lock.yaml
git commit -m "fix: update frontend vulnerable dependencies"
```
Expected: one commit containing only `vite-frontend/package.json` and `vite-frontend/pnpm-lock.yaml`.
## Task 4: Fix go-gost/x Non-DTLS Alerts
**Files:**
- Modify: `go-gost/x/go.mod`
- Modify: `go-gost/x/go.sum`
- [ ] **Step 1: Upgrade non-DTLS vulnerable Go dependencies**
Run:
```bash
(cd go-gost/x && go get github.com/sirupsen/logrus@v1.8.3 github.com/quic-go/quic-go@v0.57.0 github.com/quic-go/webtransport-go@v0.10.0)
```
Expected: `go-gost/x/go.mod` resolves these dependencies to at least:
```text
github.com/sirupsen/logrus v1.8.3
github.com/quic-go/quic-go v0.57.0
github.com/quic-go/webtransport-go v0.10.0
```
- [ ] **Step 2: Tidy go-gost/x module**
Run:
```bash
(cd go-gost/x && go mod tidy)
```
Expected: command exits with code 0.
- [ ] **Step 3: Verify go-gost/x non-DTLS dependency versions**
Run:
```bash
rg -n 'github.com/sirupsen/logrus|github.com/quic-go/quic-go|github.com/quic-go/webtransport-go' go-gost/x/go.mod
```
Expected output contains versions at or above:
```text
github.com/sirupsen/logrus v1.8.3
github.com/quic-go/quic-go v0.57.0
github.com/quic-go/webtransport-go v0.10.0
```
- [ ] **Step 4: Run go-gost/x tests after non-DTLS upgrades**
Run:
```bash
(cd go-gost/x && go test ./...)
```
Expected: command exits with code 0. If it fails, stop this task before committing and inspect the first compiler error. The only permitted follow-up edits in this task are direct API-compatibility changes in files named by the compiler under `go-gost/x`; rerun this command after each edit.
- [ ] **Step 5: Commit go-gost/x non-DTLS dependency fix**
Run:
```bash
git add go-gost/x/go.mod go-gost/x/go.sum
git commit -m "fix: update gost quic dependencies"
```
Expected: one commit containing `go-gost/x/go.mod` and `go-gost/x/go.sum`, plus only the compiler-named `go-gost/x` files edited during Step 4.
## Task 5: Migrate go-gost/x DTLS From v2 To v3
**Files:**
- Modify: `go-gost/x/go.mod`
- Modify: `go-gost/x/go.sum`
- Modify: `go-gost/x/dialer/dtls/dialer.go`
- Modify: `go-gost/x/listener/dtls/listener.go`
- [ ] **Step 1: Update DTLS imports**
In `go-gost/x/dialer/dtls/dialer.go`, change:
```go
"github.com/pion/dtls/v2"
```
to:
```go
"github.com/pion/dtls/v3"
```
In `go-gost/x/listener/dtls/listener.go`, change:
```go
"github.com/pion/dtls/v2"
```
to:
```go
"github.com/pion/dtls/v3"
```
- [ ] **Step 2: Add patched DTLS v3 module**
Run:
```bash
(cd go-gost/x && go get github.com/pion/dtls/v3@v3.0.11)
```
Expected: `go-gost/x/go.mod` contains `github.com/pion/dtls/v3 v3.0.11` and no longer needs `github.com/pion/dtls/v2`.
- [ ] **Step 3: Tidy and format go-gost/x**
Run:
```bash
(cd go-gost/x && go mod tidy)
gofmt -w go-gost/x/dialer/dtls/dialer.go go-gost/x/listener/dtls/listener.go
```
Expected: command exits with code 0.
- [ ] **Step 4: Verify v2 import and module are removed**
Run:
```bash
rg -n 'github.com/pion/dtls/v2' go-gost/x
rg -n 'github.com/pion/dtls/v3' go-gost/x/go.mod go-gost/x/dialer/dtls/dialer.go go-gost/x/listener/dtls/listener.go
```
Expected:
```text
first command: no output
second command: output includes go.mod, dialer.go, and listener.go
```
- [ ] **Step 5: Run go-gost/x tests after DTLS migration**
Run:
```bash
(cd go-gost/x && go test ./...)
```
Expected: command exits with code 0. If the compiler reports DTLS v3 API errors, edit only `go-gost/x/dialer/dtls/dialer.go` and `go-gost/x/listener/dtls/listener.go`, preserving the existing `dtls.Config`, `dtls.ClientWithContext`, and `dtls.Listen` flow, then rerun this command.
- [ ] **Step 6: Commit DTLS migration**
Run:
```bash
git add go-gost/x/go.mod go-gost/x/go.sum go-gost/x/dialer/dtls/dialer.go go-gost/x/listener/dtls/listener.go
git commit -m "fix: migrate gost dtls dependency"
```
Expected: one commit containing the DTLS import migration and Go module updates.
## Task 6: Sync go-gost Main Module
**Files:**
- Modify: `go-gost/go.mod`
- Modify: `go-gost/go.sum`
- [ ] **Step 1: Upgrade main module vulnerable dependency requirements**
Run:
```bash
(cd go-gost && go get github.com/sirupsen/logrus@v1.8.3 github.com/quic-go/quic-go@v0.57.0 github.com/quic-go/webtransport-go@v0.10.0 github.com/pion/dtls/v3@v3.0.11)
```
Expected: `go-gost/go.mod` resolves vulnerable dependencies to patched versions and preserves this replace directive:
```go
replace github.com/go-gost/x => ./x
```
- [ ] **Step 2: Tidy main agent module**
Run:
```bash
(cd go-gost && go mod tidy)
```
Expected: command exits with code 0.
- [ ] **Step 3: Verify go-gost no longer references vulnerable DTLS v2**
Run:
```bash
rg -n 'github.com/pion/dtls/v2' go-gost/go.mod go-gost/go.sum
rg -n 'github.com/pion/dtls/v3|github.com/quic-go/quic-go|github.com/quic-go/webtransport-go|github.com/sirupsen/logrus|replace github.com/go-gost/x => ./x' go-gost/go.mod
```
Expected:
```text
first command: no output
second command: output includes dtls/v3, quic-go, webtransport-go, logrus, and the local replace directive
```
- [ ] **Step 4: Run go-gost tests**
Run:
```bash
(cd go-gost && go test ./...)
```
Expected: all packages pass.
- [ ] **Step 5: Build go-gost binary**
Run:
```bash
(cd go-gost && go build .)
```
Expected: build exits with code 0.
- [ ] **Step 6: Commit go-gost module sync**
Run:
```bash
git add go-gost/go.mod go-gost/go.sum
git commit -m "fix: sync gost main dependencies"
```
Expected: one commit containing only `go-gost/go.mod` and `go-gost/go.sum`.
## Task 7: Final Dependabot Verification
**Files:**
- Read: GitHub Dependabot alerts API
- Read: Git working tree status
- [ ] **Step 1: Run all verification commands once more**
Run:
```bash
(cd go-backend && go test ./...)
(cd vite-frontend && pnpm run build)
(cd go-gost/x && go test ./...)
(cd go-gost && go test ./...)
(cd go-gost && go build .)
```
Expected: every command exits with code 0.
- [ ] **Step 2: Query open Dependabot alerts after dependency updates**
Run:
```bash
gh api 'repos/Sagit-chu/flvx/dependabot/alerts?state=open&per_page=100' --paginate \
--jq 'group_by(.security_advisory.severity) | map({severity:.[0].security_advisory.severity,count:length})'
```
Expected: counts are lower than the baseline from Task 1. If Dependabot has not rescanned yet, run the detailed query from Task 1 and confirm the manifest files now contain patched versions locally.
- [ ] **Step 3: Confirm no vulnerable dependency strings remain in manifests**
Run:
```bash
rg -n 'github.com/jackc/pgx/v5 v5\.7\.3|postcss\"\\s*:\\s*\"8\.5\.6|serialize-javascript\"\\s*:\\s*\"7\.0\.3|github.com/pion/dtls/v2|github.com/quic-go/quic-go v0\.49\.1|github.com/quic-go/webtransport-go v0\.8\.1|github.com/sirupsen/logrus v1\.8\.1' \
go-backend/go.mod vite-frontend/package.json go-gost/x/go.mod go-gost/go.mod
```
Expected: no output.
- [ ] **Step 4: Confirm working tree contains only intentional changes**
Run:
```bash
git status --short
```
Expected: no uncommitted files from this Dependabot remediation remain. Pre-existing unrelated files may still appear; do not stage or revert them.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,650 @@
# Forward Flow Reset 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 permission-checked action that resets only one forward rule's displayed upload and download counters.
**Architecture:** A dedicated repository method updates only the selected `forward` row. A dedicated authenticated handler reuses `resolveForwardAccess`, and the React page calls the endpoint from all three rule views through one confirmation modal.
**Tech Stack:** Go `net/http`, GORM, SQLite/PostgreSQL-compatible models, React, TypeScript, shadcn bridge components, Tailwind CSS v4.
## Global Constraints
- Only `forward.in_flow`, `forward.out_flow`, and `forward.updated_time` may change during reset.
- Do not modify `user`, `user_tunnel`, quota, historical statistics, nftables counter state, or running services.
- Administrators may reset any rule; non-admin users may reset only their own rules through existing `resolveForwardAccess` behavior.
- All API responses must keep the `{code, msg, data, ts}` envelope.
- Frontend imports must use `src/shadcn-bridge/heroui/*`; do not add `@heroui/*` or `@nextui-org/*` dependencies.
- Do not add frontend test infrastructure.
- Do not edit generated protobuf files, `install.sh`, or `panel_install.sh`.
---
### Task 1: Add the repository flow-reset primitive
**Files:**
- Create: `go-backend/internal/store/repo/repository_forward_flow_reset_test.go`
- Modify: `go-backend/internal/store/repo/repository_mutations.go`
**Interfaces:**
- Consumes: `model.Forward`, the repository's GORM database handle, and an explicit Unix-millisecond timestamp.
- Produces: `func (r *Repository) ResetForwardFlow(forwardID int64, now int64) error`.
- [ ] **Step 1: Write the failing repository tests**
Create `go-backend/internal/store/repo/repository_forward_flow_reset_test.go`:
```go
package repo
import (
"path/filepath"
"testing"
)
func TestResetForwardFlowOnlyUpdatesSelectedForward(t *testing.T) {
r, err := Open(filepath.Join(t.TempDir(), "forward-flow-reset.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
const originalUpdated int64 = 1000
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, 'owner', 'pwd', 1, 0, 100, 700, 900, 0, 10, 1000, 1000, 1)
`).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, 'tunnel', 1, 1, 'tls', 1, 1000, 1000, 1, NULL, 0)
`).Error; err != nil {
t.Fatalf("insert 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(10, 2, 1, 10, 100, 500, 600, 0, 0, 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, 'owner', 'target', 1, '127.0.0.1:80', 'fifo', 111, 222, 1000, ?, 1, 0),
(21, 2, 'owner', 'other', 1, '127.0.0.1:81', 'fifo', 333, 444, 1000, ?, 1, 1)
`, originalUpdated, originalUpdated).Error; err != nil {
t.Fatalf("insert forwards: %v", err)
}
const resetAt int64 = 2000
if err := r.ResetForwardFlow(20, resetAt); err != nil {
t.Fatalf("ResetForwardFlow: %v", err)
}
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM forward WHERE id = 20", 0)
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM forward WHERE id = 20", 0)
assertForwardFlowResetValue(t, r, "SELECT updated_time FROM forward WHERE id = 20", resetAt)
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM forward WHERE id = 21", 333)
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM forward WHERE id = 21", 444)
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM user WHERE id = 2", 700)
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM user WHERE id = 2", 900)
assertForwardFlowResetValue(t, r, "SELECT in_flow FROM user_tunnel WHERE id = 10", 500)
assertForwardFlowResetValue(t, r, "SELECT out_flow FROM user_tunnel WHERE id = 10", 600)
}
func TestResetForwardFlowRejectsUninitializedRepository(t *testing.T) {
var r *Repository
if err := r.ResetForwardFlow(20, 2000); err == nil {
t.Fatal("expected uninitialized repository error")
}
}
func assertForwardFlowResetValue(t *testing.T, r *Repository, query string, want int64) {
t.Helper()
var got int64
if err := r.DB().Raw(query).Scan(&got).Error; err != nil {
t.Fatalf("query %q: %v", query, err)
}
if got != want {
t.Fatalf("query %q returned %d, want %d", query, got, want)
}
}
```
- [ ] **Step 2: Run the repository tests and verify the missing method failure**
Run:
```bash
cd go-backend && go test ./internal/store/repo -run TestResetForwardFlow -count=1
```
Expected: compilation fails because `ResetForwardFlow` is undefined.
- [ ] **Step 3: Implement the minimal repository method**
Add to the flow-reset section of `go-backend/internal/store/repo/repository_mutations.go`:
```go
func (r *Repository) ResetForwardFlow(forwardID int64, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
return r.db.Model(&model.Forward{}).
Where("id = ?", forwardID).
Updates(map[string]interface{}{
"in_flow": 0,
"out_flow": 0,
"updated_time": now,
}).Error
}
```
The file already imports `errors` and `model`; do not add a new dependency.
- [ ] **Step 4: Format and run the focused repository tests**
Run:
```bash
cd go-backend && gofmt -w internal/store/repo/repository_forward_flow_reset_test.go internal/store/repo/repository_mutations.go
go test ./internal/store/repo -run TestResetForwardFlow -count=1
```
Expected: both reset tests pass.
- [ ] **Step 5: Commit the repository change**
```bash
git add go-backend/internal/store/repo/repository_mutations.go go-backend/internal/store/repo/repository_forward_flow_reset_test.go
git commit -m "feat: add forward flow reset repository method"
```
---
### Task 2: Add the authenticated reset endpoint
**Files:**
- Create: `go-backend/internal/http/handler/forward_reset_flow_test.go`
- Modify: `go-backend/internal/http/handler/handler.go`
- Modify: `go-backend/internal/http/handler/mutations.go`
**Interfaces:**
- Consumes: `POST` JSON `{ "id": number }`, `resolveForwardAccess`, and `Repository.ResetForwardFlow` from Task 1.
- Produces: `POST /api/v1/forward/reset-flow` and `func (h *Handler) forwardResetFlow(http.ResponseWriter, *http.Request)`.
- [ ] **Step 1: Write the failing handler tests**
Create `go-backend/internal/http/handler/forward_reset_flow_test.go`:
```go
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"testing"
"go-backend/internal/auth"
"go-backend/internal/http/middleware"
"go-backend/internal/store/repo"
)
func TestForwardResetFlowPermissionsAndIsolation(t *testing.T) {
tests := []struct {
name string
actorID int64
actorRole int
forwardID int64
wantCode int
wantInFlow int64
wantOutFlow int64
}{
{name: "admin resets another user's rule", actorID: 1, actorRole: 0, forwardID: 20, wantCode: 0, wantInFlow: 0, wantOutFlow: 0},
{name: "owner resets own rule", actorID: 2, actorRole: 1, forwardID: 20, wantCode: 0, wantInFlow: 0, wantOutFlow: 0},
{name: "user cannot reset another user's rule", actorID: 3, actorRole: 1, forwardID: 20, wantCode: -1, wantInFlow: 111, wantOutFlow: 222},
{name: "missing rule is rejected", actorID: 1, actorRole: 0, forwardID: 999, wantCode: -1, wantInFlow: 111, wantOutFlow: 222},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h, r := setupForwardResetFlowHandler(t)
req := newForwardResetFlowRequest(t, http.MethodPost, tt.forwardID, tt.actorID, tt.actorRole)
res := httptest.NewRecorder()
h.forwardResetFlow(res, req)
if got := decodeForwardResetFlowCode(t, res); got != tt.wantCode {
t.Fatalf("code = %d, want %d; body=%s", got, tt.wantCode, res.Body.String())
}
assertForwardResetFlowDBValue(t, r, "SELECT in_flow FROM forward WHERE id = 20", tt.wantInFlow)
assertForwardResetFlowDBValue(t, r, "SELECT out_flow FROM forward WHERE id = 20", tt.wantOutFlow)
assertForwardResetFlowDBValue(t, r, "SELECT in_flow FROM user WHERE id = 2", 700)
assertForwardResetFlowDBValue(t, r, "SELECT out_flow FROM user_tunnel WHERE id = 10", 600)
})
}
}
func TestForwardResetFlowRejectsInvalidRequests(t *testing.T) {
h, _ := setupForwardResetFlowHandler(t)
t.Run("non post", func(t *testing.T) {
req := newForwardResetFlowRequest(t, http.MethodGet, 20, 1, 0)
res := httptest.NewRecorder()
h.forwardResetFlow(res, req)
if code := decodeForwardResetFlowCode(t, res); code != -1 {
t.Fatalf("code = %d, want -1", code)
}
})
t.Run("invalid id", func(t *testing.T) {
req := newForwardResetFlowRequest(t, http.MethodPost, 0, 1, 0)
res := httptest.NewRecorder()
h.forwardResetFlow(res, req)
if code := decodeForwardResetFlowCode(t, res); code != -1 {
t.Fatalf("code = %d, want -1", code)
}
})
}
func setupForwardResetFlowHandler(t *testing.T) (*Handler, *repo.Repository) {
t.Helper()
r, err := repo.Open(filepath.Join(t.TempDir(), "forward-reset-handler.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
statements := []string{
`INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) VALUES(1, 'admin', 'pwd', 0, 0, 100, 0, 0, 0, 10, 1000, 1000, 1)`,
`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, 'owner', 'pwd', 1, 0, 100, 700, 900, 0, 10, 1000, 1000, 1)`,
`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, 'other', 'pwd', 1, 0, 100, 0, 0, 0, 10, 1000, 1000, 1)`,
`INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(1, 'tunnel', 1, 1, 'tls', 1, 1000, 1000, 1, NULL, 0)`,
`INSERT INTO user_tunnel(id, user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(10, 2, 1, 10, 100, 500, 600, 0, 0, 1)`,
`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, 'owner', 'target', 1, '127.0.0.1:80', 'fifo', 111, 222, 1000, 1000, 1, 0)`,
}
for _, statement := range statements {
if err := r.DB().Exec(statement).Error; err != nil {
t.Fatalf("seed database: %v", err)
}
}
return New(r, "test-secret"), r
}
func newForwardResetFlowRequest(t *testing.T, method string, forwardID, actorID int64, roleID int) *http.Request {
t.Helper()
body, err := json.Marshal(map[string]int64{"id": forwardID})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(method, "/api/v1/forward/reset-flow", bytes.NewReader(body))
claims := auth.Claims{Sub: strconv.FormatInt(actorID, 10), RoleID: roleID}
return req.WithContext(context.WithValue(req.Context(), middleware.ClaimsContextKey, claims))
}
func decodeForwardResetFlowCode(t *testing.T, res *httptest.ResponseRecorder) int {
t.Helper()
var payload struct {
Code int `json:"code"`
}
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v; body=%s", err, res.Body.String())
}
return payload.Code
}
func assertForwardResetFlowDBValue(t *testing.T, r *repo.Repository, query string, want int64) {
t.Helper()
var got int64
if err := r.DB().Raw(query).Scan(&got).Error; err != nil {
t.Fatalf("query %q: %v", query, err)
}
if got != want {
t.Fatalf("query %q returned %d, want %d", query, got, want)
}
}
```
If the project's default error code differs from `-1`, replace the test expectation with the actual `response.ErrDefault` code after inspecting one existing handler response; do not weaken the success and database assertions.
- [ ] **Step 2: Run the handler tests and verify the missing handler failure**
Run:
```bash
cd go-backend && go test ./internal/http/handler -run TestForwardResetFlow -count=1
```
Expected: compilation fails because `forwardResetFlow` is undefined.
- [ ] **Step 3: Register and implement the endpoint**
Add this route beside the other forward routes in `go-backend/internal/http/handler/handler.go`:
```go
mux.HandleFunc("/api/v1/forward/reset-flow", h.forwardResetFlow)
```
Add this handler beside `forwardPause` and `forwardResume` in `go-backend/internal/http/handler/mutations.go`:
```go
func (h *Handler) forwardResetFlow(w http.ResponseWriter, r *http.Request) {
id := idFromBody(r, w)
if id <= 0 {
return
}
if _, _, _, err := h.resolveForwardAccess(r, id); err != nil {
if errors.Is(err, errForwardNotFound) {
response.WriteJSON(w, response.ErrDefault("转发不存在"))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.repo.ResetForwardFlow(id, time.Now().UnixMilli()); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
```
This deliberately does not call runtime service controls or nftables reconciliation.
- [ ] **Step 4: Format and run the focused handler tests**
Run:
```bash
cd go-backend && gofmt -w internal/http/handler/forward_reset_flow_test.go internal/http/handler/handler.go internal/http/handler/mutations.go
go test ./internal/http/handler -run TestForwardResetFlow -count=1
```
Expected: all reset endpoint tests pass.
- [ ] **Step 5: Run all backend tests**
Run:
```bash
cd go-backend && go test ./...
```
Expected: all backend packages and contract tests pass, excluding environment-gated PostgreSQL tests when `FLVX_POSTGRES_TEST_DSN` is unset.
- [ ] **Step 6: Commit the endpoint change**
```bash
git add go-backend/internal/http/handler/handler.go go-backend/internal/http/handler/mutations.go go-backend/internal/http/handler/forward_reset_flow_test.go
git commit -m "feat: add forward flow reset endpoint"
```
---
### Task 3: Add the rule-page reset action and confirmation modal
**Files:**
- Modify: `vite-frontend/src/api/index.ts`
- Modify: `vite-frontend/src/pages/forward.tsx`
**Interfaces:**
- Consumes: `POST /forward/reset-flow`, the page's `Forward` shape, `refreshForwardList`, toast notifications, and existing modal/button bridge components.
- Produces: `resetForwardFlow(id: number)`, a shared reset handler, disabled zero-usage actions in all rule views, and one confirmation modal.
- [ ] **Step 1: Add the frontend API wrapper**
Add beside the forward control operations in `vite-frontend/src/api/index.ts`:
```ts
export const resetForwardFlow = (forwardId: number) =>
Network.post("/forward/reset-flow", { id: forwardId });
```
Import `resetForwardFlow` from `@/api` in `vite-frontend/src/pages/forward.tsx`.
- [ ] **Step 2: Add page state and shared reset handlers**
Add state beside the existing delete modal state:
```ts
const [resetFlowModalOpen, setResetFlowModalOpen] = useState(false);
const [resetFlowLoading, setResetFlowLoading] = useState(false);
const [forwardToResetFlow, setForwardToResetFlow] = useState<Forward | null>(null);
```
Add these handlers beside `handleDelete` and `confirmDelete`:
```ts
const handleResetFlow = (forward: Forward) => {
if ((forward.inFlow || 0) + (forward.outFlow || 0) <= 0) return;
setForwardToResetFlow(forward);
setResetFlowModalOpen(true);
};
const confirmResetFlow = async () => {
if (!forwardToResetFlow) return;
setResetFlowLoading(true);
try {
const res = await resetForwardFlow(forwardToResetFlow.id);
if (res.code !== 0) {
toast.error(res.msg || "流量清零失败");
return;
}
toast.success("规则流量已清零");
setResetFlowModalOpen(false);
setForwardToResetFlow(null);
await refreshForwardList(false);
} catch {
toast.error("流量清零失败");
} finally {
setResetFlowLoading(false);
}
};
```
- [ ] **Step 3: Add one reusable reset icon button to both table row components**
Pass `handleResetFlow` into `SortableTableRow` and `SortableCompactTableRow` at every render site. Add it to each component's destructured props.
Insert this button between diagnosis and delete in each table action cell:
```tsx
<Button
isIconOnly
className="bg-secondary/10 text-secondary hover:bg-secondary/20"
isDisabled={(forward.inFlow || 0) + (forward.outFlow || 0) <= 0}
size="sm"
title="流量清零"
onPress={() => handleResetFlow(forward)}
>
<svg
aria-hidden="true"
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M4 4v6h6M20 20v-6h-6M20 9a8 8 0 00-13.657-3.657L4 8m16 8-2.343 2.657A8 8 0 014 15"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</Button>
```
- [ ] **Step 4: Add the reset action to the card view**
Insert a fourth action button between diagnosis and delete in `renderForwardCard`:
```tsx
<Button
className="flex-1 min-h-8"
color="secondary"
isDisabled={(forward.inFlow || 0) + (forward.outFlow || 0) <= 0}
size="sm"
startContent={
<svg
aria-hidden="true"
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M4 4v6h6M20 20v-6h-6M20 9a8 8 0 00-13.657-3.657L4 8m16 8-2.343 2.657A8 8 0 014 15"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
}
variant="flat"
onPress={() => handleResetFlow(forward)}
>
清零
</Button>
```
Change the card action container from `flex gap-1.5 mt-3` to `grid grid-cols-2 gap-1.5 mt-3` so all four actions remain readable at the smallest supported card width.
- [ ] **Step 5: Add the confirmation modal**
Add beside the delete confirmation modal:
```tsx
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={resetFlowModalOpen}
placement="center"
scrollBehavior="inside"
size="lg"
onOpenChange={setResetFlowModalOpen}
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-lg font-bold text-secondary">确认流量清零</h2>
</ModalHeader>
<ModalBody>
<p className="text-default-600">
确定要清零规则{" "}
<span className="font-semibold text-foreground">
&quot;{forwardToResetFlow?.name}&quot;
</span>{" "}
当前显示的上传和下载流量吗?
</p>
<p className="text-small text-default-500 mt-2">
此操作不可撤销,但不会影响用户总流量、用户隧道配额和历史统计。
</p>
</ModalBody>
<ModalFooter>
<Button isDisabled={resetFlowLoading} variant="light" onPress={onClose}>
取消
</Button>
<Button
color="secondary"
isLoading={resetFlowLoading}
onPress={confirmResetFlow}
>
确认清零
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
```
Add this wrapper beside the other reset handlers and pass it to the modal as `onOpenChange={handleResetFlowModalOpenChange}`:
```ts
const handleResetFlowModalOpenChange = (isOpen: boolean) => {
if (resetFlowLoading) return;
setResetFlowModalOpen(isOpen);
if (!isOpen) {
setForwardToResetFlow(null);
}
};
```
- [ ] **Step 6: Format and verify the frontend**
Run:
```bash
cd vite-frontend && pnpm exec prettier --write src/api/index.ts src/pages/forward.tsx
pnpm run build
pnpm run lint
```
Expected: TypeScript/Vite build succeeds and ESLint finishes without errors.
- [ ] **Step 7: Commit the frontend change**
```bash
git add vite-frontend/src/api/index.ts vite-frontend/src/pages/forward.tsx
git commit -m "feat: add forward flow reset action"
```
---
### Task 4: Perform integrated verification
**Files:**
- Verify only; no planned source changes.
**Interfaces:**
- Consumes: the repository method, API endpoint, and rule-page action from Tasks 1-3.
- Produces: evidence that the complete feature builds and all affected tests pass.
- [ ] **Step 1: Run the complete backend suite**
```bash
cd go-backend && go test ./...
```
Expected: all available backend tests pass.
- [ ] **Step 2: Run the complete frontend checks**
```bash
cd vite-frontend && pnpm run build && pnpm run lint
```
Expected: both commands exit successfully.
- [ ] **Step 3: Check formatting and working-tree scope**
```bash
git diff --check
git status --short
git log -4 --oneline
```
Expected: no whitespace errors; the working tree is clean; the three feature commits are visible after the design and implementation-plan commits.
- [ ] **Step 4: Manually verify the feature when a local panel is available**
1. Open the Rules page as an administrator and reset a rule with non-zero upload/download traffic.
2. Confirm the modal states that user totals, tunnel quota, and history are unaffected.
3. Confirm the rule immediately shows zero after success.
4. Confirm the user page's total traffic and user-tunnel traffic values did not change.
5. Generate new traffic and confirm the rule starts accumulating from zero.
6. Log in as a normal user and confirm the user can reset an owned rule but cannot access another user's rule through a direct API request.
Expected: all six checks match the design specification.
@@ -0,0 +1,295 @@
# 面板本体一键升级设计
**日期**: 2026-05-04
**状态**: 待审核
**作者**: AI Assistant
## 概述
在 FLVX 管理面板中增加“面板升级”能力,使管理员可以在网页上检查 GitHub Release 并触发面板本体升级。目标是升级整套面板,而不是只升级转发节点或只替换后端二进制。
本设计采用 Docker Compose 整套升级方案:后端容器通过受限的 Docker socket 能力更新宿主机部署目录中的 `docker-compose.yml` 和 `.env`,然后启动独立的升级 helper 容器,由 helper 拉取新版 backend/frontend 镜像并重新启动 `backend` 与 `frontend` 服务。
## sub2api 参考结论
sub2api 的一键升级不是在宿主机执行 `docker compose pull/up`。它的运行形态是单体 Go 服务:前端构建产物 embed 到后端二进制,容器内只运行 `/app/sub2api`。升级接口下载 GitHub Release 中匹配当前系统和架构的 `sub2api_<version>_<os>_<arch>.tar.gz` 以及 `checksums.txt`,校验后把当前 `os.Executable()` 指向的 `/app/sub2api` 改名为 `/app/sub2api.backup`,再把新二进制原子替换到原路径。重启接口延迟调用 `os.Exit(0)`,依赖 Docker Compose 的 `restart: unless-stopped` 拉起同一个容器。
这种方式在 sub2api 的 Docker 部署中可行,是因为它的前后端在同一个二进制里。FLVX 当前是 `flux-panel-backend` 与 `vite-frontend` 两个容器,替换 `/app/paneld` 只能升级后端,不能升级前端页面。因此 FLVX 的“面板本体升级”需要更新 Compose 版本和两个镜像,而不是照搬二进制替换。
## 目标
1. 管理员可在面板上查看当前版本、最新版本、升级通道和升级能力状态。
2. 管理员可一键升级整套面板 backend/frontend。
3. 升级复用现有 GitHub Release 和 `FLUX_VERSION` 版本机制。
4. 升级复用现有 GitHub 加速配置 `github_proxy_enabled` / `github_proxy_url`。
5. 升级过程不接受任意命令、任意 URL 或任意 compose 路径。
6. 环境不满足时清晰提示不可用原因,不静默失败。
## 非目标
1. 不实现 sub2api 式后端二进制替换作为本次主路径。
2. 不支持从非本仓库 Release 下载升级资产。
3. 不支持普通用户触发升级。
4. 不支持在前端执行 shell 命令。
5. 不修改 `install.sh` 或 `panel_install.sh` 的本地安装菜单逻辑;发布流程仍可能覆盖这些脚本。
6. 不引入前端测试框架。
## 影响范围
### 后端
- `go-backend/internal/http/handler/handler.go`
- `go-backend/internal/http/handler/upgrade.go`
- 新增 `go-backend/internal/http/handler/system_upgrade.go`
- 新增 `go-backend/internal/http/handler/system_upgrade_test.go`
- `go-backend/Dockerfile`
### 部署模板
- `docker-compose-v4.yml`
- `docker-compose-v6.yml`
### 前端
- `vite-frontend/src/api/index.ts`
- `vite-frontend/src/api/types.ts`
- `vite-frontend/src/pages/config.tsx`
## 运行前提
升级能力仅在 Docker Compose 部署中可用,并要求后端容器具备以下条件:
1. 容器内存在 Docker CLI,且支持 `docker compose version`。
2. `/var/run/docker.sock` 挂载到后端容器。
3. 宿主部署目录挂载到容器内固定路径,例如 `/opt/flvx-panel`。
4. 环境变量 `PANEL_DEPLOY_DIR=/opt/flvx-panel`。
5. 环境变量 `PANEL_BACKEND_CONTAINER=flux-panel-backend`,为空时默认使用 `flux-panel-backend`;值必须匹配容器名安全字符集 `[A-Za-z0-9_.-]+`。
6. 部署目录内存在 `.env` 和 `docker-compose.yml`。
如果任一条件不满足,检查接口返回 `capable=false` 和明确的 `reason`,升级按钮禁用。
## 后端设计
### API
新增系统升级接口,路径使用 `/api/v1/system/*`,继续受现有 middleware 管控,仅管理员可访问。
| 方法 | 路径 | 用途 |
|------|------|------|
| `POST` | `/api/v1/system/version` | 返回当前版本、升级通道、能力状态和可选最新版本 |
| `POST` | `/api/v1/system/check-updates` | 强制查询 GitHub Release,返回最新版本和候选列表 |
| `POST` | `/api/v1/system/upgrade` | 执行升级 |
请求体:
```json
{
"channel": "stable",
"version": ""
}
```
`channel` 使用现有节点升级的通道语义:`stable` 匹配纯数字版本,`dev` 匹配 `alpha` / `beta` / `rc`。`version` 为空时自动选择该通道最新 Release。
`/api/v1/system/version` 返回:
```json
{
"currentVersion": "2.1.9-beta14",
"channel": "stable",
"latestVersion": "2.1.9",
"hasUpdate": true,
"capable": true,
"reason": "",
"deployDir": "/opt/flvx-panel",
"composeFile": "/opt/flvx-panel/docker-compose.yml",
"backendContainer": "flux-panel-backend"
}
```
`/api/v1/system/upgrade` 成功返回:
```json
{
"version": "2.1.9",
"message": "升级 helper 已启动,面板服务将短暂重启",
"commands": [
"docker run -d --rm --volumes-from flux-panel-backend ...",
"docker compose pull backend frontend",
"docker compose up -d backend frontend"
]
}
```
返回的 `commands` 只用于 UI 展示固定步骤,不包含用户输入或 shell 拼接结果。
### 版本来源
当前版本优先从容器环境变量读取:
1. `FLUX_VERSION`
2. `VITE_APP_VERSION` 不在后端容器中可靠存在,不作为后端版本来源。
3. 为空时返回 `dev`。
发布流程已经在 `panel_install.sh` 写入 `.env` 的 `FLUX_VERSION`,Compose 模板需要把该变量传给 backend 容器,保证后端可感知当前版本。
### Release 查询
复用现有 `fetchGitHubReleases`、`resolveLatestReleaseByChannel`、`normalizeReleaseChannel`、`releaseChannelFromTag`、`releaseChannelLabel` 和 GitHub 加速配置能力。新增函数只负责筛选系统升级所需资产:
- `docker-compose-v4.yml`
- `docker-compose-v6.yml`
是否下载 v4/v6 compose 文件通过当前部署目录中的 `docker-compose.yml` 判断:如果网络定义包含 `enable_ipv6: true`,选择 `docker-compose-v6.yml`;否则选择 `docker-compose-v4.yml`。
### 升级执行器
新增 `systemUpgradeExecutor`,职责明确分为可测试的小函数:
1. `checkSystemUpgradeCapability()` 检查 Docker CLI、Docker socket、部署目录、`.env`、`docker-compose.yml`。
2. `selectComposeAsset(currentCompose []byte) string` 选择 v4/v6 compose 资产。
3. `updateEnvVersion(path, version string) error` 原子更新 `.env` 中的 `FLUX_VERSION`。
4. `downloadCompose(version, assetName, dest string) error` 下载新版 compose 模板到临时文件。
5. `currentBackendImage(containerName string) (string, error)` 获取当前 backend 容器镜像 ID。
6. `startSystemUpgradeHelper(version string) error` 启动独立 helper 容器执行固定升级流程。
升级流程:
1. 获取全局升级锁,拒绝并发升级。
2. 校验目标版本存在且不是 draft。
3. 检查升级能力。
4. 备份 `.env` 为 `.env.upgrade.bak`,备份 `docker-compose.yml` 为 `docker-compose.yml.upgrade.bak`。
5. 下载目标版本的 compose 文件到部署目录临时文件。
6. 原子替换 `docker-compose.yml`。
7. 原子更新 `.env` 的 `FLUX_VERSION`。
8. 通过 Docker socket 查询当前 backend 容器的镜像 ID。
9. 使用当前 backend 镜像启动一个不属于 Compose 项目的临时 helper 容器。
10. helper 通过 `--volumes-from flux-panel-backend` 继承部署目录挂载,并显式挂载 `/var/run/docker.sock`。
11. helper 在 `PANEL_DEPLOY_DIR` 下执行 `docker compose pull backend frontend`。
12. helper 等待 5 秒,让 SQLite WAL 等文件刷盘。
13. helper 执行 `docker compose up -d backend frontend`,由 Compose 重建前端和后端。
14. 后端接口在 helper 成功启动后立即返回;浏览器随后会经历短暂断线。
PostgreSQL 模式不主动 pull 或重建 `postgres` 服务,避免无关数据库变动。新版 compose 文件仍保留 postgres 配置供后续手动迁移或重建使用。
### 命令安全
后端不暴露通用命令执行能力。后端只直接执行 Docker CLI 的固定参数,用于获取当前镜像和启动 helper:
```go
exec.CommandContext(ctx, "docker", "inspect", "-f", "{{.Image}}", backendContainer)
exec.CommandContext(ctx, "docker", "run", "-d", "--rm", "--name", helperName,
"--volumes-from", backendContainer,
"-v", "/var/run/docker.sock:/var/run/docker.sock",
"-e", "PANEL_DEPLOY_DIR=/opt/flvx-panel",
"--entrypoint", "/bin/sh", imageID,
"-c", helperScript)
```
`helperScript` 由后端固定生成,不拼接用户输入:
```sh
cd "$PANEL_DEPLOY_DIR" && docker compose pull backend frontend && sleep 5 && docker compose up -d backend frontend
```
工作目录固定为 `PANEL_DEPLOY_DIR`。`PANEL_DEPLOY_DIR` 必须是绝对路径,且必须包含 `.env` 和 `docker-compose.yml`。接口输入只允许影响 `channel` 和已验证的 Release `version`。
### 超时和错误处理
1. Release 查询超时沿用现有 GitHub API 客户端超时。
2. 下载 compose 文件使用 60 秒超时。
3. 启动 helper 使用 30 秒超时,helper 内部命令不受原 HTTP 请求生命周期影响。
4. 任一步失败时返回错误信息,并尽量保留 `.upgrade.bak` 供人工恢复。
5. 如果 `.env` 更新后后续步骤失败,不自动回滚镜像或容器,避免误判导致更大破坏;错误信息提示备份文件位置。
## 部署模板设计
`docker-compose-v4.yml` 和 `docker-compose-v6.yml` 的 backend 服务增加:
```yaml
environment:
FLUX_VERSION: ${FLUX_VERSION:-dev}
PANEL_DEPLOY_DIR: /opt/flvx-panel
PANEL_BACKEND_CONTAINER: flux-panel-backend
volumes:
- sqlite_data:/app/data
- /var/run/docker.sock:/var/run/docker.sock
- ./:/opt/flvx-panel
```
`go-backend/Dockerfile` 的 runtime 镜像通过多阶段构建从官方 `docker:27-cli` 镜像复制 Docker CLI 和 compose 插件到 Debian runtime 镜像,避免依赖 Debian apt 源中的 Docker 包可用性:
```dockerfile
FROM docker:27-cli AS dockercli
FROM debian:bookworm-slim
COPY --from=dockercli /usr/local/bin/docker /usr/local/bin/docker
COPY --from=dockercli /usr/local/libexec/docker/cli-plugins/docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose
```
实现时保留现有 Go builder 和 `/app/paneld` 入口,仅增加 Docker CLI stage 和复制步骤。
helper 容器使用当前 backend 容器的镜像 ID 启动,而不是额外依赖 `docker:cli` 镜像。这样不引入新的镜像仓库依赖,并保证 helper 内可用的 Docker CLI 与当前后端一致。
## 前端设计
在 `vite-frontend/src/pages/config.tsx` 的基本设置或数据库占用附近增加“面板升级”卡片,避免隐藏在节点页导致误解为“节点升级”。
展示内容:
1. 当前版本。
2. 最新版本。
3. 更新通道选择,复用现有 `stable` / `dev` 语义和 `UpdateReleaseChannel` 本地存储。
4. 升级能力状态:可用、不可用原因、Docker socket 高权限提示。
5. 操作按钮:检查更新、立即升级。
交互:
1. 页面加载时调用 `/system/version`。
2. 点击“检查更新”调用 `/system/check-updates`。
3. 点击“立即升级”前弹出确认框,明确提示服务会短暂中断,并提示 Docker socket 具备宿主高权限。
4. 升级请求只等待 helper 启动,超时设置为 60 秒。
5. 成功后 toast 显示“升级已触发,面板将在数十秒内重启”,并可提示用户稍后刷新。
## 安全边界
Docker socket 挂载等同于给后端容器宿主机级别控制能力。这是本设计的主要风险。缓解措施:
1. 仅 `/api/v1/system/*` 管理员接口可触发。
2. 不提供任意命令执行接口。
3. 不允许用户传入下载 URL。
4. 不允许用户传入 compose 路径。
5. 只升级本仓库 GitHub Release,且跳过 draft。
6. 前端明确展示 Docker socket 权限提示。
## 测试策略
### Go 单测
新增 `system_upgrade_test.go` 覆盖:
1. `selectComposeAsset` 对 v4/v6 compose 内容的判断。
2. `.env` 中已有 `FLUX_VERSION` 时更新值。
3. `.env` 中缺少 `FLUX_VERSION` 时追加值。
4. 缺少部署目录、`.env`、`docker-compose.yml`、Docker socket 时返回不可用原因。
5. helper 命令构造固定命令序列,不拼接用户输入。
6. 并发升级锁会拒绝第二个升级请求。
### 手动/集成验证
1. `go-backend`: `go test ./...`
2. `vite-frontend`: `pnpm run build`
3. 本地容器验证:启动 Compose 后检查设置页升级卡片可显示能力状态。
4. 在无 Docker socket 的开发环境验证按钮禁用并显示原因。
## 回滚与恢复
自动升级失败时不做自动容器回滚。后端会保留:
1. `.env.upgrade.bak`
2. `docker-compose.yml.upgrade.bak`
人工恢复步骤由错误信息提示:进入部署目录,按需恢复备份文件,再执行 `docker compose up -d backend frontend`。
## 决策记录
本设计已确定采用 Docker socket 整套升级方案,不再保留二进制替换作为本次实现路径。Docker socket 的权限风险通过管理员限制、命令白名单和前端提示控制。
@@ -0,0 +1,377 @@
# FLVX 安全问题修复设计
**日期**: 2026-05-13
**状态**: 待审核
**作者**: AI Assistant
## 概述
针对 PR #502 提到的安全问题,对 FLVX 后端认证、配置访问控制、配置写入保护、备份导出和 JWT 失效模型做一次集中修复。目标是优先消除高风险漏洞,同时保留当前必须兼容的登录页品牌配置读取和验证码兼容行为。
本设计采用“高危项一次收口,结构性问题只分析不重构”的策略:本轮修复 MD5 密码存储、未受控配置读取、敏感配置写入、备份配置泄露和 JWT 长期有效且改密后不失效的问题;不修改“无 Cloudflare secret 时允许当前 captcha 兼容行为”,也不重构 `autoMigrateAll()` 与 `migrateSchema()` 的双迁移入口。
## 背景
当前主线存在以下已确认问题:
1. `login`、`open_api/sub_store`、用户改密、管理员创建用户和管理员修改用户密码仍然使用 `security.MD5(...)`。
2. `/api/v1/config/get` 在 middleware 的 `shouldSkip()` 中被匿名放行,导致任意调用方可以读取绝大多数配置。
3. `updateConfigs()` 与 `updateSingleConfig()` 使用了两套不同的限制逻辑,敏感配置键在单项写接口中未被保护。
4. `ExportAll()` 和 `ExportPartial(types=["configs"])` 会直接导出所有配置,包含 `jwt_secret`、`license_key`、`cloudflare_secret_key`。
5. JWT 当前有效期为 90 天,且 token 在用户改密、禁用、角色变化后仍可继续使用到过期。
同时存在两个重要约束:
1. 登录页和未登录态品牌展示依赖匿名读取 `app_name`、`app_logo`、`app_favicon`、`app_bg_image` 和 `cloudflare_site_key`。
2. `tests/contract/migration_contract_test.go` 已把“无 Cloudflare secret 时允许当前 captcha 兼容行为”定义为既有契约,本轮不改变。
## 目标
1. 新增和更新后的用户密码不再以 MD5 存储。
2. 历史 MD5 用户可在首次成功认证时自动迁移到强哈希。
3. 匿名请求不能再读取任意配置,只能读取明确的公开配置白名单。
4. 通用配置写接口不能覆盖敏感配置键。
5. 备份导出默认不泄露敏感配置明文。
6. 用户改密、禁用或角色变化后,旧 JWT 应立即失效。
7. 不破坏现有登录页品牌展示和 captcha 兼容行为。
## 非目标
1. 不重构 `open_api/sub_store` 的整体认证模型;该接口仍使用现有用户名和密码查询参数语义。
2. 不实现完整 refresh token、session 管理后台或 token 黑名单体系。
3. 不改变“无 Cloudflare secret 时允许当前 captcha 兼容行为”。
4. 不在本轮重构 `autoMigrateAll()` 与 `migrateSchema()` 的启动流程。
5. 不引入前端测试框架。
## 影响范围
### 后端
- `go-backend/internal/security/`
- `go-backend/internal/auth/jwt.go`
- `go-backend/internal/http/middleware/auth.go`
- `go-backend/internal/http/handler/handler.go`
- `go-backend/internal/http/handler/mutations.go`
- `go-backend/internal/store/model/model.go`
- `go-backend/internal/store/repo/repository.go`
- `go-backend/internal/store/repo/repository_mutations.go`
- `go-backend/tests/contract/`
- `go-backend/internal/store/repo/*_test.go`
### 前端
- `vite-frontend/src/api/index.ts`
- `vite-frontend/src/config/site.ts`
- `vite-frontend/src/pages/index.tsx`
- 任何在未登录态读取品牌配置的组件
## 设计决策
### 已确认决策
1. 本轮采用安全优先策略,允许收紧危险默认行为。
2. MD5 密码采用“登录成功时自动迁移”的兼容方案。
3. captcha 在未配置 Cloudflare secret 时的兼容行为保持不变。
4. JWT 采用“最小可撤销”方案,而不是完整 session 体系。
5. 双重迁移系统只分析,不在本轮中修改。
### 迁移系统分析结论
`autoMigrateAll()` 与 `migrateSchema()` 当前职责并不相同:
1. `autoMigrateAll()` 负责表和列结构补齐。
2. `migrateSchema()` 负责基于 `schema_version` 的数据修正,以及 PostgreSQL ID 默认值修复等兼容迁移。
3. 现有 `repository_migrate_test.go` 已明确覆盖这两部分逻辑,说明它们在现有代码库中是被依赖的互补结构,而不是已确认的重复安全漏洞。
因此本轮仅记录该分析结论,不把双迁移入口纳入改动范围,避免把安全修复扩展为启动流程重构。
## 详细设计
### 1. 密码存储与认证迁移
在 `internal/security/` 中新增统一密码能力,替代各处直接使用 `security.MD5(...)` 的做法。
建议新增以下接口:
```go
func HashPassword(plain string) (string, error)
func VerifyPassword(storedHash, plain string) (ok bool, legacy bool)
func IsLegacyPasswordHash(storedHash string) bool
```
哈希算法使用 `bcrypt`:
1. `user.pwd` 当前为 `varchar(100)`,足以容纳 bcrypt 哈希。
2. 不需要修改密码列长度,改动最小。
3. 对当前 Go 后端来说,bcrypt 是最稳妥的强哈希升级路径。
所有密码入口统一改为走这套能力:
1. `login`
2. `openAPISubStore`
3. `updatePassword`
4. `userCreate`
5. `userUpdate` 中的管理员改密路径
认证迁移规则:
1. 如果数据库中存的是 bcrypt,则按 bcrypt 校验。
2. 如果数据库中存的是历史 MD5,则先按旧逻辑校验。
3. 历史 MD5 校验成功后,立即把 `pwd` 改写为 bcrypt。
4. 自动迁移不仅在网页登录时执行,也在 `open_api/sub_store` 成功鉴权时执行,避免只使用订阅接口的老用户永远停留在 MD5。
默认管理员种子账号仍保留当前默认密码语义和 `requirePasswordChange` 行为,但种子哈希改为 bcrypt,不再在新建数据库中写入 MD5 值。
### 2. JWT 最小可撤销方案
本轮不引入 refresh token 和黑名单表,而是做一个可以立即生效的最小撤销闭环。
#### 数据模型
在 `user` 表新增字段:
- `password_changed_at BIGINT NOT NULL DEFAULT 0`
该字段专门表示密码最后一次变更时间,不能复用现有 `updated_time`,原因是 `updated_time` 还会被流量、状态或其他用户资料更新触发,复用后会让非密码更新错误地使 token 失效。
#### token 签发与校验
继续使用现有 `iat` 声明,但把有效期从 90 天收紧到 7 天。
token 校验分两步:
1. 先做现有签名和过期时间校验。
2. 再读取用户最小认证状态,确认:
- 用户仍存在
- 用户状态未被禁用
- 当前 `role_id` 与 token 中一致
- `claims.iat` 不早于 `password_changed_at`
为避免 middleware 每次都查询完整用户对象,Repository 新增专用读取方法,只返回 token 校验需要的最小字段,例如:
```go
type UserAuthState struct {
ID int64
RoleID int
Status int
PasswordChangedAt int64
}
func (r *Repository) GetUserAuthState(userID int64) (*UserAuthState, error)
```
#### 失效语义
以下场景下,旧 token 应立即失效:
1. 用户修改密码
2. 管理员修改用户密码
3. 用户被禁用
4. 用户角色发生变化
这会带来一次明确的兼容收紧:升级完成后,部分历史 token 可能因为寿命策略或认证状态变化而失效,这是安全优先下的可接受行为。
### 3. 配置读取访问控制
为了避免继续让 `/api/v1/config/get` 承担“有时匿名、有时鉴权”的混合语义,本设计将公开配置读取拆成单独的 public 端点。
#### 端点设计
保留现有受保护端点:
- `POST /api/v1/config/get`
新增公开端点:
- `POST /api/v1/public/config/get`
middleware 仅对白名单 public 端点放行,不再放行 `/api/v1/config/get`。
#### 公开白名单
匿名仅允许读取以下配置:
1. `app_name`
2. `app_logo`
3. `app_favicon`
4. `app_bg_image`
5. `cloudflare_site_key`
理由:
1. 登录页与未登录态品牌渲染依赖前四项。
2. 登录页在 captcha 开启时需要读取 `cloudflare_site_key`。
3. 其他配置不应暴露给匿名方。
前端调整规则:
1. 登录页和 `site.ts` 中的未登录态品牌配置读取改走 `/public/config/get`。
2. 登录后页面仍使用现有 `/config/get` 或 `/config/list`。
3. 已登录页面中的配置读取逻辑不变,只是恢复为真正受 JWT 保护。
### 4. 配置写保护统一
当前 `updateConfigs()` 与 `updateSingleConfig()` 各自维护不同限制逻辑,是本次越权写入漏洞的根源。本轮把配置访问规则统一收口为一套辅助函数。
建议新增配置策略定义:
```go
type ConfigAccessPolicy struct {
PublicReadable bool
Sensitive bool
CommercialOnly bool
}
```
由统一函数返回某个 key 的策略,再由:
1. `public config get`
2. `config get`
3. `config list`
4. `updateConfigs()`
5. `updateSingleConfig()`
共同复用。
敏感配置键至少包含:
1. `jwt_secret`
2. `license_key`
3. `cloudflare_secret_key`
这些键的写入规则:
1. 不允许通过通用配置写接口改写。
2. 不允许通过公开读取接口读取。
3. 非管理员在配置列表接口中也不能获得。
商业版白名单键继续沿用现有语义,例如:
1. `app_name`
2. `app_logo`
3. `app_favicon`
4. `hide_footer_brand`
但其判断逻辑同样统一走同一套策略函数,避免再次出现单接口漏判。
### 5. 备份导出与导入脱敏
备份系统改为“默认安全导出”,而不是“完整明文镜像”。
#### 导出
`ExportAll()` 和 `ExportPartial(types=["configs"])` 在写入 `backup.Configs` 前都先经过统一过滤函数,移除敏感配置键。
敏感配置键与配置写保护列表保持一致:
1. `jwt_secret`
2. `license_key`
3. `cloudflare_secret_key`
#### 导入
导入配置时,即使旧备份中带有上述敏感键,也会在导入前被丢弃,不允许通过备份恢复路径覆盖在线安全配置。
该设计的取舍如下:
1. 保留大部分业务配置、节点、转发、用户数据的恢复能力。
2. 不再把备份文件当作核心密钥分发载体。
3. `UserBackup.Pwd` 仍然保留,以维持用户恢复语义;在本轮密码升级后,这些值将是 bcrypt 哈希,而不是 MD5。
### 6. captcha 兼容行为
`captcha_enabled`、`cloudflare_site_key`、`cloudflare_secret_key` 的现有兼容行为保持不变。
明确保持以下现状:
1. 当未完整配置 Cloudflare key 时,当前 contract test 约定的兼容路径继续存在。
2. 本轮不把 captcha 兼容逻辑从“兼容旧行为”切换为“严格校验”。
这样可以避免把一轮安全修复扩展成登录流程行为变更,同时与用户已确认的范围保持一致。
## 错误处理与兼容行为
### 错误处理
保持现有 API envelope:`{code, msg, data, ts}`。
建议的接口行为:
1. `POST /api/v1/public/config/get` 请求非公开 key 时返回 `403`。
2. 受保护配置端点未登录时返回 `401`。
3. 登录、订阅接口、改密接口继续返回通用认证失败,不暴露“用户名存在但密码错误”等细节。
4. token 因签名错误、过期、改密、禁用或角色变化失效时,统一返回现有 `401` 语义。
5. 备份导入中出现敏感配置键时,接口整体仍允许成功导入其他数据,敏感键静默忽略。
### 保留兼容
1. 登录页和未登录态品牌展示继续可用。
2. 未配置 Cloudflare secret 时的 captcha 兼容逻辑继续保留。
3. 历史 MD5 用户仍可继续认证,并在成功后自动迁移。
### 刻意收紧
1. 匿名方不再可读取任意配置。
2. 通用配置写接口不再能写入敏感键。
3. 备份不再导出敏感配置明文。
4. 改密、禁用和角色变化会立即使旧 token 失效。
## 测试设计
本轮以 Go 单测和 contract test 为主,覆盖以下场景。
### 密码迁移
1. 历史 MD5 用户在网页登录成功后,数据库中的 `pwd` 被升级为 bcrypt。
2. 历史 MD5 用户在 `open_api/sub_store` 成功鉴权后,同样触发迁移。
3. 新建用户后落库的是 bcrypt,而不是 MD5。
4. 管理员修改用户密码和用户自助改密后,落库的是 bcrypt。
### JWT 最小可撤销
1. 正常 token 仍可访问受保护接口。
2. 改密后旧 token 失效。
3. 用户被禁用后旧 token 失效。
4. 用户角色变化后旧 token 失效。
5. 过期 token 失效。
### 配置访问控制
1. 匿名访问公开配置成功。
2. 匿名访问非公开配置失败。
3. 已登录页面需要的普通配置读取仍然可用。
4. `updateSingleConfig()` 无法修改敏感键。
5. `updateConfigs()` 同样无法修改敏感键。
### 备份脱敏
1. `ExportAll()` 不包含敏感配置。
2. `ExportPartial(types=["configs"])` 不包含敏感配置。
3. 导入带敏感键的备份时,这些键不会被写回数据库。
4. 非敏感配置和其他业务数据仍可正常导入导出。
### 迁移系统回归
1. 现有 `repository_migrate_test.go` 保持通过。
2. 本轮不对 `autoMigrateAll()` 与 `migrateSchema()` 的职责边界做行为性改动。
## 验收标准
1. 数据库中不再新增 MD5 密码。
2. 历史 MD5 用户可在首次成功认证后自动升级到 bcrypt。
3. 匿名调用方不能再读取非公开配置。
4. `updateSingleConfig()` 和 `updateConfigs()` 都无法改写敏感配置键。
5. 备份导出默认不包含 `jwt_secret`、`license_key`、`cloudflare_secret_key`。
6. 改密、禁用和角色变化后,旧 JWT 立即失效。
7. 登录页品牌展示和 captcha 兼容行为不被破坏。
8. 现有迁移测试和本轮新增安全测试全部通过。
## PR #502 处置
PR #502 的价值在于指出了真实问题,但其实现方式只是把讽刺性注释写进生产代码,并未修复漏洞。因此该 PR 不应合并。
执行阶段的处置方式:
1. 关闭 PR #502。
2. 在关闭说明中指出:问题成立,但修复将通过正式代码与测试提交完成,而不是通过向源文件加入讽刺性注释。
3. 后续在新提交中按本设计逐项修复。
@@ -0,0 +1,231 @@
# FLVX Dependabot 告警修复设计
**日期**: 2026-05-14
**状态**: 待审核
**范围**: 仅处理 GitHub Dependabot 依赖告警
## 概述
本设计针对 `Sagit-chu/flvx` 当前 open Dependabot alerts 制定依赖修复方案。目标是在不混入业务安全逻辑改造的前提下,消除或最大限度降低依赖漏洞告警,并通过各模块现有构建和测试命令验证兼容性。
当前告警共 21 条:
- Critical: 1
- High: 6
- Medium: 13
- Low: 1
按生态划分:
- Go: 14
- npm: 7
Go 告警中有一部分因为 `go-gost/go.mod` 和 `go-gost/x/go.mod` 同时被扫描而重复出现;实际修复应按依赖和模块关系聚合处理,而不是按 alert 数逐条机械修改。
## 目标
1. 修复 `go-backend` 中 `github.com/jackc/pgx/v5` 的 critical 和 low 告警。
2. 修复 `vite-frontend` 中 npm 直接依赖、开发依赖和 lockfile 传递依赖告警。
3. 修复 `go-gost` 与 `go-gost/x` 中可升级到 patched version 的 Go 依赖告警。
4. 对 Dependabot 未给出 patched version 的依赖进行单独确认,避免盲目大版本升级。
5. 保持改动最小化,便于回滚和定位 CI 失败。
## 非目标
1. 不处理既有认证、配置读取、备份导出、JWT 失效等业务安全逻辑问题。
2. 不合并或修改 `2026-05-13-security-remediation` 相关设计和计划。
3. 不进行 `go get -u ./...` 或 `pnpm update` 级别的大范围依赖升级。
4. 不引入前端测试框架。
5. 不编辑 `install.sh`、`panel_install.sh` 或 generated `.pb.go` 文件。
## 影响范围
### Backend
- `go-backend/go.mod`
- `go-backend/go.sum`
### Frontend
- `vite-frontend/package.json`
- `vite-frontend/pnpm-lock.yaml`
### Agent
- `go-gost/go.mod`
- `go-gost/go.sum`
- `go-gost/x/go.mod`
- `go-gost/x/go.sum`
`go-gost/go.mod` 使用:
```go
replace github.com/go-gost/x => ./x
```
因此 `go-gost/x` 的依赖修复应先完成,再验证 `go-gost` 主模块。
## 修复策略
采用“分模块、最小安全升级”策略。
### 1. go-backend
Dependabot alerts:
- `github.com/jackc/pgx/v5 < 5.9.0`
- severity: critical
- summary: memory-safety vulnerability
- `github.com/jackc/pgx/v5 < 5.9.2`
- severity: low
- summary: SQL injection via placeholder confusion with dollar quoted string literals
当前版本:
- `github.com/jackc/pgx/v5 v5.7.3`
目标版本:
- `github.com/jackc/pgx/v5 v5.9.2`
设计说明:
- 直接升到 `v5.9.2`,同时覆盖 `v5.9.0` 和 `v5.9.2` 的修复要求。
- 不调整 GORM PostgreSQL driver,除非 `go mod tidy` 或测试显示必须联动升级。
- 验证以 backend 全量测试为准。
验证命令:
```bash
(cd go-backend && go test ./...)
```
### 2. vite-frontend
Dependabot alerts:
- `postcss < 8.5.10`
- appears in `vite-frontend/package.json`
- appears in `vite-frontend/pnpm-lock.yaml`
- `serialize-javascript <= 7.0.2` and `< 7.0.5`
- lockfile includes `serialize-javascript@6.0.2`
- package override currently pins `serialize-javascript` to `7.0.3`
- `fast-uri <= 3.1.1`
- lockfile currently includes `fast-uri@3.1.0`
- `@babel/plugin-transform-modules-systemjs <= 7.29.3`
- lockfile currently includes `7.29.0`
目标版本:
- `postcss >= 8.5.10`
- `serialize-javascript >= 7.0.5`
- `fast-uri >= 3.1.2`
- `@babel/plugin-transform-modules-systemjs >= 7.29.4`
设计说明:
- 对直接声明的 `postcss` 更新 `package.json`。
- 将 `overrides.serialize-javascript` 从 `7.0.3` 更新到 `7.0.5`。
- 对只出现在 lockfile 的传递依赖,优先通过 `pnpm install` 重新解析 lockfile,让上游范围自然选择 patched version。
- 如果 lockfile 仍保留 vulnerable 版本,再添加精确 `pnpm.overrides` 或现有 `overrides` 条目,避免无关依赖大升级。
- 不引入前端测试框架,验证使用现有 build。
验证命令:
```bash
(cd vite-frontend && pnpm run build)
```
可选补充检查:
```bash
(cd vite-frontend && pnpm why postcss serialize-javascript fast-uri @babel/plugin-transform-modules-systemjs)
```
### 3. go-gost/x
Dependabot alerts:
- `github.com/sirupsen/logrus < 1.8.3`
- severity: high
- current: `v1.8.1`
- target: at least `v1.8.3`
- `github.com/quic-go/quic-go < 0.57.0`
- severity: medium
- current: `v0.49.1`
- target: `v0.57.0`
- `github.com/quic-go/webtransport-go <= 0.9.0`
- severity: medium
- current: `v0.8.1-0.20241018022711-4ac2c9250e66`
- target: `v0.10.0`
- `github.com/pion/dtls/v2 <= 2.2.12`
- severity: medium
- current: `v2.2.6`
- target: no patched version provided by Dependabot
设计说明:
- 先处理 `go-gost/x`,因为它是 `go-gost` 通过 `replace` 使用的本地模块。
- 将 `logrus`、`quic-go` 和 `webtransport-go` 升级到 Dependabot 标出的 patched version。
- 单独处理 `pion/dtls/v2`,因为 Dependabot 没有提供 `first_patched_version`。
- 对 `pion/dtls/v2`,先查询可用 module versions 和 advisory 详情。如果存在 patched `v2` release,使用最小安全修复版本;如果不存在 patched version,则记录残留告警,避免在没有兼容性评估的情况下强行做高风险大版本迁移。
- 升级完成后,在 `go-gost/x` 中运行 `go mod tidy` 并编译/测试该模块。
验证命令:
```bash
(cd go-gost/x && go test ./...)
```
### 4. go-gost
Dependabot reports the same vulnerable Go dependencies in `go-gost/go.mod`.
设计说明:
- `go-gost/x` 修复后,再更新 `go-gost` 的 module requirements,使主模块也解析到 patched versions。
- 保留 `replace github.com/go-gost/x => ./x`。
- 使用针对具体漏洞依赖的 `go get` 命令,不使用宽泛的 `go get -u`。
- 定向升级后运行 `go mod tidy`。
验证命令:
```bash
(cd go-gost && go test ./...)
(cd go-gost && go build .)
```
## 处理顺序
1. 修复 `go-backend` 的 `pgx/v5`。
2. 修复 `vite-frontend` 的 npm dependencies 和 lockfile。
3. 修复 `go-gost/x` 的 Go dependencies。
4. 同步并验证 `go-gost`。
5. 再次查询 Dependabot alerts,确认 alert 数量下降,或记录有意保留的未解决告警。
这个顺序可以降低耦合:backend 和 frontend 能独立验证,而 `go-gost/x` 因本地 module replacement 必须先于 `go-gost` 处理。
## 错误处理与回退
如果定向依赖升级无法解析:
1. 使用 `go mod why`、`go mod graph` 或 `pnpm why` 检查依赖链。
2. 优先添加最小显式 requirement 或 override,以强制解析到 patched version。
3. 除非定向解析不可行,否则避免宽泛升级。
4. 如果 patched version 不可用,记录准确 advisory、受影响依赖、当前暴露面,以及保留 open 状态的原因。
如果验证失败:
1. 将失败范围限制在当前升级的模块内。
2. 先分析编译错误或测试失败,再决定是否调整版本。
3. 优先选择能通过测试和构建的最低 patched version。
4. 不通过删除测试或修改无关应用代码来掩盖失败。
## 成功标准
1. `pgx/v5` 升级后,`go-backend` 测试通过。
2. npm dependency 和 lockfile 更新后,frontend production build 通过。
3. 定向升级后,`go-gost/x` 测试通过。
4. 同步 module requirements 后,`go-gost` 测试和构建通过。
5. 最终 Dependabot API 查询显示所有可修复告警已关闭或数量明确下降。
6. 任何剩余告警都有明确记录;尤其是 `github.com/pion/dtls/v2` 如果不存在 patched version,需要记录原因和下一步动作。
@@ -0,0 +1,407 @@
# nftables 纯转发设计
**日期**: 2026-05-30
**状态**: 待审核
**作者**: Codex
## 概述
为 FLVX 增加一种不依赖 agent 的纯转发能力:节点可选择 `nftables` 转发模式,面板通过 SSH 在节点机器上下发和维护 nftables 规则。
第一阶段只支持端口级 DNAT/SNAT 纯转发。它不是 GOST 隧道能力的替代品,也不支持链路、限速、流量统计、连接数限制、Proxy Protocol、best exit 或 agent 诊断。目标是提供一个可靠、可回滚、可重建的轻量转发路径。
## 背景
当前 FLVX 的转发模型由三部分组成:
- `node` 表描述节点,现有本地节点通过 agent WebSocket 接收运行时命令。
- `tunnel` 表描述入口、出口和链路类型,`type=1` 表示端口转发,`type=2` 表示隧道转发。
- `forward` 表描述用户规则、入口端口和目标地址,运行时通过 GOST service 下发到入口节点。
nftables 模式的核心差异是没有 agent,因此不能复用现有 WebSocket command 通道,也不能依赖 agent 上报在线状态、流量和诊断结果。面板必须成为唯一控制面,通过 SSH 把数据库中的期望状态同步到远端 nftables。
## 用户决策
- 创建或编辑节点时选择转发模式。
- 选择 nftables 转发后,不需要安装 agent。
- nftables 转发不支持隧道、流量控制等能力,只支持纯转发。
- 规则由面板端维护,并通过 SSH 下放到节点。
## 推荐方案
新增节点运行时模式:
| 模式 | 含义 |
|------|------|
| `agent` | 默认模式,保持现有 GOST agent 行为 |
| `nftables` | 面板通过 SSH 管理 nftables 规则 |
业务层继续复用现有 `tunnel` 和 `forward` 概念,但对 nftables 模式加严格能力边界:
- nftables 节点只能创建端口转发隧道。
- nftables 隧道不能配置出口节点或转发链。
- 同一个隧道的入口节点必须全部是同一种运行时模式。
- nftables 转发规则创建、更新、删除时,由后端同步 SSH 规则。
- 面板提供节点级“测试 SSH”“重建规则”“清理 FLVX 规则”操作。
## 非目标
- 不支持 `tunnel.type=2` 隧道转发。
- 不支持多跳链路、远程面板共享节点和 federation runtime。
- 不支持 GOST service 能力:限速、每 IP 限速、最大连接数、Proxy Protocol、策略负载均衡。
- 不支持 agent 流量统计、实时系统指标、节点升级、回退、agent 安装命令。
- 不在第一阶段支持 HA 漂移、自动探活切换或复杂负载均衡。
- 不改写用户机器上的非 FLVX nftables 规则。
## 数据模型
### node 表
新增字段:
| 字段 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `forward_mode` | string | `agent` | `agent` 或 `nftables` |
Go 模型使用 SQLite/PostgreSQL 兼容 tag:
```go
ForwardMode string `gorm:"column:forward_mode;type:varchar(20);not null;default:'agent'"`
```
### node_ssh_config 表
新增表保存 nftables 节点 SSH 配置。SSH 凭据不放进 `node` 主表,避免普通节点列表过度暴露敏感字段。
| 字段 | 说明 |
|------|------|
| `id` | 主键 |
| `node_id` | 关联节点,唯一 |
| `host` | SSH 主机,默认可使用 node.server_ip |
| `port` | SSH 端口,默认 22 |
| `username` | SSH 用户 |
| `auth_type` | `password` 或 `private_key` |
| `password` | 加密后密码,可为空 |
| `private_key` | 加密后私钥,可为空 |
| `passphrase` | 加密后私钥口令,可为空 |
| `sudo_mode` | `none` / `sudo` |
| `created_time` | 创建时间 |
| `updated_time` | 更新时间 |
第一阶段可使用现有配置密钥派生或面板本地密钥做对称加密;如果项目尚无统一密钥管理,应至少避免在列表 API 返回完整凭据。
### nft_rule_binding 表
记录面板认为已经应用到节点的规则状态,用于更新、删除、重建和错误展示。
| 字段 | 说明 |
|------|------|
| `id` | 主键 |
| `forward_id` | 转发规则 ID |
| `node_id` | 下发节点 ID |
| `in_port` | 入口端口 |
| `protocols` | 第一阶段固定 `tcp,udp` |
| `target_addr` | 目标地址 |
| `bind_ip` | 可选监听 IP |
| `rule_hash` | 当前期望规则 hash |
| `status` | `pending` / `applied` / `error` |
| `last_error` | 最近错误 |
| `applied_time` | 最近成功应用时间 |
| `created_time` | 创建时间 |
| `updated_time` | 更新时间 |
绑定表不是最终事实来源。最终期望状态仍从 `forward`、`forward_port`、`tunnel` 和 `chain_tunnel` 推导,绑定表只记录应用结果。
## API 行为
### 节点创建和更新
`/node/create` 和 `/node/update` 新增入参:
```json
{
"forwardMode": "nftables",
"sshConfig": {
"host": "203.0.113.10",
"port": 22,
"username": "root",
"authType": "private_key",
"privateKey": "-----BEGIN OPENSSH PRIVATE KEY-----...",
"passphrase": "",
"sudoMode": "none"
}
}
```
规则:
- `forwardMode` 缺省时按 `agent`。
- `agent` 节点保留现有字段和行为。
- `nftables` 节点要求 SSH 配置完整。
- 从 `agent` 切到 `nftables` 前,若该节点已有 agent 隧道链路或转发规则,应拒绝并提示先迁移或删除。
- 从 `nftables` 切回 `agent` 前,若存在 nftables 规则,应拒绝并提示先清理或迁移。
### 隧道创建和更新
创建 nftables 隧道仍使用 `/tunnel/create`,但后端根据入口节点模式校验能力。
规则:
- 入口节点为 nftables 时,`type` 必须为 `1`。
- 不允许提交 `outNodeId` 或 `chainNodes`。
- 入口节点必须在线的现有校验不能直接套用到 nftables 节点;应改为 SSH 可用性校验或允许保存后手动测试。
- 同一隧道入口节点不能混用 `agent` 和 `nftables`。
- 更新隧道时不允许改变运行时模式;需要通过迁移规则到新隧道实现。
### 转发创建和更新
选择 nftables 隧道时,`/forward/create` 和 `/forward/update` 强制收窄字段:
- `speedId` 必须为空。
- `ipSpeedId` 必须为空。
- `maxConn` 和 `ipMaxConn` 必须为 0。
- `proxyProtocol` 必须为 0。
- 第一阶段 `remoteAddr` 只允许单目标 `host:port`。
- `strategy` 固定为 `fifo` 或忽略。
创建流程:
1. 校验权限、隧道状态、端口占用和 nftables 能力边界。
2. 在数据库创建 `forward` 和 `forward_port`。
3. 通过 nftables runtime 对关联入口节点执行同步。
4. 若同步失败,回滚数据库创建,返回 SSH/nftables 错误。
更新流程:
1. 保存旧 forward 和端口绑定。
2. 更新数据库。
3. 同步 nftables 规则。
4. 若同步失败,回滚数据库状态并尝试恢复旧规则。
删除流程:
1. 先删除远端 nftables 规则。
2. 成功后删除数据库。
3. 如果远端删除失败,普通删除返回错误;强制删除可删除数据库并保留 binding 错误记录,提示用户稍后清理。
## 后端组件
新增 package:
```text
go-backend/internal/runtime/nftables/
```
建议拆分:
| 组件 | 职责 |
|------|------|
| `Manager` | 对 handler 暴露 Apply/Delete/Reconcile/Test 方法 |
| `Planner` | 从数据库记录生成节点级期望规则 |
| `Renderer` | 把期望规则渲染为 nftables 脚本 |
| `SSHRunner` | 负责 SSH 连接、sudo 包装、命令执行和超时 |
| `Parser` | 解析目标地址、协议和错误信息 |
handler 不直接执行 SSH,也不拼 nft 脚本;handler 只做业务校验并调用 runtime manager。
## nftables 规则设计
FLVX 只维护自己的 table,避免触碰用户已有规则:
```nft
table inet flvx {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
}
chain forward {
type filter hook forward priority filter; policy accept;
}
}
```
每条 forward 生成 TCP 和 UDP 规则:
```nft
tcp dport 12345 dnat to 198.51.100.20:443 comment "flvx forward:42 tcp"
udp dport 12345 dnat to 198.51.100.20:443 comment "flvx forward:42 udp"
```
第一阶段默认生成 masquerade:
```nft
masquerade comment "flvx masquerade"
```
原因是大多数纯 DNAT 场景需要回程可达;如果不做 SNAT,目标服务回包可能绕过转发节点导致连接失败。后续可增加高级开关允许用户关闭 masquerade。
### 原子同步策略
推荐节点级 reconcile,而不是逐条追加:
1. 从数据库查询该节点所有 nftables forward。
2. 生成完整 `table inet flvx` 脚本。
3. 通过 SSH 执行 `nft -f <tempfile>`。
4. 成功后更新所有相关 `nft_rule_binding` 状态和 hash。
这样可以避免局部更新导致规则漂移,也能让“重建规则”与创建/更新走同一条路径。
## SSH 执行策略
基础要求:
- 默认超时 10-15 秒。
- 支持密码和私钥认证。
- 支持 `sudo nft ...`。
- 执行前检查 `command -v nft`。
- 执行前检查 `nft --version`,错误时提示安装 nftables。
- 所有临时脚本写入 `/tmp/flvx-nft-<nonce>.nft`,执行后删除。
建议命令流程:
```sh
cat > /tmp/flvx-nft-xxxx.nft <<'EOF'
table inet flvx {
...
}
EOF
nft list table inet flvx >/dev/null 2>&1 && nft delete table inet flvx || true
nft -f /tmp/flvx-nft-xxxx.nft
rm -f /tmp/flvx-nft-xxxx.nft
```
如果目标 nft 版本支持 `destroy table`,也可以把删除动作放进脚本:
```nft
destroy table inet flvx
table inet flvx {
...
}
```
实现时应按目标 nft 版本兼容性选择 `destroy` 或 shell 中先检测 `nft list table inet flvx`。
## 前端体验
### 节点页
节点表单新增“转发模式”:
- `Agent 节点`:默认,现有表单不变。
- `nftables 节点`:显示 SSH 配置区块,隐藏 agent 安装相关提示。
nftables 节点列表操作:
- 测试 SSH
- 重建规则
- 清理 FLVX nftables 规则
隐藏或禁用:
- 安装命令
- 升级
- 回退
- agent 协议开关
- 实时 agent 指标入口
### 隧道页
隧道类型文案建议改为更明确的运行时说明:
- `Agent 端口转发`
- `Agent 隧道转发`
- `nftables 纯转发`
如果保持现有 `端口转发 / 隧道转发` 选择器,则在选择 nftables 入口节点后禁用隧道转发,并提示“不支持出口节点和转发链”。
### 转发页
选择 nftables 隧道后:
- 隐藏限速、每 IP 限速、最大连接数、Proxy Protocol。
- 目标地址输入提示“第一阶段仅支持单目标 host:port”。
- 创建/更新失败时显示远端 SSH 或 nftables 错误。
## 错误处理
- SSH 连接失败:返回“SSH 连接失败”,保留底层错误摘要。
- 认证失败:返回“SSH 认证失败,请检查用户名和凭据”。
- `nft` 不存在:返回“节点未安装 nftables”。
- nft 脚本失败:返回 nft stderr 摘要,并记录到 `nft_rule_binding.last_error`。
- 下发超时:标记 binding 为 `error`,允许用户重试“重建规则”。
- 数据库成功但远端失败时,创建/更新路径应回滚数据库;批量重建路径不回滚业务规则,只记录错误。
## 安全边界
- SSH 凭据只在创建/更新时接收,列表 API 不返回明文。
- 私钥和密码在数据库中加密保存。
- 后端日志不得打印完整私钥、密码或 passphrase。
- nft 脚本只由后端 renderer 生成,禁止直接拼接用户提交的自由文本。
- `remoteAddr` 必须严格解析为 host/IP + port,端口必须为 1-65535。
- `inPort` 仍复用现有端口占用校验。
- comment 中只放 forward ID 和协议,不放用户输入。
## 与现有功能的关系
- `node/install` 对 nftables 节点返回错误或前端隐藏入口。
- `node/check-status` 对 nftables 节点可返回 SSH 测试状态,而不是 agent 在线状态。
- `forward/batch-redeploy` 对 nftables 规则执行节点级 reconcile。
- `tunnel/batch-redeploy` 遇到 nftables 隧道时只重建相关 nftables 节点规则,不发送 GOST chain/service 命令。
- federation 导入/共享第一阶段不支持 nftables 节点。
- backup/import 应包含新增 node mode、SSH 配置和 binding 状态;导出时默认不导出 SSH 明文凭据。
## 测试计划
后端单元测试:
- nftables 节点不能创建隧道转发。
- nftables 隧道不能包含出口节点或转发链。
- agent 和 nftables 节点不能混在同一隧道。
- nftables forward 拒绝限速、连接限制和 Proxy Protocol。
- nftables forward 拒绝多目标 remoteAddr。
- renderer 为 TCP/UDP 生成稳定脚本和 comment。
- SSH runner 正确隐藏敏感信息并返回 stderr 摘要。
后端集成测试:
- 创建 nftables forward 时数据库和 binding 同步成功。
- runtime 下发失败时创建回滚。
- 更新失败时数据库和旧规则尽量恢复。
- 删除失败时普通删除返回错误,强制删除保留清理提示。
前端验证:
- 节点表单按转发模式切换字段。
- nftables 节点隐藏安装/升级/回退操作。
- 隧道表单阻止 nftables 隧道转发配置。
- 转发表单选择 nftables 隧道后隐藏不支持字段。
验证命令:
```bash
(cd go-backend && go test ./...)
(cd vite-frontend && pnpm run build)
```
## 实施顺序
1. 数据模型和 repository:新增字段、SSH 配置表、binding 表和查询方法。
2. nftables runtime:实现 planner、renderer、SSH runner、manager。
3. handler 校验:节点、隧道、转发 create/update/delete 接入 runtime。
4. 前端节点表单:增加转发模式和 SSH 配置。
5. 前端隧道/转发表单:按 nftables 能力收窄 UI。
6. 批量重建和清理操作:提供运维入口。
7. 测试与文案打磨。
## 第一阶段固定决策
本设计先固定以下选择,除非审核时调整:
- 第一阶段同时下发 TCP 和 UDP。
- 第一阶段只支持单目标。
- 第一阶段默认启用 masquerade。
- nftables 节点的“在线状态”以 SSH 测试为准,而不是常驻连接。
@@ -0,0 +1,284 @@
# nftables 流量统计设计
**日期**: 2026-06-06
**状态**: 待审核
**作者**: Codex
## 概述
为 FLVX 的 `nftables` 转发模式补齐流量统计。当前 nftables 模式由面板通过 SSH 全量维护 `table inet flvx`,但没有 agent,因此不能复用 WebSocket 运行时上报。新方案由面板定时通过 SSH 拉取远端 nftables counter,计算增量后写入现有流量账本。
目标是让 nftables 转发在用户可见口径上尽量接近 agent 模式:
- forward 列表显示 `inFlow` / `outFlow`。
- 用户、用户隧道、配额和流量策略继续生效。
- 隧道监控继续获得分钟级 `tunnel_metric`。
- 节点不需要安装新的 agent 或常驻进程。
## 背景
现有 agent 模式通过 `/flow/upload` 接收加密上报,handler 会把服务名解析为 `forward_id/user_id/user_tunnel_id`,再复用以下路径:
- `ApplyFlowUploadDeltasBatch` 更新 `forward`、`user`、`user_tunnel`。
- `AddUserQuotaUsageBatch` 更新用户配额窗口。
- `enforceUserQuotaIfNeeded` 和 `enforceFlowPolicies` 做约束 enforcement。
- `recordTunnelMetricsFromForwardBatch` 写入分钟级隧道监控。
nftables 模式已经有 `nft_rule_binding` 记录规则应用状态,规则 comment 里包含 `forward_id`。这给 counter 到业务实体的映射提供了稳定锚点。
## 推荐方案
采用“面板 SSH 轮询 nftables counter”的方案:
1. 渲染 nftables 规则时,为每个 forward、协议和方向写入稳定 comment 和 `counter`。
2. 后端定时扫描 `forward_mode = nftables` 的节点。
3. 对每个节点通过 SSH 执行 `nft -j list table inet flvx`。
4. 解析 JSON 规则,按 comment 得到 `forward_id/protocol/direction/bytes/packets`。
5. 用数据库中的上次采样值计算 delta。
6. 将 delta 转成现有 flow upload 内部结构,复用既有入账、配额、策略和监控逻辑。
不采用节点 crontab 或 systemd timer 回推。它会重新引入节点侧组件,削弱 nftables 模式“不安装 agent”的产品边界。
## 统计口径
正式入账使用 `forward` filter chain 的计数,不使用 NAT chain 的 DNAT 命中计数作为主口径。
原因:
- DNAT counter 表示规则命中,不一定代表后续转发成功。
- filter forward chain 更接近实际经过内核转发的数据。
- SNAT/masquerade 会改变包头,入账规则应在可稳定匹配目标服务地址和端口的位置统计。
方向定义:
| direction | nft 匹配 | 写入字段 |
|-----------|----------|----------|
| `to-target` | 外部客户端到目标服务 | `in_flow` |
| `from-target` | 目标服务返回外部客户端 | `out_flow` |
用户总用量和配额仍按 `in_flow + out_flow` 计算。隧道 `traffic_ratio` 和 `flow` 倍率继续沿用 agent 模式逻辑,保证不同运行时模式的账单口径一致。
## nftables 规则设计
继续只维护 `table inet flvx`,避免触碰用户已有规则。每条 forward 对 TCP 和 UDP 各生成一组 DNAT 和统计规则。
示例:
```nft
table inet flvx {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
tcp dport 12345 counter dnat ip to 198.51.100.20:443 comment "flvx forward:42 dnat tcp"
udp dport 12345 counter dnat ip to 198.51.100.20:443 comment "flvx forward:42 dnat udp"
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
masquerade comment "flvx masquerade"
}
chain forward {
type filter hook forward priority filter; policy accept;
ip daddr 198.51.100.20 tcp dport 443 counter comment "flvx forward:42 to-target tcp"
ip saddr 198.51.100.20 tcp sport 443 counter comment "flvx forward:42 from-target tcp"
ip daddr 198.51.100.20 udp dport 443 counter comment "flvx forward:42 to-target udp"
ip saddr 198.51.100.20 udp sport 443 counter comment "flvx forward:42 from-target udp"
}
}
```
IPv6 目标使用 `ip6`:
```nft
ip6 daddr 2001:db8::20 tcp dport 443 counter comment "flvx forward:42 to-target tcp"
ip6 saddr 2001:db8::20 tcp sport 443 counter comment "flvx forward:42 from-target tcp"
```
域名目标无法在 nftables 规则中动态匹配返回方向。统计第一阶段要求 nftables forward 的 `remoteAddr` host 必须是 IP 地址;如果当前纯转发实现允许域名,开启统计时应同步收紧校验。后续若要支持域名,应在规则同步时解析并固化 IP,同时明确 DNS 变化后的重建策略。
## Comment 格式
正式统计规则使用固定格式:
```text
flvx forward:<forward_id> <direction> <protocol>
```
字段:
- `forward_id`: 十进制整数。
- `direction`: `to-target` 或 `from-target`。
- `protocol`: `tcp` 或 `udp`。
DNAT 调试规则可使用 `dnat` direction,但 collector 不入账 `dnat`。后端只依赖 comment 解析,不依赖 nft handle,因为全量重建 table 会改变 handle。
## 数据模型
新增 `nft_counter_state` 表保存上次采样基线。
| 字段 | 说明 |
|------|------|
| `id` | 主键 |
| `node_id` | nftables 节点 ID |
| `forward_id` | 转发规则 ID |
| `protocol` | `tcp` / `udp` |
| `direction` | `to-target` / `from-target` |
| `rule_hash` | 当前规则 hash |
| `bytes` | 上次采样绝对字节数 |
| `packets` | 上次采样绝对包数 |
| `collected_time` | 上次采样时间 |
| `created_time` | 创建时间 |
| `updated_time` | 更新时间 |
唯一索引:
```text
node_id, forward_id, protocol, direction
```
GORM 模型必须定义 `TableName()`,字段 tag 保持 SQLite/PostgreSQL 兼容,不使用 `jsonb`、`serial` 等数据库专属类型。
## 后端组件
扩展 `go-backend/internal/runtime/nftables`:
| 组件 | 职责 |
|------|------|
| `CounterSample` | 表达单条 nft counter 采样 |
| `Collector` | 对外提供 `Collect(ctx, cfg)` |
| `SSHRunner.ListTableJSON` | 远端执行 `nft -j list table inet flvx` |
| `ParseCounterSamples` | 解析 nft JSON 和 FLVX comment |
扩展 repository:
| 方法 | 职责 |
|------|------|
| `ListNftablesNodesForCollection` | 找到启用 nftables 且有 SSH 配置的节点 |
| `GetNftCounterStatesByNode` | 读取节点上次 counter 基线 |
| `UpsertNftCounterStates` | 批量刷新基线 |
| `DeleteNftCounterStatesByForward` | forward 删除时清理状态 |
扩展 handler/job:
- 新增 `runNftablesTrafficCollectJob(now time.Time)`。
- 默认每 60 秒运行一次。
- 对节点采集设置并发上限,建议 3 到 5。
- 单节点失败只记录日志和节点采集状态,不影响其他节点。
## 增量算法
collector 返回的是 nftables 的绝对 counter。入账前必须和上次基线做差。
规则:
- 无旧状态:只保存当前值作为基线,不入账。
- `rule_hash` 变化:只刷新基线,不入账,避免新旧规则混算。
- 新 bytes 大于等于旧 bytes:`delta = new - old`。
- 新 bytes 小于旧 bytes:认为远端 table 重建、counter reset 或系统重启,只刷新基线,不入账。
- delta 为 0:刷新采集时间,不入账。
- 样本无法映射到有效 forward:忽略并记录 debug 日志。
同一 forward 的 TCP/UDP delta 要先聚合,再转换成现有账本:
- `to-target` bytes 聚合为原始 `bytesIn`。
- `from-target` bytes 聚合为原始 `bytesOut`。
- 入账时按 `traffic_ratio` 和 `tunnel.flow` 计算 scaled `InFlow` / `OutFlow`。
- 配额使用 scaled 后的 `InFlow + OutFlow`。
- `tunnel_metric` 使用原始 `bytesIn` / `bytesOut`。
## 入账路径
新增一个 nftables 专用的 batch builder,但输出沿用现有结构:
```go
type nftTrafficDelta struct {
ForwardID int64
BytesIn int64
BytesOut int64
}
```
处理流程:
1. 收集本轮所有 `forward_id`。
2. 调用 `GetFlowUploadForwardMetas` 获取 `user_id/user_tunnel_id/tunnel_id/traffic_ratio/tunnel_flow`。
3. 构造 `repo.FlowUploadCounterDelta`。
4. 调用 `recordTunnelMetricsFromForwardBatch` 写监控。
5. 抽出共享入账 helper,复用 `applyFlowDeltasWithFallback`、`applyQuotaUsageWithFallback`、`enforceUserQuotaIfNeeded` 和 `enforceFlowPolicies`。不要通过伪造 agent service name 去调用 agent 专用 builder。
不新增独立的 nftables 流量字段。`forward.in_flow/out_flow`、`user.in_flow/out_flow`、`user_tunnel.in_flow/out_flow` 仍是统一事实来源。
## 错误处理
采集错误分为三类:
| 类型 | 行为 |
|------|------|
| SSH 连接或认证失败 | 记录日志,保留下次继续采集 |
| 远端无 `table inet flvx` | 视为规则未应用或被清理,记录 warning,不清空账本 |
| JSON 解析失败 | 记录原始错误摘要,不入账 |
不要因为采集失败禁用 forward。流量统计失败和转发运行失败不是同一件事。
可在后续 UI 增加节点级采集状态,例如最近成功时间、最近错误。但第一步只要求后端具备日志和数据库状态即可。
## 与现有行为的关系
- agent 模式 `/flow/upload` 不变。
- nftables 模式不新增节点侧 HTTP 回调。
- 现有 `nft_rule_binding.rule_hash` 继续表示规则期望状态;counter state 用它判断采样是否跨规则版本。
- `statistics_flow` 小时统计 job 不需要改,它基于用户总流量快照自然包含 nftables 入账结果。
- 用户重置流量时不需要清空 nftables counter。重置只清业务账本;下一轮采集继续从 counter state 差值入账。
## 测试计划
后端单元测试:
- renderer 为 TCP/UDP、IPv4/IPv6 目标生成 `counter` 和稳定 comment。
- comment parser 能识别合法格式,拒绝未知 direction/protocol。
- nft JSON parser 能从 `nft -j list table` 输出中提取 bytes/packets。
- delta 算法覆盖首次基线、正常增长、counter reset、rule_hash 变化和零增量。
- batch builder 正确应用 `traffic_ratio` 和 `tunnel.flow`。
repository 测试:
- `nft_counter_state` 自动迁移。
- upsert 在 SQLite 下可重复刷新。
- forward 删除时清理 counter state。
handler/job 测试:
- 单节点采集成功会调用现有流量入账路径。
- 单节点 SSH 失败不影响其他节点。
- 无旧状态时不会误把历史 counter 入账。
验证命令:
```bash
(cd go-backend && go test ./...)
```
## 分阶段落地
第一阶段:
- 规则渲染加入 filter chain counter。
- 实现 SSH collector、JSON parser、counter state 和后台 job。
- 入账到现有账本和 tunnel metric。
第二阶段:
- UI 展示 nftables 采集状态。
- 节点详情显示最近采集时间和最近错误。
- 提供手动“采集一次”诊断按钮。
第三阶段:
- 探索域名目标的解析和重建策略。
- 优化大量节点下的采集调度、退避和超时配置。
## 开放问题
- 采集周期默认 60 秒是否满足产品预期;如果需要更实时,可以降到 30 秒,但 SSH 压力会增加。
- nftables 模式是否继续允许域名 remoteAddr。如果允许,需要先定义 DNS 固化和统计匹配规则。
- 是否要在第一阶段暴露采集状态 API。推荐后端先记录,UI 后续补齐。
@@ -0,0 +1,197 @@
# 规则流量清零设计
## 背景
Issue #523 希望“规则”页面中每条隧道规则显示的流量使用量支持手动清零。
当前规则流量保存在 `forward.in_flow` 和 `forward.out_flow`。流量上报时,同一份增量还会累计到用户总流量、用户隧道流量和相关配额统计中。因此,本功能必须将“规则展示计数器清零”与“用户或隧道配额重置”严格区分。
## 目标
为单条规则提供手动流量清零能力:
- 将所选规则的上传流量和下载流量清零。
- 管理员可以清零任意规则。
- 普通用户只能清零自己的规则。
- 清零后,新产生的流量继续从零正常累计。
## 非目标
本功能不会:
- 修改用户总流量 `user.in_flow` 或 `user.out_flow`。
- 修改用户隧道流量 `user_tunnel.in_flow` 或 `user_tunnel.out_flow`。
- 修改每日或每月配额用量。
- 修改历史流量统计。
- 重置 nftables 节点计数器或其增量计算基线。
- 重启、暂停、恢复或重新部署规则服务。
- 增加批量流量清零功能。
## 后端设计
### API
新增接口:
```text
POST /api/v1/forward/reset-flow
```
请求体:
```json
{
"id": 123
}
```
成功响应沿用统一 envelope:
```json
{
"code": 0,
"msg": "success",
"data": null,
"ts": 0
}
```
具体 `msg`、`data` 和 `ts` 值继续由现有 response helper 生成。
### 参数与权限校验
Handler 执行以下步骤:
1. 只接受 `POST` 请求。
2. 从 JSON 请求体读取正整数规则 ID。
3. 调用现有 `resolveForwardAccess`:
- 管理员角色可以访问任意存在的规则。
- 普通用户仅能访问 `forward.user_id` 等于当前用户 ID 的规则。
- 对普通用户访问他人规则的情况,沿用现有逻辑返回“转发不存在”,避免暴露规则存在性。
4. 调用 Repository 完成清零。
5. 返回统一成功响应。
### Repository
新增方法:
```go
func (r *Repository) ResetForwardFlow(forwardID int64, now int64) error
```
该方法只更新指定 `forward` 记录:
```text
in_flow = 0
out_flow = 0
updated_time = now
```
Repository 不直接操作 Handler 的身份信息,也不更新任何其他表。
### 并发与后续流量
清零使用单条 SQL `UPDATE`。agent 流量上报和 nftables 流量采集仍使用原有增量累加逻辑。清零不会重置采集基线,因此下一次采集只会把清零之后新计算出的增量加回规则计数,不会把清零前的累计值整体恢复。
若清零 SQL 与流量增量 SQL 同时执行,数据库按实际语句执行顺序决定最终值;每条更新本身保持原子性。本功能不引入暂停采集或跨节点同步流程。
## 前端设计
### API 封装
在 `vite-frontend/src/api/index.ts` 新增:
```ts
export const resetForwardFlow = (id: number) =>
Network.post("/forward/reset-flow", { id });
```
### 入口
在规则页面所有单条规则操作入口中增加“流量清零”操作:
- 分组表格视图。
- 精简表格视图。
- 卡片视图。
按钮使用独立的清零/刷新语义图标和提示文本,不复用删除按钮样式。
当规则的 `inFlow + outFlow` 等于零时,按钮禁用,避免重复请求。
### 确认交互
点击按钮后打开确认弹窗,显示规则名称,并明确说明:
- 仅清零当前规则显示的上传和下载流量。
- 不影响用户总流量、用户隧道配额和历史统计。
- 操作不可撤销。
确认期间显示 loading 状态并阻止重复提交。
### 成功与失败
- 成功:关闭弹窗,显示成功 toast,并刷新规则列表。
- 失败:保留弹窗,显示后端错误信息或通用失败 toast。
- 刷新后,该规则上传和下载均显示为零;后续流量继续正常累计。
## 错误处理
- 非 POST 请求:返回现有通用请求失败响应。
- 请求体无法解析、ID 缺失或 ID 非正数:返回“请求参数错误”。
- 规则不存在或普通用户访问他人规则:返回“转发不存在”。
- Repository 更新失败:返回包含 Repository 错误信息的统一错误响应。
- 前端网络错误:显示“流量清零失败”。
## 测试策略
### Repository 测试
验证:
- 指定规则的 `in_flow`、`out_flow` 被清零。
- 指定规则的 `updated_time` 被更新。
- 其他规则的流量不变。
- 用户总流量不变。
- 用户隧道流量不变。
- Repository 未初始化时返回错误。
### Handler 测试
验证:
- 管理员能够清零任意存在的规则。
- 普通用户能够清零自己的规则。
- 普通用户不能清零他人的规则。
- 不存在的规则返回错误。
- 无效 ID 返回参数错误。
- 非 POST 请求返回请求失败。
- 成功请求不修改用户和用户隧道流量。
### 前端验证
项目没有配置前端测试框架,因此不新增前端单元测试。使用以下命令验证:
```bash
(cd vite-frontend && pnpm run build)
(cd vite-frontend && pnpm run lint)
```
后端使用:
```bash
(cd go-backend && go test ./...)
```
## 文件范围
预计修改:
- `go-backend/internal/http/handler/handler.go`
- `go-backend/internal/http/handler/mutations.go`
- `go-backend/internal/http/handler/*_test.go`
- `go-backend/internal/store/repo/repository_mutations.go`
- `go-backend/internal/store/repo/*_test.go`
- `vite-frontend/src/api/index.ts`
- `vite-frontend/src/pages/forward.tsx`
不需要数据库迁移或新增依赖。
+133
View File
@@ -0,0 +1,133 @@
# Preserve UDP reply source addresses on Linux
The Linux UDP listener preserves the destination address of each incoming
datagram so that replies use the correct local source. This change was developed
against FLVX `3.0.1-rc4`, commit
`d9303032cad09b33cd0b7c91704fd671fbfdd078`.
It also includes the `3.0.1-rc5` runtime update at
`c794586cc7773e5f967783895d3071538442bbf7`.
## Problem
A wildcard UDP socket bound to `0.0.0.0:port` or `[::]:port` can receive datagrams
addressed to several local IP addresses. Previously, the listener retained only
the client address. A reply sent through `WriteTo` could therefore use a source
IP chosen by the kernel instead of the IP the client contacted. DNS clients, for
example, can reject such replies as coming from an unexpected source.
The UDP session pool was also keyed only by the client address and port. When
one client socket contacted multiple local addresses on the same listener port,
these requests could share a session despite having different destinations.
## Changes
- Linux wildcard UDP sockets enable IPv4/IPv6 packet information. `recvmsg`
preserves the datagram's local destination and interface; `sendmsg` uses the
corresponding source address and interface when replying.
- IPv4 keeps the original destination (`ipi_addr`) separate from the local
reply address (`ipi_spec_dst`), so broadcast requests receive unicast replies
rather than attempting to send with a broadcast source address.
- IPv6 multicast destinations remain part of session identity, but replies
leave the source unspecified and retain the received interface index so the
kernel can select a valid local unicast source.
- The destination metadata travels through existing metrics, statistics,
admission and traffic-limiter wrappers. The exposed remote address remains
the real client address.
- Sessions are keyed by the local destination endpoint, remote client endpoint
and received interface index. Identical IPv4 broadcast or multicast endpoints
on different interfaces therefore retain separate reply control information.
An accepted session reports its actual local destination.
Link-local IPv6 unicast and link-local or interface-local multicast
destinations retain their interface zone, keeping identical multicast groups
on different interfaces separate even when the client uses the same
non-link-local IP and source port.
IPv4 destinations, including link-local unicast and multicast, never receive
an IPv6 zone; their interface isolation uses the separate index in the key.
- Explicitly bound UDP sockets retain their existing behavior. Non-Linux
platforms retain their existing implementation. No configuration keys or
panel API changes are required.
The fix affects the shared UDP listener, including services dynamically created
after startup. It does not configure listening addresses, NAT, routing or
firewalls. Multi-homed hosts still need correct source policy routing for their
uplinks; this patch does not fix SSH/TCP routing or packet loss before ingress.
## Build
Use Go 1.26.10 or a compatible newer toolchain. From the repository root:
```sh
cd go-gost
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath \
-ldflags '-s -w' -o flux_agent .
```
Use the agent with the normal panel integration and forwarding configuration.
Selecting a wildcard listener enables destination preservation automatically
on Linux. The patch does not select a listener address or change the upstream
connection's outbound interface.
## Regression tests
The Linux destination tests cover IPv4 wildcard and dual-stack wildcard sockets,
explicit IPv4/IPv6 bindings, one client source port contacting multiple local
destinations, repeated requests, session expiry, and active metrics, statistics,
admission and traffic-limit wrappers. A separate IPv6 case binds the client to
`::1` and checks the exact remote IP and port for every destination. Broadcast
tests exercise both IPv4 and dual-stack listeners, retaining the broadcast
destination while checking that replies use a local unicast address.
An IPv6 multicast regression sends across an isolated veth pair and checks the
multicast session destination and the unicast reply, including interface zones.
A two-interface case uses one fixed ULA client endpoint to contact the same
link-local or interface-local multicast group on both interfaces and checks
distinct sessions and their individual reply-interface metadata.
IPv4 and dual-stack cases likewise use a single fixed client endpoint to send
to one multicast group or the limited broadcast address on two interfaces,
checking separate sessions and their reply-interface indices.
Link-local IPv4 unicast and multicast regressions also check that accepted
local endpoints have no IPv6 zone, on both IPv4 and dual-stack listeners.
Run the standard suites on Linux:
```sh
(cd go-gost && go test ./...)
(cd go-gost/x && go test ./...)
```
To exercise multiple IPv6 addresses without modifying the host's live network,
compile the tests first and run them inside a disposable network namespace.
The following commands require Linux, root access for `unshare`, and `iproute2`:
```sh
cd go-gost/x
go test -c -race -o /tmp/flvx-udp-destination.test ./listener/udp
go test -c -race -o /tmp/flvx-udp-packet.test ./internal/net/udp
sudo unshare --net -- sh -eu -c '
sysctl -qw net.ipv6.conf.all.disable_ipv6=0
ip link set lo up
ip addr add 198.18.0.1/32 dev lo
ip addr add 169.254.1.2/32 dev lo
ip -6 addr add fd00:1::1/128 dev lo nodad
ip -6 addr add fd00:2::1/128 dev lo nodad
ip -6 addr add fd00:3::1/128 dev lo nodad
for suffix in "" 2; do
ip link add "mc-tx$suffix" type veth peer name "mc-rx$suffix"
ip link set "mc-tx$suffix" addrgenmode none up
ip link set "mc-rx$suffix" addrgenmode none up
ip -6 addr add fe80::1/64 dev "mc-tx$suffix" nodad
ip -6 addr add fe80::2/64 dev "mc-rx$suffix" nodad
done
ip addr add 198.18.1.1/24 dev mc-tx
ip addr add 198.18.2.1/24 dev mc-tx2
export FLUX_TEST_IPV6_ADDRS=fd00:1::1,fd00:2::1,fd00:3::1
export FLUX_TEST_IPV4_LINK_LOCAL=169.254.1.2
export FLUX_TEST_MULTICAST_RX=mc-rx FLUX_TEST_MULTICAST_TX=mc-tx
export FLUX_TEST_MULTICAST_RX2=mc-rx2 FLUX_TEST_MULTICAST_TX2=mc-tx2
/tmp/flvx-udp-destination.test -test.v -test.timeout=60s -test.count=5
exec /tmp/flvx-udp-packet.test -test.v -test.timeout=60s -test.count=5
'
```
The addresses above are isolated test addresses, not deployment settings.
The `UDP Destination Regression` GitHub Actions workflow runs these namespace
tests, the race detector, both agent module suites, and a Linux agent build.
+8 -2
View File
@@ -1,4 +1,5 @@
FROM golang:1.25-bookworm AS builder
# CVE-2026-111943: crypto/x509 is linked into paneld; rebuild with the fixed Go release.
FROM golang:1.26.10-bookworm AS builder
WORKDIR /src
COPY go.mod go.sum ./
@@ -7,12 +8,17 @@ RUN go mod download
COPY . .
ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} env ${TARGETARCH:+GOARCH=${TARGETARCH}} go build -o /out/paneld ./cmd/paneld
ARG KEYGEN_ACCOUNT_ID
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} env ${TARGETARCH:+GOARCH=${TARGETARCH}} go build -ldflags="-X 'go-backend/internal/license.AccountID=${KEYGEN_ACCOUNT_ID}'" -o /out/paneld ./cmd/paneld
FROM docker:27-cli AS dockercli
FROM debian:bookworm-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget && rm -rf /var/lib/apt/lists/*
COPY --from=builder /out/paneld /app/paneld
COPY --from=dockercli /usr/local/bin/docker /usr/local/bin/docker
COPY --from=dockercli /usr/local/libexec/docker/cli-plugins/docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose
ENV SERVER_ADDR=:6365
EXPOSE 6365
+8
View File
@@ -12,9 +12,17 @@ import (
"go-backend/internal/app"
"go-backend/internal/config"
"go-backend/internal/panelupgrade"
)
func main() {
if len(os.Args) == 3 && os.Args[1] == "panel-upgrade-helper" {
if panelupgrade.RunHelper(os.Args[2]) != nil {
log.Print("panel upgrade helper stopped; inspect durable job status")
os.Exit(1)
}
return
}
cfg := config.FromEnv()
if cfg.JWTSecret == "" {
log.Println("warning: JWT_SECRET is empty")
+10 -3
View File
@@ -1,12 +1,15 @@
module go-backend
go 1.25.0
go 1.26.10
require (
github.com/fxamacker/cbor/v2 v2.9.0
github.com/glebarez/sqlite v1.11.0
github.com/go-webauthn/webauthn v0.14.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.7.3
github.com/jackc/pgx/v5 v5.9.2
golang.org/x/crypto v0.42.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
@@ -14,15 +17,19 @@ require (
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-webauthn/x v0.1.25 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-tpm v0.9.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // 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.50.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
+24 -6
View File
@@ -3,10 +3,20 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -17,8 +27,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.3 h1:PO1wNKj/bTAwxSJnO1Z4Ai8j4magtqg2SLNjEDzcXQo=
github.com/jackc/pgx/v5 v5.7.3/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -27,6 +37,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -36,10 +48,14 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
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.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
@@ -49,6 +65,8 @@ 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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
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=
+7 -2
View File
@@ -12,12 +12,13 @@ import (
const (
algorithm = "HmacSHA256"
expireTime = 90 * 24 * time.Hour
expireTime = 7 * 24 * time.Hour
)
type Claims struct {
Sub string `json:"sub"`
Iat int64 `json:"iat"`
IatMs int64 `json:"iat_ms"`
Exp int64 `json:"exp"`
User string `json:"user"`
Name string `json:"name"`
@@ -30,11 +31,15 @@ type tokenHeader struct {
}
func GenerateToken(userID int64, username string, roleID int, secret string) (string, error) {
now := time.Now()
return GenerateTokenAt(userID, username, roleID, secret, time.Now())
}
func GenerateTokenAt(userID int64, username string, roleID int, secret string, now time.Time) (string, error) {
header := tokenHeader{Alg: algorithm, Typ: "JWT"}
claims := Claims{
Sub: strconv.FormatInt(userID, 10),
Iat: now.Unix(),
IatMs: now.UnixMilli(),
Exp: now.Add(expireTime).Unix(),
User: username,
Name: username,
+8
View File
@@ -0,0 +1,8 @@
package auth
type UserAuthState struct {
ID int64
RoleID int
Status int
PasswordChangedAt int64
}
+42 -26
View File
@@ -3,6 +3,7 @@ package client
import (
"encoding/json"
"fmt"
"go-backend/internal/relaysecurity"
"io"
"net/http"
"strings"
@@ -14,17 +15,18 @@ type FederationClient struct {
}
type RemoteNodeInfo struct {
ShareID int64 `json:"shareId"`
ShareName string `json:"shareName"`
NodeID int64 `json:"nodeId"`
NodeName string `json:"nodeName"`
ServerIP string `json:"serverIp"`
Status int `json:"status"`
MaxBandwidth int64 `json:"maxBandwidth"`
CurrentFlow int64 `json:"currentFlow"`
ExpiryTime int64 `json:"expiryTime"`
PortRangeStart int `json:"portRangeStart"`
PortRangeEnd int `json:"portRangeEnd"`
RelayAuthVersion int `json:"relayAuthVersion"`
ShareID int64 `json:"shareId"`
ShareName string `json:"shareName"`
NodeID int64 `json:"nodeId"`
NodeName string `json:"nodeName"`
ServerIP string `json:"serverIp"`
Status int `json:"status"`
MaxBandwidth int64 `json:"maxBandwidth"`
CurrentFlow int64 `json:"currentFlow"`
ExpiryTime int64 `json:"expiryTime"`
PortRangeStart int `json:"portRangeStart"`
PortRangeEnd int `json:"portRangeEnd"`
}
type RemoteTunnelResponse struct {
@@ -38,30 +40,34 @@ type RuntimeReservePortRequest struct {
}
type RuntimeReservePortResponse struct {
ReservationID string `json:"reservationId"`
BindingID string `json:"bindingId"`
AllocatedPort int `json:"allocatedPort"`
RelayAuthVersion int `json:"relayAuthVersion"`
ReservationID string `json:"reservationId"`
BindingID string `json:"bindingId"`
AllocatedPort int `json:"allocatedPort"`
}
type RuntimeTarget struct {
Host string `json:"host"`
Port int `json:"port"`
Protocol string `json:"protocol"`
RelayAuth *relaysecurity.Credentials `json:"relayAuth,omitempty"`
Host string `json:"host"`
Port int `json:"port"`
Protocol string `json:"protocol"`
}
type RuntimeApplyRoleRequest struct {
ReservationID string `json:"reservationId"`
ResourceKey string `json:"resourceKey"`
Role string `json:"role"`
Protocol string `json:"protocol"`
Strategy string `json:"strategy"`
Targets []RuntimeTarget `json:"targets"`
RelayAuth *relaysecurity.Credentials `json:"relayAuth,omitempty"`
ReservationID string `json:"reservationId"`
ResourceKey string `json:"resourceKey"`
Role string `json:"role"`
Protocol string `json:"protocol"`
Strategy string `json:"strategy"`
Targets []RuntimeTarget `json:"targets"`
}
type RuntimeApplyRoleResponse struct {
BindingID string `json:"bindingId"`
ReservationID string `json:"reservationId"`
AllocatedPort int `json:"allocatedPort"`
RelayAuthVersion int `json:"relayAuthVersion"`
BindingID string `json:"bindingId"`
ReservationID string `json:"reservationId"`
AllocatedPort int `json:"allocatedPort"`
}
type RuntimeReleaseRoleRequest struct {
@@ -247,6 +253,9 @@ func (c *FederationClient) ApplyRole(url, token, localDomain string, reqData Run
defer resp.Body.Close()
if resp.StatusCode != 200 {
if reqData.RelayAuth != nil {
return nil, fmt.Errorf("remote authenticated relay apply failed: HTTP %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
}
@@ -260,9 +269,16 @@ func (c *FederationClient) ApplyRole(url, token, localDomain string, reqData Run
return nil, err
}
if res.Code != 0 {
if reqData.RelayAuth != nil {
return nil, fmt.Errorf("remote authenticated relay apply failed: code %d", res.Code)
}
return nil, fmt.Errorf("remote api error: %s", res.Msg)
}
if reqData.RelayAuth != nil && res.Data.RelayAuthVersion != relaysecurity.Version {
return nil, fmt.Errorf("remote did not acknowledge required relay authentication")
}
return &res.Data, nil
}
@@ -0,0 +1,67 @@
package handler
import (
"net/http"
"strings"
"go-backend/internal/http/response"
"go-backend/internal/store/repo"
)
func (h *Handler) getPublicConfigByName(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req nameRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
return
}
configName := strings.ToLower(strings.TrimSpace(req.Name))
if configName == "" {
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
return
}
if !repo.IsPublicConfigKey(configName) {
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
return
}
if value, gated := h.unlicensedPublicBrandValue(configName); gated {
response.WriteJSON(w, response.OK(map[string]string{"name": configName, "value": value}))
return
}
cfg, err := h.repo.GetConfigByName(configName)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if cfg == nil {
response.WriteJSON(w, response.ErrDefault("配置不存在"))
return
}
response.WriteJSON(w, response.OK(cfg))
}
func (h *Handler) unlicensedPublicBrandValue(configName string) (string, bool) {
switch configName {
case "app_name", "app_logo", "app_favicon", "hide_footer_brand":
default:
return "", false
}
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
if isCommercial == "true" {
return "", false
}
if configName == "app_name" {
return "FLVX", true
}
if configName == "hide_footer_brand" {
return "false", true
}
return "", true
}
@@ -0,0 +1,363 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"go-backend/internal/auth"
"go-backend/internal/http/middleware"
"go-backend/internal/http/response"
"go-backend/internal/store/repo"
)
func TestPublicConfigGetAllowsBrandKeys(t *testing.T) {
router, r := setupConfigAccessTestRouter(t)
seedConfigValue(t, r, "app_name", "FLVX Brand")
seedConfigValue(t, r, "app_logo", "logo-data")
seedConfigValue(t, r, "app_favicon", "favicon-data")
seedConfigValue(t, r, "app_bg_image", "bg-data")
seedConfigValue(t, r, "app_bg_image_light", "light-bg-data")
seedConfigValue(t, r, "app_bg_image_dark", "dark-bg-data")
seedConfigValue(t, r, "cloudflare_site_key", "site-key")
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"app_name"}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertHandlerCode(t, resp, 0)
for name, want := range map[string]string{
"app_bg_image_light": "light-bg-data",
"app_bg_image_dark": "dark-bg-data",
} {
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"`+name+`"}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertHandlerConfigValue(t, resp, name, want)
}
}
func TestPublicBrandConfigFallsBackWithoutCommercialLicense(t *testing.T) {
router, r := setupConfigAccessTestRouter(t)
seedConfigValue(t, r, "app_name", "Paid Brand")
seedConfigValue(t, r, "app_logo", "logo-data")
seedConfigValue(t, r, "app_favicon", "favicon-data")
seedConfigValue(t, r, "hide_footer_brand", "true")
seedConfigValue(t, r, "is_commercial", "false")
for name, want := range map[string]string{
"app_name": "FLVX",
"app_logo": "",
"app_favicon": "",
"hide_footer_brand": "false",
} {
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"`+name+`"}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertHandlerConfigValue(t, resp, name, want)
}
}
func TestPublicBrandConfigUsesSavedValuesWithCommercialLicense(t *testing.T) {
router, r := setupConfigAccessTestRouter(t)
seedConfigValue(t, r, "app_name", "Paid Brand")
seedConfigValue(t, r, "is_commercial", "true")
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"app_name"}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertHandlerConfigValue(t, resp, "app_name", "Paid Brand")
}
func TestPublicConfigGetRejectsSensitiveKeys(t *testing.T) {
router, _ := setupConfigAccessTestRouter(t)
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"jwt_secret"}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertHandlerCodeMsg(t, resp, 403, "禁止访问敏感配置")
}
func TestConfigGetAllowsPublicCloudflareSiteKeyWithoutAuthForCachedLoginPage(t *testing.T) {
router, r := setupConfigAccessTestRouter(t)
seedConfigValue(t, r, "cloudflare_site_key", "site-key")
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/get", bytes.NewBufferString(`{"name":"cloudflare_site_key"}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertHandlerConfigValue(t, resp, "cloudflare_site_key", "site-key")
}
func TestConfigGetRejectsSensitiveKeysWithoutAuth(t *testing.T) {
router, _ := setupConfigAccessTestRouter(t)
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/get", bytes.NewBufferString(`{"name":"jwt_secret"}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertHandlerCodeMsg(t, resp, 403, "禁止访问敏感配置")
}
func TestConfigGetAllowsSensitiveKeysForAdmin(t *testing.T) {
router, r := setupConfigAccessTestRouter(t)
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
seedConfigValue(t, r, "jwt_secret", "jwt-secret")
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/get", bytes.NewBufferString(`{"name":"jwt_secret"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", adminToken)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertHandlerConfigValue(t, resp, "jwt_secret", "jwt-secret")
}
func TestConfigGetNeverReturnsLicenseCredentials(t *testing.T) {
router, r := setupConfigAccessTestRouter(t)
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
seedConfigValue(t, r, "license_key", "license-secret")
seedConfigValue(t, r, "machine_fingerprint", "fingerprint-secret")
for _, name := range []string{"license_key", "license_machine_id", "machine_fingerprint"} {
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/get", bytes.NewBufferString(`{"name":"`+name+`"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", adminToken)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertHandlerCodeMsg(t, resp, 403, "禁止访问系统授权凭据")
}
}
func TestConfigListNeverReturnsLicenseCredentials(t *testing.T) {
router, r := setupConfigAccessTestRouter(t)
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
seedConfigValue(t, r, "license_key", "license-secret")
seedConfigValue(t, r, "license_machine_id", "machine-id")
seedConfigValue(t, r, "machine_fingerprint", "fingerprint-secret")
seedConfigValue(t, r, "is_commercial", "true")
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/list", nil)
req.Header.Set("Authorization", adminToken)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
var out struct {
Code int `json:"code"`
Data map[string]string `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 || out.Data["is_commercial"] != "true" {
t.Fatalf("unexpected config response: %+v", out)
}
if _, ok := out.Data["license_key"]; ok {
t.Fatal("license_key must not be returned")
}
if _, ok := out.Data["license_machine_id"]; ok {
t.Fatal("license_machine_id must not be returned")
}
if _, ok := out.Data["machine_fingerprint"]; ok {
t.Fatal("machine_fingerprint must not be returned")
}
}
func TestConfigUpdateAllowsSensitiveKeysForAdmin(t *testing.T) {
router, _ := setupConfigAccessTestRouter(t)
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/update", bytes.NewBufferString(`{"jwt_secret":"rotated-secret"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", adminToken)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertHandlerCode(t, resp, 0)
}
func TestConfigUpdateSingleAllowsSensitiveKeysForAdmin(t *testing.T) {
router, _ := setupConfigAccessTestRouter(t)
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/update-single", bytes.NewBufferString(`{"name":"jwt_secret","value":"rotated-secret"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", adminToken)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertHandlerCode(t, resp, 0)
}
func TestConfigUpdateAllowsCloudflareSecretKeyWrite(t *testing.T) {
router, r := setupConfigAccessTestRouter(t)
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/update", bytes.NewBufferString(`{"cloudflare_secret_key":"turnstile-secret"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", adminToken)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertHandlerCode(t, resp, 0)
cfg, err := r.GetConfigByName("cloudflare_secret_key")
if err != nil {
t.Fatalf("get config: %v", err)
}
if cfg == nil || cfg.Value != "turnstile-secret" {
t.Fatalf("expected cloudflare_secret_key to be updated, got %#v", cfg)
}
}
func TestConfigUpdateSingleAllowsCloudflareSecretKeyWrite(t *testing.T) {
router, r := setupConfigAccessTestRouter(t)
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/update-single", bytes.NewBufferString(`{"name":"cloudflare_secret_key","value":"turnstile-secret"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", adminToken)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertHandlerCode(t, resp, 0)
cfg, err := r.GetConfigByName("cloudflare_secret_key")
if err != nil {
t.Fatalf("get config: %v", err)
}
if cfg == nil || cfg.Value != "turnstile-secret" {
t.Fatalf("expected cloudflare_secret_key to be updated, got %#v", cfg)
}
}
func TestConfigUpdateRejectsLicenseKeyWrite(t *testing.T) {
router, r := setupConfigAccessTestRouter(t)
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/update", bytes.NewBufferString(`{"license_key":"license-secret"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", adminToken)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertHandlerCodeMsg(t, resp, -1, "该配置由系统管理")
if cfg, err := r.GetConfigByName("license_key"); err != nil || cfg != nil {
t.Fatalf("license_key should not be written, got %#v err=%v", cfg, err)
}
}
func TestConfigUpdateSingleRejectsLicenseKeyWrite(t *testing.T) {
router, r := setupConfigAccessTestRouter(t)
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/update-single", bytes.NewBufferString(`{"name":"license_key","value":"license-secret"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", adminToken)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertHandlerCodeMsg(t, resp, -1, "该配置由系统管理")
if cfg, err := r.GetConfigByName("license_key"); err != nil || cfg != nil {
t.Fatalf("license_key should not be written, got %#v err=%v", cfg, err)
}
}
func setupConfigAccessTestRouter(t *testing.T) (http.Handler, *repo.Repository) {
t.Helper()
r, err := repo.Open(t.TempDir() + "/config-access.db")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
_ = r.Close()
})
h := New(r, "unit-test-secret")
mux := http.NewServeMux()
h.Register(mux)
wrapped := middleware.Recover(mux)
wrapped = middleware.JWT(middleware.AuthOptions{JWTSecret: "unit-test-secret", GetUserAuthState: h.GetUserAuthState})(wrapped)
wrapped = middleware.RequestLog(wrapped)
wrapped = middleware.CORS(wrapped)
return wrapped, r
}
func seedConfigValue(t *testing.T, r *repo.Repository, name, value string) {
t.Helper()
if err := r.DB().Exec(`INSERT INTO vite_config(name, value, time) VALUES(?, ?, 0) ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time`, name, value).Error; err != nil {
t.Fatalf("seed config %s: %v", name, err)
}
}
func mustGenerateConfigAccessToken(t *testing.T, userID int64, username string, roleID int) string {
t.Helper()
token, err := auth.GenerateToken(userID, username, roleID, "unit-test-secret")
if err != nil {
t.Fatalf("generate token: %v", err)
}
return token
}
func assertHandlerCode(t *testing.T, rec *httptest.ResponseRecorder, expected int) {
t.Helper()
var out response.R
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != expected {
t.Fatalf("expected code %d, got %d", expected, out.Code)
}
}
func assertHandlerCodeMsg(t *testing.T, rec *httptest.ResponseRecorder, expectedCode int, expectedMsg string) {
t.Helper()
var out response.R
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != expectedCode || out.Msg != expectedMsg {
t.Fatalf("expected (%d,%q), got (%d,%q)", expectedCode, expectedMsg, out.Code, out.Msg)
}
}
func assertHandlerConfigValue(t *testing.T, rec *httptest.ResponseRecorder, expectedName, expectedValue string) {
t.Helper()
var out struct {
Code int `json:"code"`
Data struct {
Name string `json:"name"`
Value string `json:"value"`
} `json:"data"`
}
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d", out.Code)
}
if out.Data.Name != expectedName || out.Data.Value != expectedValue {
t.Fatalf("expected config (%q,%q), got (%q,%q)", expectedName, expectedValue, out.Data.Name, out.Data.Value)
}
}
+228 -37
View File
@@ -14,6 +14,7 @@ import (
"time"
"go-backend/internal/http/client"
runtimenft "go-backend/internal/runtime/nftables"
"go-backend/internal/store/model"
"go-backend/internal/ws"
)
@@ -58,6 +59,7 @@ type diagnosisWorkItem struct {
type diagnosisExecOptions struct {
commandTimeout time.Duration
pingTimeoutMS int
pingCount int
timeoutMessage string
}
@@ -240,6 +242,19 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
if err != nil {
return nil, err
}
nftMode, entryNodeIDs, err := h.tunnelUsesNftables(forward.TunnelID)
if err != nil {
return nil, err
}
if nftMode {
if err := h.validateNftablesForwardRequest(tunnel, forward.RemoteAddr, entryNodeIDs); err != nil {
return nil, err
}
if len(entryNodeIDs) == 0 {
return nil, errors.New("nftables 转发缺少入口节点")
}
return nil, h.syncNftablesNode(entryNodeIDs[0])
}
ports, err := h.listForwardPorts(forward.ID)
if err != nil {
return nil, err
@@ -519,7 +534,22 @@ func buildForwardServiceDeleteNames(bases []string) []string {
return names
}
type forwardServiceControlOptions struct {
tolerateNotFound bool
tolerateOffline bool
allowSelfHeal bool
continueOnError bool
}
func (h *Handler) controlForwardServices(forward *forwardRecord, commandType string, tolerateNotFound bool) error {
return h.controlForwardServicesWithOptions(forward, commandType, forwardServiceControlOptions{
tolerateNotFound: tolerateNotFound,
tolerateOffline: true,
allowSelfHeal: true,
})
}
func (h *Handler) controlForwardServicesWithOptions(forward *forwardRecord, commandType string, opts forwardServiceControlOptions) error {
if h == nil || forward == nil {
return errors.New("invalid forward control context")
}
@@ -546,6 +576,7 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
candidateTunnelIDs = append(candidateTunnelIDs, userTunnelIDs...)
candidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)
bases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)
var nodeErrors []error
if strings.EqualFold(strings.TrimSpace(commandType), "DeleteService") {
seen := map[int64]struct{}{}
for _, fp := range ports {
@@ -554,16 +585,20 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
}
seen[fp.NodeID] = struct{}{}
if err := h.deleteForwardServiceBasesOnNode(fp.NodeID, bases); err != nil {
if isNodeOfflineOrTimeoutError(err) {
if opts.tolerateOffline && isNodeOfflineOrTimeoutError(err) {
continue
}
if tolerateNotFound && isNotFoundError(err) {
if opts.tolerateNotFound && isNotFoundError(err) {
continue
}
if opts.continueOnError {
nodeErrors = append(nodeErrors, h.nodeCommandError(fp.NodeID, err))
continue
}
return err
}
}
return nil
return errors.Join(nodeErrors...)
}
seen := map[int64]struct{}{}
healed := false
@@ -573,37 +608,50 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
}
seen[fp.NodeID] = struct{}{}
nodeHandled, lastNotFoundErr, err := h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
if err != nil {
if isNodeOfflineOrTimeoutError(err) {
continue
}
return err
}
if !nodeHandled && lastNotFoundErr != nil && !healed && shouldSelfHealForwardServiceControl(commandType) {
if healErr := h.syncForwardServices(forward, "UpdateService", true); healErr != nil {
return healErr
}
healed = true
nodeHandled, lastNotFoundErr, err = h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
nodeErr := func() error {
nodeHandled, lastNotFoundErr, err := h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
if err != nil {
if opts.tolerateOffline && isNodeOfflineOrTimeoutError(err) {
return nil
}
return err
}
}
if nodeHandled {
continue
if opts.allowSelfHeal && !nodeHandled && lastNotFoundErr != nil && !healed && shouldSelfHealForwardServiceControl(commandType) {
if healErr := h.syncForwardServices(forward, "UpdateService", true); healErr != nil {
return healErr
}
healed = true
nodeHandled, lastNotFoundErr, err = h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
if err != nil {
return err
}
}
if nodeHandled || opts.tolerateNotFound {
return nil
}
if lastNotFoundErr != nil {
return lastNotFoundErr
}
return errors.New("service control failed")
}()
if nodeErr != nil {
if !opts.continueOnError {
return nodeErr
}
nodeErrors = append(nodeErrors, h.nodeCommandError(fp.NodeID, nodeErr))
}
if tolerateNotFound {
continue
}
if lastNotFoundErr != nil {
return lastNotFoundErr
}
return errors.New("service control failed")
}
return nil
return errors.Join(nodeErrors...)
}
func (h *Handler) nodeCommandError(nodeID int64, err error) error {
name := strconv.FormatInt(nodeID, 10)
if node, lookupErr := h.getNodeRecord(nodeID); lookupErr == nil && node != nil {
name = defaultString(strings.TrimSpace(node.Name), name)
}
return fmt.Errorf("节点 %s (ID %d): %w", name, nodeID, err)
}
func (h *Handler) controlForwardServicesOnNode(nodeID int64, bases []string, commandType string) (bool, error, error) {
@@ -759,6 +807,9 @@ func (h *Handler) diagnoseForwardRuntime(ctx context.Context, forward *forwardRe
if ctx == nil {
ctx = context.Background()
}
if payload, handled, err := h.diagnoseNftablesForwardRuntime(forward); handled || err != nil {
return payload, err
}
forwardName, workItems, err := h.prepareForwardDiagnosis(forward)
if err != nil {
return nil, err
@@ -774,6 +825,111 @@ func (h *Handler) diagnoseForwardRuntime(ctx context.Context, forward *forwardRe
return payload, nil
}
func (h *Handler) diagnoseNftablesForwardRuntime(forward *forwardRecord) (map[string]interface{}, bool, error) {
if forward == nil {
return nil, false, errForwardNotFound
}
nftMode, entryNodeIDs, err := h.tunnelUsesNftables(forward.TunnelID)
if err != nil {
return nil, false, err
}
if !nftMode {
return nil, false, nil
}
if len(entryNodeIDs) == 0 {
return nil, true, errors.New("nftables 转发缺少入口节点")
}
targets, err := resolveDiagnosisTargets(forward.RemoteAddr)
if err != nil {
return nil, true, err
}
results, err := h.buildNftablesForwardDiagnosisResults(forward, entryNodeIDs[0], targets)
if err != nil {
return nil, true, err
}
payload := map[string]interface{}{
"forwardName": forward.Name,
"timestamp": time.Now().UnixMilli(),
"results": results,
}
return payload, true, nil
}
func (h *Handler) buildNftablesForwardDiagnosisResults(forward *forwardRecord, nodeID int64, targets []diagnosisTarget) ([]map[string]interface{}, error) {
if h == nil || h.repo == nil {
return nil, errors.New("handler not initialized")
}
node, err := h.getNodeRecord(nodeID)
if err != nil {
return nil, err
}
bindings, err := h.repo.ListNftRuleBindingsByNode(nodeID)
if err != nil {
return nil, err
}
var binding *model.NftRuleBinding
for i := range bindings {
if bindings[i].ForwardID == forward.ID {
binding = &bindings[i]
break
}
}
target := diagnosisTarget{}
if len(targets) > 0 {
target = targets[0]
}
status := "missing"
message := "nftables 规则未下发"
success := false
inPort := 0
protocols := ""
targetAddr := strings.TrimSpace(forward.RemoteAddr)
ruleHash := ""
if binding != nil {
status = strings.ToLower(strings.TrimSpace(binding.Status))
inPort = binding.InPort
protocols = strings.TrimSpace(binding.Protocols)
targetAddr = strings.TrimSpace(binding.TargetAddr)
ruleHash = strings.TrimSpace(binding.RuleHash)
if status == "" {
status = "pending"
}
if status == runtimenft.StatusApplied {
success = true
message = "nftables 规则已下发"
} else if strings.TrimSpace(binding.LastError) != "" {
message = binding.LastError
} else {
message = "nftables 规则未完成下发"
}
}
packetLoss := 100
if success {
packetLoss = 0
}
result := map[string]interface{}{
"success": success,
"nodeName": node.Name,
"nodeId": strconv.FormatInt(nodeID, 10),
"targetIp": target.IP,
"targetPort": target.Port,
"description": fmt.Sprintf("nftables规则(%s)->目标(%s)", node.Name, defaultString(target.Address, targetAddr)),
"averageTime": 0,
"packetLoss": packetLoss,
"message": message,
"fromChainType": 1,
"forwardMode": "nftables",
"nftRuleStatus": status,
"nftRuleHash": ruleHash,
"inPort": inPort,
"protocols": protocols,
"targetAddr": targetAddr,
}
return []map[string]interface{}{result}, nil
}
func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []diagnosisWorkItem, error) {
if forward == nil {
return "", nil, errForwardNotFound
@@ -1474,10 +1630,14 @@ func (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int, options diag
if options.pingTimeoutMS <= 0 {
options.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)
}
pingCount := options.pingCount
if pingCount <= 0 {
pingCount = 4
}
res, err := h.sendNodeCommandWithTimeout(nodeID, "TcpPing", map[string]interface{}{
"ip": ip,
"port": port,
"count": 4,
"count": pingCount,
"timeout": options.pingTimeoutMS,
}, options.commandTimeout, false, false)
if err != nil {
@@ -1504,12 +1664,16 @@ func (h *Handler) tcpPingViaRemoteNode(node *nodeRecord, ip string, port int, op
if options.pingTimeoutMS <= 0 {
options.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)
}
pingCount := options.pingCount
if pingCount <= 0 {
pingCount = 4
}
fc := client.NewFederationClientWithTimeout(options.commandTimeout)
return fc.Diagnose(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeDiagnoseRequest{
IP: strings.TrimSpace(ip),
Port: port,
Count: 4,
Count: pingCount,
Timeout: options.pingTimeoutMS,
Protocol: "tcp",
})
@@ -1744,6 +1908,7 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
services := make([]map[string]interface{}, 0, 2)
targets := splitRemoteTargets(forward.RemoteAddr)
strategy := strings.TrimSpace(forward.Strategy)
proxyProtocolReceive, proxyProtocolSend := normalizeForwardProxyProtocol(forward.ProxyProtocol, forward.ProxyProtocolReceive, forward.ProxyProtocolSend)
if strategy == "" {
strategy = "fifo"
}
@@ -1788,12 +1953,16 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
if runtimeLimiters.TrafficLimiter != "" {
service["limiter"] = runtimeLimiters.TrafficLimiter
}
if forward.ProxyProtocol > 0 {
if proxyProtocolReceive > 0 {
serviceMetadata := ensureServiceMetadata(service)
serviceMetadata["proxyProtocol"] = proxyProtocolReceive
}
if proxyProtocolSend > 0 {
handlerConfig := service["handler"].(map[string]interface{})
if handlerConfig["metadata"] == nil {
handlerConfig["metadata"] = map[string]interface{}{}
}
handlerConfig["metadata"].(map[string]interface{})["proxyProtocol"] = forward.ProxyProtocol
handlerConfig["metadata"].(map[string]interface{})["proxyProtocol"] = proxyProtocolSend
}
if protocol == "udp" {
listenerMetadata := map[string]interface{}{
@@ -1803,13 +1972,16 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
service["listener"].(map[string]interface{})["metadata"] = listenerMetadata
}
if tunnel != nil && tunnel.Type == 2 {
service["handler"].(map[string]interface{})["chain"] = fmt.Sprintf("chains_%d", forward.TunnelID)
handlerConfig := service["handler"].(map[string]interface{})
handlerConfig["chain"] = fmt.Sprintf("chains_%d", forward.TunnelID)
if handlerConfig["metadata"] == nil {
handlerConfig["metadata"] = map[string]interface{}{}
}
handlerConfig["metadata"].(map[string]interface{})["chain.strict"] = true
}
if tunnel != nil && tunnel.Type == 1 && strings.TrimSpace(node.InterfaceName) != "" {
if service["metadata"] == nil {
service["metadata"] = map[string]interface{}{}
}
service["metadata"].(map[string]interface{})["interface"] = node.InterfaceName
serviceMetadata := ensureServiceMetadata(service)
serviceMetadata["interface"] = node.InterfaceName
}
services = append(services, service)
}
@@ -1828,6 +2000,25 @@ func buildForwarderNodes(targets []string) []map[string]interface{} {
return nodes
}
func ensureServiceMetadata(service map[string]interface{}) map[string]interface{} {
if service["metadata"] == nil {
service["metadata"] = map[string]interface{}{}
}
metadata, ok := service["metadata"].(map[string]interface{})
if !ok {
metadata = map[string]interface{}{}
service["metadata"] = metadata
}
return metadata
}
func normalizeForwardProxyProtocol(legacy, receive, send int) (int, int) {
if send == 0 && legacy > 0 {
send = legacy
}
return receive, send
}
func processServerAddress(serverAddr string) string {
serverAddr = normalizeServerAddressInput(serverAddr)
if serverAddr == "" {
@@ -0,0 +1,45 @@
package handler
import (
"errors"
"go-backend/internal/http/response"
"go-backend/internal/store/repo"
"net/http"
)
func (h *Handler) userDisplayOrder(w http.ResponseWriter, r *http.Request) {
updateDisplayOrder(w, r, h.repo.UpdateUserDisplayOrder)
}
func (h *Handler) speedLimitDisplayOrder(w http.ResponseWriter, r *http.Request) {
updateDisplayOrder(w, r, h.repo.UpdateSpeedLimitDisplayOrder)
}
func updateDisplayOrder(w http.ResponseWriter, r *http.Request, save func([]int64, []int64) error) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.Err(400, "请求方法错误"))
return
}
var req struct {
IDs []int64 `json:"ids"`
ExpectedIDs []int64 `json:"expectedIds"`
}
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.Err(400, "请求参数错误"))
return
}
if err := save(req.IDs, req.ExpectedIDs); err != nil {
code := 500
if errors.Is(err, repo.ErrInvalidDisplayOrder) {
code = 400
}
if errors.Is(err, repo.ErrDisplayOrderConflict) {
code = 409
}
msg := "排序保存失败,请刷新后重试"
if code != 500 {
msg = err.Error()
}
response.WriteJSON(w, response.Err(code, msg))
return
}
response.WriteJSON(w, response.OKEmpty())
}
+252 -189
View File
@@ -1,8 +1,11 @@
package handler
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
@@ -14,6 +17,7 @@ import (
"go-backend/internal/http/client"
"go-backend/internal/http/response"
"go-backend/internal/relaysecurity"
"go-backend/internal/store/repo"
)
@@ -65,18 +69,20 @@ type federationRuntimeReservePortRequest struct {
}
type federationRuntimeTarget struct {
Host string `json:"host"`
Port int `json:"port"`
Protocol string `json:"protocol"`
RelayAuth *relaysecurity.Credentials `json:"relayAuth,omitempty"`
Host string `json:"host"`
Port int `json:"port"`
Protocol string `json:"protocol"`
}
type federationRuntimeApplyRoleRequest struct {
ReservationID string `json:"reservationId"`
ResourceKey string `json:"resourceKey"`
Role string `json:"role"`
Protocol string `json:"protocol"`
Strategy string `json:"strategy"`
Targets []federationRuntimeTarget `json:"targets"`
RelayAuth *relaysecurity.Credentials `json:"relayAuth,omitempty"`
ReservationID string `json:"reservationId"`
ResourceKey string `json:"resourceKey"`
Role string `json:"role"`
Protocol string `json:"protocol"`
Strategy string `json:"strategy"`
Targets []federationRuntimeTarget `json:"targets"`
}
type federationRuntimeReleaseRoleRequest struct {
@@ -112,6 +118,13 @@ func buildFederationMiddleChainConfig(chainName string, runtimeID int64, protoco
connector := map[string]interface{}{
"type": "relay",
}
if target.RelayAuth != nil {
if err := target.RelayAuth.Validate(); err != nil {
return nil, err
}
connector["auth"] = target.RelayAuth.Config()
}
if isTCPTunnelProtocol(targetProtocol) {
connector["metadata"] = map[string]interface{}{
"nodelay": true,
@@ -218,7 +231,7 @@ type remoteUsageNodeItem struct {
SyncError string `json:"syncError,omitempty"`
}
func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName string, targetCount int, interfaceName string) map[string]interface{} {
func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName string, targetCount int, interfaceName string, credentials *relaysecurity.Credentials) map[string]interface{} {
service := map[string]interface{}{
"name": serviceName,
"addr": addr,
@@ -227,6 +240,10 @@ func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName s
},
"listener": buildTunnelListenerConfig(protocol),
}
if credentials != nil {
service["handler"].(map[string]interface{})["auth"] = credentials.Config()
}
if isTCPTunnelProtocol(protocol) {
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{
"nodelay": true,
@@ -240,6 +257,13 @@ func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName s
}
}
if role == "middle" {
handler := service["handler"].(map[string]interface{})
md, _ := handler["metadata"].(map[string]interface{})
if md == nil {
md = map[string]interface{}{}
handler["metadata"] = md
}
md["chain.strict"] = true
service["handler"].(map[string]interface{})["chain"] = chainName
if targetCount > 1 {
service["handler"].(map[string]interface{})["retries"] = targetCount - 1
@@ -408,9 +432,26 @@ func (h *Handler) federationShareDelete(w http.ResponseWriter, r *http.Request)
return
}
share, _ := h.repo.GetPeerShare(req.ID)
share, err := h.repo.GetPeerShare(req.ID)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if share != nil {
// Revoke new allocations before cleanup; failed deletions keep the
// disabled share and its reservations available for retry.
share.IsActive = 0
share.UpdatedTime = time.Now().UnixMilli()
if err := h.repo.UpdatePeerShare(share); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
}
h.cleanupPeerShareRuntimes(req.ID)
if err := h.cleanupPeerShareRuntimes(req.ID); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
h.cleanupFederationTunnels(req.ID)
if err := h.repo.DeletePeerShare(req.ID); err != nil {
@@ -418,10 +459,6 @@ func (h *Handler) federationShareDelete(w http.ResponseWriter, r *http.Request)
return
}
if share != nil && h.wsServer != nil {
h.wsServer.SendCommand(share.NodeID, "reload", nil, time.Second*5)
}
response.WriteJSON(w, response.OKEmpty())
}
@@ -805,16 +842,6 @@ func (h *Handler) authPeer(next http.HandlerFunc) http.HandlerFunc {
return
}
if share.IsActive == 0 {
response.WriteJSON(w, response.Err(403, "Share is disabled"))
return
}
if share.ExpiryTime > 0 && share.ExpiryTime < time.Now().UnixMilli() {
response.WriteJSON(w, response.Err(403, "Share expired"))
return
}
if strings.TrimSpace(share.AllowedIPs) != "" {
clientIP := resolvePeerClientIP(r)
if clientIP == nil {
@@ -847,10 +874,48 @@ func (h *Handler) authPeer(next http.HandlerFunc) http.HandlerFunc {
}
}
if share.IsActive != 1 || (share.ExpiryTime > 0 && share.ExpiryTime <= time.Now().UnixMilli()) || isPeerShareFlowExceeded(share) {
if !isFederationCleanupRequest(r) {
response.WriteJSON(w, response.Err(403, "Share is inactive, expired, or over quota"))
return
}
}
next(w, r)
}
}
// Revoked allocation privileges must not revoke cleanup privileges. Inspect
// only supported destructive commands, preserving the body for the handler.
func isFederationCleanupRequest(r *http.Request) bool {
if r.Method != http.MethodPost {
return false
}
switch r.URL.Path {
case "/api/v1/federation/runtime/release-role":
return true
case "/api/v1/federation/runtime/command":
if r.Body == nil {
return false
}
var copied bytes.Buffer
original := r.Body
var request federationRuntimeCommandRequest
err := json.NewDecoder(io.TeeReader(io.LimitReader(original, 1<<20), &copied)).Decode(&request)
r.Body = struct {
io.Reader
io.Closer
}{Reader: io.MultiReader(&copied, original), Closer: original}
if err != nil || !isFederationRuntimeCommandAllowed(request.CommandType) {
return false
}
_, action := federationResourceCommandKind(request.CommandType)
return action == "delete"
default:
return false
}
}
func (h *Handler) federationConnect(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("Invalid method"))
@@ -871,17 +936,18 @@ func (h *Handler) federationConnect(w http.ResponseWriter, r *http.Request) {
}
response.WriteJSON(w, response.OK(map[string]interface{}{
"shareId": share.ID,
"shareName": share.Name,
"nodeId": share.NodeID,
"nodeName": nodeInfo.Name,
"serverIp": nodeInfo.ServerIP,
"status": nodeInfo.Status,
"maxBandwidth": share.MaxBandwidth,
"currentFlow": share.CurrentFlow,
"expiryTime": share.ExpiryTime,
"portRangeStart": share.PortRangeStart,
"portRangeEnd": share.PortRangeEnd,
"shareId": share.ID,
"relayAuthVersion": relaysecurity.Version,
"shareName": share.Name,
"nodeId": share.NodeID,
"nodeName": nodeInfo.Name,
"serverIp": nodeInfo.ServerIP,
"status": nodeInfo.Status,
"maxBandwidth": share.MaxBandwidth,
"currentFlow": share.CurrentFlow,
"expiryTime": share.ExpiryTime,
"portRangeStart": share.PortRangeStart,
"portRangeEnd": share.PortRangeEnd,
}))
}
@@ -982,8 +1048,6 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
return
}
h.wsServer.SendCommand(share.NodeID, "reload", nil, time.Second*5)
response.WriteJSON(w, response.OK(map[string]interface{}{
"tunnelId": tunnelID,
}))
@@ -1013,21 +1077,35 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
return
}
peerRoleRuntimeMu.Lock()
defer peerRoleRuntimeMu.Unlock()
share, err = h.repo.GetPeerShare(share.ID)
if err != nil || share == nil || share.IsActive != 1 || (share.ExpiryTime > 0 && share.ExpiryTime <= time.Now().UnixMilli()) {
response.WriteJSON(w, response.Err(403, "Share is unavailable"))
return
}
if isPeerShareFlowExceeded(share) {
response.WriteJSON(w, response.Err(403, "Share traffic limit exceeded"))
return
}
existing, err := h.repo.GetPeerShareRuntimeByResourceKey(share.ID, req.ResourceKey)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if existing != nil && existing.Status == 1 {
response.WriteJSON(w, response.OK(map[string]interface{}{
"reservationId": existing.ReservationID,
"allocatedPort": existing.Port,
"bindingId": existing.BindingID,
}))
if existing != nil && existing.ReleasePending != 0 {
response.WriteJSON(w, response.ErrDefault("Runtime release is pending"))
return
}
if isPeerShareFlowExceeded(share) {
response.WriteJSON(w, response.Err(403, "Share traffic limit exceeded"))
if existing != nil && existing.Status == 1 {
response.WriteJSON(w, response.OK(map[string]interface{}{
"reservationId": existing.ReservationID,
"allocatedPort": existing.Port,
"relayAuthVersion": relaysecurity.Version,
"bindingId": existing.BindingID,
}))
return
}
@@ -1039,6 +1117,7 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
now := time.Now().UnixMilli()
if existing != nil {
existing.ReservationID = randomToken(24)
existing.Protocol = defaultString(req.Protocol, "tls")
existing.Port = allocatedPort
existing.BindingID = ""
@@ -1047,6 +1126,8 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
existing.ServiceName = ""
existing.Strategy = "round"
existing.Target = ""
existing.RelayAuth = ""
existing.RelayAuthVersion = 0
existing.Applied = 0
existing.Status = 1
existing.UpdatedTime = now
@@ -1055,9 +1136,10 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
return
}
response.WriteJSON(w, response.OK(map[string]interface{}{
"reservationId": existing.ReservationID,
"allocatedPort": existing.Port,
"bindingId": existing.BindingID,
"reservationId": existing.ReservationID,
"allocatedPort": existing.Port,
"relayAuthVersion": relaysecurity.Version,
"bindingId": existing.BindingID,
}))
return
}
@@ -1086,9 +1168,10 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
}
response.WriteJSON(w, response.OK(map[string]interface{}{
"reservationId": runtime.ReservationID,
"allocatedPort": runtime.Port,
"bindingId": runtime.BindingID,
"reservationId": runtime.ReservationID,
"allocatedPort": runtime.Port,
"relayAuthVersion": relaysecurity.Version,
"bindingId": runtime.BindingID,
}))
}
@@ -1116,6 +1199,14 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
return
}
peerRoleRuntimeMu.Lock()
defer peerRoleRuntimeMu.Unlock()
share, err = h.repo.GetPeerShare(share.ID)
if err != nil || share == nil || share.IsActive != 1 || (share.ExpiryTime > 0 && share.ExpiryTime <= time.Now().UnixMilli()) {
response.WriteJSON(w, response.ErrDefault("Share is unavailable"))
return
}
var runtime *repo.PeerShareRuntime
if strings.TrimSpace(req.ReservationID) != "" {
runtime, err = h.repo.GetPeerShareRuntimeByReservationID(share.ID, strings.TrimSpace(req.ReservationID))
@@ -1131,115 +1222,59 @@ 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,
"reservationId": runtime.ReservationID,
}))
if runtime.ReleasePending != 0 {
response.WriteJSON(w, response.ErrDefault("Runtime release is pending"))
return
}
if isPeerShareFlowExceeded(share) {
response.WriteJSON(w, response.Err(403, "Share traffic limit exceeded"))
return
}
if share.PortRangeStart > 0 && share.PortRangeEnd > 0 && runtime.Port > 0 {
if runtime.Port < share.PortRangeStart || runtime.Port > share.PortRangeEnd {
response.WriteJSON(w, response.Err(403, fmt.Sprintf("port %d out of allowed range %d-%d", runtime.Port, share.PortRangeStart, share.PortRangeEnd)))
return
}
if runtime.Role != "" && runtime.Role != req.Role {
response.WriteJSON(w, response.ErrDefault("Runtime role cannot change without release"))
return
}
if share.PortRangeStart > 0 && share.PortRangeEnd > 0 && (runtime.Port < share.PortRangeStart || runtime.Port > share.PortRangeEnd) {
response.WriteJSON(w, response.ErrDefault("Reserved port is outside share range"))
return
}
if _, err := decodeRuntimeRelayAuth(runtime); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if err := validateFederationRelayAuth(runtime.RelayAuth, req.RelayAuth, req.Targets); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if req.RelayAuth != nil {
encoded, _ := json.Marshal(req.RelayAuth)
runtime.RelayAuth = string(encoded)
runtime.RelayAuthVersion = relaysecurity.Version
}
if strings.TrimSpace(runtime.BindingID) == "" {
runtime.BindingID = randomToken(24)
}
runtime.Role = req.Role
runtime.ServiceName = fmt.Sprintf("fed_svc_%d", runtime.ID)
runtime.ChainName = ""
if req.Role == "middle" {
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, "AddChains", chainData, true, false); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
runtime.ChainName = federationRuntimeChainName(runtime.BindingID)
}
targetCount := len(req.Targets)
service := buildFederationServiceConfig(
serviceName,
fmt.Sprintf("%s:%d", node.TCPListenAddr, runtime.Port),
protocol,
req.Role,
chainName,
targetCount,
node.InterfaceName,
)
if _, err := h.sendNodeCommand(share.NodeID, "AddService", []map[string]interface{}{service}, true, false); err != nil {
if req.Role == "middle" {
_, _ = h.sendNodeCommand(share.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
}
runtime.Protocol = defaultString(req.Protocol, runtime.Protocol)
runtime.Strategy = defaultString(req.Strategy, "round")
targetBytes, _ := json.Marshal(req.Targets)
runtime.Target = string(targetBytes)
if err := h.applyPeerShareRoleRuntime(runtime); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
targetBytes, _ := json.Marshal(req.Targets)
runtime.BindingID = fmt.Sprintf("%d", runtime.ID)
runtime.Role = req.Role
runtime.ChainName = ""
if req.Role == "middle" {
runtime.ChainName = chainName
}
runtime.ServiceName = serviceName
runtime.Protocol = protocol
runtime.Strategy = strategy
runtime.Target = string(targetBytes)
runtime.Applied = 1
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,
"reservationId": runtime.ReservationID,
"allocatedPort": runtime.Port,
"relayAuthVersion": relayAuthVersion(req.RelayAuth),
"bindingId": runtime.BindingID,
"reservationId": runtime.ReservationID,
"allocatedPort": runtime.Port,
}))
}
@@ -1282,17 +1317,8 @@ func (h *Handler) federationRuntimeReleaseRole(w http.ResponseWriter, r *http.Re
return
}
if runtime.Applied == 1 {
if strings.TrimSpace(runtime.ServiceName) != "" {
_, _ = h.sendNodeCommand(share.NodeID, "DeleteService", map[string]interface{}{"services": []string{runtime.ServiceName}}, false, true)
}
if strings.TrimSpace(runtime.Role) == "middle" && strings.TrimSpace(runtime.ChainName) != "" {
_, _ = h.sendNodeCommand(share.NodeID, "DeleteChains", map[string]interface{}{"chain": runtime.ChainName}, false, true)
}
}
if err := h.repo.MarkPeerShareRuntimeReleased(runtime.ID, time.Now().UnixMilli()); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
if err := h.releasePeerShareRuntime(runtime); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
@@ -1385,6 +1411,15 @@ func (h *Handler) federationRuntimeCommand(w http.ResponseWriter, r *http.Reques
return
}
h.peerResourceMu.Lock()
defer h.peerResourceMu.Unlock()
// Recheck after acquiring the mutation lock: an earlier authentication
// decision cannot authorize a recreation after quota/expiry cleanup.
share, err = h.repo.GetPeerShare(share.ID)
if err != nil || share == nil {
response.WriteJSON(w, response.ErrDefault("share ownership unavailable"))
return
}
if isFederationServiceCommand(cmd) {
if err := validateFederationCommandPorts(share, req.Data); err != nil {
response.WriteJSON(w, response.Err(403, err.Error()))
@@ -1392,17 +1427,35 @@ func (h *Handler) federationRuntimeCommand(w http.ResponseWriter, r *http.Reques
}
}
res, err := h.sendNodeCommand(share.NodeID, cmd, req.Data, false, false)
_, action := federationResourceCommandKind(cmd)
if action != "delete" && (share.IsActive != 1 || (share.ExpiryTime > 0 && share.ExpiryTime <= time.Now().UnixMilli()) || isPeerShareFlowExceeded(share)) {
response.WriteJSON(w, response.Err(403, "share is inactive, expired, or over quota"))
return
}
if strings.EqualFold(cmd, "tcpping") {
res, err := h.sendNodeCommand(share.NodeID, "TcpPing", req.Data, false, false)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.OK(res))
return
}
items, err := h.preparePeerResourceCommand(share, cmd, req.Data)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if strings.EqualFold(cmd, "addservice") || strings.EqualFold(cmd, "updateservice") {
h.bindPeerShareForwardRuntimeServices(share, req.Data)
} else if strings.EqualFold(cmd, "deleteservice") {
h.releasePeerShareForwardRuntimeServices(share, req.Data)
var result interface{} = map[string]interface{}{"success": true}
for _, item := range items {
res, err := h.applyPeerShareResource(item)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
result = res
}
response.WriteJSON(w, response.OK(res))
response.WriteJSON(w, response.OK(result))
}
type federationForwardServiceBinding struct {
@@ -1436,10 +1489,15 @@ func parseFederationForwardServiceBindings(data interface{}) []federationForward
bindings := make([]federationForwardServiceBinding, 0, len(serviceList))
for _, svcMap := range serviceList {
name := normalizeForwardRuntimeServiceName(asString(svcMap["name"]))
originalName := name
if shareID, original, ok := parsePeerShareServiceName(asString(svcMap["name"])); ok {
originalName = normalizeForwardRuntimeServiceName(original)
name = peerShareResourceName(shareID, "service", originalName)
}
if name == "" {
continue
}
if _, _, _, ok := parseFlowServiceIDs(name); !ok {
if _, _, _, ok := parseFlowServiceIDs(originalName); !ok {
continue
}
addr := strings.TrimSpace(asString(svcMap["addr"]))
@@ -1498,29 +1556,29 @@ func parseFederationForwardServiceNamesForRelease(data interface{}) []string {
return out
}
func (h *Handler) bindPeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) {
func (h *Handler) bindPeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) error {
if h == nil || h.repo == nil || share == nil {
return
return nil
}
bindings := parseFederationForwardServiceBindings(data)
if len(bindings) == 0 {
return
return nil
}
now := time.Now().UnixMilli()
for _, binding := range bindings {
runtime, err := h.repo.GetActiveForwardPeerShareRuntimeByPort(share.ID, binding.Port)
if err != nil {
continue
return err
}
if runtime == nil {
runtime, err = h.repo.GetActiveForwardPeerShareRuntimeByServiceName(share.ID, binding.Name)
if err != nil {
continue
return err
}
}
if runtime == nil {
_ = h.repo.CreatePeerShareRuntime(&repo.PeerShareRuntime{
if err := h.repo.CreatePeerShareRuntime(&repo.PeerShareRuntime{
ShareID: share.ID,
NodeID: share.NodeID,
ReservationID: randomToken(24),
@@ -1537,9 +1595,14 @@ func (h *Handler) bindPeerShareForwardRuntimeServices(share *repo.PeerShare, dat
Status: 1,
CreatedTime: now,
UpdatedTime: now,
})
}); err != nil {
return err
}
continue
}
if runtime.ReleasePending != 0 {
return fmt.Errorf("runtime release is pending")
}
if runtime.ServiceName == binding.Name && runtime.Applied == 1 && runtime.Port == binding.Port && runtime.Status == 1 {
continue
}
@@ -1554,8 +1617,11 @@ func (h *Handler) bindPeerShareForwardRuntimeServices(share *repo.PeerShare, dat
if strings.TrimSpace(runtime.Strategy) == "" {
runtime.Strategy = "fifo"
}
_ = h.repo.UpdatePeerShareRuntime(runtime)
if err := h.repo.UpdatePeerShareRuntime(runtime); err != nil {
return err
}
}
return nil
}
func (h *Handler) releasePeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) {
@@ -1575,7 +1641,7 @@ func (h *Handler) releasePeerShareForwardRuntimeServices(share *repo.PeerShare,
func isFederationRuntimeCommandAllowed(commandType string) bool {
switch strings.ToLower(strings.TrimSpace(commandType)) {
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "deletechains", "addlimiters", "updatelimiters", "deletelimiters", "tcpping", "reload":
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "updatechains", "deletechains", "addlimiters", "updatelimiters", "deletelimiters", "addclimiters", "updateclimiters", "deleteclimiters", "tcpping":
return true
default:
return false
@@ -1893,27 +1959,24 @@ func (h *Handler) syncRemoteNodeStatuses(items []map[string]interface{}) {
}
}
func (h *Handler) cleanupPeerShareRuntimes(shareID int64) {
func (h *Handler) cleanupPeerShareRuntimes(shareID int64) error {
if h == nil || h.repo == nil || shareID <= 0 {
return
return nil
}
if err := h.releasePeerShareResources(shareID); err != nil {
return err
}
runtimes, err := h.repo.ListActivePeerShareRuntimesByShareID(shareID)
if err != nil || len(runtimes) == 0 {
return
if err != nil {
return err
}
now := time.Now().UnixMilli()
for _, runtime := range runtimes {
if h.wsServer != nil && runtime.Applied == 1 {
if strings.TrimSpace(runtime.ServiceName) != "" {
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteService", map[string]interface{}{"services": []string{runtime.ServiceName}}, false, true)
}
if strings.TrimSpace(runtime.Role) == "middle" && strings.TrimSpace(runtime.ChainName) != "" {
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteChains", map[string]interface{}{"chain": runtime.ChainName}, false, true)
}
var cleanupErr error
for i := range runtimes {
if err := h.releasePeerShareRuntime(&runtimes[i]); err != nil {
cleanupErr = errors.Join(cleanupErr, err)
}
_ = h.repo.MarkPeerShareRuntimeReleased(runtime.ID, now)
}
return cleanupErr
}
func (h *Handler) cleanupFederationTunnels(shareID int64) {
@@ -0,0 +1,275 @@
package handler
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"go-backend/internal/http/response"
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
"go-backend/internal/ws"
)
func consumerCleanupFixture(t *testing.T, remoteURL string) (*repo.Repository, *Handler) {
t.Helper()
r, err := repo.Open(filepath.Join(t.TempDir(), "consumer-cleanup.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = r.Close() })
if err := r.DB().Create(&model.Node{ID: 1, Name: "remote", Secret: "remote", ServerIP: "127.0.0.1", Port: "31000-31010", IsRemote: 1, Status: 1, RemoteURL: sql.NullString{String: remoteURL, Valid: true}, RemoteToken: sql.NullString{String: "token", Valid: true}}).Error; err != nil {
t.Fatal(err)
}
if err := r.UpsertFederationTunnelBinding(&repo.FederationTunnelBinding{TunnelID: 42, NodeID: 1, ChainType: 3, RemoteURL: remoteURL, ResourceKey: "tunnel:42:node:1:type:3:hop:0", RemoteBindingID: "binding-old", AllocatedPort: 31001, Status: 1}); err != nil {
t.Fatal(err)
}
if err := r.DB().Create(&model.Tunnel{ID: 42, Name: "live tunnel", Type: 2, Protocol: "tls", Flow: 1, TrafficRatio: 1, Status: 1}).Error; err != nil {
t.Fatal(err)
}
return r, &Handler{repo: r, wsServer: ws.NewServer(r, "consumer-cleanup")}
}
func TestTunnelUpdateValidationPreservesSharedRuntime(t *testing.T) {
for _, body := range []string{
`{"id":42,"type":2,"name":"invalid update","inNodeId":[]}`,
`{"id":42,"type":2,"name":"invalid update","inNodeId":[{"nodeId":999}],"outNodeId":[{"nodeId":1,"port":31001}]}`,
} {
t.Run(body, func(t *testing.T) {
var releases atomic.Int64
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
releases.Add(1)
response.WriteJSON(w, response.OKEmpty())
}))
defer remote.Close()
r, h := consumerCleanupFixture(t, remote.URL)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
h.tunnelUpdate(rec, req)
var payload response.R
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
bindings, err := r.ListActiveFederationTunnelBindingsByTunnel(42)
if err != nil {
t.Fatal(err)
}
name, err := r.GetTunnelName(42)
if err != nil {
t.Fatal(err)
}
if payload.Code == 0 || releases.Load() != 0 || len(bindings) != 1 || name != "live tunnel" {
t.Fatalf("invalid update changed runtime: code=%d releases=%d bindings=%v name=%s", payload.Code, releases.Load(), bindings, name)
}
})
}
}
func TestFederationCleanupRetainsFailedBindingAndRetriesOnlyPending(t *testing.T) {
var unavailable atomic.Bool
unavailable.Store(true)
var releases atomic.Int64
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
releases.Add(1)
if unavailable.Load() {
http.Error(w, "peer unavailable", http.StatusServiceUnavailable)
return
}
response.WriteJSON(w, response.OKEmpty())
}))
defer remote.Close()
r, h := consumerCleanupFixture(t, remote.URL)
if err := r.UpsertFederationTunnelBinding(&repo.FederationTunnelBinding{TunnelID: 43, NodeID: 1, ChainType: 3, RemoteURL: remote.URL, RemoteBindingID: "unrelated", ResourceKey: "unrelated", Status: 1}); err != nil {
t.Fatal(err)
}
if err := h.cleanupFederationRuntime(42); err == nil {
t.Fatal("expected release failure")
}
pending, err := r.ListPendingFederationTunnelBindings()
if err != nil || len(pending) != 1 {
t.Fatalf("lost pending cleanup: %v %v", pending, err)
}
if err := h.cleanupFederationRuntime(42); err == nil {
t.Fatal("expected second release failure")
}
if releases.Load() != 2 {
t.Fatalf("cleanup was not retried: %d", releases.Load())
}
unavailable.Store(false)
if err := h.retryPendingFederationRuntimeCleanup(); err != nil {
t.Fatal(err)
}
pending, err = r.ListPendingFederationTunnelBindings()
if err != nil || len(pending) != 0 {
t.Fatalf("completed cleanup remains: %v %v", pending, err)
}
active, err := r.ListActiveFederationTunnelBindingsByTunnel(43)
if err != nil || len(active) != 1 || releases.Load() != 3 {
t.Fatalf("retry touched active binding: %v %v calls=%d", active, err, releases.Load())
}
}
func TestTunnelDeleteReportsRemoteFailureAndKeepsTunnel(t *testing.T) {
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
http.Error(w, "peer unavailable", http.StatusServiceUnavailable)
}))
defer remote.Close()
r, h := consumerCleanupFixture(t, remote.URL)
rec := httptest.NewRecorder()
h.tunnelDelete(rec, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/delete", strings.NewReader(`{"id":42}`)))
var payload response.R
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload.Code == 0 {
t.Fatal("delete reported success before peer cleanup")
}
if name, err := r.GetTunnelName(42); err != nil || name != "live tunnel" {
t.Fatalf("lost tunnel: %s %v", name, err)
}
pending, err := r.ListPendingFederationTunnelBindings()
if err != nil || len(pending) != 1 {
t.Fatalf("lost binding: %v %v", pending, err)
}
}
func TestFederationRollbackReleaseIsDurableAndRetryable(t *testing.T) {
var unavailable atomic.Bool
unavailable.Store(true)
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if unavailable.Load() {
http.Error(w, "peer unavailable", http.StatusServiceUnavailable)
return
}
response.WriteJSON(w, response.OKEmpty())
}))
defer remote.Close()
r, h := consumerCleanupFixture(t, remote.URL)
refs := []federationRuntimeReleaseRef{{RemoteURL: remote.URL, RemoteToken: "token", BindingID: "new-binding", ReservationID: "new-reservation", ResourceKey: "new-key"}}
if err := h.releaseFederationRuntimeRefs(refs); err == nil {
t.Fatal("expected release error")
}
if err := h.releaseFederationRuntimeRefs(refs); err == nil {
t.Fatal("expected repeat error")
}
pending, err := r.ListPendingFederationReleases()
if err != nil || len(pending) != 1 {
t.Fatalf("rollback queue lost or duplicated release: %v %v", pending, err)
}
unavailable.Store(false)
// A new Handler has no in-memory state from the failed rollback.
restarted := &Handler{repo: r}
if err := restarted.retryPendingFederationRuntimeCleanup(); err != nil {
t.Fatal(err)
}
pending, err = r.ListPendingFederationReleases()
if err != nil || len(pending) != 0 {
t.Fatalf("completed rollback remains queued: %v %v", pending, err)
}
active, err := r.ListActiveFederationTunnelBindingsByTunnel(42)
if err != nil || len(active) != 1 {
t.Fatalf("rollback retry removed active binding: %v %v", active, err)
}
}
func TestFederationApplyFailureReturnsReservationForRollback(t *testing.T) {
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if strings.HasSuffix(req.URL.Path, "reserve-port") {
response.WriteJSON(w, response.OK(map[string]interface{}{"reservationId": "reserved", "bindingId": "binding", "allocatedPort": 31001}))
return
}
http.Error(w, "apply failed", http.StatusServiceUnavailable)
}))
defer remote.Close()
_, h := consumerCleanupFixture(t, remote.URL)
state := &tunnelCreateState{TunnelID: 43, Type: 2, OutNodes: []tunnelRuntimeNode{{NodeID: 1, Port: 31001}}, Nodes: map[int64]*nodeRecord{1: {ID: 1, Name: "remote", IsRemote: 1, RemoteURL: remote.URL, RemoteToken: "token"}}}
_, refs, err := h.applyFederationRuntime(state, "")
if err == nil || len(refs) != 1 || refs[0].ReservationID != "reserved" {
t.Fatalf("failed apply lost reservation: refs=%+v err=%v", refs, err)
}
}
func TestTunnelUpdateDatabaseValidationPreservesSharedRuntime(t *testing.T) {
var releases atomic.Int64
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
releases.Add(1)
response.WriteJSON(w, response.OKEmpty())
}))
defer remote.Close()
r, h := consumerCleanupFixture(t, remote.URL)
if err := r.DB().Create(&model.Node{ID: 2, Name: "entry", Secret: "entry", ServerIP: "127.0.0.2", Port: "31000-31010", Status: 1}).Error; err != nil {
t.Fatal(err)
}
if err := r.DB().Exec(`CREATE TRIGGER reject_tunnel_update BEFORE UPDATE ON tunnel BEGIN SELECT RAISE(ABORT, 'test constraint rejected'); END`).Error; err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
h.tunnelUpdate(rec, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", strings.NewReader(`{"id":42,"type":2,"name":"new name","inNodeId":[{"nodeId":2}],"outNodeId":[{"nodeId":1,"port":31001}]}`)))
var payload response.R
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
active, err := r.ListActiveFederationTunnelBindingsByTunnel(42)
if err != nil || payload.Code == 0 || !strings.Contains(payload.Msg, "test constraint rejected") || releases.Load() != 0 || len(active) != 1 {
t.Fatalf("DB validation touched runtime: payload=%+v bindings=%v calls=%d err=%v", payload, active, releases.Load(), err)
}
}
func TestTunnelUpdateRemoteReleaseFailurePreservesMetadata(t *testing.T) {
var requests atomic.Int64
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
requests.Add(1)
http.Error(w, "peer unavailable", http.StatusServiceUnavailable)
}))
defer remote.Close()
r, h := consumerCleanupFixture(t, remote.URL)
if err := r.DB().Create(&model.Node{ID: 2, Name: "entry", Secret: "entry", ServerIP: "127.0.0.2", Port: "31000-31010", Status: 1}).Error; err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
h.tunnelUpdate(rec, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", strings.NewReader(`{"id":42,"type":2,"name":"new name","inNodeId":[{"nodeId":2}],"outNodeId":[{"nodeId":1,"port":31001}]}`)))
var payload response.R
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
name, err := r.GetTunnelName(42)
pending, pendingErr := r.ListPendingFederationTunnelBindings()
if err != nil || pendingErr != nil || payload.Code == 0 || name != "live tunnel" || requests.Load() != 1 || len(pending) != 1 {
t.Fatalf("failed cleanup continued update: payload=%+v name=%s calls=%d pending=%v err=%v/%v", payload, name, requests.Load(), pending, err, pendingErr)
}
}
func TestTunnelUpdateChainWriteValidationPreservesSharedRuntime(t *testing.T) {
var calls atomic.Int64
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
calls.Add(1)
response.WriteJSON(w, response.OKEmpty())
}))
defer remote.Close()
r, h := consumerCleanupFixture(t, remote.URL)
if err := r.DB().Create(&model.Node{ID: 2, Name: "entry", Secret: "entry", ServerIP: "127.0.0.2", Port: "31000-31010", Status: 1}).Error; err != nil {
t.Fatal(err)
}
if err := r.DB().Exec(`CREATE TRIGGER reject_chain_insert BEFORE INSERT ON chain_tunnel BEGIN SELECT RAISE(ABORT, 'chain write rejected'); END`).Error; err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
h.tunnelUpdate(rec, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", strings.NewReader(`{"id":42,"type":2,"name":"new name","inNodeId":[{"nodeId":2}],"outNodeId":[{"nodeId":1,"port":31001}]}`)))
var payload response.R
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
active, err := r.ListActiveFederationTunnelBindingsByTunnel(42)
if err != nil || payload.Code == 0 || !strings.Contains(payload.Msg, "chain write rejected") || calls.Load() != 0 || len(active) != 1 {
t.Fatalf("chain validation touched runtime: payload=%+v active=%v calls=%d err=%v", payload, active, calls.Load(), err)
}
if name, err := r.GetTunnelName(42); err != nil || name != "live tunnel" {
t.Fatalf("preflight metadata update was committed: %s %v", name, err)
}
}
@@ -0,0 +1,703 @@
package handler
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"go-backend/internal/store/repo"
"go-backend/internal/ws"
)
const peerShareResourcePrefix = "peer-share-"
func peerShareResourceName(shareID int64, kind, original string) string {
return fmt.Sprintf("%s%d-%s-%s", peerShareResourcePrefix, shareID, kind, base64.RawURLEncoding.EncodeToString([]byte(original)))
}
func parsePeerShareServiceName(name string) (shareID int64, originalName string, ok bool) {
if !strings.HasPrefix(name, peerShareResourcePrefix) {
return
}
parts := strings.SplitN(strings.TrimPrefix(name, peerShareResourcePrefix), "-", 3)
if len(parts) != 3 || parts[1] != "service" {
return
}
shareID, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || shareID <= 0 {
return 0, "", false
}
decoded, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || len(decoded) == 0 {
return 0, "", false
}
return shareID, string(decoded), true
}
func federationResourceCommandKind(cmd string) (kind, action string) {
lower := strings.ToLower(cmd)
for _, entry := range []struct{ suffix, kind string }{{"service", "service"}, {"chains", "chain"}, {"climiters", "climiter"}, {"limiters", "limiter"}} {
if strings.HasSuffix(lower, entry.suffix) {
return entry.kind, strings.TrimSuffix(lower, entry.suffix)
}
}
return "", ""
}
// Every peer-supplied reference is resolved within the same share namespace.
// Composite traffic limiters use a comma-separated list in GOST.
func scopePeerResourceReferences(value interface{}, shareID int64) {
switch v := value.(type) {
case map[string]interface{}:
for key, child := range v {
if strings.EqualFold(key, "chains") {
if names, ok := child.([]interface{}); ok {
for i, name := range names {
if raw, ok := name.(string); ok {
names[i] = peerShareResourceName(shareID, "chain", raw)
}
}
continue
}
}
kind := ""
switch strings.ToLower(key) {
case "chain":
kind = "chain"
case "limiter":
kind = "limiter"
case "climiter":
kind = "climiter"
}
if raw, ok := child.(string); ok && kind != "" && strings.TrimSpace(raw) != "" {
names := strings.Split(raw, ",")
for i, name := range names {
names[i] = peerShareResourceName(shareID, kind, strings.TrimSpace(name))
}
v[key] = strings.Join(names, ",")
} else {
scopePeerResourceReferences(child, shareID)
}
}
case []interface{}:
for _, child := range v {
scopePeerResourceReferences(child, shareID)
}
}
}
func peerResourceDeletePayload(item repo.PeerShareResource) interface{} {
if item.Kind == "service" {
return map[string]interface{}{"services": []string{item.RuntimeName}}
}
key := item.Kind
if key == "climiter" {
key = "limiter"
}
return map[string]interface{}{key: item.RuntimeName}
}
func peerResourceDeleteCommand(kind string) string {
switch kind {
case "service":
return "DeleteService"
case "chain":
return "DeleteChains"
case "climiter":
return "DeleteCLimiters"
default:
return "DeleteLimiters"
}
}
// preparePeerResourceCommand validates and writes desired state before the node
// sees any command, closing the flow-report race even without a reservation.
func (h *Handler) preparePeerResourceCommand(share *repo.PeerShare, cmd string, data interface{}) ([]repo.PeerShareResource, error) {
kind, action := federationResourceCommandKind(cmd)
if kind == "" {
return nil, fmt.Errorf("command not allowed")
}
raw, err := json.Marshal(data)
if err != nil {
return nil, err
}
var decoded interface{}
if err = json.Unmarshal(raw, &decoded); err != nil {
return nil, err
}
var configs []map[string]interface{}
var names []string
setting := action == "add" || action == "update"
if setting {
if kind == "service" {
configs = extractFederationServiceEntries(decoded)
} else if m, ok := decoded.(map[string]interface{}); ok {
if nested, ok := m["data"].(map[string]interface{}); ok {
configs = []map[string]interface{}{nested}
} else {
configs = []map[string]interface{}{m}
}
}
if len(configs) == 0 {
return nil, fmt.Errorf("resource configuration is required")
}
for _, c := range configs {
names = append(names, strings.TrimSpace(asString(c["name"])))
}
} else {
if kind == "service" {
if m, ok := decoded.(map[string]interface{}); ok {
for _, v := range asAnySlice(m["services"]) {
names = append(names, strings.TrimSpace(asString(v)))
}
}
} else {
if s, ok := decoded.(string); ok {
names = []string{strings.TrimSpace(s)}
} else if m, ok := decoded.(map[string]interface{}); ok {
key := kind
if key == "climiter" {
key = "limiter"
}
names = []string{strings.TrimSpace(asString(m[key]))}
}
}
if len(names) == 0 {
return nil, fmt.Errorf("resource name is required")
}
}
requestedNames := make(map[string]bool, len(names))
for _, name := range names {
requestedNames[name] = true
}
items := make([]repo.PeerShareResource, 0, len(names))
seen := map[string]bool{}
for i, name := range names {
if name == "" || strings.HasPrefix(name, peerShareResourcePrefix) {
return nil, fmt.Errorf("invalid peer resource name")
}
if seen[name] {
return nil, fmt.Errorf("duplicate resource name")
}
seen[name] = true
old, err := h.repo.GetPeerShareResource(share.ID, kind, name)
if err != nil {
return nil, err
}
item := repo.PeerShareResource{ShareID: share.ID, NodeID: share.NodeID, Kind: kind, OriginalName: name, RuntimeName: peerShareResourceName(share.ID, kind, name), DesiredState: "active", UpdatedTime: time.Now().UnixMilli()}
if old != nil {
item = *old
item.Applied = 0
item.UpdatedTime = time.Now().UnixMilli()
}
if setting {
if old == nil && kind == "service" {
legacy, err := h.peerShareLegacyServiceNames(share, name)
if err != nil {
return nil, err
}
if len(legacy) > 0 {
encoded, _ := json.Marshal(legacy)
item.LegacyNames = string(encoded)
item.LegacyServiceBase = normalizeForwardRuntimeServiceName(name)
}
}
config := configs[i]
if err := validatePeerResourceReferences(config, kind); err != nil {
return nil, err
}
scopePeerResourceReferences(config, share.ID)
config["name"] = item.RuntimeName
encoded, err := json.Marshal(config)
if err != nil {
return nil, err
}
item.Config = string(encoded)
item.DesiredState = "active"
item.ReleaseLegacyFamily = false
} else {
if old == nil {
// Delete callers send candidate base/TCP/UDP names. Missing names
// are safe no-ops; never forward a raw legacy name to the node.
if action == "delete" {
if kind != "service" {
continue
}
legacy, err := h.peerShareLegacyServiceNames(share, name)
if err != nil {
return nil, err
}
if len(legacy) == 0 {
continue
}
encoded, _ := json.Marshal(legacy)
item.LegacyNames = string(encoded)
item.LegacyServiceBase = normalizeForwardRuntimeServiceName(name)
} else {
return nil, fmt.Errorf("service %q not found", name)
}
}
if old != nil && old.DesiredState == "deleted" && action != "delete" {
return nil, fmt.Errorf("service %q not found", name)
}
switch action {
case "delete":
item.DesiredState = "deleted"
base := normalizeForwardRuntimeServiceName(name)
if requestedNames[base] && requestedNames[base+"_tcp"] && requestedNames[base+"_udp"] {
item.ReleaseLegacyFamily = true
}
case "pause":
item.DesiredState = "paused"
case "resume":
if item.Config == "" {
return nil, fmt.Errorf("resource has no saved configuration")
}
item.DesiredState = "active"
default:
return nil, fmt.Errorf("command not allowed")
}
}
items = append(items, item)
}
// Ownership and desired config commit atomically. A failed resource write
// must not rename a legacy binding and expose its remaining transports.
err = h.repo.WithPeerShareResourceTransaction(func(tx *repo.Repository) error {
if setting && kind == "service" {
scoped := make([]interface{}, 0, len(items))
for _, item := range items {
var c map[string]interface{}
_ = json.Unmarshal([]byte(item.Config), &c)
scoped = append(scoped, c)
}
binder := &Handler{repo: tx}
if err := binder.bindPeerShareForwardRuntimeServices(share, scoped); err != nil {
return err
}
}
return tx.SavePeerShareResources(items)
})
if err != nil {
return nil, err
}
return items, nil
}
func (h *Handler) applyPeerShareResource(item repo.PeerShareResource) (ws.CommandResult, error) {
var result ws.CommandResult
var err error
if item.LegacyNames != "" {
var names []string
if err := json.Unmarshal([]byte(item.LegacyNames), &names); err != nil {
return result, err
}
share, loadErr := h.repo.GetPeerShare(item.ShareID)
if loadErr != nil {
return result, loadErr
}
if share == nil {
return result, fmt.Errorf("legacy resource ownership missing")
}
for _, name := range names {
owned, checkErr := h.peerShareLegacyServiceNames(share, name)
if checkErr != nil {
return result, checkErr
}
if len(owned) == 0 {
return result, fmt.Errorf("legacy resource ownership missing")
}
}
if _, err = h.sendNodeCommand(item.NodeID, "DeleteService", map[string]interface{}{"services": names}, false, true); err != nil {
return result, err
}
if err = h.repo.ClearPeerShareResourceLegacyNames(item.ShareID, item.Kind, item.OriginalName); err != nil {
return result, err
}
}
if item.DesiredState == "deleted" || item.DesiredState == "paused" {
// The provider retains the paused configuration durably. Keep the
// listener absent on reconnect instead of briefly starting it before
// sending a second pause command. Resume reapplies the saved config.
result, err = h.sendNodeCommand(item.NodeID, peerResourceDeleteCommand(item.Kind), peerResourceDeletePayload(item), false, true)
} else {
var config map[string]interface{}
if err = json.Unmarshal([]byte(item.Config), &config); err != nil {
return result, err
}
if item.Kind == "service" {
result, err = h.sendNodeCommand(item.NodeID, "UpdateService", []interface{}{config}, false, false)
} else {
suffix := "Limiters"
key := "limiter"
if item.Kind == "chain" {
suffix = "Chains"
key = "chain"
}
if item.Kind == "climiter" {
suffix = "CLimiters"
}
result, err = h.sendNodeCommand(item.NodeID, "Add"+suffix, config, false, false)
if err != nil && isAlreadyExistsMessage(err.Error()) {
result, err = h.sendNodeCommand(item.NodeID, "Update"+suffix, map[string]interface{}{key: item.RuntimeName, "data": config}, false, false)
}
}
}
if err != nil {
return result, err
}
if item.Kind == "service" && item.DesiredState == "deleted" {
// TCP and UDP may share one reservation. Release only after all variants
// have an acknowledged tombstone.
items, listErr := h.repo.ListPeerShareResourcesByNode(item.NodeID)
if listErr != nil {
return result, listErr
}
base := normalizeForwardRuntimeServiceName(item.OriginalName)
for _, other := range items {
if other.ShareID == item.ShareID && other.Kind == "service" && other.OriginalName != item.OriginalName && normalizeForwardRuntimeServiceName(other.OriginalName) == base && (other.DesiredState != "deleted" || other.Applied == 0) {
return result, h.repo.MarkPeerShareResourceApplied(item.ShareID, item.Kind, item.OriginalName)
}
}
legacyBase := item.LegacyServiceBase
if legacyBase == "" {
for _, other := range items {
if other.ShareID == item.ShareID && normalizeForwardRuntimeServiceName(other.OriginalName) == base && other.LegacyServiceBase != "" {
legacyBase = other.LegacyServiceBase
break
}
}
}
if legacyBase != "" && item.ReleaseLegacyFamily {
share, loadErr := h.repo.GetPeerShare(item.ShareID)
if loadErr != nil {
return result, loadErr
}
if share == nil {
return result, fmt.Errorf("share ownership missing")
}
if _, checkErr := h.peerShareLegacyServiceNames(share, legacyBase); checkErr != nil {
return result, checkErr
}
if _, deleteErr := h.sendNodeCommand(item.NodeID, "DeleteService", map[string]interface{}{"services": buildForwardServiceDeleteNames([]string{legacyBase})}, false, true); deleteErr != nil {
return result, deleteErr
}
}
if legacyBase != "" && !item.ReleaseLegacyFamily {
return result, h.repo.MarkPeerShareResourceApplied(item.ShareID, item.Kind, item.OriginalName)
}
runtimes, listErr := h.repo.ListActivePeerShareRuntimesByShareID(item.ShareID)
if listErr != nil {
return result, listErr
}
return result, h.repo.WithPeerShareResourceTransaction(func(tx *repo.Repository) error {
for _, runtime := range runtimes {
sid, original, scoped := parsePeerShareServiceName(runtime.ServiceName)
ownedScoped := scoped && sid == item.ShareID && normalizeForwardRuntimeServiceName(original) == base
ownedLegacy := !scoped && runtime.Role == "forward" && item.ReleaseLegacyFamily && legacyBase != "" && normalizeForwardRuntimeServiceName(runtime.ServiceName) == legacyBase
if ownedScoped || ownedLegacy {
if err = tx.CompletePeerShareRuntimeRelease(runtime.ID); err != nil {
return err
}
}
}
if legacyBase != "" && item.ReleaseLegacyFamily {
if err := tx.ClearPeerShareResourceLegacyFamily(item.ShareID, legacyBase); err != nil {
return err
}
}
return tx.MarkPeerShareResourceApplied(item.ShareID, item.Kind, item.OriginalName)
})
}
return result, h.repo.MarkPeerShareResourceApplied(item.ShareID, item.Kind, item.OriginalName)
}
func orderPeerShareResources(items []repo.PeerShareResource) {
rank := func(item repo.PeerShareResource) int {
if item.DesiredState == "deleted" {
if item.Kind == "service" {
return 0
}
return 1
}
if item.Kind == "service" {
return 4
}
if item.Kind == "chain" {
return 3
}
return 2
}
sort.SliceStable(items, func(i, j int) bool { return rank(items[i]) < rank(items[j]) })
}
func (h *Handler) reconcilePeerShareResourcesOnNode(nodeID int64) error {
return h.reconcilePeerShareResources(nodeID, false)
}
func (h *Handler) retryPendingPeerShareResourcesOnNode(nodeID int64) error {
return h.reconcilePeerShareResources(nodeID, true)
}
func (h *Handler) reconcilePeerShareResources(nodeID int64, pendingOnly bool) error {
h.peerResourceMu.Lock()
defer h.peerResourceMu.Unlock()
items, err := h.repo.ListPeerShareResourcesByNode(nodeID)
if err != nil {
return err
}
runtimes, err := h.repo.ListActivePeerShareRuntimesByNode(nodeID)
if err != nil {
return err
}
pending := map[string]bool{}
for _, runtime := range runtimes {
if runtime.ReleasePending != 0 {
sid, name, ok := parsePeerShareServiceName(runtime.ServiceName)
if ok {
pending[fmt.Sprintf("%d:%s", sid, normalizeForwardRuntimeServiceName(name))] = true
}
}
}
groups := map[int64][]repo.PeerShareResource{}
var ids []int64
for _, item := range items {
if _, ok := groups[item.ShareID]; !ok {
ids = append(ids, item.ShareID)
}
groups[item.ShareID] = append(groups[item.ShareID], item)
}
var failures []error
for _, shareID := range ids {
share, err := h.repo.GetPeerShare(shareID)
if err != nil {
failures = append(failures, err)
continue
}
expired := share == nil || share.IsActive != 1 || (share.ExpiryTime > 0 && share.ExpiryTime <= time.Now().UnixMilli()) || isPeerShareFlowExceeded(share)
group := groups[shareID]
var changed []repo.PeerShareResource
for i := range group {
item := &group[i]
if (expired || (item.Kind == "service" && pending[fmt.Sprintf("%d:%s", item.ShareID, normalizeForwardRuntimeServiceName(item.OriginalName))])) && (item.DesiredState != "deleted" || item.Applied == 0 || item.LegacyServiceBase != "") {
item.DesiredState = "deleted"
item.ReleaseLegacyFamily = true
item.Applied = 0
changed = append(changed, *item)
}
}
if err := h.repo.SavePeerShareResources(changed); err != nil {
failures = append(failures, err)
continue
}
orderPeerShareResources(group)
dependencyFailed := false
deletionFailed := false
for _, item := range group {
if item.Applied == 1 && (pendingOnly || item.DesiredState == "deleted") {
continue
}
if dependencyFailed && item.Kind == "service" && item.DesiredState != "deleted" {
continue
}
if deletionFailed && item.Kind != "service" && item.DesiredState == "deleted" {
continue
}
if _, err := h.applyPeerShareResource(item); err != nil {
failures = append(failures, fmt.Errorf("share %d %s %s: %w", item.ShareID, item.Kind, item.OriginalName, err))
if item.Kind != "service" {
dependencyFailed = true
}
if item.DesiredState == "deleted" {
deletionFailed = true
}
}
}
}
return errors.Join(failures...)
}
func (h *Handler) releasePeerShareResources(shareID int64) error {
h.peerResourceMu.Lock()
defer h.peerResourceMu.Unlock()
share, err := h.repo.GetPeerShare(shareID)
if err != nil {
return err
}
if share == nil {
return nil
}
all, err := h.repo.ListPeerShareResourcesByNode(share.NodeID)
if err != nil {
return err
}
var items []repo.PeerShareResource
for _, item := range all {
if item.ShareID == shareID && !(item.DesiredState == "deleted" && item.Applied == 1 && item.LegacyServiceBase == "") {
item.DesiredState = "deleted"
item.ReleaseLegacyFamily = true
item.Applied = 0
items = append(items, item)
}
}
if err = h.repo.SavePeerShareResources(items); err != nil {
return err
}
orderPeerShareResources(items)
for _, item := range items {
if _, err = h.applyPeerShareResource(item); err != nil {
return err
}
}
return nil
}
// A legacy name is eligible for migration only when its runtime registration
// proves unique ownership. An identically numbered local forward is ambiguous.
func (h *Handler) peerShareLegacyServiceNames(share *repo.PeerShare, original string) ([]string, error) {
base := normalizeForwardRuntimeServiceName(original)
runtimes, err := h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(share.NodeID, base)
if err != nil {
return nil, err
}
owned := false
for _, runtime := range runtimes {
if runtime.ShareID != share.ID {
return nil, fmt.Errorf("legacy resource %q has ambiguous ownership", original)
}
if runtime.ReleasePending != 0 {
return nil, fmt.Errorf("runtime release is pending")
}
owned = true
}
if !owned {
resources, err := h.repo.ListPeerShareResourcesByNode(share.NodeID)
if err != nil {
return nil, err
}
for _, resource := range resources {
if resource.ShareID == share.ID && resource.LegacyServiceBase == base {
owned = true
break
}
}
}
if !owned {
return nil, nil
}
if id, _, _, ok := parseFlowServiceIDs(base); ok {
local, err := h.repo.GetForwardRecord(id)
if err != nil {
return nil, err
}
if local != nil {
return nil, fmt.Errorf("legacy resource %q collides with a local forward", original)
}
}
// Only delete the requested transport. Other variants remain registered to
// their legacy runtime until they are independently migrated.
return []string{original}, nil
}
// releasePeerShareForwardRuntimeResources removes exact persisted transport
// names belonging to a single forward reservation, retaining failed tombstones.
func (h *Handler) releasePeerShareForwardRuntimeResources(runtime *repo.PeerShareRuntime) error {
h.peerResourceMu.Lock()
defer h.peerResourceMu.Unlock()
shareID, original, ok := parsePeerShareServiceName(runtime.ServiceName)
if !ok || shareID != runtime.ShareID {
return fmt.Errorf("runtime is not a scoped shared forward")
}
all, err := h.repo.ListPeerShareResourcesByNode(runtime.NodeID)
if err != nil {
return err
}
var items []repo.PeerShareResource
for _, item := range all {
if item.ShareID == runtime.ShareID && item.Kind == "service" && normalizeForwardRuntimeServiceName(item.OriginalName) == normalizeForwardRuntimeServiceName(original) {
item.DesiredState = "deleted"
item.ReleaseLegacyFamily = true
item.Applied = 0
items = append(items, item)
}
}
if len(items) == 0 {
return fmt.Errorf("shared forward resource ownership is missing")
}
if err = h.repo.SavePeerShareResources(items); err != nil {
return err
}
for _, item := range items {
if _, err = h.applyPeerShareResource(item); err != nil {
return err
}
}
return nil
}
// Registry kinds without an owned command API cannot be referenced by peers.
func validatePeerResourceReferences(value interface{}, kind string) error {
var walk func(interface{}) error
walk = func(value interface{}) error {
switch v := value.(type) {
case map[string]interface{}:
for key, child := range v {
switch strings.ToLower(key) {
case "auther", "authers", "admission", "admissions", "bypass", "bypasses", "resolver", "hosts", "rlimiter", "logger", "loggers", "observer", "recorders", "hop", "sd":
if child != nil {
empty := false
switch x := child.(type) {
case string:
empty = strings.TrimSpace(x) == ""
case []interface{}:
empty = len(x) == 0
}
if !empty {
return fmt.Errorf("unsupported shared registry reference: %s", key)
}
}
case "forwarder":
if f, ok := child.(map[string]interface{}); ok {
for field, value := range f {
if strings.EqualFold(field, "name") && strings.TrimSpace(asString(value)) != "" {
return fmt.Errorf("named shared forwarder references are unsupported")
}
}
}
case "hops":
for _, hop := range asMapSlice(child) {
if _, ok := hop["nodes"]; !ok {
return fmt.Errorf("shared chains require inline hop nodes")
}
for _, loader := range []string{"file", "redis", "http", "plugin"} {
if hop[loader] != nil {
return fmt.Errorf("shared hop loaders are unsupported")
}
}
}
}
if err := walk(child); err != nil {
return err
}
}
case []interface{}:
for _, child := range v {
if err := walk(child); err != nil {
return err
}
}
}
return nil
}
if kind == "limiter" || kind == "climiter" {
if config, ok := value.(map[string]interface{}); ok {
for _, loader := range []string{"file", "redis", "http", "plugin"} {
if config[loader] != nil {
return fmt.Errorf("shared limiter loaders are unsupported")
}
}
}
}
return walk(value)
}
@@ -0,0 +1,540 @@
package handler
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"go-backend/internal/http/response"
"go-backend/internal/store/repo"
"gorm.io/gorm"
)
func resourceTestShare(t *testing.T, h *Handler, token string) *repo.PeerShare {
t.Helper()
now := time.Now().UnixMilli()
share := &repo.PeerShare{Name: token, NodeID: 1, Token: token, IsActive: 1, PortRangeStart: 31000, PortRangeEnd: 32000, CreatedTime: now, UpdatedTime: now}
if err := h.repo.CreatePeerShare(share); err != nil {
t.Fatal(err)
}
return share
}
func resourceTestCommand(t *testing.T, h *Handler, share *repo.PeerShare, cmd string, data interface{}) response.R {
t.Helper()
body, err := json.Marshal(federationRuntimeCommandRequest{CommandType: cmd, Data: data})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/runtime/command", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+share.Token)
rec := httptest.NewRecorder()
h.federationRuntimeCommand(rec, req)
var result response.R
if err = json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
return result
}
func resourceTestService(name string, port int) []interface{} {
return []interface{}{map[string]interface{}{"name": name, "addr": fmt.Sprintf(":%d", port), "handler": map[string]interface{}{"type": "tcp", "chain": "70"}, "listener": map[string]interface{}{"type": "tcp"}, "limiter": "10,20", "climiter": "30"}}
}
func TestPeerResourceCommandIsolationAndDurableRestore(t *testing.T) {
a := newCleanupAgent(t)
first := resourceTestShare(t, a.h, "resource-first")
second := resourceTestShare(t, a.h, "resource-second")
for i, share := range []*repo.PeerShare{first, second} {
for _, entry := range []struct{ cmd, name string }{{"AddLimiters", "10"}, {"AddCLimiters", "30"}, {"AddChains", "70"}} {
result := resourceTestCommand(t, a.h, share, entry.cmd, map[string]interface{}{"name": entry.name})
if result.Code != 0 {
t.Fatal(result.Msg)
}
}
result := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_tcp", 31001+i))
if result.Code != 0 {
t.Fatal(result.Msg)
}
}
commands := a.commandsOfType("UpdateService")
if len(commands) != 2 {
t.Fatalf("commands: %+v", commands)
}
for i, share := range []*repo.PeerShare{first, second} {
var configs []map[string]interface{}
if err := json.Unmarshal(commands[i].Data, &configs); err != nil {
t.Fatal(err)
}
c := configs[0]
if c["name"] != peerShareResourceName(share.ID, "service", "70_1_0_tcp") {
t.Fatalf("unscoped service: %v", c)
}
if c["handler"].(map[string]interface{})["chain"] != peerShareResourceName(share.ID, "chain", "70") {
t.Fatalf("unscoped chain: %v", c)
}
want := peerShareResourceName(share.ID, "limiter", "10") + "," + peerShareResourceName(share.ID, "limiter", "20")
if c["limiter"] != want || c["climiter"] != peerShareResourceName(share.ID, "climiter", "30") {
t.Fatalf("unscoped limiter: %v", c)
}
}
result := resourceTestCommand(t, a.h, first, "DeleteService", map[string]interface{}{"services": []string{"70_1_0_tcp"}})
if result.Code != 0 {
t.Fatal(result.Msg)
}
deleted := a.commandsOfType("DeleteService")
if len(deleted) != 1 || !strings.Contains(string(deleted[0].Data), peerShareResourceName(first.ID, "service", "70_1_0_tcp")) {
t.Fatalf("wrong deletion: %+v", deleted)
}
// A new Handler simulates restart with only durable desired state retained.
restarted := &Handler{repo: a.h.repo, wsServer: a.h.wsServer}
if err := restarted.reconcilePeerShareResourcesOnNode(1); err != nil {
t.Fatal(err)
}
commands = a.commandsOfType("UpdateService")
if len(commands) != 3 || !strings.Contains(string(commands[2].Data), peerShareResourceName(second.ID, "service", "70_1_0_tcp")) {
t.Fatalf("wrong recovery: %+v", commands)
}
if got := resourceTestCommand(t, a.h, first, "DeleteService", map[string]interface{}{"services": []string{peerShareResourceName(second.ID, "service", "70_1_0_tcp")}}); got.Code == 0 {
t.Fatal("accepted foreign scoped name")
}
if got := resourceTestCommand(t, a.h, first, "Reload", nil); got.Code == 0 {
t.Fatal("accepted global reload")
}
}
func TestPeerResourcePreRegistrationAndDatabaseFailure(t *testing.T) {
a := newCleanupAgent(t)
share := resourceTestShare(t, a.h, "resource-preregister")
items, err := a.h.preparePeerResourceCommand(share, "AddService", resourceTestService("70_1_0_tcp", 31001))
if err != nil {
t.Fatal(err)
}
if len(a.commandsOfType("UpdateService")) != 0 {
t.Fatal("prepare sent service before persistence")
}
stored, err := a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
if err != nil || stored == nil || stored.Applied != 0 {
t.Fatalf("missing pending ownership: %+v %v", stored, err)
}
runCleanupPath(a.h, "single", []string{items[0].RuntimeName})
a.probe(t)
if len(a.commandsOfType("DeleteService")) != 0 {
t.Fatal("flow report removed pending service")
}
if err := a.h.repo.DB().Callback().Create().Before("gorm:create").Register("fail-resource-registration", func(tx *gorm.DB) {
if tx.Statement.Table == "peer_share_resource" {
tx.AddError(errors.New("simulated resource write failure"))
}
}); err != nil {
t.Fatal(err)
}
defer a.h.repo.DB().Callback().Create().Remove("fail-resource-registration")
got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("71_1_0", 31002))
if got.Code == 0 {
t.Fatal("database failure returned success")
}
if len(a.commandsOfType("UpdateService")) != 0 {
t.Fatal("node received command after persistence failure")
}
}
func TestPeerResourceBindingDatabaseFailure(t *testing.T) {
a := newCleanupAgent(t)
share := resourceTestShare(t, a.h, "resource-bind-failure")
if err := a.h.repo.DB().Callback().Create().Before("gorm:create").Register("fail-runtime-registration", func(tx *gorm.DB) {
if tx.Statement.Table == "peer_share_runtime" {
tx.AddError(errors.New("simulated binding failure"))
}
}); err != nil {
t.Fatal(err)
}
defer a.h.repo.DB().Callback().Create().Remove("fail-runtime-registration")
got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0", 31001))
if got.Code == 0 {
t.Fatal("binding failure returned success")
}
if len(a.commandsOfType("UpdateService")) != 0 {
t.Fatal("service sent before successful binding")
}
}
func TestPeerResourceScopedNameRoundTrip(t *testing.T) {
for _, name := range []string{"70_1_0", "70_1_0_tcp", "70_1_0_udp", "a-b_c"} {
scoped := peerShareResourceName(12, "service", name)
id, got, ok := parsePeerShareServiceName(scoped)
if !ok || id != 12 || got != name {
t.Fatalf("round trip failed: %q", scoped)
}
}
}
func TestPeerResourceDeletesCandidateNamesAndPreservesFailedTombstone(t *testing.T) {
a := newCleanupAgent(t)
share := resourceTestShare(t, a.h, "resource-delete-candidates")
got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_tcp", 31001))
if got.Code != 0 {
t.Fatal(got.Msg)
}
got = resourceTestCommand(t, a.h, share, "DeleteService", map[string]interface{}{"services": []string{"70_1_0_tcp", "70_1_0_udp", "70_1_0"}})
if got.Code != 0 {
t.Fatal(got.Msg)
}
if len(a.commandsOfType("DeleteService")) != 1 {
t.Fatal("unregistered names were sent to the node")
}
// A disconnected node cannot acknowledge deletion: keep the pending row.
if got = resourceTestCommand(t, a.h, share, "AddService", resourceTestService("71_1_0", 31002)); got.Code != 0 {
t.Fatal(got.Msg)
}
offline := &Handler{repo: a.h.repo}
got = resourceTestCommand(t, offline, share, "DeleteService", map[string]interface{}{"services": []string{"71_1_0"}})
if got.Code == 0 {
t.Fatal("offline deletion reported success")
}
pending, err := a.h.repo.GetPeerShareResource(share.ID, "service", "71_1_0")
if err != nil || pending.DesiredState != "deleted" || pending.Applied != 0 {
t.Fatalf("lost pending deletion: %+v %v", pending, err)
}
if err := a.h.reconcilePeerShareResourcesOnNode(1); err != nil {
t.Fatal(err)
}
pending, err = a.h.repo.GetPeerShareResource(share.ID, "service", "71_1_0")
if err != nil || pending.Applied != 1 {
t.Fatalf("deletion not retried: %+v %v", pending, err)
}
if got = resourceTestCommand(t, a.h, share, "ResumeService", map[string]interface{}{"services": []string{"71_1_0"}}); got.Code == 0 {
t.Fatal("resume resurrected a deleted resource")
}
}
func TestPeerResourceLegacyMigrationRequiresUnambiguousOwner(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_tcp", 31001))
if got.Code != 0 {
t.Fatal(got.Msg)
}
deleted := a.commandsOfType("DeleteService")
if len(deleted) != 1 || string(deleted[0].Data) != `{"services":["70_1_0_tcp"]}` {
t.Fatalf("legacy deletion was not exact: %+v", deleted)
}
item, err := a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
if err != nil || item.LegacyNames != "" {
t.Fatalf("legacy migration acknowledgment not persisted: %+v %v", item, err)
}
// Two shares with the same legacy name are never resolved by guessing.
other := resourceTestShare(t, a.h, "resource-ambiguous")
now := time.Now().UnixMilli()
for i, sid := range []int64{share.ID, other.ID} {
if err := a.h.repo.CreatePeerShareRuntime(&repo.PeerShareRuntime{ShareID: sid, NodeID: 1, ReservationID: fmt.Sprintf("ambiguous-%d", i), ResourceKey: fmt.Sprintf("ambiguous-%d", i), Role: "forward", ServiceName: "71_1_0", Port: 31003 + i, Applied: 1, Status: 1, CreatedTime: now, UpdatedTime: now}); err != nil {
t.Fatal(err)
}
}
got = resourceTestCommand(t, a.h, share, "AddService", resourceTestService("71_1_0_tcp", 31003))
if got.Code == 0 {
t.Fatal("ambiguous legacy owner accepted")
}
if len(a.commandsOfType("DeleteService")) != 1 {
t.Fatal("ambiguous legacy service deleted")
}
}
func TestPeerResourceLegacyFamilyPersistsAcrossPartialMigration(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_tcp", 31001)); got.Code != 0 {
t.Fatal(got.Msg)
}
item, err := a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
if err != nil || item.LegacyServiceBase != "70_1_0" {
t.Fatalf("lost legacy family ownership: %+v %v", item, err)
}
// A later request can still prove ownership of the old UDP transport.
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_udp", 31001)); got.Code != 0 {
t.Fatal(got.Msg)
}
deletes := a.commandsOfType("DeleteService")
if len(deletes) != 2 || string(deletes[1].Data) != `{"services":["70_1_0_udp"]}` {
t.Fatalf("lost UDP migration ownership: %+v", deletes)
}
if got := resourceTestCommand(t, a.h, share, "DeleteService", map[string]interface{}{"services": []string{"70_1_0_tcp", "70_1_0_udp", "70_1_0"}}); got.Code != 0 {
t.Fatal(got.Msg)
}
item, err = a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
if err != nil || item.LegacyServiceBase != "" {
t.Fatalf("legacy family not released: %+v %v", item, err)
}
}
func TestPeerResourceFailedRegistrationDoesNotRenameLegacyRuntime(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
if err := a.h.repo.DB().Callback().Create().Before("gorm:create").Register("fail-atomic-resource", func(tx *gorm.DB) {
if tx.Statement.Table == "peer_share_resource" {
tx.AddError(errors.New("simulated desired-state failure"))
}
}); err != nil {
t.Fatal(err)
}
defer a.h.repo.DB().Callback().Create().Remove("fail-atomic-resource")
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_tcp", 31001)); got.Code == 0 {
t.Fatal("registration failure returned success")
}
runtimes, err := a.h.repo.ListActivePeerShareRuntimesByShareID(share.ID)
if err != nil || len(runtimes) != 1 || runtimes[0].ServiceName != "70_1_0" {
t.Fatalf("legacy binding changed after rollback: %+v %v", runtimes, err)
}
if len(a.commandsOfType("DeleteService"))+len(a.commandsOfType("UpdateService")) != 0 {
t.Fatal("node mutated despite transaction rollback")
}
}
func TestPeerResourcePausedReconcileNeverStartsListener(t *testing.T) {
a := newCleanupAgent(t)
share := resourceTestShare(t, a.h, "resource-pause-recovery")
for _, cmd := range []string{"AddService", "PauseService"} {
var data interface{} = resourceTestService("70_1_0_tcp", 31001)
if cmd == "PauseService" {
data = map[string]interface{}{"services": []string{"70_1_0_tcp"}}
}
if got := resourceTestCommand(t, a.h, share, cmd, data); got.Code != 0 {
t.Fatal(got.Msg)
}
}
if err := a.h.reconcilePeerShareResourcesOnNode(1); err != nil {
t.Fatal(err)
}
if len(a.commandsOfType("UpdateService")) != 1 {
t.Fatal("paused listener started during reconciliation")
}
if got := resourceTestCommand(t, a.h, share, "ResumeService", map[string]interface{}{"services": []string{"70_1_0_tcp"}}); got.Code != 0 {
t.Fatal(got.Msg)
}
if len(a.commandsOfType("UpdateService")) != 2 {
t.Fatal("resume did not restore saved service")
}
}
func TestPeerResourceDeleteUnmigratedLegacyOwner(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
got := resourceTestCommand(t, a.h, share, "DeleteService", map[string]interface{}{"services": []string{"70_1_0_tcp", "70_1_0_udp", "70_1_0"}})
if got.Code != 0 {
t.Fatal(got.Msg)
}
removed := false
for _, cmd := range a.commandsOfType("DeleteService") {
var body struct {
Services []string `json:"services"`
}
if err = json.Unmarshal(cmd.Data, &body); err != nil {
t.Fatal(err)
}
for _, name := range body.Services {
if name == "70_1_0" {
removed = true
}
}
}
if !removed {
t.Fatal("legacy delete reported success without sending old service deletion")
}
}
func TestPeerResourcePartialDeletePreservesLegacyFamily(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_tcp", 31001)); got.Code != 0 {
t.Fatal(got.Msg)
}
if got := resourceTestCommand(t, a.h, share, "DeleteService", map[string]interface{}{"services": []string{"70_1_0_tcp"}}); got.Code != 0 {
t.Fatal(got.Msg)
}
item, err := a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
if err != nil || item.LegacyServiceBase != "70_1_0" {
t.Fatalf("partial delete lost remaining legacy family: %+v %v", item, err)
}
for _, cmd := range a.commandsOfType("DeleteService") {
if strings.Contains(string(cmd.Data), `"70_1_0_udp"`) {
t.Fatal("partial TCP delete removed legacy UDP")
}
}
if err = a.h.releasePeerShareResources(share.ID); err != nil {
t.Fatal(err)
}
item, err = a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
if err != nil || item.LegacyServiceBase != "" {
t.Fatalf("full release lost legacy cleanup: %+v %v", item, err)
}
}
func TestPeerResourceChainGroupsAreScopedAndOtherRegistriesRejected(t *testing.T) {
a := newCleanupAgent(t)
share := resourceTestShare(t, a.h, "resource-chain-groups")
data := resourceTestService("70_1_0", 31001)
data[0].(map[string]interface{})["handler"].(map[string]interface{})["chainGroup"] = map[string]interface{}{"chains": []string{"one", "two"}}
if got := resourceTestCommand(t, a.h, share, "AddService", data); got.Code != 0 {
t.Fatal(got.Msg)
}
body := string(a.commandsOfType("UpdateService")[0].Data)
for _, name := range []string{"one", "two"} {
if !strings.Contains(body, peerShareResourceName(share.ID, "chain", name)) {
t.Fatalf("chainGroup reference was not scoped: %s", body)
}
}
for _, reference := range []string{"resolver", "auther", "observer", "hop"} {
data := resourceTestService("71_1_0", 31002)
data[0].(map[string]interface{})[reference] = "global-resource"
if got := resourceTestCommand(t, a.h, share, "AddService", data); got.Code == 0 {
t.Fatalf("accepted global %s reference", reference)
}
}
}
func TestPeerResourceReconcileContinuesAfterAnotherShareFails(t *testing.T) {
a := newCleanupAgent(t)
first := resourceTestShare(t, a.h, "resource-failed-share")
second := resourceTestShare(t, a.h, "resource-good-share")
for i, share := range []*repo.PeerShare{first, second} {
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0", 31001+i)); got.Code != 0 {
t.Fatal(got.Msg)
}
}
if err := a.h.repo.SavePeerShareResources([]repo.PeerShareResource{{ShareID: first.ID, NodeID: 1, Kind: "limiter", OriginalName: "broken", RuntimeName: peerShareResourceName(first.ID, "limiter", "broken"), Config: "{", DesiredState: "active", UpdatedTime: time.Now().UnixMilli()}}); err != nil {
t.Fatal(err)
}
if err := a.h.reconcilePeerShareResourcesOnNode(1); err == nil {
t.Fatal("invalid dependency was not reported")
}
commands := a.commandsOfType("UpdateService")
if len(commands) != 3 || !strings.Contains(string(commands[2].Data), peerShareResourceName(second.ID, "service", "70_1_0")) {
t.Fatalf("failed share blocked healthy share, or failed dependency service was started: %+v", commands)
}
}
func TestPeerResourceRechecksShareBeforeRecreation(t *testing.T) {
for _, state := range []string{"inactive", "expired", "exceeded"} {
t.Run(state, func(t *testing.T) {
a := newCleanupAgent(t)
share := resourceTestShare(t, a.h, "resource-state-"+state)
switch state {
case "inactive":
share.IsActive = 0
case "expired":
share.ExpiryTime = time.Now().Add(-time.Hour).UnixMilli()
case "exceeded":
share.MaxBandwidth = 1
share.CurrentFlow = 1 << 40
}
if err := a.h.repo.UpdatePeerShare(share); err != nil {
t.Fatal(err)
}
if state == "exceeded" {
if err := a.h.repo.AddPeerShareCurrentFlow(share.ID, share.CurrentFlow); err != nil {
t.Fatal(err)
}
}
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0", 31001)); got.Code == 0 {
t.Fatal("invalid share recreated resources")
}
if len(a.commandsOfType("UpdateService")) != 0 {
t.Fatal("invalid share reached the node")
}
})
}
}
func TestPeerResourcePendingRetryLeavesAppliedSiblingAlone(t *testing.T) {
a := newCleanupAgent(t)
share := resourceTestShare(t, a.h, "resource-pending-only")
offline := &Handler{repo: a.h.repo}
if got := resourceTestCommand(t, offline, share, "AddService", resourceTestService("70_1_0", 31001)); got.Code == 0 {
t.Fatal("offline apply reported success")
}
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("71_1_0", 31002)); got.Code != 0 {
t.Fatal(got.Msg)
}
if err := a.h.retryPendingPeerShareResourcesOnNode(1); err != nil {
t.Fatal(err)
}
commands := a.commandsOfType("UpdateService")
if len(commands) != 2 || !strings.Contains(string(commands[1].Data), peerShareResourceName(share.ID, "service", "70_1_0")) {
t.Fatalf("pending retry restarted applied sibling: %+v", commands)
}
if err := a.h.retryPendingPeerShareResourcesOnNode(1); err != nil {
t.Fatal(err)
}
if len(a.commandsOfType("UpdateService")) != 2 {
t.Fatal("no-op pending retry restarted applied services")
}
}
func TestPeerResourceLegacyReleaseAcknowledgmentIsAtomic(t *testing.T) {
var failCompletion atomic.Bool
a := newCleanupAgent(t, func(r *repo.Repository) {
if err := r.DB().Callback().Update().Before("gorm:update").Register("fail-runtime-release", func(tx *gorm.DB) {
if failCompletion.Load() && tx.Statement.Table == "peer_share_runtime" {
tx.AddError(errors.New("simulated completion failure"))
}
}); err != nil {
t.Fatal(err)
}
})
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
failCompletion.Store(true)
got := resourceTestCommand(t, a.h, share, "DeleteService", map[string]interface{}{"services": []string{"70_1_0", "70_1_0_tcp", "70_1_0_udp"}})
if got.Code == 0 {
t.Fatal("completion database failure reported success")
}
items, err := a.h.repo.ListPeerShareResourcesByNode(1)
if err != nil {
t.Fatal(err)
}
proof := false
pending := false
for _, item := range items {
proof = proof || item.LegacyServiceBase == "70_1_0"
pending = pending || item.Applied == 0
}
if !proof || !pending {
t.Fatalf("failure lost ownership or retry marker: %+v", items)
}
failCompletion.Store(false)
if err := a.h.retryPendingPeerShareResourcesOnNode(1); err != nil {
t.Fatal(err)
}
runtimes, err := a.h.repo.ListActivePeerShareRuntimesByShareID(share.ID)
if err != nil || len(runtimes) != 0 {
t.Fatalf("retry leaked legacy reservation: %+v %v", runtimes, err)
}
}
@@ -228,7 +228,7 @@ func TestPrepareTunnelCreateStateAllowsOfflineRemoteMiddleNode(t *testing.T) {
}
func TestBuildFederationServiceConfig_MiddleRoleWithMultipleTargets_SetsRetries(t *testing.T) {
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 3, "")
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 3, "", nil)
handler := service["handler"].(map[string]interface{})
if handler["chain"] != "chain-next" {
t.Fatalf("expected chain 'chain-next', got %v", handler["chain"])
@@ -239,7 +239,7 @@ func TestBuildFederationServiceConfig_MiddleRoleWithMultipleTargets_SetsRetries(
}
func TestBuildFederationServiceConfig_MiddleRoleWithSingleTarget_NoRetries(t *testing.T) {
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 1, "")
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 1, "", nil)
handler := service["handler"].(map[string]interface{})
if handler["chain"] != "chain-next" {
t.Fatalf("expected chain 'chain-next', got %v", handler["chain"])
@@ -250,7 +250,7 @@ func TestBuildFederationServiceConfig_MiddleRoleWithSingleTarget_NoRetries(t *te
}
func TestBuildFederationServiceConfig_ExitRole_NoRetriesRegardlessOfTargets(t *testing.T) {
service := buildFederationServiceConfig("svc-exit", ":40000", "tls", "exit", "", 3, "eth0")
service := buildFederationServiceConfig("svc-exit", ":40000", "tls", "exit", "", 3, "eth0", nil)
handler := service["handler"].(map[string]interface{})
if _, hasChain := handler["chain"]; hasChain {
t.Fatalf("expected no chain for exit role, got %v", handler["chain"])
@@ -265,7 +265,7 @@ func TestBuildFederationServiceConfig_ExitRole_NoRetriesRegardlessOfTargets(t *t
}
func TestBuildFederationServiceConfig_TLSTunnelProtocol_SetsNodelay(t *testing.T) {
service := buildFederationServiceConfig("svc-tls", ":40000", "tls", "middle", "chain-next", 2, "")
service := buildFederationServiceConfig("svc-tls", ":40000", "tls", "middle", "chain-next", 2, "", nil)
handler := service["handler"].(map[string]interface{})
meta := handler["metadata"].(map[string]interface{})
if meta["nodelay"] != true {
@@ -274,10 +274,14 @@ func TestBuildFederationServiceConfig_TLSTunnelProtocol_SetsNodelay(t *testing.T
}
func TestBuildFederationServiceConfig_NonTLSProtocol_NoNodelay(t *testing.T) {
service := buildFederationServiceConfig("svc-tcp", ":40000", "tcp", "middle", "chain-next", 2, "")
service := buildFederationServiceConfig("svc-tcp", ":40000", "tcp", "middle", "chain-next", 2, "", nil)
handler := service["handler"].(map[string]interface{})
if _, hasMeta := handler["metadata"]; hasMeta {
t.Fatalf("expected no metadata for non-TLS protocol, got %v", handler["metadata"])
metadata, _ := handler["metadata"].(map[string]interface{})
if _, hasNodelay := metadata["nodelay"]; hasNodelay {
t.Fatal("non-TLS protocol must not set nodelay")
}
if metadata["chain.strict"] != true {
t.Fatal("middle relay must require its configured chain")
}
}
@@ -224,18 +224,14 @@ func TestFederationShareListIncludesRemoteUsedPorts(t *testing.T) {
}
func TestFederationShareDeleteCleansUpRuntimes(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
agent := newCleanupAgent(t)
r := agent.h.repo
h := agent.h
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "delete-cleanup-share",
NodeID: 99,
NodeID: 1,
Token: "delete-cleanup-token",
MaxBandwidth: 4096,
PortRangeStart: 40000,
@@ -257,8 +253,8 @@ func TestFederationShareDeleteCleansUpRuntimes(t *testing.T) {
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
share.ID, 99, "dc-r1", "dc-rk1", "dc-b1", "exit", "", "fed_svc_dc1", "tls", "round", 40001, "", 1, 1, now, now,
share.ID, 99, "dc-r2", "dc-rk2", "dc-b2", "middle", "fed_chain_dc2", "fed_svc_dc2", "tls", "round", 40002, "", 1, 1, now, now,
share.ID, 1, "dc-r1", "dc-rk1", "dc-b1", "exit", "", "fed_svc_dc1", "tls", "round", 40001, "", 1, 1, now, now,
share.ID, 1, "dc-r2", "dc-rk2", "dc-b2", "middle", "fed_chain_dc2", "fed_svc_dc2", "tls", "round", 40002, "", 1, 1, now, now,
).Error; err != nil {
t.Fatalf("insert peer_share_runtime rows: %v", err)
}
@@ -309,11 +305,17 @@ func TestFederationRemoteUsageListSyncErrorFallback(t *testing.T) {
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
// Model an unavailable provider entirely on loopback instead of relying on
// external DNS or network restrictions to produce the sync error.
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "provider unavailable", http.StatusServiceUnavailable)
}))
defer provider.Close()
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "sync-error-node", "sync-error-secret", "10.50.60.70", "10.50.60.70", "", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://unreachable.invalid:9999", "bad-token", `{"shareId":42,"maxBandwidth":5368709120,"currentFlow":999999,"portRangeStart":32000,"portRangeEnd":32010}`).Error; err != nil {
`, "sync-error-node", "sync-error-secret", "10.50.60.70", "10.50.60.70", "", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, provider.URL, "bad-token", `{"shareId":42,"maxBandwidth":5368709120,"currentFlow":999999,"portRangeStart":32000,"portRangeEnd":32010}`).Error; err != nil {
t.Fatalf("insert remote node: %v", err)
}
@@ -352,7 +354,7 @@ func TestFederationRemoteUsageListSyncErrorFallback(t *testing.T) {
syncErr, _ := first["syncError"].(string)
if syncErr == "" {
t.Fatalf("expected non-empty syncError field on unreachable provider")
t.Fatalf("expected non-empty syncError field on unavailable provider")
}
}
@@ -862,11 +864,15 @@ func TestFederationRemoteUsageList(t *testing.T) {
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "provider unavailable", http.StatusServiceUnavailable)
}))
defer provider.Close()
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "remote-consumer-node", "remote-consumer-secret", "10.30.40.50", "10.30.40.50", "", "31000-31010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://peer.example", "peer-token", `{"shareId":88,"maxBandwidth":2147483648,"currentFlow":1073741824,"portRangeStart":31000,"portRangeEnd":31010}`).Error; err != nil {
`, "remote-consumer-node", "remote-consumer-secret", "10.30.40.50", "10.30.40.50", "", "31000-31010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, provider.URL, "peer-token", `{"shareId":88,"maxBandwidth":2147483648,"currentFlow":1073741824,"portRangeStart":31000,"portRangeEnd":31010}`).Error; err != nil {
t.Fatalf("insert remote node: %v", err)
}
nodeID := mustLastInsertID(t, r, "remote-consumer-node")
@@ -886,8 +892,8 @@ func TestFederationRemoteUsageList(t *testing.T) {
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
tunnelAID, nodeID, 2, 1, "http://peer.example", "rk-a", "rb-a", 31001, 1, now, now,
tunnelBID, nodeID, 3, 0, "http://peer.example", "rk-b", "rb-b", 31002, 1, now, now,
tunnelAID, nodeID, 2, 1, provider.URL, "rk-a", "rb-a", 31001, 1, now, now,
tunnelBID, nodeID, 3, 0, provider.URL, "rk-b", "rb-b", 31002, 1, now, now,
).Error; err != nil {
t.Fatalf("insert federation bindings: %v", err)
}
@@ -0,0 +1,305 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"reflect"
"sort"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gorilla/websocket"
"gorm.io/gorm"
"go-backend/internal/security"
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
"go-backend/internal/ws"
)
type cleanupAgentCommand struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
RequestID string `json:"requestId"`
}
// cleanupAgent exercises the actual command transport. Each command is recorded
// before its ACK, so synchronous handler calls need no sleeps to inspect it.
type cleanupAgent struct {
h *Handler
mu sync.Mutex
commands []cleanupAgentCommand
}
func newCleanupAgent(t *testing.T, setup ...func(*repo.Repository)) *cleanupAgent {
t.Helper()
r, err := repo.Open(filepath.Join(t.TempDir(), "cleanup.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = r.Close() })
const secret = "cleanup-regression-node"
if err := r.DB().Create(&model.Node{ID: 1, Name: "relay", Secret: secret, ServerIP: "127.0.0.1", Port: "30000", Status: 1}).Error; err != nil {
t.Fatal(err)
}
// Register test-local database callbacks before WebSocket goroutines can
// execute them. Keep registries immutable until the fixture is closed.
for _, configure := range setup {
configure(r)
}
server := ws.NewServer(r, "test-jwt-secret")
online := make(chan struct{})
server.SetNodeOnlineHook(func(int64) { close(online) })
serverDone := make(chan struct{})
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
defer close(serverDone)
server.ServeHTTP(w, req)
}))
t.Cleanup(ts.Close)
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(ts.URL, "http")+"?type=1&secret="+secret, nil)
if err != nil {
t.Fatal(err)
}
crypto, err := security.NewAESCrypto(secret)
if err != nil {
t.Fatal(err)
}
a := &cleanupAgent{h: &Handler{repo: r, wsServer: server}}
readerDone := make(chan struct{})
go func() {
defer close(readerDone)
for {
_, payload, err := conn.ReadMessage()
if err != nil {
return
}
var envelope struct {
Encrypted bool `json:"encrypted"`
Data string `json:"data"`
}
if err := json.Unmarshal(payload, &envelope); err != nil {
t.Errorf("decode command envelope: %v", err)
return
}
if envelope.Encrypted {
payload, err = crypto.Decrypt(envelope.Data)
if err != nil {
t.Errorf("decrypt command: %v", err)
return
}
}
var command cleanupAgentCommand
if err := json.Unmarshal(payload, &command); err != nil {
t.Errorf("decode command: %v", err)
return
}
a.mu.Lock()
a.commands = append(a.commands, command)
a.mu.Unlock()
if err := conn.WriteJSON(map[string]interface{}{"type": command.Type, "requestId": command.RequestID, "success": true}); err != nil {
return
}
}
}()
t.Cleanup(func() {
_ = conn.Close()
<-readerDone
<-serverDone
})
select {
case <-online:
case <-time.After(3 * time.Second):
t.Fatal("mock node did not come online")
}
a.probe(t)
return a
}
func (a *cleanupAgent) probe(t *testing.T) {
t.Helper()
if _, err := a.h.wsServer.SendCommand(1, "CleanupTestProbe", nil, time.Second); err != nil {
t.Fatalf("mock agent transport unavailable: %v", err)
}
}
func (a *cleanupAgent) commandsOfType(commandType string) []cleanupAgentCommand {
a.mu.Lock()
defer a.mu.Unlock()
var commands []cleanupAgentCommand
for _, command := range a.commands {
if command.Type == commandType {
commands = append(commands, command)
}
}
return commands
}
func (a *cleanupAgent) addRuntime(t *testing.T, name string, nodeID int64, status, applied int, updated time.Time) int64 {
t.Helper()
now := time.Now().UnixMilli()
share := &repo.PeerShare{Name: "shared", NodeID: nodeID, Token: "cleanup-share-token", PortRangeStart: 31000, PortRangeEnd: 31010, IsActive: 1, CreatedTime: now, UpdatedTime: now}
if err := a.h.repo.CreatePeerShare(share); err != nil {
t.Fatal(err)
}
stored, err := a.h.repo.GetPeerShareByToken(share.Token)
if err != nil || stored == nil {
t.Fatalf("load share: %v", err)
}
// SQL preserves status=0; GORM's default tag would replace that with 1.
if err := a.h.repo.DB().Exec(`INSERT INTO peer_share_runtime
(share_id, node_id, reservation_id, resource_key, role, service_name, applied, status, created_time, updated_time)
VALUES (?, ?, 'cleanup-reservation', 'cleanup-resource', 'forward', ?, ?, ?, ?, ?)`,
stored.ID, nodeID, name, applied, status, now, updated.UnixMilli()).Error; err != nil {
t.Fatal(err)
}
return stored.ID
}
func TestSharedForwardCleanupRegression(t *testing.T) {
for _, mode := range []string{"single", "batch", "config"} {
for _, runtimeName := range []string{"70_1_0", "70_1_0_tcp", "70_1_0_udp"} {
t.Run(mode+"/active/"+runtimeName, func(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, runtimeName, 1, 1, 1, time.Now())
runCleanupPath(a.h, mode, []string{"70_1_0", "70_1_0_tcp", "70_1_0_udp"})
a.probe(t)
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
t.Fatalf("active shared service family was deleted: %+v", commands)
}
if mode != "config" && runtimeName == "70_1_0" {
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil || share == nil || share.CurrentFlow != 600 {
t.Fatalf("shared traffic should accumulate 600 bytes: share=%+v err=%v", share, err)
}
}
})
}
for _, tc := range []struct {
name string
serviceName string
nodeID int64
status int
applied int
age time.Duration
wantDelete bool
}{
{name: "recent-unbound", nodeID: 1, status: 1, age: time.Minute},
{name: "stale-unbound", nodeID: 1, status: 1, age: 11 * time.Minute, wantDelete: true},
{name: "released", serviceName: "70_1_0", nodeID: 1, applied: 1, wantDelete: true},
{name: "other-node-bound", serviceName: "70_1_0", nodeID: 2, status: 1, applied: 1, wantDelete: true},
{name: "other-node-unbound", nodeID: 2, status: 1, wantDelete: true},
{name: "unrelated-active-family", serviceName: "71_1_0", nodeID: 1, status: 1, applied: 1, wantDelete: true},
} {
t.Run(mode+"/"+tc.name, func(t *testing.T) {
a := newCleanupAgent(t)
a.addRuntime(t, tc.serviceName, tc.nodeID, tc.status, tc.applied, time.Now().Add(-tc.age))
runCleanupPath(a.h, mode, []string{"70_1_0_tcp"})
a.probe(t)
commands := a.commandsOfType("DeleteService")
if !tc.wantDelete {
if len(commands) != 0 {
t.Fatalf("recent unbound shared runtime was deleted: %+v", commands)
}
return
}
if len(commands) != 1 {
t.Fatalf("expected one orphan cleanup command, got %+v", commands)
}
var data struct {
Services []string `json:"services"`
}
if err := json.Unmarshal(commands[0].Data, &data); err != nil {
t.Fatal(err)
}
sort.Strings(data.Services)
if want := []string{"70_1_0", "70_1_0_tcp", "70_1_0_udp"}; !reflect.DeepEqual(data.Services, want) {
t.Fatalf("orphan cleanup must delete complete family: got %v, want %v", data.Services, want)
}
})
}
}
}
func runCleanupPath(h *Handler, mode string, names []string) {
items := make([]flowItem, 0, len(names))
services := make([]namedConfigItem, 0, len(names))
for _, name := range names {
items = append(items, flowItem{N: name, U: 120, D: 80})
services = append(services, namedConfigItem{Name: name})
}
switch mode {
case "single":
for _, item := range items {
h.processFlowItem(1, item)
}
case "batch":
h.applyFlowUploadBatch(1, h.buildNodeFlowUploadBatch(1, items, nil), time.Now())
case "config":
h.cleanOrphanedServices(1, services)
}
}
func TestSharedForwardCleanupMissingMetadataPreservesLocalForward(t *testing.T) {
a := newCleanupAgent(t)
if err := a.h.repo.DB().Create(&model.Forward{ID: 70, UserID: 1, UserName: "local", Name: "local", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 1}).Error; err != nil {
t.Fatal(err)
}
runCleanupPath(a.h, "batch", []string{"70_1_0", "70_1_0_tcp", "70_1_0_udp"})
a.probe(t)
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
t.Fatalf("missing batch metadata must not delete a local forward: %+v", commands)
}
}
func TestSharedForwardCleanupChecksActualDeleteFamily(t *testing.T) {
for _, mode := range []string{"single", "batch", "config"} {
t.Run(mode, func(t *testing.T) {
a := newCleanupAgent(t)
a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
// Legacy parsing accepts additional suffixes. Cleanup must check
// the family it would delete, not just the reported service name.
runCleanupPath(a.h, mode, []string{"70_1_0_old", "70_1_0_old_tcp"})
a.probe(t)
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
t.Fatalf("suffix variation must not delete a protected shared family: %+v", commands)
}
})
}
}
func TestSharedForwardCleanupQueryFailurePreservesServices(t *testing.T) {
for _, mode := range []string{"single", "batch", "config"} {
for _, failure := range []string{"forward", "peer_share_runtime"} {
t.Run(mode+"/"+failure, func(t *testing.T) {
a := newCleanupAgent(t)
// Fail only the ownership lookup; leave node lookup and WebSocket
// delivery functional so an erroneous delete remains observable.
callback := "test:cleanup-query-failure"
var injected atomic.Int32
if err := a.h.repo.DB().Callback().Query().Before("gorm:query").Register(callback, func(tx *gorm.DB) {
if tx.Statement.Table == failure {
injected.Add(1)
tx.AddError(errors.New("injected ownership query failure"))
}
}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = a.h.repo.DB().Callback().Query().Remove(callback) })
runCleanupPath(a.h, mode, []string{"70_1_0_tcp"})
a.probe(t)
if injected.Load() == 0 {
t.Fatal("test did not inject the expected query failure")
}
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
t.Fatalf("ownership query failure must preserve services: %+v", commands)
}
})
}
}
}
@@ -0,0 +1,145 @@
package handler
import (
"encoding/json"
"errors"
"testing"
"time"
"gorm.io/gorm"
)
func TestConfigCleanupPreservesSharedDependencies(t *testing.T) {
a := newCleanupAgent(t)
a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
a.h.cleanNodeConfigs(1, `{
"services": [{"name":"70_1_0_tcp", "handler":{"chain":"chains_88"}, "limiter":"13, rule_traffic_limit_70"}],
"chains": [{"name":"fed_chain_17"}, {"name":"chains_88"}, {"name":"chains_999"}],
"limiters": [{"name":"13"}, {"name":"rule_traffic_limit_70"}, {"name":"99"}]
}`)
a.probe(t)
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
t.Fatalf("shared service was deleted: %+v", commands)
}
assertCleanupDependency(t, a, "DeleteChains", "chain", "chains_999")
assertCleanupDependency(t, a, "DeleteLimiters", "limiter", "99")
}
func TestConfigCleanupRemovesOrphanedForwardLimiter(t *testing.T) {
a := newCleanupAgent(t)
a.h.cleanNodeConfigs(1, `{"limiters":[{"name":"rule_traffic_limit_70"}]}`)
a.probe(t)
assertCleanupDependency(t, a, "DeleteLimiters", "limiter", "rule_traffic_limit_70")
}
func TestConfigCleanupProtectsPendingSharedDependencies(t *testing.T) {
for _, tc := range []struct {
name string
age time.Duration
keep bool
}{
{name: "pending", age: time.Minute, keep: true},
{name: "expired-reservation", age: 11 * time.Minute},
} {
t.Run(tc.name, func(t *testing.T) {
a := newCleanupAgent(t)
a.addRuntime(t, "", 1, 1, 0, time.Now().Add(-tc.age))
// Dependencies may arrive before the service and its runtime binding.
a.h.cleanNodeConfigs(1, `{"chains":[{"name":"chains_88"}],"limiters":[{"name":"13"}]}`)
a.probe(t)
if tc.keep {
for _, commandType := range []string{"DeleteChains", "DeleteLimiters"} {
if commands := a.commandsOfType(commandType); len(commands) != 0 {
t.Fatalf("pending shared dependencies deleted: %+v", commands)
}
}
return
}
assertCleanupDependency(t, a, "DeleteChains", "chain", "chains_88")
assertCleanupDependency(t, a, "DeleteLimiters", "limiter", "13")
})
}
}
func TestConfigCleanupPreservesDependenciesOnLookupFailure(t *testing.T) {
for _, table := range []string{"peer_share_runtime", "tunnel", "speed_limit", "forward"} {
t.Run(table, func(t *testing.T) {
a := newCleanupAgent(t)
callback := "test:config-cleanup-query-failure"
injected := false
if err := a.h.repo.DB().Callback().Query().Before("gorm:query").Register(callback, func(tx *gorm.DB) {
if tx.Statement.Table == table {
injected = true
tx.AddError(errors.New("injected dependency lookup failure"))
}
}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = a.h.repo.DB().Callback().Query().Remove(callback) })
configs := map[string]string{
"peer_share_runtime": `{"chains":[{"name":"chains_88"}],"limiters":[{"name":"13"}]}`,
"tunnel": `{"chains":[{"name":"chains_88"}]}`,
"speed_limit": `{"limiters":[{"name":"13"}]}`,
"forward": `{"limiters":[{"name":"rule_traffic_limit_70"}]}`,
}
a.h.cleanNodeConfigs(1, configs[table])
a.probe(t)
if !injected {
t.Fatal("expected dependency lookup failure to be injected")
}
for _, commandType := range []string{"DeleteChains", "DeleteLimiters"} {
if commands := a.commandsOfType(commandType); len(commands) != 0 {
t.Fatalf("lookup failure must not authorize cleanup: %+v", commands)
}
}
})
}
}
func TestSharedForwardCleanupDuringRuntimeBinding(t *testing.T) {
for _, mode := range []string{"single", "batch", "config"} {
t.Run(mode, func(t *testing.T) {
a := newCleanupAgent(t)
a.addRuntime(t, "", 1, 1, 0, time.Now())
callback := "test:bind-during-cleanup"
bound := false
if err := a.h.repo.DB().Callback().Query().After("gorm:query").Register(callback, func(tx *gorm.DB) {
if tx.Statement.Table != "peer_share_runtime" || bound {
return
}
bound = true
// Reproduce binding immediately after the first ownership read.
// Separate name/unbound queries would both miss this runtime.
if err := a.h.repo.DB().Exec("UPDATE peer_share_runtime SET service_name = ?, applied = 1", "70_1_0").Error; err != nil {
t.Errorf("bind shared runtime: %v", err)
}
}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = a.h.repo.DB().Callback().Query().Remove(callback) })
runCleanupPath(a.h, mode, []string{"70_1_0_tcp"})
a.probe(t)
if !bound {
t.Fatal("binding transition did not run")
}
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
t.Fatalf("service was deleted during binding: %+v", commands)
}
})
}
}
func assertCleanupDependency(t *testing.T, a *cleanupAgent, commandType, key, want string) {
t.Helper()
commands := a.commandsOfType(commandType)
if len(commands) != 1 {
t.Fatalf("expected one %s for %s, got %+v", commandType, want, commands)
}
var data map[string]string
if err := json.Unmarshal(commands[0].Data, &data); err != nil {
t.Fatal(err)
}
if data[key] != want {
t.Fatalf("unexpected %s target: got %q, want %q", commandType, data[key], want)
}
}
@@ -0,0 +1,118 @@
package handler
import (
"log"
"strings"
"go-backend/internal/store/repo"
)
// Classify ownership before building local counters or enforcing local quotas.
// Numeric IDs from a consuming panel are not IDs in the provider's database.
func (h *Handler) buildNodeFlowUploadBatch(nodeID int64, items []flowItem, metas map[int64]repo.FlowUploadForwardMeta) flowUploadBatch {
if h == nil || h.repo == nil || nodeID <= 0 {
return flowUploadBatch{}
}
runtimes, err := h.repo.ListActiveForwardPeerShareRuntimesByNode(nodeID)
if err != nil {
log.Printf("flow ownership lookup failed node_id=%d err=%v", nodeID, err)
return flowUploadBatch{}
}
resources, err := h.repo.ListPeerShareResourcesByNode(nodeID)
if err != nil {
log.Printf("flow resource lookup failed node_id=%d err=%v", nodeID, err)
return flowUploadBatch{}
}
forwardIDs, err := h.repo.ListForwardIDsByNode(nodeID)
if err != nil {
log.Printf("flow node ownership lookup failed node_id=%d err=%v", nodeID, err)
return flowUploadBatch{}
}
nodeForwards := make(map[int64]struct{}, len(forwardIDs))
for _, forwardID := range forwardIDs {
nodeForwards[forwardID] = struct{}{}
}
legacyOwners := make(map[string]map[int64]struct{})
addLegacyOwner := func(name string, shareID int64) {
name = normalizeForwardRuntimeServiceName(name)
if name == "" {
return
}
if legacyOwners[name] == nil {
legacyOwners[name] = make(map[int64]struct{})
}
legacyOwners[name][shareID] = struct{}{}
}
for _, runtime := range runtimes {
addLegacyOwner(runtime.ServiceName, runtime.ShareID)
}
resourceOwners := make(map[string]int64)
for _, resource := range resources {
// A migrated TCP service may still own a legacy UDP sibling. This alias
// remains authoritative until the whole legacy family is acknowledged gone.
addLegacyOwner(resource.LegacyServiceBase, resource.ShareID)
// A deletion intent does not prove the listener is gone. Failed or
// timed-out commands retain ownership until the node acknowledges it.
if resource.Kind == "service" && (resource.DesiredState != "deleted" || resource.Applied == 0) {
resourceOwners[resource.RuntimeName] = resource.ShareID
}
}
localItems := make([]flowItem, 0, len(items))
sharedUsage := make(map[int64]int64)
for _, item := range items {
name := strings.TrimSpace(item.N)
if strings.HasPrefix(name, "peer-share-") {
shareID, _, ok := parsePeerShareServiceName(name)
if ok && resourceOwners[name] == shareID && item.U >= 0 && item.D >= 0 {
sharedUsage[shareID] += item.U + item.D
}
// Unknown or confirmed-deleted scoped names must never become local IDs.
continue
}
if owners := legacyOwners[normalizeForwardRuntimeServiceName(name)]; len(owners) > 0 {
forwardID, userID, userTunnelID, parsed := parseFlowServiceIDs(name)
meta, local := metas[forwardID]
_, onNode := nodeForwards[forwardID]
if parsed && local && onNode && meta.UserID == userID && meta.UserTunnelID == userTunnelID {
// Legacy names can be genuinely ambiguous. Preserve the listener
// but do not debit either owner based on an ID guess.
log.Printf("ambiguous legacy flow ownership node_id=%d service=%s", nodeID, name)
continue
}
if len(owners) == 1 && item.U >= 0 && item.D >= 0 {
for shareID := range owners {
sharedUsage[shareID] += item.U + item.D
}
}
continue
}
if forwardID, userID, _, ok := parseFlowServiceIDs(name); ok {
if meta, exists := metas[forwardID]; exists {
if _, onNode := nodeForwards[forwardID]; !onNode || meta.UserID != userID {
continue
}
}
}
localItems = append(localItems, item)
}
batch := h.buildFlowUploadBatch(localItems, metas)
batch.peerShareUsage = sharedUsage
return batch
}
func (h *Handler) addPeerShareFlow(nodeID, shareID, delta int64) {
if nodeID <= 0 || shareID <= 0 || delta <= 0 {
return
}
share, err := h.repo.GetPeerShare(shareID)
if err != nil || share == nil || share.NodeID != nodeID {
return
}
if err := h.repo.AddPeerShareCurrentFlow(shareID, delta); err != nil {
return
}
share, err = h.repo.GetPeerShare(shareID)
if err == nil && isPeerShareFlowExceeded(share) {
h.enforcePeerShareFlowLimit(shareID)
}
}
@@ -0,0 +1,235 @@
package handler
import (
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
"testing"
"time"
)
func TestFlowOwnershipSharedFlowDoesNotChargeLocalCollision(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
if err := a.h.repo.DB().Create(&model.Tunnel{ID: 9, Name: "local", TrafficRatio: 1, Flow: 1, Type: 1, Status: 1}).Error; err != nil {
t.Fatal(err)
}
if err := a.h.repo.DB().Create(&model.Forward{ID: 70, UserID: 2, UserName: "local", Name: "local", TunnelID: 9, RemoteAddr: "127.0.0.1:80", Status: 1}).Error; err != nil {
t.Fatal(err)
}
metas, err := a.h.repo.GetFlowUploadForwardMetas([]int64{70})
if err != nil {
t.Fatal(err)
}
a.h.applyFlowUploadBatch(1, a.h.buildNodeFlowUploadBatch(1, []flowItem{{N: "70_1_0_tcp", U: 120, D: 80}}, metas), time.Now())
var local model.Forward
if err := a.h.repo.DB().First(&local, 70).Error; err != nil {
t.Fatal(err)
}
shared, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
t.Logf("local user=%d local bytes=%d shared bytes=%d", local.UserID, local.InFlow+local.OutFlow, shared.CurrentFlow)
if local.InFlow+local.OutFlow != 0 {
t.Errorf("shared flow charged colliding local forward: %d", local.InFlow+local.OutFlow)
}
if shared.CurrentFlow != 200 {
t.Errorf("shared flow missing: %d", shared.CurrentFlow)
}
}
func TestFlowOwnershipScopedServiceRequiresStoredNodeOwnership(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
name := peerShareResourceName(shareID, "service", "70_1_0_tcp")
if err := a.h.repo.SavePeerShareResources([]repo.PeerShareResource{{
ShareID: shareID, NodeID: 1, Kind: "service", OriginalName: "70_1_0_tcp",
RuntimeName: name, DesiredState: "active", Applied: 1,
}}); err != nil {
t.Fatal(err)
}
for _, nodeID := range []int64{2, 1} {
batch := a.h.buildNodeFlowUploadBatch(nodeID, []flowItem{{N: name, U: 120, D: 80}}, nil)
a.h.applyFlowUploadBatch(nodeID, batch, time.Now())
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
want := int64(0)
if nodeID == 1 {
want = 200
}
if share.CurrentFlow != want {
t.Fatalf("node=%d: got flow=%d want=%d", nodeID, share.CurrentFlow, want)
}
if len(batch.flowDeltas) != 0 || len(batch.quotaUsage) != 0 || len(batch.orphanServices) != 0 {
t.Fatalf("scoped traffic entered local accounting: %+v", batch)
}
}
a.probe(t)
if cmds := a.commandsOfType("DeleteService"); len(cmds) != 0 {
t.Fatalf("scoped service deleted: %+v", cmds)
}
}
func TestFlowOwnershipRoleRuntimeRequiresReportingNode(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, "fed_svc_17", 1, 1, 1, time.Now())
if err := a.h.repo.DB().Exec("UPDATE peer_share_runtime SET id = 17, role = 'exit'").Error; err != nil {
t.Fatal(err)
}
a.h.processFlowItem(2, flowItem{N: "fed_svc_17", U: 120, D: 80})
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
if share.CurrentFlow != 0 {
t.Fatalf("wrong node charged role runtime: %d", share.CurrentFlow)
}
a.h.processFlowItem(1, flowItem{N: "fed_svc_17", U: 120, D: 80})
share, err = a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
if share.CurrentFlow != 200 {
t.Fatalf("owner node flow missing: %d", share.CurrentFlow)
}
}
func TestFlowOwnershipAmbiguousLegacyNameDoesNotDebitEitherOwner(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
if err := a.h.repo.DB().Create(&model.Forward{ID: 70, UserID: 1, UserName: "local", Name: "local", TunnelID: 9, RemoteAddr: "127.0.0.1:80", Status: 1}).Error; err != nil {
t.Fatal(err)
}
if err := a.h.repo.DB().Create(&model.ForwardPort{ForwardID: 70, NodeID: 1, Port: 32000}).Error; err != nil {
t.Fatal(err)
}
metas, err := a.h.repo.GetFlowUploadForwardMetas([]int64{70})
if err != nil {
t.Fatal(err)
}
batch := a.h.buildNodeFlowUploadBatch(1, []flowItem{{N: "70_1_0_tcp", U: 120, D: 80}}, metas)
a.h.applyFlowUploadBatch(1, batch, time.Now())
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
if share.CurrentFlow != 0 || len(batch.flowDeltas) != 0 {
t.Fatalf("ambiguous flow charged an owner: share=%d local=%+v", share.CurrentFlow, batch.flowDeltas)
}
a.probe(t)
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
t.Fatalf("ambiguous legacy service deleted: %+v", commands)
}
}
func TestFlowOwnershipPreservesUnmigratedLegacyTransport(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, peerShareResourceName(1, "service", "70_1_0"), 1, 1, 1, time.Now())
if err := a.h.repo.SavePeerShareResources([]repo.PeerShareResource{{
ShareID: shareID, NodeID: 1, Kind: "service", OriginalName: "70_1_0_tcp",
RuntimeName: peerShareResourceName(shareID, "service", "70_1_0_tcp"),
LegacyServiceBase: "70_1_0", DesiredState: "deleted", Applied: 1,
}}); err != nil {
t.Fatal(err)
}
a.h.processFlowItem(1, flowItem{N: "70_1_0_udp", U: 120, D: 80})
a.h.cleanNodeConfigs(1, `{"services":[{"name":"70_1_0_udp"}]}`)
a.probe(t)
if commands := a.commandsOfType("DeleteService"); len(commands) != 0 {
t.Fatalf("unmigrated transport deleted: %+v", commands)
}
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
if share.CurrentFlow != 200 {
t.Fatalf("legacy transport accounting lost: %d", share.CurrentFlow)
}
}
func TestFlowOwnershipLocalForwardRequiresReportingNode(t *testing.T) {
a := newCleanupAgent(t)
if err := a.h.repo.DB().Create(&model.Forward{ID: 70, UserID: 1, UserName: "local", Name: "local", TunnelID: 9, RemoteAddr: "127.0.0.1:80", Status: 1}).Error; err != nil {
t.Fatal(err)
}
if err := a.h.repo.DB().Create(&model.ForwardPort{ForwardID: 70, NodeID: 2, Port: 32000}).Error; err != nil {
t.Fatal(err)
}
metas, err := a.h.repo.GetFlowUploadForwardMetas([]int64{70})
if err != nil {
t.Fatal(err)
}
for _, nodeID := range []int64{1, 2} {
batch := a.h.buildNodeFlowUploadBatch(nodeID, []flowItem{{N: "70_1_0_tcp", U: 120, D: 80}}, metas)
a.h.applyFlowUploadBatch(nodeID, batch, time.Now())
var forward model.Forward
if err := a.h.repo.DB().First(&forward, 70).Error; err != nil {
t.Fatal(err)
}
want := int64(0)
if nodeID == 2 {
want = 200
}
if forward.InFlow+forward.OutFlow != want {
t.Fatalf("node %d local traffic=%d want=%d", nodeID, forward.InFlow+forward.OutFlow, want)
}
}
}
func TestPeerShareMaintenanceRetriesOnlyUnfinishedOperations(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
name := peerShareResourceName(shareID, "service", "80_1_0_tcp")
if err := a.h.repo.SavePeerShareResources([]repo.PeerShareResource{{ShareID: shareID, NodeID: 1,
Kind: "service", OriginalName: "80_1_0_tcp", RuntimeName: name, DesiredState: "deleted", Applied: 0,
}}); err != nil {
t.Fatal(err)
}
a.h.retryPendingPeerShareOperations()
a.probe(t)
if commands := a.commandsOfType("DeleteService"); len(commands) != 1 {
t.Fatalf("pending delete was not retried: %+v", commands)
}
a.h.retryPendingPeerShareOperations()
a.probe(t)
if commands := a.commandsOfType("DeleteService"); len(commands) != 1 {
t.Fatalf("completed delete was replayed: %+v", commands)
}
ids, err := a.h.repo.ListPendingPeerShareNodeIDs()
if err != nil {
t.Fatal(err)
}
if len(ids) != 0 {
t.Fatalf("completed operation still pending: %v", ids)
}
}
func TestFlowOwnershipDoesNotCrossNodes(t *testing.T) {
a := newCleanupAgent(t)
shareID := a.addRuntime(t, "70_1_0", 1, 1, 1, time.Now())
share, err := a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
share.MaxBandwidth = 100
if err := a.h.repo.UpdatePeerShare(share); err != nil {
t.Fatal(err)
}
// Node 2 has no shared runtime. It reports the same local numeric service name.
a.h.applyFlowUploadBatch(2, a.h.buildNodeFlowUploadBatch(2, []flowItem{{N: "70_1_0_tcp", U: 120, D: 80}}, nil), time.Now())
a.probe(t)
share, err = a.h.repo.GetPeerShare(shareID)
if err != nil {
t.Fatal(err)
}
commands := a.commandsOfType("DeleteService")
t.Logf("node1 share charged from node2: %d; node1 delete commands: %+v", share.CurrentFlow, commands)
if share.CurrentFlow != 0 {
t.Errorf("flow crossed node boundary: %d", share.CurrentFlow)
}
if len(commands) != 0 {
t.Errorf("node2 flow deleted node1 shared service: %+v", commands)
}
}
@@ -0,0 +1,57 @@
package handler
import (
"testing"
"time"
)
func TestFlowOwnershipPendingDeletionRemainsBillableUntilAcknowledged(t *testing.T) {
for _, mode := range []string{"single", "batch"} {
t.Run(mode, func(t *testing.T) {
a := newCleanupAgent(t)
share := resourceTestShare(t, a.h, "pending-deletion-"+mode)
if got := resourceTestCommand(t, a.h, share, "AddService", resourceTestService("70_1_0_tcp", 31001)); got.Code != 0 {
t.Fatal(got.Msg)
}
name := peerShareResourceName(share.ID, "service", "70_1_0_tcp")
// Simulate a failed delivery after the deletion intent is committed. The
// mock node's previously installed listener has received no delete command.
disconnected := &Handler{repo: a.h.repo}
if got := resourceTestCommand(t, disconnected, share, "DeleteService", map[string]interface{}{"services": []string{"70_1_0_tcp"}}); got.Code == 0 {
t.Fatal("failed deletion reported success")
}
resource, err := a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
if err != nil || resource == nil || resource.DesiredState != "deleted" || resource.Applied != 0 {
t.Fatalf("missing pending tombstone: %+v %v", resource, err)
}
report := func() {
item := flowItem{N: name, U: 120, D: 80}
if mode == "single" {
a.h.processFlowItem(1, item)
} else {
a.h.applyFlowUploadBatch(1, a.h.buildNodeFlowUploadBatch(1, []flowItem{item}, nil), time.Now())
}
}
report()
stored, err := a.h.repo.GetPeerShare(share.ID)
if err != nil || stored.CurrentFlow != 200 {
t.Fatalf("unconfirmed deletion stopped billing: share=%+v err=%v", stored, err)
}
if len(a.commandsOfType("DeleteService")) != 0 {
t.Fatal("flow handling deleted a pending shared listener")
}
if err := a.h.retryPendingPeerShareResourcesOnNode(1); err != nil {
t.Fatal(err)
}
resource, err = a.h.repo.GetPeerShareResource(share.ID, "service", "70_1_0_tcp")
if err != nil || resource.Applied != 1 {
t.Fatalf("deletion acknowledgment not recorded: %+v %v", resource, err)
}
report()
stored, err = a.h.repo.GetPeerShare(share.ID)
if err != nil || stored.CurrentFlow != 200 {
t.Fatalf("confirmed-deleted listener was billed again: share=%+v err=%v", stored, err)
}
})
}
}
+274 -200
View File
@@ -3,21 +3,36 @@ package handler
import (
"encoding/json"
"errors"
"fmt"
"log"
"math"
"strconv"
"strings"
"time"
"go-backend/internal/store/model"
)
const bytesPerGB int64 = 1024 * 1024 * 1024
const bytesPerMiB int64 = 1024 * 1024
func flowLimitBytes(flowGB, flowMiB int64) int64 {
if flowMiB > 0 {
if flowMiB > math.MaxInt64/bytesPerMiB {
return math.MaxInt64
}
return flowMiB * bytesPerMiB
}
if flowGB > math.MaxInt64/bytesPerGB {
return math.MaxInt64
}
return flowGB * bytesPerGB
}
type userTunnelPolicy struct {
ID int64
UserID int64
TunnelID int64
Flow int64
FlowMiB int64
InFlow int64
OutFlow int64
ExpTime int64
@@ -32,38 +47,22 @@ type gostConfigSnapshot struct {
}
type namedConfigItem struct {
Name string `json:"name"`
Name string `json:"name"`
Limiter string `json:"limiter,omitempty"`
Handler *struct {
Chain string `json:"chain"`
} `json:"handler,omitempty"`
}
func (h *Handler) processFlowItem(nodeID int64, item flowItem) {
serviceName := strings.TrimSpace(item.N)
if serviceName == "" || serviceName == "web_api" {
if h == nil || h.repo == nil || nodeID <= 0 {
return
}
forwardID, userID, userTunnelID, ok := parseFlowServiceIDs(serviceName)
if ok {
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)
return
metas, err := h.repo.GetFlowUploadForwardMetas(collectFlowUploadForwardIDs([]flowItem{item}))
if err != nil {
metas = nil
}
runtimeID, ok := parsePeerShareRuntimeServiceID(serviceName)
if !ok {
return
}
h.processPeerShareFlow(runtimeID, item)
h.applyFlowUploadBatch(nodeID, h.buildNodeFlowUploadBatch(nodeID, []flowItem{item}, metas), time.Now())
}
func parseFlowServiceIDs(serviceName string) (int64, int64, int64, bool) {
@@ -138,73 +137,49 @@ func parsePeerShareIDFromFederationTunnelName(tunnelName string) (int64, bool) {
return shareID, true
}
func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
if h == nil || h.repo == nil || runtimeID <= 0 {
func (h *Handler) processPeerShareFlow(nodeID, runtimeID int64, item flowItem) {
if h == nil || h.repo == nil || nodeID <= 0 || runtimeID <= 0 {
return
}
runtime, err := h.repo.GetPeerShareRuntimeByID(runtimeID)
if err != nil || runtime == nil || runtime.ShareID <= 0 || runtime.Status != 1 {
if err != nil || runtime == nil || runtime.NodeID != nodeID || runtime.Status != 1 {
return
}
delta := item.D + item.U
if delta <= 0 {
return
}
_ = h.repo.AddPeerShareCurrentFlow(runtime.ShareID, delta)
share, err := h.repo.GetPeerShare(runtime.ShareID)
if err != nil || share == nil {
return
}
if !isPeerShareFlowExceeded(share) {
return
}
h.enforcePeerShareFlowLimit(share.ID)
h.addPeerShareFlow(nodeID, runtime.ShareID, item.D+item.U)
}
func (h *Handler) processPeerShareFlowFromForward(forwardID int64, nodeID int64, serviceName string, item flowItem) {
if h == nil || h.repo == nil || forwardID <= 0 {
if h == nil || h.repo == nil || forwardID <= 0 || nodeID <= 0 {
return
}
delta := item.D + item.U
if delta <= 0 {
// Prefer the reporting node's explicit shared ownership over a coincidentally
// equal local forward ID. Never fall back to a service on another node.
runtimes, err := h.repo.ListActiveForwardPeerShareRuntimesByNode(nodeID)
if err != nil {
return
}
for _, runtime := range runtimes {
if normalizeForwardRuntimeServiceName(runtime.ServiceName) == normalizeForwardRuntimeServiceName(serviceName) {
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
}
forward, err := h.getForwardRecord(forwardID)
if err != nil || forward == nil {
// Forward not found in local database - might be a federation port-forward
// Try to find by service name in peer_share_runtime
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
_, userID, _, ok := parseFlowServiceIDs(serviceName)
if !ok || userID != forward.UserID {
return
}
tunnelName, err := h.repo.GetTunnelName(forward.TunnelID)
if err != nil {
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
shareID, ok := parsePeerShareIDFromFederationTunnelName(tunnelName)
if !ok {
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
if ok {
h.addPeerShareFlow(nodeID, shareID, item.D+item.U)
}
if err := h.repo.AddPeerShareCurrentFlow(shareID, delta); err != nil {
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
share, err := h.repo.GetPeerShare(shareID)
if err != nil || share == nil {
return
}
if !isPeerShareFlowExceeded(share) {
return
}
h.enforcePeerShareFlowLimit(share.ID)
}
func normalizeForwardRuntimeServiceName(serviceName string) string {
@@ -219,63 +194,26 @@ func normalizeForwardRuntimeServiceName(serviceName string) string {
}
func (h *Handler) processPeerShareFlowByServiceName(nodeID int64, serviceName string, item flowItem) {
if h == nil || h.repo == nil || strings.TrimSpace(serviceName) == "" {
if h == nil || h.repo == nil || nodeID <= 0 || strings.TrimSpace(serviceName) == "" {
return
}
delta := item.D + item.U
if delta <= 0 {
runtimes, err := h.repo.ListActiveForwardPeerShareRuntimesByNode(nodeID)
if err != nil {
return
}
normalized := normalizeForwardRuntimeServiceName(serviceName)
var runtimes []model.PeerShareRuntime
var err error
// Try node-scoped query first if nodeID is valid
if nodeID > 0 {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID, normalized)
if err != nil {
var shareID int64
for _, runtime := range runtimes {
if normalizeForwardRuntimeServiceName(runtime.ServiceName) != normalizeForwardRuntimeServiceName(serviceName) {
continue
}
if shareID != 0 {
log.Printf("ambiguous peer share runtime service=%s node_id=%d", serviceName, nodeID)
return
}
if len(runtimes) == 0 && normalized != serviceName {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID, serviceName)
if err != nil {
return
}
}
shareID = runtime.ShareID
}
// Fallback to global query if node-scoped query returned nothing or nodeID is invalid
if len(runtimes) == 0 {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByServiceName(normalized)
if err != nil {
return
}
if len(runtimes) == 0 && normalized != serviceName {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByServiceName(serviceName)
if err != nil {
return
}
}
}
if len(runtimes) != 1 {
if len(runtimes) > 1 {
log.Printf("WARN: ambiguous peer share runtime match for service=%s nodeID=%d count=%d", serviceName, nodeID, len(runtimes))
}
return
}
runtime := runtimes[0]
_ = h.repo.AddPeerShareCurrentFlow(runtime.ShareID, delta)
matchedShare, err := h.repo.GetPeerShare(runtime.ShareID)
if err != nil || matchedShare == nil {
return
}
if isPeerShareFlowExceeded(matchedShare) {
h.enforcePeerShareFlowLimit(matchedShare.ID)
if shareID > 0 {
h.addPeerShareFlow(nodeID, shareID, item.D+item.U)
}
}
@@ -283,22 +221,8 @@ func (h *Handler) enforcePeerShareFlowLimit(shareID int64) {
if h == nil || h.repo == nil || shareID <= 0 {
return
}
runtimes, err := h.repo.ListActivePeerShareRuntimesByShareID(shareID)
if err != nil || len(runtimes) == 0 {
return
}
now := time.Now().UnixMilli()
for _, runtime := range runtimes {
if h.wsServer != nil && runtime.Applied == 1 {
if strings.TrimSpace(runtime.ServiceName) != "" {
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteService", map[string]interface{}{"services": []string{runtime.ServiceName}}, false, true)
}
if strings.TrimSpace(runtime.Role) == "middle" && strings.TrimSpace(runtime.ChainName) != "" {
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteChains", map[string]interface{}{"chain": runtime.ChainName}, false, true)
}
}
_ = h.repo.MarkPeerShareRuntimeReleased(runtime.ID, now)
if err := h.cleanupPeerShareRuntimes(shareID); err != nil {
log.Printf("peer share quota cleanup pending share_id=%d err=%v", shareID, err)
}
}
@@ -358,7 +282,7 @@ func (h *Handler) ensureUserTunnelForwardAllowed(userID int64, tunnelID int64, n
return errors.New("账号已过期")
}
flowLimit := user.Flow * bytesPerGB
flowLimit := flowLimitBytes(user.Flow, user.FlowMiB)
current := user.InFlow + user.OutFlow
if flowLimit < current {
return errors.New("流量已超额,禁止开启转发")
@@ -400,7 +324,7 @@ func (h *Handler) ensureUserTunnelForwardAllowed(userID int64, tunnelID int64, n
return errors.New("该隧道已过期")
}
utFlowLimit := policy.Flow * bytesPerGB
utFlowLimit := flowLimitBytes(policy.Flow, policy.FlowMiB)
utCurrent := policy.InFlow + policy.OutFlow
if utCurrent >= utFlowLimit {
return errors.New("该隧道流量已超额,禁止开启转发")
@@ -425,7 +349,7 @@ func (h *Handler) shouldPauseUser(userID int64, now int64) bool {
return false
}
flowLimit := user.Flow * bytesPerGB
flowLimit := flowLimitBytes(user.Flow, user.FlowMiB)
current := user.InFlow + user.OutFlow
if flowLimit < current {
return true
@@ -441,7 +365,7 @@ func shouldPauseUserTunnel(policy *userTunnelPolicy, now int64) bool {
return false
}
flowLimit := policy.Flow * bytesPerGB
flowLimit := flowLimitBytes(policy.Flow, policy.FlowMiB)
current := policy.InFlow + policy.OutFlow
if current >= flowLimit {
return true
@@ -465,11 +389,53 @@ func (h *Handler) getUserTunnelPolicy(userTunnelID int64) (*userTunnelPolicy, er
}
return &userTunnelPolicy{
ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID,
Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow,
Flow: ut.Flow, FlowMiB: ut.FlowMiB, InFlow: ut.InFlow, OutFlow: ut.OutFlow,
ExpTime: ut.ExpTime, Status: ut.Status, Num: ut.Num,
}, nil
}
// disableUserAccess retains unconfirmed forwards as active so a repeated disable
// can retry them. Login and tunnel access remain disabled when a node is offline.
func (h *Handler) disableUserAccess(userID int64, now int64) error {
permissionErr := h.repo.DisableUserTunnels(userID)
forwards, err := h.listActiveForwardsByUser(userID)
if err != nil {
return errors.Join(permissionErr, err)
}
errs := []error{permissionErr}
for i := range forwards {
forward := &forwards[i]
nftMode, entryNodeIDs, pauseErr := h.tunnelUsesNftables(forward.TunnelID)
if pauseErr == nil {
if nftMode {
// Exclude the rule from the desired plan before marking its pause
// confirmed. A failed SSH apply must remain eligible for retry.
pauseErr = h.syncNftablesNode(entryNodeIDs[0], forward.ID)
if pauseErr != nil {
pauseErr = h.nodeCommandError(entryNodeIDs[0], pauseErr)
}
} else {
// Missing services are already stopped. Self-healing would recreate
// services on every entry and could restart one already paused.
pauseErr = h.controlForwardServicesWithOptions(forward, "PauseService", forwardServiceControlOptions{
tolerateNotFound: true,
tolerateOffline: false,
allowSelfHeal: false,
continueOnError: true,
})
}
}
if pauseErr != nil {
errs = append(errs, fmt.Errorf("转发 %d 暂停失败: %w", forward.ID, pauseErr))
continue
}
if err := h.repo.UpdateForwardStatus(forward.ID, 0, now); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func (h *Handler) pauseUserForwards(userID int64, now int64) {
forwards, err := h.listActiveForwardsByUser(userID)
if err != nil {
@@ -515,43 +481,102 @@ func (h *Handler) cleanNodeConfigs(nodeID int64, rawConfig string) {
return
}
h.cleanOrphanedServices(nodeID, snapshot.Services)
h.cleanOrphanedChains(nodeID, snapshot.Chains)
h.cleanOrphanedLimiters(nodeID, snapshot.Limiters)
}
func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem) {
runtimeServiceNames, err := h.repo.ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID)
protection, err := h.loadForwardServiceProtection(nodeID)
if err != nil {
return
}
minUpdatedTime := time.Now().Add(-10 * time.Minute).UnixMilli()
hasUnboundForwardPeerRuntime, err := h.repo.HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID, minUpdatedTime)
if err != nil {
hasUnboundForwardPeerRuntime = false
h.cleanOrphanedServicesWithProtection(nodeID, snapshot.Services, protection)
// Dependencies are sent before services. A pending shared reservation may
// therefore have chains/limiters that are not referenced in this snapshot yet.
if protection.unbound {
return
}
runtimeServiceSet := make(map[string]struct{}, len(runtimeServiceNames))
for _, serviceName := range runtimeServiceNames {
serviceName = strings.TrimSpace(serviceName)
chainsInUse := make(map[string]struct{})
limitersInUse := make(map[string]struct{})
for _, service := range snapshot.Services {
if service.Handler != nil {
if chain := strings.TrimSpace(service.Handler.Chain); chain != "" {
chainsInUse[chain] = struct{}{}
}
}
for _, limiter := range strings.Split(service.Limiter, ",") {
if limiter = strings.TrimSpace(limiter); limiter != "" {
limitersInUse[limiter] = struct{}{}
}
}
}
// Keep dependencies referenced by the reported services, even when their
// IDs belong to a different panel. Orphan dependencies can be collected on
// the next report after their services have actually disappeared.
h.cleanOrphanedChains(nodeID, snapshot.Chains, chainsInUse)
h.cleanOrphanedLimiters(nodeID, snapshot.Limiters, limitersInUse)
}
type forwardServiceProtection struct {
sharedNames map[string]struct{}
unbound bool
}
// Shared forward IDs belong to another panel and need not exist in our forward
// table. Use the same node-scoped ownership check for config and flow reports.
func (h *Handler) loadForwardServiceProtection(nodeID int64) (forwardServiceProtection, error) {
protection := forwardServiceProtection{sharedNames: make(map[string]struct{})}
// Read names and pending bindings in one snapshot, so a concurrent bind
// cannot fall between two queries and disappear from both protections.
runtimes, err := h.repo.ListActiveForwardPeerShareRuntimesByNode(nodeID)
if err != nil {
return protection, err
}
minUpdatedTime := time.Now().Add(-10 * time.Minute).UnixMilli()
for _, runtime := range runtimes {
serviceName := normalizeForwardRuntimeServiceName(runtime.ServiceName)
if serviceName == "" {
if runtime.Applied == 0 && runtime.UpdatedTime >= minUpdatedTime {
protection.unbound = true
}
continue
}
runtimeServiceSet[serviceName] = struct{}{}
protection.sharedNames[serviceName] = struct{}{}
}
resources, err := h.repo.ListPeerShareResourcesByNode(nodeID)
if err != nil {
return protection, err
}
for _, resource := range resources {
if base := normalizeForwardRuntimeServiceName(resource.LegacyServiceBase); base != "" {
protection.sharedNames[base] = struct{}{}
}
}
return protection, nil
}
func (p forwardServiceProtection) preserves(serviceName string) bool {
_, shared := p.sharedNames[normalizeForwardRuntimeServiceName(serviceName)]
return shared || p.unbound
}
func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem) {
if h == nil || h.repo == nil || nodeID <= 0 {
return
}
protection, err := h.loadForwardServiceProtection(nodeID)
if err != nil {
// A failed ownership lookup must never authorize deletion.
return
}
h.cleanOrphanedServicesWithProtection(nodeID, services, protection)
}
func (h *Handler) cleanOrphanedServicesWithProtection(nodeID int64, services []namedConfigItem, protection forwardServiceProtection) {
for _, item := range services {
name := strings.TrimSpace(item.Name)
if name == "" || name == "web_api" {
continue
}
if strings.HasPrefix(name, "fed_svc_") {
if strings.HasPrefix(name, "fed_svc_") || strings.HasPrefix(name, "peer-share-") {
continue
}
normalizedName := normalizeForwardRuntimeServiceName(name)
if _, ok := runtimeServiceSet[normalizedName]; ok {
continue
}
if _, ok := runtimeServiceSet[name]; ok {
if _, ok := protection.sharedNames[normalizeForwardRuntimeServiceName(name)]; ok {
continue
}
@@ -564,15 +589,9 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
continue
}
if len(parts) >= 3 {
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
continue
}
if err == nil && forwardID > 0 && !h.forwardExists(forwardID) {
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name, parts[0] + "_" + parts[1] + "_" + parts[2], parts[0] + "_" + parts[1] + "_" + parts[2] + "_tcp", parts[0] + "_" + parts[1] + "_" + parts[2] + "_udp"}}, false, true)
continue
}
if _, _, _, ok := parseFlowServiceIDs(name); ok {
h.deleteOrphanedForwardService(nodeID, name, protection)
continue
}
suffix := parts[len(parts)-1]
@@ -591,23 +610,17 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
}
continue
}
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
continue
}
if err != nil || forwardID <= 0 || h.forwardExists(forwardID) {
continue
}
base := strings.TrimSuffix(name, "_tcp")
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{base + "_tcp", base + "_udp"}}, false, true)
}
}
}
func (h *Handler) cleanOrphanedChains(nodeID int64, chains []namedConfigItem) {
func (h *Handler) cleanOrphanedChains(nodeID int64, chains []namedConfigItem, inUse map[string]struct{}) {
for _, item := range chains {
name := strings.TrimSpace(item.Name)
if name == "" {
if name == "" || strings.HasPrefix(name, "fed_chain_") || strings.HasPrefix(name, "peer-share-") {
continue
}
if _, ok := inUse[name]; ok {
continue
}
@@ -616,17 +629,28 @@ func (h *Handler) cleanOrphanedChains(nodeID int64, chains []namedConfigItem) {
continue
}
tunnelID, err := strconv.ParseInt(name[idx+1:], 10, 64)
if err != nil || tunnelID <= 0 || h.tunnelExists(tunnelID) {
if err != nil || tunnelID <= 0 {
continue
}
exists, err := h.repo.TunnelExists(tunnelID)
if err != nil || exists {
continue
}
_, _ = h.sendNodeCommand(nodeID, "DeleteChains", map[string]interface{}{"chain": name}, false, true)
}
}
func (h *Handler) cleanOrphanedLimiters(nodeID int64, limiters []namedConfigItem) {
func (h *Handler) cleanOrphanedLimiters(nodeID int64, limiters []namedConfigItem, inUse map[string]struct{}) {
for _, item := range limiters {
name := strings.TrimSpace(item.Name)
if name == "" || h.speedLimiterExists(name) {
if name == "" || strings.HasPrefix(name, "peer-share-") {
continue
}
if _, ok := inUse[name]; ok {
continue
}
exists, err := h.lookupSpeedLimiter(name)
if err != nil || exists {
continue
}
_, _ = h.sendNodeCommand(nodeID, "DeleteLimiters", map[string]interface{}{"limiter": name}, false, true)
@@ -644,28 +668,78 @@ func (h *Handler) forwardExists(forwardID int64) bool {
}
func (h *Handler) sendDeleteOrphanedForwardService(nodeID int64, serviceName string) {
h.sendDeleteOrphanedForwardServices(nodeID, []string{serviceName})
}
func (h *Handler) sendDeleteOrphanedForwardServices(nodeID int64, serviceNames []string) {
if h == nil || h.repo == nil || nodeID <= 0 || len(serviceNames) == 0 {
return
}
protection, err := h.loadForwardServiceProtection(nodeID)
if err != nil {
return
}
seen := make(map[string]struct{}, len(serviceNames))
for _, serviceName := range serviceNames {
serviceName = normalizeForwardRuntimeServiceName(serviceName)
if _, ok := seen[serviceName]; ok {
continue
}
seen[serviceName] = struct{}{}
h.deleteOrphanedForwardService(nodeID, serviceName, protection)
}
}
func (h *Handler) deleteOrphanedForwardService(nodeID int64, serviceName string, protection forwardServiceProtection) {
forwardID, _, _, ok := parseFlowServiceIDs(serviceName)
if !ok || protection.preserves(serviceName) {
return
}
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]
// Parsing accepts legacy suffixes, while deletion targets the entire base
// family. Verify the actual targets cannot include a protected share.
if protection.preserves(base) {
return
}
// Batch metadata can be missing after a read failure, or stale by the time
// cleanup runs. Confirm absence before issuing a destructive command.
exists, err := h.repo.ForwardExists(forwardID)
if err != nil || exists {
return
}
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{
"services": []string{base + "_tcp", base + "_udp"},
"services": buildForwardServiceDeleteNames([]string{base}),
}, false, true)
}
func (h *Handler) speedLimiterExists(name string) bool {
exists, _ := h.lookupSpeedLimiter(name)
return exists
}
func (h *Handler) lookupSpeedLimiter(name string) (bool, error) {
name = strings.TrimSpace(name)
if name == "" {
return false
return false, nil
}
const forwardRulePrefix = "rule_traffic_limit_"
if strings.HasPrefix(name, forwardRulePrefix) {
forwardID, err := strconv.ParseInt(strings.TrimPrefix(name, forwardRulePrefix), 10, 64)
if err != nil || forwardID <= 0 {
return false, nil
}
forward, err := h.getForwardRecord(forwardID)
if errors.Is(err, errForwardNotFound) {
return false, nil
}
return forward != nil && forward.IPSpeedID.Valid && forward.IPSpeedID.Int64 > 0, err
}
id, err := strconv.ParseInt(name, 10, 64)
if err != nil || id <= 0 {
return false
return false, nil
}
ok, _ := h.repo.SpeedLimitExists(id)
return ok
return h.repo.SpeedLimitExists(id)
}
@@ -10,11 +10,8 @@ import (
)
func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
a := newCleanupAgent(t)
r := a.h.repo
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
@@ -43,7 +40,7 @@ func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
t.Fatalf("insert peer_share_runtime: %v", err)
}
h := &Handler{repo: r}
h := a.h
h.processFlowItem(1, flowItem{N: "fed_svc_17", U: 1200, D: 900})
updatedShare, err := r.GetPeerShare(share.ID)
@@ -120,6 +117,9 @@ func TestProcessFlowItemTracksPeerShareFlowForFederationPortForward(t *testing.T
}
h := &Handler{repo: r}
if err := r.DB().Exec("INSERT INTO forward_port(forward_id, node_id, port) VALUES(20, 1, 30001)").Error; err != nil {
t.Fatal(err)
}
h.processFlowItem(1, flowItem{N: "20_2_10", U: 120, D: 80})
updatedShare, err := r.GetPeerShare(share.ID)
@@ -0,0 +1,38 @@
package handler
import (
"path/filepath"
"testing"
"go-backend/internal/store/repo"
)
func TestSpeedLimiterExistsPreservesForwardRuleLimiter(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
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, ip_speed_id)
VALUES(8, 1, 'user', 'forward', 1, '127.0.0.1:80', 'fifo', 0, 0, 1, 1, 1, 0, 3),
(9, 1, 'user', 'forward-without-ip-limit', 1, '127.0.0.1:81', 'fifo', 0, 0, 1, 1, 1, 0, NULL)
`).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
h := &Handler{repo: r}
if !h.speedLimiterExists("rule_traffic_limit_8") {
t.Fatal("expected runtime limiter for existing forward to be preserved")
}
if h.speedLimiterExists("rule_traffic_limit_9") {
t.Fatal("expected runtime limiter for forward without per-IP speed limit to be treated as orphaned")
}
if h.speedLimiterExists("rule_traffic_limit_10") {
t.Fatal("expected runtime limiter for missing forward to be treated as orphaned")
}
if h.speedLimiterExists("rule_traffic_limit_invalid") {
t.Fatal("expected malformed runtime limiter name to be treated as orphaned")
}
}
@@ -23,6 +23,7 @@ type flowUploadBatch struct {
orphanServices map[string]struct{}
peerShareForwardItems map[string]flowItem
peerShareRuntimeItems map[int64]flowItem
peerShareUsage map[int64]int64
}
func (h *Handler) buildFlowUploadBatch(items []flowItem, metas map[int64]repo.FlowUploadForwardMeta) flowUploadBatch {
@@ -65,6 +66,9 @@ func (h *Handler) buildFlowUploadBatch(items []flowItem, metas map[int64]repo.Fl
batch.orphanServices[serviceName] = struct{}{}
continue
}
// Local accounting uses database ownership, not foreign IDs embedded in
// a service name (including stale user-tunnel IDs after reassignment).
userID, userTunnelID = meta.UserID, meta.UserTunnelID
raw := batch.forwardTraffic[forwardID]
raw.bytesIn += item.D
@@ -120,8 +124,12 @@ func (h *Handler) applyFlowUploadBatch(nodeID int64, batch flowUploadBatch, now
}
h.enforceFlowPolicies(target.UserID, target.UserTunnelID)
}
for serviceName := range batch.orphanServices {
h.sendDeleteOrphanedForwardService(nodeID, serviceName)
if len(batch.orphanServices) > 0 {
serviceNames := make([]string, 0, len(batch.orphanServices))
for serviceName := range batch.orphanServices {
serviceNames = append(serviceNames, serviceName)
}
h.sendDeleteOrphanedForwardServices(nodeID, serviceNames)
}
for serviceName, item := range batch.peerShareForwardItems {
forwardID, _, _, ok := parseFlowServiceIDs(serviceName)
@@ -130,7 +138,10 @@ func (h *Handler) applyFlowUploadBatch(nodeID int64, batch flowUploadBatch, now
}
}
for runtimeID, item := range batch.peerShareRuntimeItems {
h.processPeerShareFlow(runtimeID, item)
h.processPeerShareFlow(nodeID, runtimeID, item)
}
for shareID, delta := range batch.peerShareUsage {
h.addPeerShareFlow(nodeID, shareID, delta)
}
}
@@ -14,6 +14,8 @@ func TestBuildFlowUploadBatchAggregatesForwardQuotaPeerShareAndCleanupTargets(t
metas := map[int64]repo.FlowUploadForwardMeta{
20: {
ForwardID: 20,
UserID: 2,
UserTunnelID: 10,
TunnelID: 1,
TrafficRatio: 2,
TunnelFlow: 3,
@@ -9,14 +9,15 @@ import (
"go-backend/internal/store/repo"
)
func TestBuildForwardServiceConfigsSendsProxyProtocolToForwardHandler(t *testing.T) {
func TestBuildForwardServiceConfigsAppliesProxyProtocolReceiveAndSendIndependently(t *testing.T) {
forward := &forwardRecord{
ID: 1,
UserID: 2,
TunnelID: 3,
RemoteAddr: "1.1.1.1:443",
Strategy: "fifo",
ProxyProtocol: 2,
ID: 1,
UserID: 2,
TunnelID: 3,
RemoteAddr: "1.1.1.1:443",
Strategy: "fifo",
ProxyProtocolReceive: 1,
ProxyProtocolSend: 2,
}
tunnel := &tunnelRecord{Type: 1}
node := &nodeRecord{
@@ -38,8 +39,8 @@ func TestBuildForwardServiceConfigsSendsProxyProtocolToForwardHandler(t *testing
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)
if serviceMetadata["proxyProtocol"] != 1 {
t.Fatalf("expected service proxyProtocol 1 for receive mode, got %v", serviceMetadata["proxyProtocol"])
}
handlerConfig, ok := service["handler"].(map[string]interface{})
@@ -56,6 +57,46 @@ func TestBuildForwardServiceConfigsSendsProxyProtocolToForwardHandler(t *testing
}
}
func TestBuildForwardServiceConfigsKeepsLegacyProxyProtocolAsSend(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{
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, _ := service["metadata"].(map[string]interface{})
if _, ok := serviceMetadata["proxyProtocol"]; ok {
t.Fatalf("legacy proxyProtocol should not enable receive mode: %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 legacy proxyProtocol to send version 2, got %v", handlerMetadata["proxyProtocol"])
}
}
}
func TestRollbackForwardMutationRestoresProxyProtocol(t *testing.T) {
r, err := repo.Open(":memory:")
if err != nil {
@@ -0,0 +1,129 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"testing"
"go-backend/internal/auth"
"go-backend/internal/http/middleware"
"go-backend/internal/store/repo"
)
func TestForwardResetFlowPermissionsAndIsolation(t *testing.T) {
tests := []struct {
name string
actorID int64
actorRole int
forwardID int64
wantCode int
wantInFlow int64
wantOutFlow int64
}{
{name: "admin resets another user's rule", actorID: 1, actorRole: 0, forwardID: 20, wantCode: 0, wantInFlow: 0, wantOutFlow: 0},
{name: "owner resets own rule", actorID: 2, actorRole: 1, forwardID: 20, wantCode: 0, wantInFlow: 0, wantOutFlow: 0},
{name: "user cannot reset another user's rule", actorID: 3, actorRole: 1, forwardID: 20, wantCode: -1, wantInFlow: 111, wantOutFlow: 222},
{name: "missing rule is rejected", actorID: 1, actorRole: 0, forwardID: 999, wantCode: -1, wantInFlow: 111, wantOutFlow: 222},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h, r := setupForwardResetFlowHandler(t)
req := newForwardResetFlowRequest(t, http.MethodPost, tt.forwardID, tt.actorID, tt.actorRole)
res := httptest.NewRecorder()
h.forwardResetFlow(res, req)
if got := decodeForwardResetFlowCode(t, res); got != tt.wantCode {
t.Fatalf("code = %d, want %d; body=%s", got, tt.wantCode, res.Body.String())
}
assertForwardResetFlowDBValue(t, r, "SELECT in_flow FROM forward WHERE id = 20", tt.wantInFlow)
assertForwardResetFlowDBValue(t, r, "SELECT out_flow FROM forward WHERE id = 20", tt.wantOutFlow)
assertForwardResetFlowDBValue(t, r, "SELECT in_flow FROM user WHERE id = 2", 700)
assertForwardResetFlowDBValue(t, r, "SELECT out_flow FROM user_tunnel WHERE id = 10", 600)
})
}
}
func TestForwardResetFlowRejectsInvalidRequests(t *testing.T) {
h, _ := setupForwardResetFlowHandler(t)
t.Run("non post", func(t *testing.T) {
req := newForwardResetFlowRequest(t, http.MethodGet, 20, 1, 0)
res := httptest.NewRecorder()
h.forwardResetFlow(res, req)
if code := decodeForwardResetFlowCode(t, res); code != -1 {
t.Fatalf("code = %d, want -1", code)
}
})
t.Run("invalid id", func(t *testing.T) {
req := newForwardResetFlowRequest(t, http.MethodPost, 0, 1, 0)
res := httptest.NewRecorder()
h.forwardResetFlow(res, req)
if code := decodeForwardResetFlowCode(t, res); code != -1 {
t.Fatalf("code = %d, want -1", code)
}
})
}
func setupForwardResetFlowHandler(t *testing.T) (*Handler, *repo.Repository) {
t.Helper()
r, err := repo.Open(filepath.Join(t.TempDir(), "forward-reset-handler.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
statements := []string{
`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, 'owner', 'pwd', 1, 0, 100, 700, 900, 0, 10, 1000, 1000, 1)`,
`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, 'other', 'pwd', 1, 0, 100, 0, 0, 0, 10, 1000, 1000, 1)`,
`INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(1, 'tunnel', 1, 1, 'tls', 1, 1000, 1000, 1, NULL, 0)`,
`INSERT INTO user_tunnel(id, user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(10, 2, 1, 10, 100, 500, 600, 0, 0, 1)`,
`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, 'owner', 'target', 1, '127.0.0.1:80', 'fifo', 111, 222, 1000, 1000, 1, 0)`,
}
for _, statement := range statements {
if err := r.DB().Exec(statement).Error; err != nil {
t.Fatalf("seed database: %v", err)
}
}
return New(r, "test-secret"), r
}
func newForwardResetFlowRequest(t *testing.T, method string, forwardID, actorID int64, roleID int) *http.Request {
t.Helper()
body, err := json.Marshal(map[string]int64{"id": forwardID})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(method, "/api/v1/forward/reset-flow", bytes.NewReader(body))
claims := auth.Claims{Sub: strconv.FormatInt(actorID, 10), RoleID: roleID}
return req.WithContext(context.WithValue(req.Context(), middleware.ClaimsContextKey, claims))
}
func decodeForwardResetFlowCode(t *testing.T, res *httptest.ResponseRecorder) int {
t.Helper()
var payload struct {
Code int `json:"code"`
}
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v; body=%s", err, res.Body.String())
}
return payload.Code
}
func assertForwardResetFlowDBValue(t *testing.T, r *repo.Repository, query string, want int64) {
t.Helper()
var got int64
if err := r.DB().Raw(query).Scan(&got).Error; err != nil {
t.Fatalf("query %q: %v", query, err)
}
if got != want {
t.Fatalf("query %q returned %d, want %d", query, got, want)
}
}
+178 -68
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
@@ -20,9 +21,9 @@ import (
"go-backend/internal/health"
"go-backend/internal/http/middleware"
"go-backend/internal/http/response"
"go-backend/internal/license"
"go-backend/internal/metrics"
"go-backend/internal/monitoring"
runtimenft "go-backend/internal/runtime/nftables"
"go-backend/internal/security"
"go-backend/internal/store/repo"
"go-backend/internal/ws"
@@ -31,25 +32,36 @@ import (
)
type Handler struct {
repo *repo.Repository
jwtSecret string
wsServer *ws.Server
metrics *metrics.IngestionService
healthCheck *health.Checker
repo *repo.Repository
jwtSecret string
wsServer *ws.Server
metrics *metrics.IngestionService
healthCheck *health.Checker
nftablesManager nftablesRuntimeManager
captchaMu sync.Mutex
captchaTokens map[string]int64
tunnelLifecycleMu sync.Mutex
tunnelLifecycleLocks map[int64]*tunnelLifecycleLock
jobsMu sync.Mutex
jobsCancel context.CancelFunc
jobsStarted bool
jobsWG sync.WaitGroup
peerResourceMu sync.Mutex
captchaMu sync.Mutex
captchaTokens map[string]int64
passkeyMu sync.Mutex
passkeyPending map[string]passkeyCeremony
upgradeMu sync.Mutex
pendingUpgradeRedeploy map[int64]struct{}
nodeOnlineRedeployAt map[int64]time.Time
nodeOnlineRedeployQueued map[int64]struct{}
nodeOnlineRedeploying map[int64]struct{}
jobsMu sync.Mutex
jobsCancel context.CancelFunc
jobsStarted bool
jobsWG sync.WaitGroup
fingerprintMu sync.Mutex
licenseValidationMu sync.Mutex
upgradeMu sync.Mutex
systemUpgradeMu sync.Mutex
pendingUpgradeRedeploy map[int64]struct{}
nodeOnlineRedeployAt map[int64]time.Time
nodeOnlineRedeployQueued map[int64]struct{}
nodeOnlineRedeploying map[int64]struct{}
nodeLocalRuntimeRetryQueued map[int64]struct{}
qualityProber *tunnelQualityProber
bestExit *bestExitManager
@@ -107,7 +119,9 @@ func New(repo *repo.Repository, jwtSecret string) *Handler {
wsServer: ws.NewServer(repo, jwtSecret),
metrics: metrics.NewIngestionService(repo),
healthCheck: nil,
nftablesManager: runtimenft.NewManager(nil),
captchaTokens: make(map[string]int64),
passkeyPending: make(map[string]passkeyCeremony),
pendingUpgradeRedeploy: make(map[int64]struct{}),
nodeOnlineRedeployAt: make(map[int64]time.Time),
nodeOnlineRedeployQueued: make(map[int64]struct{}),
@@ -135,6 +149,7 @@ func New(repo *repo.Repository, jwtSecret string) *Handler {
}
h.metrics.RecordNodeMetric(nodeID, metricInfo)
})
h.wsServer.SetUserAuthStateLookup(h.GetUserAuthState)
return h
}
@@ -142,8 +157,19 @@ func (h *Handler) WebSocketHandler() http.Handler {
return h.wsServer
}
func (h *Handler) GetUserAuthState(userID int64) (*auth.UserAuthState, error) {
return h.repo.GetUserAuthState(userID)
}
func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/user/login", h.login)
mux.HandleFunc("/api/v1/user/passkey/status", h.passkeyStatus)
mux.HandleFunc("/api/v1/user/passkey/login/begin", h.passkeyLoginBegin)
mux.HandleFunc("/api/v1/user/passkey/login/finish", h.passkeyLoginFinish)
mux.HandleFunc("/api/v1/user/passkey/register/begin", h.passkeyRegisterBegin)
mux.HandleFunc("/api/v1/user/passkey/register/finish", h.passkeyRegisterFinish)
mux.HandleFunc("/api/v1/user/passkey/list", h.passkeyList)
mux.HandleFunc("/api/v1/user/passkey/delete", h.passkeyDelete)
mux.HandleFunc("/api/v1/user/list", h.userList)
mux.HandleFunc("/api/v1/user/create", h.userCreate)
mux.HandleFunc("/api/v1/user/update", h.userUpdate)
@@ -151,11 +177,16 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/user/reset", h.userResetFlow)
mux.HandleFunc("/api/v1/user/quota/reset", h.userQuotaReset)
mux.HandleFunc("/api/v1/user/groups", h.userGroups)
mux.HandleFunc("/api/v1/public/config/get", h.getPublicConfigByName)
mux.HandleFunc("/api/v1/config/get", h.getConfigByName)
mux.HandleFunc("/api/v1/config/list", h.getConfigs)
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig)
mux.HandleFunc("/api/v1/system/storage", h.storageSummary)
mux.HandleFunc("/api/v1/system/version", h.systemVersion)
mux.HandleFunc("/api/v1/system/check-updates", h.systemCheckUpdates)
mux.HandleFunc("/api/v1/system/upgrade", h.systemUpgrade)
mux.HandleFunc("/api/v1/system/upgrade/status", h.systemUpgradeStatus)
mux.HandleFunc("/api/v1/license/activate", h.licenseActivate)
mux.HandleFunc("/api/v1/backup/export", h.backupExport)
mux.HandleFunc("/api/v1/backup/import", h.backupImport)
@@ -180,6 +211,9 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/node/batch-upgrade", h.nodeBatchUpgrade)
mux.HandleFunc("/api/v1/node/rollback", h.nodeRollback)
mux.HandleFunc("/api/v1/node/releases", h.listReleases)
mux.HandleFunc("/api/v1/node/nftables/test", h.nodeNftablesTest)
mux.HandleFunc("/api/v1/node/nftables/reconcile", h.nodeNftablesReconcile)
mux.HandleFunc("/api/v1/node/nftables/clear", h.nodeNftablesClear)
mux.HandleFunc("/api/v1/tunnel/list", h.tunnelList)
mux.HandleFunc("/api/v1/tunnel/create", h.tunnelCreate)
mux.HandleFunc("/api/v1/tunnel/get", h.tunnelGet)
@@ -194,6 +228,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/tunnel/update-order", h.tunnelUpdateOrder)
mux.HandleFunc("/api/v1/tunnel/batch-delete", h.tunnelBatchDelete)
mux.HandleFunc("/api/v1/tunnel/batch-redeploy", h.tunnelBatchRedeploy)
mux.HandleFunc("/api/v1/tunnel/batch-redeploy/stream", h.tunnelBatchRedeployStream)
mux.HandleFunc("/api/v1/tunnel/user/assign", h.userTunnelAssign)
mux.HandleFunc("/api/v1/tunnel/user/batch-assign", h.userTunnelBatchAssign)
mux.HandleFunc("/api/v1/tunnel/user/remove", h.userTunnelRemove)
@@ -205,6 +240,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/forward/force-delete", h.forwardForceDelete)
mux.HandleFunc("/api/v1/forward/pause", h.forwardPause)
mux.HandleFunc("/api/v1/forward/resume", h.forwardResume)
mux.HandleFunc("/api/v1/forward/reset-flow", h.forwardResetFlow)
mux.HandleFunc("/api/v1/forward/diagnose", h.forwardDiagnose)
mux.HandleFunc("/api/v1/forward/diagnose/stream", h.forwardDiagnoseStream)
mux.HandleFunc("/api/v1/forward/update-order", h.forwardUpdateOrder)
@@ -213,6 +249,8 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/forward/batch-resume", h.forwardBatchResume)
mux.HandleFunc("/api/v1/forward/batch-redeploy", h.forwardBatchRedeploy)
mux.HandleFunc("/api/v1/forward/batch-change-tunnel", h.forwardBatchChangeTunnel)
mux.HandleFunc("/api/v1/user/update-order", h.userDisplayOrder)
mux.HandleFunc("/api/v1/speed-limit/update-order", h.speedLimitDisplayOrder)
mux.HandleFunc("/api/v1/speed-limit/list", h.speedLimitList)
mux.HandleFunc("/api/v1/speed-limit/create", h.speedLimitCreate)
mux.HandleFunc("/api/v1/speed-limit/update", h.speedLimitUpdate)
@@ -330,7 +368,8 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("账号或密码错误"))
return
}
if user.Pwd != security.MD5(req.Password) {
passwordMatched, passwordWasLegacy := security.VerifyPassword(user.Pwd, req.Password)
if !passwordMatched {
response.WriteJSON(w, response.ErrDefault("账号或密码错误"))
return
}
@@ -338,8 +377,20 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("账号被停用"))
return
}
issueAt := time.Now()
if passwordWasLegacy {
updatedAt := time.Now().UnixMilli()
hashedPassword, err := security.HashPassword(req.Password)
if err != nil {
log.Printf("legacy password rehash skipped user_id=%d path=login err=%v", user.ID, err)
} else if err := h.repo.UpdateUserPassword(user.ID, hashedPassword, updatedAt); err != nil {
log.Printf("legacy password rehash update skipped user_id=%d path=login err=%v", user.ID, err)
} else {
issueAt = time.UnixMilli(updatedAt + 1)
}
}
token, err := auth.GenerateToken(user.ID, user.User, user.RoleID, h.jwtSecret)
token, err := auth.GenerateTokenAt(user.ID, user.User, user.RoleID, h.jwtSecret, issueAt)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -370,13 +421,21 @@ func (h *Handler) getConfigByName(w http.ResponseWriter, r *http.Request) {
return
}
configName := strings.ToLower(strings.TrimSpace(req.Name))
switch configName {
case "license_key", "cloudflare_secret_key", "jwt_secret":
if configName == "license_key" || configName == "license_machine_id" || configName == "machine_fingerprint" {
response.WriteJSON(w, response.Err(403, "禁止访问系统授权凭据"))
return
}
if repo.IsSensitiveConfigKey(configName) && !isAdminRequest(r) {
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
return
}
cfg, err := h.repo.GetConfigByName(req.Name)
if _, ok := r.Context().Value(middleware.ClaimsContextKey).(auth.Claims); !ok && !repo.IsPublicConfigKey(configName) {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return
}
cfg, err := h.repo.GetConfigByName(configName)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -401,11 +460,13 @@ func (h *Handler) getConfigs(w http.ResponseWriter, r *http.Request) {
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")
claims, isAdmin := ctxClaims.(auth.Claims)
if !isAdmin || claims.RoleID != 0 {
cfgMap = repo.FilterSensitiveConfigs(cfgMap)
}
delete(cfgMap, "license_key")
delete(cfgMap, "license_machine_id")
delete(cfgMap, "machine_fingerprint")
response.WriteJSON(w, response.OK(cfgMap))
}
@@ -553,10 +614,27 @@ func (h *Handler) openAPISubStore(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if user == nil || user.Pwd != security.MD5(password) {
if user == nil {
response.WriteJSON(w, response.ErrDefault("鉴权失败"))
return
}
passwordMatched, passwordWasLegacy := security.VerifyPassword(user.Pwd, password)
if !passwordMatched {
response.WriteJSON(w, response.ErrDefault("鉴权失败"))
return
}
if user.Status == 0 {
response.WriteJSON(w, response.ErrDefault("账号被停用"))
return
}
if passwordWasLegacy {
hashedPassword, err := security.HashPassword(password)
if err != nil {
log.Printf("legacy password rehash skipped user_id=%d path=sub_store err=%v", user.ID, err)
} else if err := h.repo.UpdateUserPassword(user.ID, hashedPassword, time.Now().UnixMilli()); err != nil {
log.Printf("legacy password rehash update skipped user_id=%d path=sub_store err=%v", user.ID, err)
}
}
const giga = int64(1024 * 1024 * 1024)
headerValue := ""
@@ -660,6 +738,7 @@ func (h *Handler) userTunnelList(w http.ResponseWriter, r *http.Request) {
"tunnelName": t.TunnelName,
"status": t.Status,
"flow": t.Flow,
"flowMiB": t.FlowMiB,
"num": t.Num,
"expTime": t.ExpTime,
"flowResetTime": t.FlowResetTime,
@@ -816,7 +895,7 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
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)
batch := h.buildNodeFlowUploadBatch(node.ID, items, metas)
h.recordTunnelMetricsFromForwardBatch(node.ID, batch.forwardTraffic, metas, now.UnixMilli())
h.applyFlowUploadBatch(node.ID, batch, now)
}
@@ -827,10 +906,16 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) getOrCreateMachineFingerprint() (string, error) {
fp, _ := h.repo.GetViteConfigValue("machine_fingerprint")
h.fingerprintMu.Lock()
defer h.fingerprintMu.Unlock()
fp, err := h.repo.GetViteConfigValue("machine_fingerprint")
if fp != "" {
return fp, nil
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", err
}
newFp := uuid.New().String()
now := time.Now().UnixMilli()
@@ -857,56 +942,35 @@ func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("授权码不能为空"))
return
}
h.licenseValidationMu.Lock()
defer h.licenseValidationMu.Unlock()
accountID := "1bc96cac-09de-4cf4-af34-26afdad63a90"
fingerprint, err := h.getOrCreateMachineFingerprint()
valResp, err := h.validateLicenseForMachine(key)
if err != nil {
response.WriteJSON(w, response.ErrDefault("生成设备指纹失败"))
return
}
client := license.NewKeygenClient(accountID, "")
valResp, err := client.ValidateKeyWithFingerprint(key, fingerprint)
if err != nil {
response.WriteJSON(w, response.ErrDefault("连接授权服务器失败: "+err.Error()))
log.Printf("license activation failed: %v", err)
response.WriteJSON(w, response.ErrDefault(licenseValidationErrorMessage(err)))
return
}
if !valResp.Meta.Valid {
if valResp.Meta.Code == "NO_MACHINES" || valResp.Meta.Code == "NO_MACHINE" || valResp.Meta.Code == "MACHINE_SCOPE_REQUIRED" || valResp.Meta.Code == "FINGERPRINT_SCOPE_MISMATCH" {
// Needs machine activation
client.Token = key
err = client.ActivateMachine(valResp.Data.ID, fingerprint)
if err != nil {
// Translate specific error messages or log them
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 {
response.WriteJSON(w, response.ErrDefault("授权码无效或已过期 (Code: "+valResp.Meta.Code+")"))
return
}
response.WriteJSON(w, response.ErrDefault("授权码无效或已过期 (Code: "+valResp.Meta.Code+")"))
return
}
now := time.Now().UnixMilli()
if err := h.repo.UpsertConfig("license_key", key, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.repo.UpsertConfig("is_commercial", "true", now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
expiry := valResp.Data.Attributes.Expiry
if expiry == "" {
expiry = "never"
}
if err := h.repo.UpsertConfig("license_expiry", expiry, now); err != nil {
licenseState := map[string]string{
"license_key": key,
"is_commercial": "true",
"license_expiry": expiry,
}
if valResp.MachineID != "" {
licenseState["license_machine_id"] = valResp.MachineID
}
if err := h.repo.UpsertConfigs(licenseState, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
@@ -944,6 +1008,14 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
if key == "" {
continue
}
if repo.IsSensitiveConfigKey(key) && !isAdminRequest(r) {
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
return
}
if repo.IsSystemManagedConfigKey(key) {
response.WriteJSON(w, response.ErrDefault("该配置由系统管理"))
return
}
if protectedKeys[key] && isCommercial != "true" {
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
@@ -960,6 +1032,7 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
h.notifyTunnelQualityConfigChanged(key)
}
response.WriteJSON(w, response.OKEmpty())
@@ -981,6 +1054,14 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
return
}
if repo.IsSensitiveConfigKey(name) && !isAdminRequest(r) {
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
return
}
if repo.IsSystemManagedConfigKey(name) {
response.WriteJSON(w, response.ErrDefault("该配置由系统管理"))
return
}
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
if (name == "app_name" || name == "app_logo" || name == "app_favicon" || name == "hide_footer_brand") && isCommercial != "true" {
@@ -1003,10 +1084,19 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
h.notifyTunnelQualityConfigChanged(name)
response.WriteJSON(w, response.OKEmpty())
}
func isAdminRequest(r *http.Request) bool {
if r == nil {
return false
}
claims, ok := r.Context().Value(middleware.ClaimsContextKey).(auth.Claims)
return ok && claims.RoleID == 0
}
func normalizeAndValidateConfigValue(key, value string) (string, error) {
switch strings.TrimSpace(key) {
case "app_logo", "app_favicon":
@@ -1043,11 +1133,23 @@ func normalizeAndValidateConfigValue(key, value string) (string, error) {
}
case monitoring.ConfigMonitorRetentionDays:
return monitoring.NormalizeMonitoringRetentionDays(value)
case monitoring.ConfigTunnelQualityProbeIntervalSec:
return monitoring.NormalizeTunnelQualityProbeIntervalSeconds(value)
default:
return value, nil
}
}
func (h *Handler) notifyTunnelQualityConfigChanged(key string) {
if h == nil || h.qualityProber == nil {
return
}
switch strings.TrimSpace(key) {
case monitorTunnelQualityEnabledConfigKey, monitoring.ConfigTunnelQualityProbeIntervalSec:
h.qualityProber.NotifyConfigChanged()
}
}
func (h *Handler) isTunnelQualityMonitoringEnabled() bool {
if h == nil || h.repo == nil {
return true
@@ -1131,6 +1233,7 @@ func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
"tunnelName": t.TunnelName,
"tunnelFlow": t.TunnelFlow,
"flow": t.Flow,
"flowMiB": t.FlowMiB,
"inFlow": t.InFlow,
"outFlow": t.OutFlow,
"num": t.Num,
@@ -1180,6 +1283,7 @@ func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
"user": user.User,
"status": user.Status,
"flow": user.Flow,
"flowMiB": user.FlowMiB,
"inFlow": user.InFlow,
"outFlow": user.OutFlow,
"num": user.Num,
@@ -1251,7 +1355,8 @@ func (h *Handler) updatePassword(w http.ResponseWriter, r *http.Request) {
return
}
if user.Pwd != security.MD5(req.CurrentPassword) {
passwordMatched, _ := security.VerifyPassword(user.Pwd, req.CurrentPassword)
if !passwordMatched {
response.WriteJSON(w, response.ErrDefault("当前密码错误"))
return
}
@@ -1266,7 +1371,12 @@ func (h *Handler) updatePassword(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.repo.UpdateUserNameAndPassword(userID, req.NewUsername, security.MD5(req.NewPassword), time.Now().UnixMilli()); err != nil {
hashedPassword, err := security.HashPassword(req.NewPassword)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.repo.UpdateUserNameAndPassword(userID, req.NewUsername, hashedPassword, time.Now().UnixMilli()); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
+120 -13
View File
@@ -2,11 +2,12 @@ package handler
import (
"context"
"log"
"time"
"go-backend/internal/license"
)
var nftablesTrafficCollectInterval = 30 * time.Second
func (h *Handler) StartBackgroundJobs() {
if h == nil || h.repo == nil {
return
@@ -20,7 +21,7 @@ func (h *Handler) StartBackgroundJobs() {
ctx, cancel := context.WithCancel(context.Background())
h.jobsCancel = cancel
h.jobsStarted = true
h.jobsWG.Add(7)
h.jobsWG.Add(9)
h.jobsMu.Unlock()
go h.runHourlyStatsLoop(ctx)
@@ -30,10 +31,58 @@ func (h *Handler) StartBackgroundJobs() {
go h.runHealthChecks(ctx)
go h.runTunnelQualityProber(ctx)
go h.runValidateLicenseJob(ctx)
go h.runNftablesTrafficCollectLoop(ctx)
go h.runFederationCleanupRetryLoop(ctx)
}
func (h *Handler) runFederationCleanupRetryLoop(ctx context.Context) {
defer h.jobsWG.Done()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
default:
}
if err := h.retryPendingFederationRuntimeCleanup(); err != nil {
log.Printf("federation cleanup remains pending: %v", err)
}
if err := h.retryPendingTunnelRuntimeCleanup(); err != nil {
log.Printf("tunnel runtime cleanup remains pending: %v", err)
}
h.retryPendingPeerShareOperations()
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (h *Handler) retryPendingPeerShareOperations() {
nodeIDs, err := h.repo.ListPendingPeerShareNodeIDs()
if err != nil {
log.Printf("peer share pending operation lookup failed: %v", err)
return
}
for _, nodeID := range nodeIDs {
node, err := h.repo.GetNodeByID(nodeID)
if err != nil || node == nil || node.Status != 1 {
continue
}
if err := h.retryPendingPeerShareResourcesOnNode(nodeID); err != nil {
log.Printf("peer share resource retry failed node_id=%d err=%v", nodeID, err)
}
if err := h.retryPendingPeerShareRoleRuntimesOnNode(nodeID); err != nil {
log.Printf("peer share role retry failed node_id=%d err=%v", nodeID, err)
}
}
}
func (h *Handler) runValidateLicenseJob(ctx context.Context) {
defer h.jobsWG.Done()
h.validateLicenseJob()
ticker := time.NewTicker(12 * time.Hour)
defer ticker.Stop()
@@ -51,22 +100,25 @@ func (h *Handler) validateLicenseJob() {
if h == nil || h.repo == nil {
return
}
accountID := "1bc96cac-09de-4cf4-af34-26afdad63a90"
h.licenseValidationMu.Lock()
defer h.licenseValidationMu.Unlock()
key, _ := h.repo.GetViteConfigValue("license_key")
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
if key == "" || isCommercial != "true" {
if key == "" {
return // Nothing to validate
}
fingerprint, _ := h.repo.GetViteConfigValue("machine_fingerprint")
client := license.NewKeygenClient(accountID, "")
valResp, err := client.ValidateKeyWithFingerprint(key, fingerprint)
valResp, err := h.validateLicenseForMachine(key)
if err != nil {
// Network error or timeout. Grace period by not revoking immediately here.
// Network and decode failures have no validation response, so retain the
// current state as a grace period. A rejected machine binding still has
// the original invalid response and must not stay commercially enabled.
if licenseValidationErrorIsDefinitive(valResp, err) {
now := time.Now().UnixMilli()
_ = h.repo.UpsertConfig("is_commercial", "false", now)
}
return
}
@@ -80,7 +132,14 @@ func (h *Handler) validateLicenseJob() {
if expiry == "" {
expiry = "never"
}
_ = h.repo.UpsertConfig("license_expiry", expiry, now)
licenseState := map[string]string{
"is_commercial": "true",
"license_expiry": expiry,
}
if valResp.MachineID != "" {
licenseState["license_machine_id"] = valResp.MachineID
}
_ = h.repo.UpsertConfigs(licenseState, now)
}
}
@@ -128,6 +187,54 @@ func (h *Handler) runTunnelQualityProber(ctx context.Context) {
h.qualityProber.Start(ctx)
}
func (h *Handler) runNftablesTrafficCollectLoop(ctx context.Context) {
defer h.jobsWG.Done()
h.runNftablesStartupReconcile(ctx)
select {
case <-ctx.Done():
return
default:
h.runNftablesTrafficCollectJob(time.Now())
}
interval := nftablesTrafficCollectInterval
if interval <= 0 {
interval = 30 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
h.runNftablesTrafficCollectJob(time.Now())
}
}
}
func (h *Handler) runNftablesStartupReconcile(ctx context.Context) {
if h == nil || h.repo == nil {
return
}
nodes, err := h.repo.ListNftablesNodesForCollection()
if err != nil {
log.Printf("nftables startup reconcile failed op=list_nodes err=%v", err)
return
}
for _, node := range nodes {
select {
case <-ctx.Done():
return
default:
}
if err := h.syncNftablesNode(node.NodeID); err != nil {
log.Printf("nftables startup reconcile failed node_id=%d err=%v", node.NodeID, err)
}
}
}
func (h *Handler) runHourlyStatsLoop(ctx context.Context) {
defer h.jobsWG.Done()
@@ -0,0 +1,109 @@
package handler
import (
"errors"
"fmt"
"net/http"
"os"
"strings"
"go-backend/internal/license"
)
var newLicenseClient = license.NewKeygenClient
func keygenAccountID() string {
if value := strings.TrimSpace(license.AccountID); value != "" {
return value
}
return strings.TrimSpace(os.Getenv("KEYGEN_ACCOUNT_ID"))
}
func licenseNeedsMachineActivation(code string) bool {
switch strings.ToUpper(strings.TrimSpace(code)) {
case "NO_MACHINES", "NO_MACHINE", "MACHINE_SCOPE_REQUIRED", "FINGERPRINT_SCOPE_MISMATCH":
return true
default:
return false
}
}
func licenseValidationErrorIsDefinitive(validation *license.ValidateResponse, err error) bool {
if err == nil {
return validation != nil && !validation.Meta.Valid
}
var apiErr *license.APIError
if !errors.As(err, &apiErr) {
return false
}
if apiErr.StatusCode == http.StatusTooManyRequests || apiErr.StatusCode >= http.StatusInternalServerError {
return false
}
return apiErr.Operation == "activate machine" && validation != nil && !validation.Meta.Valid
}
func licenseValidationErrorMessage(err error) string {
if strings.Contains(err.Error(), "keygen account id is not configured") {
return "授权服务配置错误"
}
var apiErr *license.APIError
if errors.As(err, &apiErr) {
if apiErr.HasCode("MACHINE_LIMIT_EXCEEDED") {
return "授权设备数量已达上限"
}
if apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden {
return "授权码无效或无权绑定设备"
}
}
return "连接授权服务器失败,请稍后重试"
}
func (h *Handler) validateLicenseForMachine(key string) (*license.ValidateResponse, error) {
fingerprint, err := h.getOrCreateMachineFingerprint()
if err != nil {
return nil, fmt.Errorf("prepare machine fingerprint: %w", err)
}
storedMachineID, _ := h.repo.GetViteConfigValue("license_machine_id")
accountID := keygenAccountID()
if accountID == "" {
return nil, fmt.Errorf("keygen account id is not configured")
}
client := newLicenseClient(accountID, "")
var validation *license.ValidateResponse
if storedMachineID != "" {
validation, err = client.ValidateKeyWithMachine(key, fingerprint, storedMachineID)
} else {
validation, err = client.ValidateKeyWithFingerprint(key, fingerprint)
}
if err != nil {
return nil, err
}
if validation.Meta.Valid || !licenseNeedsMachineActivation(validation.Meta.Code) {
if validation.Meta.Valid {
validation.MachineID = storedMachineID
}
return validation, nil
}
client.Token = key
machineID, err := client.ActivateMachine(validation.Data.ID, fingerprint)
if err != nil {
return validation, err
}
if machineID == "" {
machineID, err = client.GetMachineID(fingerprint)
if err != nil {
return validation, fmt.Errorf("retrieve activated machine: %w", err)
}
}
validation, err = client.ValidateKeyWithMachine(key, fingerprint, machineID)
if err != nil {
return nil, err
}
if validation.Meta.Valid {
validation.MachineID = machineID
}
return validation, nil
}
@@ -0,0 +1,358 @@
package handler
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"go-backend/internal/license"
"go-backend/internal/store/repo"
)
func TestValidateLicenseJobRepairsMissingMachineBinding(t *testing.T) {
r := openLicenseTestRepository(t)
now := time.Now().UnixMilli()
seedLicenseConfig(t, r, "license_key", "license-secret", now)
seedLicenseConfig(t, r, "is_commercial", "true", now)
var validations atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch {
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
if validations.Add(1) == 1 {
_, _ = fmt.Fprint(w, `{"meta":{"valid":false,"code":"NO_MACHINE"},"data":{"id":"license-id","attributes":{}}}`)
return
}
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"2030-01-02T00:00:00.000Z"}}}`)
case strings.HasSuffix(req.URL.Path, "/machines"):
w.WriteHeader(http.StatusCreated)
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
case strings.Contains(req.URL.Path, "/machines/"):
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
default:
http.NotFound(w, req)
}
}))
defer server.Close()
restoreLicenseClientFactory(t, server.URL)
h := &Handler{repo: r}
h.validateLicenseJob()
assertLicenseConfig(t, r, "is_commercial", "true")
assertLicenseConfig(t, r, "license_expiry", "2030-01-02T00:00:00.000Z")
fingerprint, err := r.GetViteConfigValue("machine_fingerprint")
if err != nil || strings.TrimSpace(fingerprint) == "" {
t.Fatalf("expected persisted machine fingerprint, got value=%q err=%v", fingerprint, err)
}
if got := validations.Load(); got != 2 {
t.Fatalf("validation calls = %d, want 2", got)
}
}
func TestValidateLicenseJobAcceptsExistingMachineActivation(t *testing.T) {
r := openLicenseTestRepository(t)
now := time.Now().UnixMilli()
seedLicenseConfig(t, r, "license_key", "license-secret", now)
seedLicenseConfig(t, r, "is_commercial", "true", now)
var validations atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch {
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
if validations.Add(1) == 1 {
_, _ = fmt.Fprint(w, `{"meta":{"valid":false,"code":"NO_MACHINE"},"data":{"id":"license-id","attributes":{}}}`)
return
}
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"never"}}}`)
case strings.HasSuffix(req.URL.Path, "/machines"):
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = fmt.Fprint(w, `{"errors":[{"code":"FINGERPRINT_TAKEN"},{"code":"MACHINE_LIMIT_EXCEEDED"}]}`)
case strings.Contains(req.URL.Path, "/machines/"):
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
default:
http.NotFound(w, req)
}
}))
defer server.Close()
restoreLicenseClientFactory(t, server.URL)
h := &Handler{repo: r}
h.validateLicenseJob()
assertLicenseConfig(t, r, "is_commercial", "true")
assertLicenseConfig(t, r, "license_expiry", "never")
if got := validations.Load(); got != 2 {
t.Fatalf("validation calls = %d, want 2", got)
}
}
func TestLicenseActivateRequiresSuccessfulPostActivationValidation(t *testing.T) {
r := openLicenseTestRepository(t)
var validations atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch {
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
code := "NO_MACHINE"
if validations.Add(1) > 1 {
code = "FINGERPRINT_SCOPE_MISMATCH"
}
_, _ = fmt.Fprintf(w, `{"meta":{"valid":false,"code":%q},"data":{"id":"license-id","attributes":{}}}`, code)
case strings.HasSuffix(req.URL.Path, "/machines"):
w.WriteHeader(http.StatusCreated)
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
case strings.Contains(req.URL.Path, "/machines/"):
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
default:
http.NotFound(w, req)
}
}))
defer server.Close()
restoreLicenseClientFactory(t, server.URL)
h := &Handler{repo: r}
req := httptest.NewRequest(http.MethodPost, "/api/v1/license/activate", bytes.NewBufferString(`{"license_key":"license-secret"}`))
res := httptest.NewRecorder()
h.licenseActivate(res, req)
if !strings.Contains(res.Body.String(), "FINGERPRINT_SCOPE_MISMATCH") {
t.Fatalf("expected post-activation validation failure, got %s", res.Body.String())
}
assertLicenseConfig(t, r, "is_commercial", "false")
for _, name := range []string{"license_key", "license_expiry"} {
if value, err := r.GetViteConfigValue(name); err == nil || value != "" {
t.Fatalf("%s should not be persisted, got value=%q err=%v", name, value, err)
}
}
}
func TestLicenseActivatePersistsValidatedState(t *testing.T) {
r := openLicenseTestRepository(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch {
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"2030-01-02T00:00:00.000Z"}}}`)
default:
http.NotFound(w, req)
}
}))
defer server.Close()
restoreLicenseClientFactory(t, server.URL)
h := &Handler{repo: r}
req := httptest.NewRequest(http.MethodPost, "/api/v1/license/activate", bytes.NewBufferString(`{"license_key":"license-secret"}`))
res := httptest.NewRecorder()
h.licenseActivate(res, req)
if !strings.Contains(res.Body.String(), `"code":0`) {
t.Fatalf("expected activation success, got %s", res.Body.String())
}
assertLicenseConfig(t, r, "license_key", "license-secret")
assertLicenseConfig(t, r, "is_commercial", "true")
assertLicenseConfig(t, r, "license_expiry", "2030-01-02T00:00:00.000Z")
}
func TestValidateLicenseJobDowngradesWhenMachineBindingIsRejected(t *testing.T) {
r := openLicenseTestRepository(t)
now := time.Now().UnixMilli()
seedLicenseConfig(t, r, "license_key", "license-secret", now)
seedLicenseConfig(t, r, "is_commercial", "true", now)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch {
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
_, _ = fmt.Fprint(w, `{"meta":{"valid":false,"code":"NO_MACHINE"},"data":{"id":"license-id","attributes":{}}}`)
case strings.HasSuffix(req.URL.Path, "/machines"):
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = fmt.Fprint(w, `{"errors":[{"code":"MACHINE_LIMIT_EXCEEDED"}]}`)
default:
http.NotFound(w, req)
}
}))
defer server.Close()
restoreLicenseClientFactory(t, server.URL)
h := &Handler{repo: r}
h.validateLicenseJob()
assertLicenseConfig(t, r, "is_commercial", "false")
}
func TestValidateLicenseJobRestoresCommercialStateWhenLicenseRecovers(t *testing.T) {
r := openLicenseTestRepository(t)
now := time.Now().UnixMilli()
seedLicenseConfig(t, r, "license_key", "license-secret", now)
seedLicenseConfig(t, r, "is_commercial", "false", now)
seedLicenseConfig(t, r, "machine_fingerprint", "fingerprint", now)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key") {
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"never"}}}`)
return
}
http.NotFound(w, req)
}))
defer server.Close()
restoreLicenseClientFactory(t, server.URL)
h := &Handler{repo: r}
h.validateLicenseJob()
assertLicenseConfig(t, r, "is_commercial", "true")
assertLicenseConfig(t, r, "license_expiry", "never")
}
func TestValidateLicenseJobUsesStoredMachineScope(t *testing.T) {
r := openLicenseTestRepository(t)
now := time.Now().UnixMilli()
seedLicenseConfig(t, r, "license_key", "license-secret", now)
seedLicenseConfig(t, r, "is_commercial", "true", now)
seedLicenseConfig(t, r, "machine_fingerprint", "fingerprint", now)
seedLicenseConfig(t, r, "license_machine_id", "machine-id", now)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if !strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key") {
t.Fatalf("unexpected request %s %s", req.Method, req.URL.Path)
}
var body struct {
Meta struct {
Scope map[string]string `json:"scope"`
} `json:"meta"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
if body.Meta.Scope["machine"] != "machine-id" || body.Meta.Scope["fingerprint"] != "fingerprint" {
t.Fatalf("unexpected validation scope: %+v", body.Meta.Scope)
}
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"never"}}}`)
}))
defer server.Close()
restoreLicenseClientFactory(t, server.URL)
h := &Handler{repo: r}
h.validateLicenseJob()
assertLicenseConfig(t, r, "is_commercial", "true")
assertLicenseConfig(t, r, "license_machine_id", "machine-id")
}
func TestValidateLicenseJobKeepsStateOnMachineLookupServerFailure(t *testing.T) {
r := openLicenseTestRepository(t)
now := time.Now().UnixMilli()
seedLicenseConfig(t, r, "license_key", "license-secret", now)
seedLicenseConfig(t, r, "is_commercial", "true", now)
seedLicenseConfig(t, r, "machine_fingerprint", "fingerprint", now)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch {
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
_, _ = fmt.Fprint(w, `{"meta":{"valid":false,"code":"MACHINE_SCOPE_REQUIRED"},"data":{"id":"license-id","attributes":{}}}`)
case strings.HasSuffix(req.URL.Path, "/machines"):
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = fmt.Fprint(w, `{"errors":[{"code":"FINGERPRINT_TAKEN"}]}`)
case strings.Contains(req.URL.Path, "/machines/"):
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = fmt.Fprint(w, `{"errors":[{"code":"SERVICE_UNAVAILABLE"}]}`)
default:
http.NotFound(w, req)
}
}))
defer server.Close()
restoreLicenseClientFactory(t, server.URL)
h := &Handler{repo: r}
h.validateLicenseJob()
assertLicenseConfig(t, r, "is_commercial", "true")
}
func TestValidateLicenseJobKeepsStateOnMachineLookupNotFound(t *testing.T) {
r := openLicenseTestRepository(t)
now := time.Now().UnixMilli()
seedLicenseConfig(t, r, "license_key", "license-secret", now)
seedLicenseConfig(t, r, "is_commercial", "true", now)
seedLicenseConfig(t, r, "machine_fingerprint", "fingerprint", now)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch {
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
_, _ = fmt.Fprint(w, `{"meta":{"valid":false,"code":"MACHINE_SCOPE_REQUIRED"},"data":{"id":"license-id","attributes":{}}}`)
case strings.HasSuffix(req.URL.Path, "/machines"):
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = fmt.Fprint(w, `{"errors":[{"code":"FINGERPRINT_TAKEN"}]}`)
case strings.Contains(req.URL.Path, "/machines/"):
http.NotFound(w, req)
default:
http.NotFound(w, req)
}
}))
defer server.Close()
restoreLicenseClientFactory(t, server.URL)
h := &Handler{repo: r}
h.validateLicenseJob()
assertLicenseConfig(t, r, "is_commercial", "true")
}
func TestLicenseValidationErrorMessageDoesNotExposeKeygenResponse(t *testing.T) {
err := &license.APIError{
Operation: "activate machine",
StatusCode: http.StatusUnprocessableEntity,
Body: `{"errors":[{"code":"MACHINE_LIMIT_EXCEEDED","detail":"private detail"}]}`,
}
message := licenseValidationErrorMessage(err)
if message != "授权设备数量已达上限" || strings.Contains(message, "private detail") {
t.Fatalf("unexpected public error message %q", message)
}
}
func openLicenseTestRepository(t *testing.T) *repo.Repository {
t.Helper()
r, err := repo.Open(filepath.Join(t.TempDir(), "license.db"))
if err != nil {
t.Fatalf("repo.Open() error = %v", err)
}
t.Cleanup(func() { _ = r.Close() })
return r
}
func seedLicenseConfig(t *testing.T, r *repo.Repository, name, value string, now int64) {
t.Helper()
if err := r.UpsertConfig(name, value, now); err != nil {
t.Fatalf("UpsertConfig(%q) error = %v", name, err)
}
}
func assertLicenseConfig(t *testing.T, r *repo.Repository, name, want string) {
t.Helper()
got, err := r.GetViteConfigValue(name)
if err != nil {
t.Fatalf("GetViteConfigValue(%q) error = %v", name, err)
}
if got != want {
t.Fatalf("config %q = %q, want %q", name, got, want)
}
}
func restoreLicenseClientFactory(t *testing.T, baseURL string) {
t.Helper()
t.Setenv("KEYGEN_ACCOUNT_ID", "account-id")
previous := newLicenseClient
newLicenseClient = func(accountID, token string) *license.KeygenClient {
client := license.NewKeygenClient(accountID, token)
client.BaseURL = baseURL
return client
}
t.Cleanup(func() { newLicenseClient = previous })
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,375 @@
package handler
import (
"context"
"database/sql"
"errors"
"fmt"
"net"
"net/http"
"slices"
"strings"
"time"
"go-backend/internal/http/response"
runtimenft "go-backend/internal/runtime/nftables"
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
"gorm.io/gorm"
)
type nftablesRuntimeManager interface {
Test(ctx context.Context, cfg runtimenft.SSHConfig) error
Reconcile(ctx context.Context, cfg runtimenft.SSHConfig, plan runtimenft.NodePlan) (runtimenft.ApplyResult, error)
Clear(ctx context.Context, cfg runtimenft.SSHConfig) error
CollectCounters(ctx context.Context, cfg runtimenft.SSHConfig) ([]runtimenft.CounterSample, error)
}
func isNftablesForwardMode(mode string) bool {
return strings.EqualFold(strings.TrimSpace(mode), runtimenft.ModeNftables)
}
func (h *Handler) nodeUsesNftables(nodeID int64) (bool, error) {
return h.nodeUsesNftablesTx(nil, nodeID)
}
func (h *Handler) nodeUsesNftablesTx(tx *gorm.DB, nodeID int64) (bool, error) {
if h == nil || h.repo == nil {
return false, errors.New("handler not initialized")
}
var (
mode string
err error
)
if tx != nil {
mode, err = h.repo.GetNodeForwardModeTx(tx, nodeID)
} else {
mode, err = h.repo.GetNodeForwardMode(nodeID)
}
if err != nil {
return false, err
}
return isNftablesForwardMode(mode), nil
}
func (h *Handler) tunnelUsesNftables(tunnelID int64) (bool, []int64, error) {
entryNodeIDs, err := h.tunnelEntryNodeIDs(tunnelID)
if err != nil {
return false, nil, err
}
for _, nodeID := range entryNodeIDs {
ok, modeErr := h.nodeUsesNftables(nodeID)
if modeErr != nil {
return false, nil, modeErr
}
if ok {
return true, entryNodeIDs, nil
}
}
return false, entryNodeIDs, nil
}
func (h *Handler) validateNftablesForwardRequest(tunnel *tunnelRecord, remoteAddr string, entryNodeIDs []int64) error {
if tunnel == nil {
return errors.New("隧道不存在")
}
if tunnel.Type != 1 {
return errors.New("nftables 节点仅支持直连隧道")
}
if len(entryNodeIDs) != 1 {
return errors.New("nftables 节点仅支持单入口隧道")
}
target, err := runtimenft.ParseSingleTarget(remoteAddr)
if err != nil {
return err
}
if net.ParseIP(strings.Trim(strings.TrimSpace(target.Host), "[]")) == nil {
return errors.New("nftables 节点仅支持 IP 目标地址")
}
return nil
}
func sshConfigFromModel(cfg *model.NodeSSHConfig) (runtimenft.SSHConfig, error) {
if cfg == nil {
return runtimenft.SSHConfig{}, errors.New("节点缺少 SSH 配置")
}
if strings.TrimSpace(cfg.Host) == "" || strings.TrimSpace(cfg.Username) == "" {
return runtimenft.SSHConfig{}, errors.New("节点 SSH 配置不完整")
}
return runtimenft.SSHConfig{
Host: strings.TrimSpace(cfg.Host),
Port: cfg.Port,
Username: strings.TrimSpace(cfg.Username),
AuthType: strings.TrimSpace(cfg.AuthType),
Password: cfg.Password.String,
PrivateKey: cfg.PrivateKey.String,
Passphrase: cfg.Passphrase.String,
SudoMode: strings.TrimSpace(cfg.SudoMode),
}, nil
}
func (h *Handler) validateNftablesTunnelState(entryNodeIDs []int64) error {
return h.validateNftablesTunnelStateTx(nil, entryNodeIDs)
}
func (h *Handler) validateNftablesTunnelStateTx(tx *gorm.DB, entryNodeIDs []int64) error {
if h == nil || h.repo == nil {
return errors.New("handler not initialized")
}
for _, nodeID := range entryNodeIDs {
isNft, err := h.nodeUsesNftablesTx(tx, nodeID)
if err != nil {
return err
}
if !isNft {
continue
}
var cfg *model.NodeSSHConfig
if tx != nil {
cfg, err = h.repo.GetNodeSSHConfigTx(tx, nodeID)
} else {
cfg, err = h.repo.GetNodeSSHConfig(nodeID)
}
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return errors.New("nftables 节点缺少 SSH 配置")
}
return err
}
sshCfg, err := sshConfigFromModel(cfg)
if err != nil {
return err
}
if h.nftablesManager == nil {
return errors.New("nftables manager not initialized")
}
if err := h.nftablesManager.Test(context.Background(), sshCfg); err != nil {
return fmt.Errorf("nftables 节点能力校验失败: %w", err)
}
}
return nil
}
func (h *Handler) buildNftablesNodePlan(nodeID int64, excludedForwardIDs ...int64) (runtimenft.NodePlan, *model.NodeSSHConfig, error) {
cfg, err := h.repo.GetNodeSSHConfig(nodeID)
if err != nil {
return runtimenft.NodePlan{}, nil, err
}
forwards, err := h.repo.ListActiveForwardsByNode(nodeID)
if err != nil {
return runtimenft.NodePlan{}, nil, err
}
plan := runtimenft.NodePlan{NodeID: nodeID, Rules: make([]runtimenft.Rule, 0, len(forwards))}
for i := range forwards {
forward := &forwards[i]
if slices.Contains(excludedForwardIDs, forward.ID) {
continue
}
tunnel, err := h.getTunnelRecord(forward.TunnelID)
if err != nil || tunnel == nil || tunnel.Status != 1 {
continue
}
entryNodeIDs, err := h.tunnelEntryNodeIDs(forward.TunnelID)
if err != nil {
return runtimenft.NodePlan{}, nil, err
}
if len(entryNodeIDs) != 1 || entryNodeIDs[0] != nodeID {
continue
}
if err := h.validateNftablesForwardRequest(tunnel, forward.RemoteAddr, entryNodeIDs); err != nil {
return runtimenft.NodePlan{}, nil, err
}
ports, err := h.listForwardPorts(forward.ID)
if err != nil {
return runtimenft.NodePlan{}, nil, err
}
for _, fp := range ports {
if fp.NodeID != nodeID {
continue
}
target, err := runtimenft.ParseSingleTarget(forward.RemoteAddr)
if err != nil {
return runtimenft.NodePlan{}, nil, err
}
plan.Rules = append(plan.Rules, runtimenft.Rule{
ForwardID: forward.ID,
InPort: fp.Port,
BindIP: strings.TrimSpace(fp.InIP),
TargetHost: target.Host,
TargetPort: target.Port,
Protocols: []string{"tcp", "udp"},
})
}
}
return plan, cfg, nil
}
func (h *Handler) syncNftablesNode(nodeID int64, excludedForwardIDs ...int64) error {
if h == nil || h.repo == nil {
return errors.New("handler not initialized")
}
plan, cfgModel, err := h.buildNftablesNodePlan(nodeID, excludedForwardIDs...)
if err != nil {
return err
}
sshCfg, err := sshConfigFromModel(cfgModel)
if err != nil {
return err
}
result, err := h.nftablesManager.Reconcile(context.Background(), sshCfg, plan)
now := time.Now().UnixMilli()
if err != nil {
bindings, _ := h.repo.ListNftRuleBindingsByNode(nodeID)
for _, binding := range bindings {
_ = h.repo.MarkNftRuleBindingError(binding.ForwardID, nodeID, err.Error(), now)
}
return err
}
activeForwardIDs := make(map[int64]struct{}, len(plan.Rules))
for _, rule := range plan.Rules {
activeForwardIDs[rule.ForwardID] = struct{}{}
hash := result.Hashes[rule.ForwardID]
_ = h.repo.UpsertNftRuleBinding(modelToRuleBindingInput(nodeID, rule, hash), now)
}
bindings, _ := h.repo.ListNftRuleBindingsByNode(nodeID)
for _, binding := range bindings {
if _, ok := activeForwardIDs[binding.ForwardID]; ok {
continue
}
_ = h.repo.DeleteNftRuleBindingsByForward(binding.ForwardID)
}
return nil
}
func modelToRuleBindingInput(nodeID int64, rule runtimenft.Rule, hash string) repo.NftRuleBindingInput {
return repo.NftRuleBindingInput{
ForwardID: rule.ForwardID,
NodeID: nodeID,
InPort: rule.InPort,
Protocols: strings.Join(rule.Protocols, ","),
TargetAddr: fmt.Sprintf("%s:%d", rule.TargetHost, rule.TargetPort),
BindIP: rule.BindIP,
RuleHash: hash,
Status: runtimenft.StatusApplied,
}
}
func (h *Handler) nftablesNodeIDFromRequest(r *http.Request, w http.ResponseWriter) (int64, bool) {
nodeID := asInt64FromBodyKey(r, w, "nodeId")
if nodeID <= 0 {
return 0, false
}
return nodeID, true
}
func (h *Handler) loadNftablesSSHConfig(nodeID int64) (runtimenft.SSHConfig, error) {
cfg, err := h.repo.GetNodeSSHConfig(nodeID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return runtimenft.SSHConfig{}, errors.New("nftables 节点缺少 SSH 配置")
}
return runtimenft.SSHConfig{}, err
}
return sshConfigFromModel(cfg)
}
func (h *Handler) clearNftablesNode(nodeID int64) error {
if h == nil || h.repo == nil {
return errors.New("handler not initialized")
}
sshCfg, err := h.loadNftablesSSHConfig(nodeID)
if err != nil {
return err
}
if h.nftablesManager == nil {
return errors.New("nftables manager not initialized")
}
if err := h.nftablesManager.Clear(context.Background(), sshCfg); err != nil {
return err
}
bindings, listErr := h.repo.ListNftRuleBindingsByNode(nodeID)
if listErr != nil {
return listErr
}
for _, binding := range bindings {
if err := h.repo.DeleteNftRuleBindingsByForward(binding.ForwardID); err != nil {
return err
}
}
return nil
}
func (h *Handler) reconcileNftablesNodeByRequest(nodeID int64) error {
usesNft, err := h.nodeUsesNftables(nodeID)
if err != nil {
return err
}
if !usesNft {
return errors.New("节点未启用 nftables 转发模式")
}
return h.syncNftablesNode(nodeID)
}
func (h *Handler) nodeNftablesTest(w http.ResponseWriter, r *http.Request) {
nodeID, ok := h.nftablesNodeIDFromRequest(r, w)
if !ok {
return
}
usesNft, err := h.nodeUsesNftables(nodeID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if !usesNft {
response.WriteJSON(w, response.ErrDefault("节点未启用 nftables 转发模式"))
return
}
sshCfg, err := h.loadNftablesSSHConfig(nodeID)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if h.nftablesManager == nil {
response.WriteJSON(w, response.Err(-2, "nftables manager not initialized"))
return
}
if err := h.nftablesManager.Test(context.Background(), sshCfg); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) nodeNftablesReconcile(w http.ResponseWriter, r *http.Request) {
nodeID, ok := h.nftablesNodeIDFromRequest(r, w)
if !ok {
return
}
if err := h.reconcileNftablesNodeByRequest(nodeID); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) nodeNftablesClear(w http.ResponseWriter, r *http.Request) {
nodeID, ok := h.nftablesNodeIDFromRequest(r, w)
if !ok {
return
}
usesNft, err := h.nodeUsesNftables(nodeID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if !usesNft {
response.WriteJSON(w, response.ErrDefault("节点未启用 nftables 转发模式"))
return
}
if err := h.clearNftablesNode(nodeID); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
@@ -0,0 +1,711 @@
package handler
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/middleware"
runtimenft "go-backend/internal/runtime/nftables"
"go-backend/internal/store/repo"
)
type fakeNftablesManager struct {
mu sync.Mutex
testErr error
reconcileErr error
reconcileHit int
clearErr error
clearHit int
collectErr error
collectHit int
counterSamples []runtimenft.CounterSample
lastConfig runtimenft.SSHConfig
lastPlan runtimenft.NodePlan
}
func (f *fakeNftablesManager) Test(_ context.Context, cfg runtimenft.SSHConfig) error {
f.mu.Lock()
defer f.mu.Unlock()
f.lastConfig = cfg
return f.testErr
}
func (f *fakeNftablesManager) Reconcile(_ context.Context, cfg runtimenft.SSHConfig, plan runtimenft.NodePlan) (runtimenft.ApplyResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.reconcileHit++
f.lastConfig = cfg
f.lastPlan = plan
if f.reconcileErr != nil {
return runtimenft.ApplyResult{}, f.reconcileErr
}
return runtimenft.ApplyResult{
NodeID: plan.NodeID,
Script: "table inet flvx {}",
Hashes: runtimenft.PlanHashes(plan),
}, nil
}
func (f *fakeNftablesManager) Clear(context.Context, runtimenft.SSHConfig) error {
f.mu.Lock()
defer f.mu.Unlock()
f.clearHit++
return f.clearErr
}
func (f *fakeNftablesManager) CollectCounters(_ context.Context, cfg runtimenft.SSHConfig) ([]runtimenft.CounterSample, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.collectHit++
f.lastConfig = cfg
if f.collectErr != nil {
return nil, f.collectErr
}
return f.counterSamples, nil
}
func (f *fakeNftablesManager) reconcileCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.reconcileHit
}
func (f *fakeNftablesManager) collectCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.collectHit
}
type nftablesTestFixture struct {
handler *Handler
nodeID int64
}
func TestTunnelCreateRejectsNftablesEntryNodeWithoutSSHConfig(t *testing.T) {
fixture := setupNftablesHandler(t)
err := fixture.handler.validateNftablesTunnelState([]int64{fixture.nodeID})
if err == nil {
t.Fatalf("expected validation failure")
}
if !strings.Contains(err.Error(), "SSH") {
t.Fatalf("expected SSH config validation error, got %q", err)
}
}
func TestTunnelUpdateRejectsNftablesEntryNodeWhenCapabilityTestFails(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
manager := &fakeNftablesManager{testErr: errors.New("ssh failed")}
h.nftablesManager = manager
seedNftablesSSHConfig(t, h, fixture.nodeID)
err := h.validateNftablesTunnelState([]int64{fixture.nodeID})
if err == nil {
t.Fatalf("expected validation failure")
}
if !strings.Contains(err.Error(), "ssh failed") {
t.Fatalf("expected capability error in response, got %q", err)
}
}
func TestSyncForwardServicesWithWarningsUsesNftablesRuntime(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
manager := &fakeNftablesManager{}
h.nftablesManager = manager
seedNftablesSSHConfig(t, h, fixture.nodeID)
tunnelID := seedTunnelForNftables(t, h, "nft-tunnel", fixture.nodeID)
forward := seedForwardForNftables(t, h, tunnelID, fixture.nodeID, "203.0.113.9:8080")
warnings, err := h.syncForwardServicesWithWarnings(forward, "UpdateService", true)
if err != nil {
t.Fatalf("sync forward services: %v", err)
}
if len(warnings) != 0 {
t.Fatalf("expected no warnings, got %v", warnings)
}
if manager.reconcileHit != 1 {
t.Fatalf("expected nftables reconcile to run once, got %d", manager.reconcileHit)
}
if manager.lastPlan.NodeID != fixture.nodeID {
t.Fatalf("expected plan for node %d, got %+v", fixture.nodeID, manager.lastPlan)
}
if len(manager.lastPlan.Rules) != 1 || manager.lastPlan.Rules[0].ForwardID != forward.ID {
t.Fatalf("unexpected plan: %+v", manager.lastPlan)
}
}
func TestNodeNftablesTestEndpointRunsCapabilityCheck(t *testing.T) {
fixture := setupNftablesHandler(t)
seedNftablesSSHConfig(t, fixture.handler, fixture.nodeID)
manager := &fakeNftablesManager{}
fixture.handler.nftablesManager = manager
res := postJSONToHandler(t, fixture.handler.nodeNftablesTest, map[string]int64{"nodeId": fixture.nodeID})
assertNftablesSuccess(t, res)
if manager.lastConfig.Host != "203.0.113.10" {
t.Fatalf("expected SSH config to be passed to manager, got %+v", manager.lastConfig)
}
}
func TestNodeNftablesReconcileEndpointPersistsBindings(t *testing.T) {
fixture := setupNftablesHandler(t)
seedNftablesSSHConfig(t, fixture.handler, fixture.nodeID)
tunnelID := seedTunnelForNftables(t, fixture.handler, "nft-tunnel", fixture.nodeID)
forward := seedForwardForNftables(t, fixture.handler, tunnelID, fixture.nodeID, "203.0.113.9:8080")
manager := &fakeNftablesManager{}
fixture.handler.nftablesManager = manager
res := postJSONToHandler(t, fixture.handler.nodeNftablesReconcile, map[string]int64{"nodeId": fixture.nodeID})
assertNftablesSuccess(t, res)
if manager.reconcileHit != 1 {
t.Fatalf("expected reconcile once, got %d", manager.reconcileHit)
}
bindings, err := fixture.handler.repo.ListNftRuleBindingsByNode(fixture.nodeID)
if err != nil {
t.Fatalf("list bindings: %v", err)
}
if len(bindings) != 1 || bindings[0].ForwardID != forward.ID {
t.Fatalf("unexpected bindings: %+v", bindings)
}
}
func TestStartBackgroundJobsReconcilesNftablesRulesAtStartup(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
seedNftablesSSHConfig(t, h, fixture.nodeID)
tunnelID := seedTunnelForNftables(t, h, "nft-startup-tunnel", fixture.nodeID)
seedForwardForNftables(t, h, tunnelID, fixture.nodeID, "203.0.113.9:8080")
manager := &fakeNftablesManager{}
h.nftablesManager = manager
h.StartBackgroundJobs()
t.Cleanup(h.StopBackgroundJobs)
waitForCondition(t, time.Second, func() bool {
return manager.reconcileCount() > 0
}, "nftables startup reconcile")
}
func TestStartBackgroundJobsCollectsNftablesTrafficImmediatelyAndUsesFastInterval(t *testing.T) {
fixture := setupNftablesCollectionFixture(t)
h := fixture.handler
manager := &fakeNftablesManager{counterSamples: []runtimenft.CounterSample{
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1000, Packets: 10},
}}
h.nftablesManager = manager
oldInterval := nftablesTrafficCollectInterval
nftablesTrafficCollectInterval = 20 * time.Millisecond
t.Cleanup(func() { nftablesTrafficCollectInterval = oldInterval })
h.StartBackgroundJobs()
t.Cleanup(h.StopBackgroundJobs)
waitForCondition(t, time.Second, func() bool {
return manager.collectCount() >= 2
}, "immediate and repeated nftables traffic collection")
}
func TestNftablesTrafficCollectIntervalDefaultsToThirtySeconds(t *testing.T) {
if nftablesTrafficCollectInterval != 30*time.Second {
t.Fatalf("expected default nftables traffic collection interval 30s, got %s", nftablesTrafficCollectInterval)
}
}
func TestNodeNftablesClearEndpointClearsBindings(t *testing.T) {
fixture := setupNftablesHandler(t)
seedNftablesSSHConfig(t, fixture.handler, fixture.nodeID)
now := time.Now().UnixMilli()
if err := fixture.handler.repo.UpsertNftRuleBinding(repo.NftRuleBindingInput{
ForwardID: 99,
NodeID: fixture.nodeID,
InPort: 24000,
Protocols: "tcp",
TargetAddr: "203.0.113.9:8080",
Status: runtimenft.StatusApplied,
}, now); err != nil {
t.Fatalf("seed binding: %v", err)
}
manager := &fakeNftablesManager{}
fixture.handler.nftablesManager = manager
res := postJSONToHandler(t, fixture.handler.nodeNftablesClear, map[string]int64{"nodeId": fixture.nodeID})
assertNftablesSuccess(t, res)
if manager.clearHit != 1 {
t.Fatalf("expected clear once, got %d", manager.clearHit)
}
if bindings, err := fixture.handler.repo.ListNftRuleBindingsByNode(fixture.nodeID); err != nil {
t.Fatalf("list bindings after clear: %v", err)
} else if len(bindings) != 0 {
t.Fatalf("expected bindings to be cleared, got %+v", bindings)
}
}
func TestNodeCreatePersistsNftablesSSHConfig(t *testing.T) {
fixture := setupNftablesHandler(t)
req := newAuthenticatedJSONRequest(t, map[string]interface{}{
"name": "nft-node-created",
"serverIp": "203.0.113.20",
"serverIpV4": "203.0.113.20",
"port": "20000-20100",
"forwardMode": "nftables",
"sshConfig": map[string]interface{}{
"host": "203.0.113.21",
"port": 2222,
"username": "root",
"authType": "private_key",
"privateKey": "TEST-PRIVATE-KEY",
"passphrase": "secret",
"sudoMode": "sudo",
},
})
res := httptest.NewRecorder()
fixture.handler.nodeCreate(res, req)
assertNftablesSuccessWithBody(t, res)
nodes, err := fixture.handler.repo.ListNodes()
if err != nil {
t.Fatalf("list nodes: %v", err)
}
var createdNodeID int64
for _, item := range nodes {
if item["name"] == "nft-node-created" {
createdNodeID = item["id"].(int64)
break
}
}
if createdNodeID <= 0 {
t.Fatalf("expected created node to exist")
}
createdNode, err := fixture.handler.repo.GetNodeRecord(createdNodeID)
if err != nil {
t.Fatalf("load created node: %v", err)
}
if createdNode == nil {
t.Fatal("expected created node record, got nil")
}
if createdNode.Status != 1 {
t.Fatalf("expected nftables node to be online, got status %d", createdNode.Status)
}
cfg, err := fixture.handler.repo.GetNodeSSHConfig(createdNodeID)
if err != nil {
t.Fatalf("load ssh config: %v", err)
}
if cfg.Host != "203.0.113.21" || cfg.Port != 2222 || cfg.Username != "root" || cfg.AuthType != "private_key" {
t.Fatalf("unexpected ssh config: %+v", cfg)
}
if !cfg.PrivateKey.Valid || cfg.PrivateKey.String != "TEST-PRIVATE-KEY" {
t.Fatalf("expected private key to persist, got %+v", cfg)
}
}
func TestNodeUpdatePreservesExistingNftablesSecretsWhenFieldsOmitted(t *testing.T) {
fixture := setupNftablesHandler(t)
seedNftablesSSHConfig(t, fixture.handler, fixture.nodeID)
req := newAuthenticatedJSONRequest(t, map[string]interface{}{
"id": fixture.nodeID,
"name": "nft-node-updated",
"serverIp": "198.51.100.10",
"serverIpV4": "198.51.100.10",
"port": "1000-65535",
"forwardMode": "nftables",
"sshConfig": map[string]interface{}{
"host": "203.0.113.30",
"port": 22,
"username": "admin",
"authType": "password",
"sudoMode": "none",
},
})
res := httptest.NewRecorder()
fixture.handler.nodeUpdate(res, req)
assertNftablesSuccessWithBody(t, res)
cfg, err := fixture.handler.repo.GetNodeSSHConfig(fixture.nodeID)
if err != nil {
t.Fatalf("load ssh config: %v", err)
}
if cfg.Host != "203.0.113.30" || cfg.Username != "admin" || cfg.AuthType != "password" {
t.Fatalf("unexpected ssh config after update: %+v", cfg)
}
if !cfg.Password.Valid || cfg.Password.String != "secret" {
t.Fatalf("expected password secret to be preserved, got %+v", cfg)
}
}
func TestNodeUpdateSkipsAgentProtocolCommandForNftablesNode(t *testing.T) {
fixture := setupNftablesHandler(t)
seedNftablesSSHConfig(t, fixture.handler, fixture.nodeID)
req := newAuthenticatedJSONRequest(t, map[string]interface{}{
"id": fixture.nodeID,
"name": "nft-node-updated",
"serverIp": "198.51.100.10",
"serverIpV4": "198.51.100.10",
"port": "1000-65535",
"forwardMode": "nftables",
"http": 1,
"tls": 1,
"socks": 1,
"sshConfig": map[string]interface{}{
"host": "203.0.113.30",
"port": 22,
"username": "admin",
"authType": "password",
"sudoMode": "none",
},
})
res := httptest.NewRecorder()
fixture.handler.nodeUpdate(res, req)
assertNftablesSuccessWithBody(t, res)
cfg, err := fixture.handler.repo.GetNodeSSHConfig(fixture.nodeID)
if err != nil {
t.Fatalf("load ssh config: %v", err)
}
if !cfg.Password.Valid || cfg.Password.String != "secret" {
t.Fatalf("expected password secret to be preserved, got %+v", cfg)
}
}
func TestValidateNftablesForwardRequestRejectsHostnameTarget(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
tunnelID := seedTunnelForNftables(t, h, "nft-tunnel", fixture.nodeID)
tunnel, err := h.getTunnelRecord(tunnelID)
if err != nil {
t.Fatalf("load tunnel: %v", err)
}
err = h.validateNftablesForwardRequest(tunnel, "example.com:443", []int64{fixture.nodeID})
if err == nil {
t.Fatalf("expected hostname target to be rejected")
}
if !strings.Contains(err.Error(), "IP") {
t.Fatalf("expected IP literal validation error, got %q", err)
}
}
func TestForwardDeleteReconcilesNftablesAfterDBDelete(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
seedNftablesSSHConfig(t, h, fixture.nodeID)
tunnelID := seedTunnelForNftables(t, h, "nft-tunnel", fixture.nodeID)
forward := seedForwardForNftables(t, h, tunnelID, fixture.nodeID, "203.0.113.9:8080")
manager := &fakeNftablesManager{}
h.nftablesManager = manager
req := newAuthenticatedJSONRequest(t, map[string]int64{"id": forward.ID})
res := httptest.NewRecorder()
h.forwardDelete(res, req)
assertNftablesSuccessWithBody(t, res)
if manager.reconcileHit != 1 {
t.Fatalf("expected reconcile once, got %d", manager.reconcileHit)
}
if len(manager.lastPlan.Rules) != 0 {
t.Fatalf("expected reconcile after DB delete to render no rules, got %+v", manager.lastPlan.Rules)
}
if _, err := h.getForwardRecord(forward.ID); !errors.Is(err, errForwardNotFound) {
t.Fatalf("expected forward to be deleted, got %v", err)
}
}
func TestForwardForceDeleteRemovesNftablesBindingAndReconciles(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
seedNftablesSSHConfig(t, h, fixture.nodeID)
tunnelID := seedTunnelForNftables(t, h, "nft-tunnel", fixture.nodeID)
forward := seedForwardForNftables(t, h, tunnelID, fixture.nodeID, "203.0.113.9:8080")
if err := h.repo.UpsertNftRuleBinding(repo.NftRuleBindingInput{
ForwardID: forward.ID,
NodeID: fixture.nodeID,
InPort: 20000,
Protocols: "tcp,udp",
TargetAddr: "203.0.113.9:8080",
Status: runtimenft.StatusApplied,
}, time.Now().UnixMilli()); err != nil {
t.Fatalf("seed binding: %v", err)
}
manager := &fakeNftablesManager{}
h.nftablesManager = manager
req := newAuthenticatedJSONRequest(t, map[string]int64{"id": forward.ID})
req.URL.Path = "/api/v1/forward/force-delete"
res := httptest.NewRecorder()
mux := http.NewServeMux()
h.Register(mux)
mux.ServeHTTP(res, req)
assertNftablesSuccessWithBody(t, res)
if manager.reconcileHit != 1 {
t.Fatalf("expected reconcile once, got %d", manager.reconcileHit)
}
if _, err := h.getForwardRecord(forward.ID); !errors.Is(err, errForwardNotFound) {
t.Fatalf("expected forward to be deleted, got %v", err)
}
if bindings, err := h.repo.ListNftRuleBindingsByNode(fixture.nodeID); err != nil {
t.Fatalf("list bindings after delete: %v", err)
} else if len(bindings) != 0 {
t.Fatalf("expected no bindings after delete, got %+v", bindings)
}
}
func TestForwardBatchDeleteReconcilesNftablesAfterDBDelete(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
seedNftablesSSHConfig(t, h, fixture.nodeID)
tunnelID := seedTunnelForNftables(t, h, "nft-tunnel", fixture.nodeID)
forward := seedForwardForNftables(t, h, tunnelID, fixture.nodeID, "203.0.113.9:8080")
manager := &fakeNftablesManager{}
h.nftablesManager = manager
req := newAuthenticatedJSONRequest(t, map[string][]int64{"ids": {forward.ID}})
res := httptest.NewRecorder()
h.forwardBatchDelete(res, req)
assertNftablesSuccessWithBody(t, res)
if manager.reconcileHit != 1 {
t.Fatalf("expected reconcile once, got %d", manager.reconcileHit)
}
if len(manager.lastPlan.Rules) != 0 {
t.Fatalf("expected reconcile after DB delete to render no rules, got %+v", manager.lastPlan.Rules)
}
if _, err := h.getForwardRecord(forward.ID); !errors.Is(err, errForwardNotFound) {
t.Fatalf("expected forward to be deleted, got %v", err)
}
}
func TestForwardBatchRedeployUsesNftablesReconcile(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
seedNftablesSSHConfig(t, h, fixture.nodeID)
tunnelID := seedTunnelForNftables(t, h, "nft-tunnel", fixture.nodeID)
forward := seedForwardForNftables(t, h, tunnelID, fixture.nodeID, "203.0.113.9:8080")
manager := &fakeNftablesManager{}
h.nftablesManager = manager
req := newAuthenticatedJSONRequest(t, map[string][]int64{"ids": {forward.ID}})
res := httptest.NewRecorder()
h.forwardBatchRedeploy(res, req)
assertNftablesSuccessWithBody(t, res)
if manager.reconcileHit != 1 {
t.Fatalf("expected reconcile once, got %d", manager.reconcileHit)
}
}
func TestTunnelBatchRedeployUsesNftablesReconcile(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
seedNftablesSSHConfig(t, h, fixture.nodeID)
tunnelID := seedTunnelForNftables(t, h, "nft-tunnel", fixture.nodeID)
seedForwardForNftables(t, h, tunnelID, fixture.nodeID, "203.0.113.9:8080")
manager := &fakeNftablesManager{}
h.nftablesManager = manager
req := newAuthenticatedJSONRequest(t, map[string][]int64{"ids": {tunnelID}})
res := httptest.NewRecorder()
h.tunnelBatchRedeploy(res, req)
assertNftablesSuccessWithBody(t, res)
if manager.reconcileHit != 1 {
t.Fatalf("expected reconcile once, got %d", manager.reconcileHit)
}
}
func TestDiagnoseForwardRuntimeReturnsNftablesRuleStatus(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
tunnelID := seedTunnelForNftables(t, h, "nft-diagnose-tunnel", fixture.nodeID)
forward := seedForwardForNftables(t, h, tunnelID, fixture.nodeID, "203.0.113.9:8080")
now := time.Now().UnixMilli()
if err := h.repo.UpsertNftRuleBinding(repo.NftRuleBindingInput{
ForwardID: forward.ID,
NodeID: fixture.nodeID,
InPort: 20000,
Protocols: "tcp,udp",
TargetAddr: "203.0.113.9:8080",
RuleHash: "hash-a",
Status: runtimenft.StatusApplied,
}, now); err != nil {
t.Fatalf("seed nft binding: %v", err)
}
payload, err := h.diagnoseForwardRuntime(context.Background(), &forwardRecord{
ID: forward.ID,
Name: forward.Name,
TunnelID: tunnelID,
RemoteAddr: "203.0.113.9:8080",
})
if err != nil {
t.Fatalf("diagnose forward: %v", err)
}
results, ok := payload["results"].([]map[string]interface{})
if !ok || len(results) != 1 {
t.Fatalf("expected one nftables diagnosis result, got %#v", payload["results"])
}
result := results[0]
if result["forwardMode"] != "nftables" || result["nftRuleStatus"] != runtimenft.StatusApplied {
t.Fatalf("expected nftables applied result, got %#v", result)
}
if result["success"] != true {
t.Fatalf("expected nftables diagnosis success, got %#v", result)
}
if !strings.Contains(asString(result["message"]), "已下发") {
t.Fatalf("expected applied message, got %#v", result["message"])
}
}
func waitForCondition(t *testing.T, timeout time.Duration, condition func() bool, description string) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if condition() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", description)
}
func setupNftablesHandler(t *testing.T) nftablesTestFixture {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "handler-nftables.sqlite")
r, err := repo.Open(dbPath)
if err != nil {
t.Fatalf("open repo: %v", err)
}
h := New(r, "test-secret")
now := time.Now().UnixMilli()
if _, err := r.CreateUser("admin", "hash", 0, now+86400000, 1, 1, 100, 1, 0, now); err != nil {
t.Fatalf("create user: %v", err)
}
if err := r.CreateNode("nft-node", "secret", "198.51.100.10", nil, nil, "1000-65535", nil, nil, nil, nil, nil, 0, 0, 0, now, 1, "", "", 1, 0, nil, nil, nil, nil, "nftables"); err != nil {
t.Fatalf("create node: %v", err)
}
node, err := r.GetNodeRecord(1)
if err != nil || node == nil {
t.Fatalf("get node: %v", err)
}
return nftablesTestFixture{handler: h, nodeID: node.ID}
}
func seedNftablesSSHConfig(t *testing.T, h *Handler, nodeID int64) {
t.Helper()
if err := h.repo.UpsertNodeSSHConfig(nodeID, repo.NftSSHConfigInput{
Host: "203.0.113.10",
Port: 22,
Username: "root",
AuthType: "password",
Password: "secret",
SudoMode: "none",
}, time.Now().UnixMilli()); err != nil {
t.Fatalf("upsert ssh config: %v", err)
}
}
func seedTunnelForNftables(t *testing.T, h *Handler, name string, nodeID int64) int64 {
t.Helper()
now := time.Now().UnixMilli()
tx := h.repo.BeginTx()
if tx == nil {
t.Fatal("begin tx: nil transaction")
}
if tx.Error != nil {
t.Fatalf("begin tx: %v", tx.Error)
}
tunnelID, err := h.repo.CreateTunnelTx(tx, name, 1, 1, 1, now, 1, nil, 1, "", "", 0)
if err != nil {
_ = tx.Rollback().Error
t.Fatalf("create tunnel: %v", err)
}
if err := h.repo.CreateChainTunnelTx(tx, tunnelID, "1", nodeID, sql.NullInt64{}, "", 1, "tls", ""); err != nil {
_ = tx.Rollback().Error
t.Fatalf("create chain tunnel: %v", err)
}
if err := tx.Commit().Error; err != nil {
_ = tx.Rollback().Error
t.Fatalf("commit tx: %v", err)
}
return tunnelID
}
func seedForwardForNftables(t *testing.T, h *Handler, tunnelID, nodeID int64, remoteAddr string) *forwardRecord {
t.Helper()
now := time.Now().UnixMilli()
forwardID, err := h.repo.CreateForwardTx(
1, "admin", "nft-forward", tunnelID, remoteAddr, "fifo", now, 1,
[]int64{nodeID}, 20000, "", nil, 0, 0, nil, 0, 0, 0,
)
if err != nil {
t.Fatalf("create forward: %v", err)
}
forward, err := h.getForwardRecord(forwardID)
if err != nil {
t.Fatalf("get forward: %v", err)
}
return forward
}
func postJSONToHandler(t *testing.T, fn func(http.ResponseWriter, *http.Request), payload any) *httptest.ResponseRecorder {
t.Helper()
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
res := httptest.NewRecorder()
fn(res, req)
return res
}
func newAuthenticatedJSONRequest(t *testing.T, payload any) *http.Request {
t.Helper()
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
token, err := auth.GenerateToken(1, "admin", 0, "test-secret")
if err != nil {
t.Fatalf("create token: %v", err)
}
req.Header.Set("Authorization", token)
claims, ok := auth.ValidateToken(token, "test-secret")
if !ok {
t.Fatalf("validate token failed")
}
return req.WithContext(context.WithValue(req.Context(), middleware.ClaimsContextKey, claims))
}
func assertNftablesSuccess(t *testing.T, res *httptest.ResponseRecorder) {
t.Helper()
assertNftablesSuccessWithBody(t, res)
}
func assertNftablesSuccessWithBody(t *testing.T, res *httptest.ResponseRecorder) {
t.Helper()
var payload struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if res.Code != http.StatusOK {
t.Fatalf("expected HTTP %d, got %d", http.StatusOK, res.Code)
}
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Code != 0 {
t.Fatalf("expected success, got %+v", payload)
}
}
@@ -0,0 +1,400 @@
package handler
import (
"context"
"log"
"math"
"sort"
"strings"
"time"
runtimenft "go-backend/internal/runtime/nftables"
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
)
type nftTrafficDelta struct {
ForwardID int64
BytesIn int64
BytesOut int64
}
type nftCounterStateKey struct {
forwardID int64
protocol string
direction string
}
func (h *Handler) runNftablesTrafficCollectJob(now time.Time) {
if h == nil || h.repo == nil {
return
}
nodes, err := h.repo.ListNftablesNodesForCollection()
if err != nil {
log.Printf("nftables traffic collection failed op=list_nodes err=%v", err)
return
}
for i := range nodes {
node := &nodes[i]
h.collectNftablesNodeTraffic(node.NodeID, &node.Config, now)
}
}
func (h *Handler) collectNftablesNodeTraffic(nodeID int64, cfgModel *model.NodeSSHConfig, now time.Time) {
if h == nil || h.repo == nil {
return
}
if h.nftablesManager == nil {
log.Printf("nftables traffic collection failed op=collect node_id=%d err=%v", nodeID, "nftables manager not initialized")
return
}
sshCfg, err := sshConfigFromModel(cfgModel)
if err != nil {
log.Printf("nftables traffic collection failed op=ssh_config node_id=%d err=%v", nodeID, err)
return
}
samples, err := h.nftablesManager.CollectCounters(context.Background(), sshCfg)
if err != nil {
log.Printf("nftables traffic collection failed op=collect node_id=%d err=%v", nodeID, err)
return
}
oldStates, err := h.repo.GetNftCounterStatesByNode(nodeID)
if err != nil {
log.Printf("nftables traffic collection failed op=list_states node_id=%d err=%v", nodeID, err)
return
}
bindings, err := h.repo.ListNftRuleBindingsByNode(nodeID)
if err != nil {
log.Printf("nftables traffic collection failed op=list_bindings node_id=%d err=%v", nodeID, err)
return
}
hashes := make(map[int64]string, len(bindings))
for _, binding := range bindings {
if strings.ToLower(strings.TrimSpace(binding.Status)) != runtimenft.StatusApplied {
continue
}
ruleHash := strings.TrimSpace(binding.RuleHash)
if ruleHash == "" {
continue
}
hashes[binding.ForwardID] = ruleHash
}
nowMs := now.UnixMilli()
boundSamples := filterNftCounterSamplesWithBinding(samples, hashes)
deltas, newStates := buildNftCounterDeltas(nodeID, boundSamples, oldStates, hashes, nowMs)
if len(newStates) == 0 {
if len(deltas) != 0 {
log.Printf("nftables traffic collection skipped suspicious deltas without states node_id=%d deltas=%d", nodeID, len(deltas))
}
return
}
var metas map[int64]repo.FlowUploadForwardMeta
forwardIDs := make([]int64, 0, len(deltas))
for _, delta := range deltas {
if delta.ForwardID > 0 {
forwardIDs = append(forwardIDs, delta.ForwardID)
}
}
if len(deltas) != 0 {
metas, err = h.repo.GetFlowUploadForwardMetas(forwardIDs)
if err != nil {
log.Printf("nftables traffic collection failed op=load_flow_metas node_id=%d err=%v", nodeID, err)
return
}
if missingForwardID, ok := firstNftDeltaMissingMeta(deltas, metas); ok {
log.Printf("nftables traffic collection skipped state advance op=missing_flow_meta node_id=%d forward_id=%d", nodeID, missingForwardID)
return
}
}
if len(deltas) == 0 {
if err := h.repo.UpsertNftCounterStates(newStates, nowMs); err != nil {
log.Printf("nftables traffic collection failed op=upsert_states node_id=%d err=%v", nodeID, err)
return
}
return
}
batch := buildNftFlowUploadBatch(deltas, metas)
if missingForwardID, ok := firstNftBatchMissingDelta(deltas, batch); ok {
log.Printf("nftables traffic collection skipped state advance op=unaccounted_delta node_id=%d forward_id=%d", nodeID, missingForwardID)
return
}
quotaViews, err := h.repo.ApplyNftTrafficAccounting(batch.flowDeltas, batch.quotaUsage, newStates, now)
if err != nil {
log.Printf("nftables traffic collection failed op=accounting node_id=%d err=%v", nodeID, err)
return
}
h.recordTunnelMetricsFromForwardBatch(nodeID, batch.forwardTraffic, metas, nowMs)
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)
}
}
func firstNftBatchMissingDelta(deltas []nftTrafficDelta, batch flowUploadBatch) (int64, bool) {
flowSeen := make(map[int64]struct{}, len(batch.flowDeltas))
for _, delta := range batch.flowDeltas {
flowSeen[delta.ForwardID] = struct{}{}
}
expectedRaw := make(map[int64]tunnelTrafficDelta, len(batch.forwardTraffic))
for _, delta := range deltas {
if delta.ForwardID <= 0 || (delta.BytesIn == 0 && delta.BytesOut == 0) {
continue
}
if delta.BytesIn < 0 || delta.BytesOut < 0 {
return delta.ForwardID, true
}
raw := expectedRaw[delta.ForwardID]
if raw.bytesIn > math.MaxInt64-delta.BytesIn || raw.bytesOut > math.MaxInt64-delta.BytesOut {
return delta.ForwardID, true
}
raw.bytesIn += delta.BytesIn
raw.bytesOut += delta.BytesOut
expectedRaw[delta.ForwardID] = raw
}
for forwardID, expected := range expectedRaw {
actual, ok := batch.forwardTraffic[forwardID]
if !ok || actual.bytesIn != expected.bytesIn || actual.bytesOut != expected.bytesOut {
return forwardID, true
}
if expected.bytesIn != 0 || expected.bytesOut != 0 {
if _, ok := flowSeen[forwardID]; !ok {
return forwardID, true
}
}
}
return 0, false
}
func firstNftDeltaMissingMeta(deltas []nftTrafficDelta, metas map[int64]repo.FlowUploadForwardMeta) (int64, bool) {
for _, delta := range deltas {
if delta.ForwardID <= 0 {
continue
}
if _, ok := metas[delta.ForwardID]; !ok {
return delta.ForwardID, true
}
}
return 0, false
}
func filterNftCounterSamplesWithBinding(samples []runtimenft.CounterSample, hashes map[int64]string) []runtimenft.CounterSample {
if len(samples) == 0 || len(hashes) == 0 {
return nil
}
filtered := make([]runtimenft.CounterSample, 0, len(samples))
for _, sample := range samples {
if _, ok := hashes[sample.ForwardID]; !ok {
continue
}
filtered = append(filtered, sample)
}
return filtered
}
func nftCounterKey(forwardID int64, protocol, direction string) nftCounterStateKey {
return nftCounterStateKey{
forwardID: forwardID,
protocol: strings.ToLower(strings.TrimSpace(protocol)),
direction: strings.ToLower(strings.TrimSpace(direction)),
}
}
func buildNftCounterDeltas(nodeID int64, samples []runtimenft.CounterSample, oldStates []model.NftCounterState, hashes map[int64]string, nowMs int64) ([]nftTrafficDelta, []repo.NftCounterStateInput) {
oldByKey := make(map[nftCounterStateKey]model.NftCounterState, len(oldStates))
for _, old := range oldStates {
if old.NodeID != nodeID {
continue
}
oldByKey[nftCounterKey(old.ForwardID, old.Protocol, old.Direction)] = old
}
stateInputs := make([]repo.NftCounterStateInput, 0, len(samples))
deltaByForward := make(map[int64]nftTrafficDelta)
for _, sample := range samples {
direction := strings.ToLower(strings.TrimSpace(sample.Direction))
if direction != runtimenft.CounterDirectionToTarget && direction != runtimenft.CounterDirectionFromTarget {
continue
}
protocol := strings.ToLower(strings.TrimSpace(sample.Protocol))
if protocol != "tcp" && protocol != "udp" {
continue
}
if sample.Bytes > uint64(math.MaxInt64) || sample.Packets > uint64(math.MaxInt64) {
continue
}
ruleHash := strings.TrimSpace(hashes[sample.ForwardID])
stateInput := repo.NftCounterStateInput{
NodeID: nodeID,
ForwardID: sample.ForwardID,
Protocol: protocol,
Direction: direction,
RuleHash: ruleHash,
Bytes: sample.Bytes,
Packets: sample.Packets,
CollectedTime: nowMs,
}
old, exists := oldByKey[nftCounterKey(sample.ForwardID, protocol, direction)]
if !exists || old.RuleHash != ruleHash {
stateInputs = append(stateInputs, stateInput)
continue
}
if old.Bytes < 0 {
stateInputs = append(stateInputs, stateInput)
continue
}
oldBytes := uint64(old.Bytes)
if sample.Bytes < oldBytes {
stateInputs = append(stateInputs, stateInput)
continue
}
rawDelta := sample.Bytes - oldBytes
if rawDelta == 0 {
stateInputs = append(stateInputs, stateInput)
continue
}
delta := deltaByForward[sample.ForwardID]
delta.ForwardID = sample.ForwardID
rawDeltaInt := int64(rawDelta)
if direction == runtimenft.CounterDirectionToTarget {
if delta.BytesIn > math.MaxInt64-rawDeltaInt {
continue
}
delta.BytesIn += rawDeltaInt
} else {
if delta.BytesOut > math.MaxInt64-rawDeltaInt {
continue
}
delta.BytesOut += rawDeltaInt
}
stateInputs = append(stateInputs, stateInput)
deltaByForward[sample.ForwardID] = delta
}
forwardIDs := make([]int64, 0, len(deltaByForward))
for forwardID := range deltaByForward {
forwardIDs = append(forwardIDs, forwardID)
}
sort.Slice(forwardIDs, func(i, j int) bool { return forwardIDs[i] < forwardIDs[j] })
deltas := make([]nftTrafficDelta, 0, len(forwardIDs))
for _, forwardID := range forwardIDs {
delta := deltaByForward[forwardID]
if delta.BytesIn == 0 && delta.BytesOut == 0 {
continue
}
deltas = append(deltas, delta)
}
return deltas, stateInputs
}
func buildNftFlowUploadBatch(deltas []nftTrafficDelta, 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 _, delta := range deltas {
meta, exists := metas[delta.ForwardID]
if !exists {
continue
}
raw := batch.forwardTraffic[delta.ForwardID]
if delta.BytesIn < 0 || delta.BytesOut < 0 || raw.bytesIn > math.MaxInt64-delta.BytesIn || raw.bytesOut > math.MaxInt64-delta.BytesOut {
continue
}
scaledIn, ok := scaleNftTrafficBytes(delta.BytesIn, meta.TrafficRatio, meta.TunnelFlow)
if !ok {
continue
}
scaledOut, ok := scaleNftTrafficBytes(delta.BytesOut, meta.TrafficRatio, meta.TunnelFlow)
if !ok {
continue
}
if scaledIn > math.MaxInt64-scaledOut {
continue
}
quotaDelta := scaledIn + scaledOut
if batch.quotaUsage[meta.UserID] > math.MaxInt64-quotaDelta {
continue
}
flowIdx, flowExists := flowSeen[delta.ForwardID]
if flowExists && (batch.flowDeltas[flowIdx].InFlow > math.MaxInt64-scaledIn || batch.flowDeltas[flowIdx].OutFlow > math.MaxInt64-scaledOut) {
continue
}
raw.bytesIn += delta.BytesIn
raw.bytesOut += delta.BytesOut
batch.forwardTraffic[delta.ForwardID] = raw
if flowExists {
batch.flowDeltas[flowIdx].InFlow += scaledIn
batch.flowDeltas[flowIdx].OutFlow += scaledOut
} else {
flowSeen[delta.ForwardID] = len(batch.flowDeltas)
batch.flowDeltas = append(batch.flowDeltas, repo.FlowUploadCounterDelta{
ForwardID: delta.ForwardID,
UserID: meta.UserID,
UserTunnelID: meta.UserTunnelID,
InFlow: scaledIn,
OutFlow: scaledOut,
})
}
batch.quotaUsage[meta.UserID] += quotaDelta
target := flowPolicyTarget{UserID: meta.UserID, UserTunnelID: meta.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 scaleNftTrafficBytes(bytes int64, ratio float64, tunnelFlow int64) (int64, bool) {
if bytes < 0 || ratio < 0 || tunnelFlow < 0 {
return 0, false
}
var scaled int64
if ratio == 1 {
scaled = bytes
} else {
scaledFloat := float64(bytes) * ratio
if math.IsNaN(scaledFloat) || math.IsInf(scaledFloat, 0) || scaledFloat < 0 || scaledFloat >= math.Pow(2, 63) {
return 0, false
}
scaled = int64(scaledFloat)
}
if tunnelFlow != 0 && scaled > math.MaxInt64/tunnelFlow {
return 0, false
}
return scaled * tunnelFlow, true
}
@@ -0,0 +1,680 @@
package handler
import (
"errors"
"math"
"testing"
"time"
runtimenft "go-backend/internal/runtime/nftables"
"go-backend/internal/store/model"
"go-backend/internal/store/repo"
)
func TestBuildNftCounterDeltasSavesFirstBaselineWithoutDelta(t *testing.T) {
nowMs := int64(1700000000123)
deltas, states := buildNftCounterDeltas(11, []runtimenft.CounterSample{
{ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1000, Packets: 10},
}, nil, map[int64]string{42: "hash-a"}, nowMs)
if len(deltas) != 0 {
t.Fatalf("expected no deltas for first baseline, got %#v", deltas)
}
if len(states) != 1 {
t.Fatalf("expected one state input, got %d", len(states))
}
state := states[0]
if state.NodeID != 11 || state.ForwardID != 42 || state.Protocol != "tcp" || state.Direction != runtimenft.CounterDirectionToTarget {
t.Fatalf("unexpected state identity: %#v", state)
}
if state.RuleHash != "hash-a" || state.Bytes != 1000 || state.Packets != 10 || state.CollectedTime != nowMs {
t.Fatalf("unexpected state values: %#v", state)
}
}
func TestBuildNftCounterDeltasNormalGrowthProducesDirectionalBytes(t *testing.T) {
deltas, states := buildNftCounterDeltas(11, []runtimenft.CounterSample{
{ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1500, Packets: 15},
{ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionFromTarget, Bytes: 2600, Packets: 26},
}, []model.NftCounterState{
{NodeID: 11, ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-a", Bytes: 1000, Packets: 10},
{NodeID: 11, ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionFromTarget, RuleHash: "hash-a", Bytes: 2000, Packets: 20},
}, map[int64]string{42: "hash-a"}, 2000)
if len(deltas) != 1 {
t.Fatalf("expected one aggregated delta, got %#v", deltas)
}
if deltas[0].ForwardID != 42 || deltas[0].BytesIn != 500 || deltas[0].BytesOut != 600 {
t.Fatalf("unexpected delta: %#v", deltas[0])
}
if len(states) != 2 {
t.Fatalf("expected two state inputs, got %d", len(states))
}
}
func TestBuildNftCounterDeltasResetRefreshesBaselineWithoutDelta(t *testing.T) {
deltas, states := buildNftCounterDeltas(11, []runtimenft.CounterSample{
{ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 25, Packets: 2},
}, []model.NftCounterState{
{NodeID: 11, ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-a", Bytes: 1000, Packets: 10},
}, map[int64]string{42: "hash-a"}, 2000)
if len(deltas) != 0 {
t.Fatalf("expected reset to produce no deltas, got %#v", deltas)
}
if len(states) != 1 || states[0].Bytes != 25 || states[0].RuleHash != "hash-a" {
t.Fatalf("expected refreshed baseline state, got %#v", states)
}
}
func TestBuildNftCounterDeltasRuleHashChangeRefreshesBaselineWithoutDelta(t *testing.T) {
deltas, states := buildNftCounterDeltas(11, []runtimenft.CounterSample{
{ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1500, Packets: 15},
}, []model.NftCounterState{
{NodeID: 11, ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-a", Bytes: 1000, Packets: 10},
}, map[int64]string{42: "hash-b"}, 2000)
if len(deltas) != 0 {
t.Fatalf("expected rule hash change to produce no deltas, got %#v", deltas)
}
if len(states) != 1 || states[0].Bytes != 1500 || states[0].RuleHash != "hash-b" {
t.Fatalf("expected refreshed hash baseline state, got %#v", states)
}
}
func TestBuildNftCounterDeltasEqualBytesRefreshesBaselineWithoutDelta(t *testing.T) {
deltas, states := buildNftCounterDeltas(11, []runtimenft.CounterSample{
{ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1000, Packets: 11},
}, []model.NftCounterState{
{NodeID: 11, ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-a", Bytes: 1000, Packets: 10},
}, map[int64]string{42: "hash-a"}, 2000)
if len(deltas) != 0 {
t.Fatalf("expected equal bytes to produce no deltas, got %#v", deltas)
}
if len(states) != 1 || states[0].Bytes != 1000 || states[0].Packets != 11 || states[0].RuleHash != "hash-a" {
t.Fatalf("expected refreshed baseline state, got %#v", states)
}
}
func TestBuildNftCounterDeltasAggregatesProtocolsAndDirections(t *testing.T) {
deltas, _ := buildNftCounterDeltas(11, []runtimenft.CounterSample{
{ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1100, Packets: 11},
{ForwardID: 42, Protocol: "udp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 2200, Packets: 22},
{ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionFromTarget, Bytes: 3300, Packets: 33},
{ForwardID: 42, Protocol: "udp", Direction: runtimenft.CounterDirectionFromTarget, Bytes: 4400, Packets: 44},
}, []model.NftCounterState{
{NodeID: 11, ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-a", Bytes: 1000},
{NodeID: 11, ForwardID: 42, Protocol: "udp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-a", Bytes: 2000},
{NodeID: 11, ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionFromTarget, RuleHash: "hash-a", Bytes: 3000},
{NodeID: 11, ForwardID: 42, Protocol: "udp", Direction: runtimenft.CounterDirectionFromTarget, RuleHash: "hash-a", Bytes: 4000},
}, map[int64]string{42: "hash-a"}, 2000)
if len(deltas) != 1 {
t.Fatalf("expected one aggregated delta, got %#v", deltas)
}
if deltas[0].ForwardID != 42 || deltas[0].BytesIn != 300 || deltas[0].BytesOut != 700 {
t.Fatalf("unexpected aggregated delta: %#v", deltas[0])
}
}
func TestBuildNftCounterDeltasSkipsInvalidProtocolBeforeStateAndDelta(t *testing.T) {
deltas, states := buildNftCounterDeltas(11, []runtimenft.CounterSample{
{ForwardID: 42, Protocol: "icmp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1500, Packets: 15},
}, []model.NftCounterState{
{NodeID: 11, ForwardID: 42, Protocol: "icmp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-a", Bytes: 1000, Packets: 10},
}, map[int64]string{42: "hash-a"}, 2000)
if len(deltas) != 0 {
t.Fatalf("expected invalid protocol to produce no deltas, got %#v", deltas)
}
if len(states) != 0 {
t.Fatalf("expected invalid protocol to produce no state inputs, got %#v", states)
}
}
func TestBuildNftCounterDeltasSkipsOversizedPacketsBeforeStateAndDelta(t *testing.T) {
deltas, states := buildNftCounterDeltas(11, []runtimenft.CounterSample{
{ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1500, Packets: uint64(math.MaxInt64) + 1},
}, []model.NftCounterState{
{NodeID: 11, ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-a", Bytes: 1000, Packets: 10},
}, map[int64]string{42: "hash-a"}, 2000)
if len(deltas) != 0 {
t.Fatalf("expected oversized packets to produce no deltas, got %#v", deltas)
}
if len(states) != 0 {
t.Fatalf("expected oversized packets to produce no state inputs, got %#v", states)
}
}
func TestBuildNftCounterDeltasSkipsOversizedBytesBeforeStateAndDelta(t *testing.T) {
deltas, states := buildNftCounterDeltas(11, []runtimenft.CounterSample{
{ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: uint64(math.MaxInt64) + 1, Packets: 10},
}, []model.NftCounterState{
{NodeID: 11, ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-a", Bytes: 1000, Packets: 10},
}, map[int64]string{42: "hash-a"}, 2000)
if len(deltas) != 0 {
t.Fatalf("expected oversized bytes to produce no deltas, got %#v", deltas)
}
if len(states) != 0 {
t.Fatalf("expected oversized bytes to produce no state inputs, got %#v", states)
}
}
func TestBuildNftCounterDeltasSkipsOverflowingAggregateSampleWithoutState(t *testing.T) {
deltas, states := buildNftCounterDeltas(11, []runtimenft.CounterSample{
{ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: uint64(math.MaxInt64), Packets: 10},
{ForwardID: 42, Protocol: "udp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 10, Packets: 1},
}, []model.NftCounterState{
{NodeID: 11, ForwardID: 42, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-a", Bytes: 1, Packets: 1},
{NodeID: 11, ForwardID: 42, Protocol: "udp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-a", Bytes: 1, Packets: 1},
}, map[int64]string{42: "hash-a"}, 2000)
if len(deltas) != 1 {
t.Fatalf("expected only non-overflowing aggregate delta, got %#v", deltas)
}
if deltas[0].ForwardID != 42 || deltas[0].BytesIn != math.MaxInt64-1 || deltas[0].BytesOut != 0 {
t.Fatalf("unexpected aggregate delta: %#v", deltas[0])
}
if len(states) != 1 {
t.Fatalf("expected only the accounted safe sample to advance baseline, got %#v", states)
}
if states[0].ForwardID != 42 || states[0].Protocol != "tcp" || states[0].Bytes != uint64(math.MaxInt64) {
t.Fatalf("expected safe sample state input to be preserved, got %#v", states)
}
}
func TestBuildNftCounterDeltasSkipsUnknownDirectionAndOversizedDelta(t *testing.T) {
deltas, states := buildNftCounterDeltas(11, []runtimenft.CounterSample{
{ForwardID: 42, Protocol: "tcp", Direction: "sideways", Bytes: 1500, Packets: 15},
{ForwardID: 43, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: uint64(math.MaxInt64) + 1, Packets: 1},
}, []model.NftCounterState{
{NodeID: 11, ForwardID: 43, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, RuleHash: "hash-b", Bytes: 100},
}, map[int64]string{42: "hash-a", 43: "hash-b"}, 2000)
if len(deltas) != 0 {
t.Fatalf("expected no delta for skipped/oversized samples, got %#v", deltas)
}
if len(states) != 0 {
t.Fatalf("expected no state inputs for skipped/oversized samples, got %#v", states)
}
}
func TestBuildNftFlowUploadBatchScalesFlowAndPreservesRawTunnelTraffic(t *testing.T) {
batch := buildNftFlowUploadBatch([]nftTrafficDelta{
{ForwardID: 20, BytesIn: 80, BytesOut: 110},
{ForwardID: 21, BytesIn: 7, BytesOut: 11},
{ForwardID: 20, BytesIn: 20, BytesOut: 10},
}, map[int64]repo.FlowUploadForwardMeta{
20: {ForwardID: 20, UserID: 2, UserTunnelID: 10, TunnelID: 1, TrafficRatio: 2, TunnelFlow: 3},
21: {ForwardID: 21, UserID: 2, UserTunnelID: 10, TunnelID: 1, TrafficRatio: 1.5, TunnelFlow: 2},
})
if len(batch.flowDeltas) != 2 {
t.Fatalf("expected two flow deltas, got %#v", batch.flowDeltas)
}
if batch.flowDeltas[0].ForwardID != 20 || batch.flowDeltas[0].InFlow != 600 || batch.flowDeltas[0].OutFlow != 720 {
t.Fatalf("unexpected first flow delta: %#v", batch.flowDeltas[0])
}
if batch.flowDeltas[1].ForwardID != 21 || batch.flowDeltas[1].InFlow != 20 || batch.flowDeltas[1].OutFlow != 32 {
t.Fatalf("unexpected second flow delta: %#v", batch.flowDeltas[1])
}
if batch.quotaUsage[2] != 1372 {
t.Fatalf("expected quota usage 1372, got %d", batch.quotaUsage[2])
}
if len(batch.policyTargets) != 1 || batch.policyTargets[0].UserID != 2 || batch.policyTargets[0].UserTunnelID != 10 {
t.Fatalf("expected deduped policy target, got %#v", batch.policyTargets)
}
if traffic := batch.forwardTraffic[20]; traffic.bytesIn != 100 || traffic.bytesOut != 120 {
t.Fatalf("expected raw traffic for forward 20, got %#v", traffic)
}
if traffic := batch.forwardTraffic[21]; traffic.bytesIn != 7 || traffic.bytesOut != 11 {
t.Fatalf("expected raw traffic for forward 21, got %#v", traffic)
}
}
func TestBuildNftFlowUploadBatchSkipsOverflowingScaledFlow(t *testing.T) {
batch := buildNftFlowUploadBatch([]nftTrafficDelta{
{ForwardID: 20, BytesIn: math.MaxInt64, BytesOut: 0},
}, map[int64]repo.FlowUploadForwardMeta{
20: {ForwardID: 20, UserID: 2, UserTunnelID: 10, TrafficRatio: 2, TunnelFlow: 2},
})
if len(batch.flowDeltas) != 0 {
t.Fatalf("expected overflowing scaled flow to be skipped, got %#v", batch.flowDeltas)
}
if len(batch.quotaUsage) != 0 {
t.Fatalf("expected no quota usage for overflowing scaled flow, got %#v", batch.quotaUsage)
}
if len(batch.policyTargets) != 0 {
t.Fatalf("expected no policy targets for overflowing scaled flow, got %#v", batch.policyTargets)
}
if len(batch.forwardTraffic) != 0 {
t.Fatalf("expected no raw traffic for overflowing scaled flow, got %#v", batch.forwardTraffic)
}
}
func TestBuildNftFlowUploadBatchSkipsRawForwardTrafficOverflow(t *testing.T) {
batch := buildNftFlowUploadBatch([]nftTrafficDelta{
{ForwardID: 20, BytesIn: math.MaxInt64, BytesOut: 0},
{ForwardID: 20, BytesIn: 1, BytesOut: 0},
}, map[int64]repo.FlowUploadForwardMeta{
20: {ForwardID: 20, UserID: 2, UserTunnelID: 10, TrafficRatio: 0.5, TunnelFlow: 1},
})
traffic := batch.forwardTraffic[20]
if traffic.bytesIn != math.MaxInt64 || traffic.bytesOut != 0 {
t.Fatalf("expected overflowing raw delta to be skipped without negative traffic, got %#v", traffic)
}
if len(batch.flowDeltas) != 1 || batch.flowDeltas[0].ForwardID != 20 {
t.Fatalf("expected only the safe flow delta, got %#v", batch.flowDeltas)
}
if len(batch.policyTargets) != 1 || batch.policyTargets[0].UserID != 2 || batch.policyTargets[0].UserTunnelID != 10 {
t.Fatalf("expected policy target only from safe delta, got %#v", batch.policyTargets)
}
}
func TestBuildNftFlowUploadBatchSkipsQuotaOverflow(t *testing.T) {
batch := buildNftFlowUploadBatch([]nftTrafficDelta{
{ForwardID: 20, BytesIn: math.MaxInt64, BytesOut: 0},
{ForwardID: 21, BytesIn: 1, BytesOut: 0},
}, map[int64]repo.FlowUploadForwardMeta{
20: {ForwardID: 20, UserID: 2, UserTunnelID: 10, TrafficRatio: 1, TunnelFlow: 1},
21: {ForwardID: 21, UserID: 2, UserTunnelID: 10, TrafficRatio: 1, TunnelFlow: 1},
})
if len(batch.flowDeltas) != 1 || batch.flowDeltas[0].ForwardID != 20 || batch.flowDeltas[0].InFlow != math.MaxInt64 {
t.Fatalf("expected only non-overflowing quota delta, got %#v", batch.flowDeltas)
}
if batch.quotaUsage[2] != math.MaxInt64 {
t.Fatalf("expected quota usage to remain at max int64, got %#v", batch.quotaUsage)
}
if len(batch.policyTargets) != 1 || batch.policyTargets[0].UserID != 2 || batch.policyTargets[0].UserTunnelID != 10 {
t.Fatalf("expected one policy target from non-overflowing delta, got %#v", batch.policyTargets)
}
if _, ok := batch.forwardTraffic[21]; ok {
t.Fatalf("expected quota-overflowing delta to be skipped from raw traffic")
}
}
func TestBuildNftFlowUploadBatchSkipsMissingMeta(t *testing.T) {
batch := buildNftFlowUploadBatch([]nftTrafficDelta{
{ForwardID: 20, BytesIn: 80, BytesOut: 110},
{ForwardID: 99, BytesIn: 1, BytesOut: 2},
}, map[int64]repo.FlowUploadForwardMeta{
20: {ForwardID: 20, UserID: 2, UserTunnelID: 10, TrafficRatio: 1, TunnelFlow: 1},
})
if len(batch.flowDeltas) != 1 || batch.flowDeltas[0].ForwardID != 20 {
t.Fatalf("expected only forward 20 delta, got %#v", batch.flowDeltas)
}
if _, ok := batch.forwardTraffic[99]; ok {
t.Fatalf("expected missing meta forward to be skipped from raw traffic")
}
}
func TestNftBatchCoversDeltasRequiresRawAndFlowEntries(t *testing.T) {
deltas := []nftTrafficDelta{{ForwardID: 20, BytesIn: 1, BytesOut: 0}}
batch := flowUploadBatch{
forwardTraffic: map[int64]tunnelTrafficDelta{20: {bytesIn: 1}},
flowDeltas: []repo.FlowUploadCounterDelta{{ForwardID: 20, UserID: 2, UserTunnelID: 10, InFlow: 1}},
}
if missing, ok := firstNftBatchMissingDelta(deltas, batch); ok || missing != 0 {
t.Fatalf("expected batch to cover delta, missing=%d ok=%v", missing, ok)
}
delete(batch.forwardTraffic, 20)
if missing, ok := firstNftBatchMissingDelta(deltas, batch); !ok || missing != 20 {
t.Fatalf("expected missing raw traffic for forward 20, got missing=%d ok=%v", missing, ok)
}
batch.forwardTraffic[20] = tunnelTrafficDelta{bytesIn: 1}
batch.flowDeltas = nil
if missing, ok := firstNftBatchMissingDelta(deltas, batch); !ok || missing != 20 {
t.Fatalf("expected missing flow delta for forward 20, got missing=%d ok=%v", missing, ok)
}
}
func TestNftBatchCoversDeltasRequiresAggregateRawTotals(t *testing.T) {
deltas := []nftTrafficDelta{
{ForwardID: 20, BytesIn: math.MaxInt64, BytesOut: 0},
{ForwardID: 20, BytesIn: 1, BytesOut: 0},
}
batch := buildNftFlowUploadBatch(deltas, map[int64]repo.FlowUploadForwardMeta{
20: {ForwardID: 20, UserID: 2, UserTunnelID: 10, TrafficRatio: 0.5, TunnelFlow: 1},
})
if missing, ok := firstNftBatchMissingDelta(deltas, batch); !ok || missing != 20 {
t.Fatalf("expected aggregate raw overflow/mismatch for forward 20, got missing=%d ok=%v", missing, ok)
}
}
func TestCollectNftablesNodeTrafficFirstBaselineSavesStateWithoutFlow(t *testing.T) {
fixture := setupNftablesCollectionFixture(t)
h := fixture.handler
manager := &fakeNftablesManager{counterSamples: []runtimenft.CounterSample{
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1000, Packets: 10},
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionFromTarget, Bytes: 2000, Packets: 20},
}}
h.nftablesManager = manager
cfg := mustCollectionSSHConfig(t, h, fixture.nodeID)
h.collectNftablesNodeTraffic(fixture.nodeID, cfg, time.Unix(1700000000, 0))
if manager.collectHit != 1 {
t.Fatalf("expected one collection, got %d", manager.collectHit)
}
states, err := h.repo.GetNftCounterStatesByNode(fixture.nodeID)
if err != nil {
t.Fatalf("load states: %v", err)
}
if len(states) != 2 {
t.Fatalf("expected two baseline states, got %+v", states)
}
if got := mustHandlerCount(t, h, `SELECT in_flow FROM forward WHERE id = ?`, fixture.forwardID); got != 0 {
t.Fatalf("expected no forward flow on baseline, got %d", got)
}
if got := mustHandlerCount(t, h, `SELECT out_flow FROM user WHERE id = 1`); got != 0 {
t.Fatalf("expected no user flow on baseline, got %d", got)
}
}
func TestCollectNftablesNodeTrafficGrowthAppliesFlowAndUpdatesState(t *testing.T) {
fixture := setupNftablesCollectionFixture(t)
h := fixture.handler
manager := &fakeNftablesManager{}
h.nftablesManager = manager
cfg := mustCollectionSSHConfig(t, h, fixture.nodeID)
manager.counterSamples = []runtimenft.CounterSample{
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1000, Packets: 10},
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionFromTarget, Bytes: 2000, Packets: 20},
}
h.collectNftablesNodeTraffic(fixture.nodeID, cfg, time.Unix(1700000000, 0))
manager.counterSamples = []runtimenft.CounterSample{
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1400, Packets: 14},
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionFromTarget, Bytes: 2600, Packets: 26},
}
h.collectNftablesNodeTraffic(fixture.nodeID, cfg, time.Unix(1700000060, 0))
if got := mustHandlerCount(t, h, `SELECT in_flow FROM forward WHERE id = ?`, fixture.forwardID); got != 400 {
t.Fatalf("expected forward in_flow=400, got %d", got)
}
if got := mustHandlerCount(t, h, `SELECT out_flow FROM forward WHERE id = ?`, fixture.forwardID); got != 600 {
t.Fatalf("expected forward out_flow=600, got %d", got)
}
if got := mustHandlerCount(t, h, `SELECT in_flow FROM user WHERE id = 1`); got != 400 {
t.Fatalf("expected user in_flow=400, got %d", got)
}
if got := mustHandlerCount(t, h, `SELECT out_flow FROM user_tunnel WHERE id = ?`, fixture.userTunnelID); got != 600 {
t.Fatalf("expected user_tunnel out_flow=600, got %d", got)
}
if got := mustHandlerCount(t, h, `SELECT COALESCE((SELECT daily_used_bytes FROM user_quota WHERE user_id = 1), 0)`); got != 1000 {
t.Fatalf("expected daily quota usage=1000, got %d", got)
}
states, err := h.repo.GetNftCounterStatesByNode(fixture.nodeID)
if err != nil {
t.Fatalf("load states: %v", err)
}
if len(states) != 2 {
t.Fatalf("expected two states after growth, got %+v", states)
}
for _, state := range states {
if state.Direction == runtimenft.CounterDirectionToTarget && state.Bytes != 1400 {
t.Fatalf("expected to-target state bytes 1400, got %+v", state)
}
if state.Direction == runtimenft.CounterDirectionFromTarget && state.Bytes != 2600 {
t.Fatalf("expected from-target state bytes 2600, got %+v", state)
}
}
}
func TestCollectNftablesNodeTrafficSkippedBatchDeltaDoesNotAdvanceState(t *testing.T) {
fixture := setupNftablesCollectionFixture(t)
h := fixture.handler
if err := h.repo.DB().Exec(`UPDATE tunnel SET traffic_ratio = 2 WHERE id = (SELECT tunnel_id FROM forward WHERE id = ?)`, fixture.forwardID).Error; err != nil {
t.Fatalf("update tunnel ratio: %v", err)
}
manager := &fakeNftablesManager{}
h.nftablesManager = manager
cfg := mustCollectionSSHConfig(t, h, fixture.nodeID)
manager.counterSamples = []runtimenft.CounterSample{
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 0, Packets: 0},
}
h.collectNftablesNodeTraffic(fixture.nodeID, cfg, time.Unix(1700000000, 0))
manager.counterSamples = []runtimenft.CounterSample{
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: uint64(math.MaxInt64), Packets: 1},
}
h.collectNftablesNodeTraffic(fixture.nodeID, cfg, time.Unix(1700000060, 0))
states, err := h.repo.GetNftCounterStatesByNode(fixture.nodeID)
if err != nil {
t.Fatalf("load states: %v", err)
}
if len(states) != 1 {
t.Fatalf("expected one state, got %+v", states)
}
if states[0].Bytes != 0 || states[0].Packets != 0 {
t.Fatalf("expected state to remain at old baseline after skipped batch delta, got %+v", states[0])
}
if got := mustHandlerCount(t, h, `SELECT in_flow FROM forward WHERE id = ?`, fixture.forwardID); got != 0 {
t.Fatalf("expected no forward flow for skipped batch delta, got %d", got)
}
}
func TestCollectNftablesNodeTrafficMetadataErrorDoesNotAdvanceState(t *testing.T) {
fixture := setupNftablesCollectionFixture(t)
h := fixture.handler
manager := &fakeNftablesManager{}
h.nftablesManager = manager
cfg := mustCollectionSSHConfig(t, h, fixture.nodeID)
manager.counterSamples = []runtimenft.CounterSample{
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1000, Packets: 10},
}
h.collectNftablesNodeTraffic(fixture.nodeID, cfg, time.Unix(1700000000, 0))
if err := h.repo.DB().Exec(`DROP TABLE tunnel`).Error; err != nil {
t.Fatalf("drop tunnel table: %v", err)
}
manager.counterSamples = []runtimenft.CounterSample{
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1400, Packets: 14},
}
h.collectNftablesNodeTraffic(fixture.nodeID, cfg, time.Unix(1700000060, 0))
states, err := h.repo.GetNftCounterStatesByNode(fixture.nodeID)
if err != nil {
t.Fatalf("load states: %v", err)
}
if len(states) != 1 {
t.Fatalf("expected one baseline state, got %+v", states)
}
if states[0].Bytes != 1000 || states[0].Packets != 10 {
t.Fatalf("expected state to remain at first baseline after metadata failure, got %+v", states[0])
}
if got := mustHandlerCount(t, h, `SELECT in_flow FROM forward WHERE id = ?`, fixture.forwardID); got != 0 {
t.Fatalf("expected no flow after metadata failure, got %d", got)
}
}
func TestCollectNftablesNodeTrafficMissingMetaDoesNotAdvanceState(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
seedNftablesSSHConfig(t, h, fixture.nodeID)
forwardID := int64(4242)
nowMs := time.Now().UnixMilli()
if err := h.repo.UpsertNftRuleBinding(repo.NftRuleBindingInput{
ForwardID: forwardID,
NodeID: fixture.nodeID,
InPort: 20000,
Protocols: "tcp",
TargetAddr: "203.0.113.9:8080",
RuleHash: "hash-a",
Status: runtimenft.StatusApplied,
}, nowMs); err != nil {
t.Fatalf("seed stale applied binding: %v", err)
}
if err := h.repo.UpsertNftCounterStates([]repo.NftCounterStateInput{{
NodeID: fixture.nodeID,
ForwardID: forwardID,
Protocol: "tcp",
Direction: runtimenft.CounterDirectionToTarget,
RuleHash: "hash-a",
Bytes: 1000,
Packets: 10,
CollectedTime: nowMs,
}}, nowMs); err != nil {
t.Fatalf("seed counter state: %v", err)
}
h.nftablesManager = &fakeNftablesManager{counterSamples: []runtimenft.CounterSample{
{ForwardID: forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1400, Packets: 14},
}}
cfg := mustCollectionSSHConfig(t, h, fixture.nodeID)
h.collectNftablesNodeTraffic(fixture.nodeID, cfg, time.Unix(1700000060, 0))
states, err := h.repo.GetNftCounterStatesByNode(fixture.nodeID)
if err != nil {
t.Fatalf("load states: %v", err)
}
if len(states) != 1 {
t.Fatalf("expected one state, got %+v", states)
}
if states[0].Bytes != 1000 || states[0].Packets != 10 {
t.Fatalf("expected state to remain at old baseline when meta is missing, got %+v", states[0])
}
}
func TestCollectNftablesNodeTrafficSkipsSamplesWithoutBinding(t *testing.T) {
fixture := setupNftablesCollectionFixture(t)
h := fixture.handler
if err := h.repo.DeleteNftRuleBindingsByForward(fixture.forwardID); err != nil {
t.Fatalf("delete nft binding: %v", err)
}
h.nftablesManager = &fakeNftablesManager{counterSamples: []runtimenft.CounterSample{
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1000, Packets: 10},
}}
cfg := mustCollectionSSHConfig(t, h, fixture.nodeID)
h.collectNftablesNodeTraffic(fixture.nodeID, cfg, time.Unix(1700000000, 0))
states, err := h.repo.GetNftCounterStatesByNode(fixture.nodeID)
if err != nil {
t.Fatalf("load states: %v", err)
}
if len(states) != 0 {
t.Fatalf("expected no state for unbound sample, got %+v", states)
}
if got := mustHandlerCount(t, h, `SELECT in_flow FROM forward WHERE id = ?`, fixture.forwardID); got != 0 {
t.Fatalf("expected no flow for unbound sample, got %d", got)
}
}
func TestCollectNftablesNodeTrafficSkipsNonAppliedBinding(t *testing.T) {
fixture := setupNftablesCollectionFixture(t)
h := fixture.handler
if err := h.repo.MarkNftRuleBindingError(fixture.forwardID, fixture.nodeID, "apply failed", time.Now().UnixMilli()); err != nil {
t.Fatalf("mark binding error: %v", err)
}
h.nftablesManager = &fakeNftablesManager{counterSamples: []runtimenft.CounterSample{
{ForwardID: fixture.forwardID, Protocol: "tcp", Direction: runtimenft.CounterDirectionToTarget, Bytes: 1000, Packets: 10},
}}
cfg := mustCollectionSSHConfig(t, h, fixture.nodeID)
h.collectNftablesNodeTraffic(fixture.nodeID, cfg, time.Unix(1700000000, 0))
states, err := h.repo.GetNftCounterStatesByNode(fixture.nodeID)
if err != nil {
t.Fatalf("load states: %v", err)
}
if len(states) != 0 {
t.Fatalf("expected no state for non-applied binding, got %+v", states)
}
if got := mustHandlerCount(t, h, `SELECT in_flow FROM forward WHERE id = ?`, fixture.forwardID); got != 0 {
t.Fatalf("expected no flow for non-applied binding, got %d", got)
}
}
func TestCollectNftablesNodeTrafficCollectionErrorDoesNotWriteState(t *testing.T) {
fixture := setupNftablesCollectionFixture(t)
h := fixture.handler
h.nftablesManager = &fakeNftablesManager{collectErr: errors.New("ssh failed")}
cfg := mustCollectionSSHConfig(t, h, fixture.nodeID)
h.collectNftablesNodeTraffic(fixture.nodeID, cfg, time.Unix(1700000000, 0))
states, err := h.repo.GetNftCounterStatesByNode(fixture.nodeID)
if err != nil {
t.Fatalf("load states: %v", err)
}
if len(states) != 0 {
t.Fatalf("expected no state on collection error, got %+v", states)
}
if got := mustHandlerCount(t, h, `SELECT in_flow FROM forward WHERE id = ?`, fixture.forwardID); got != 0 {
t.Fatalf("expected no flow on collection error, got %d", got)
}
}
type nftablesCollectionFixture struct {
handler *Handler
nodeID int64
forwardID int64
userTunnelID int64
}
func setupNftablesCollectionFixture(t *testing.T) nftablesCollectionFixture {
t.Helper()
fixture := setupNftablesHandler(t)
h := fixture.handler
seedNftablesSSHConfig(t, h, fixture.nodeID)
tunnelID := seedTunnelForNftables(t, h, "nft-traffic-tunnel", fixture.nodeID)
now := time.Now().UnixMilli()
if err := h.repo.DB().Exec(`
INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(1, ?, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelID).Error; err != nil {
t.Fatalf("seed user_tunnel: %v", err)
}
forward := seedForwardForNftables(t, h, tunnelID, fixture.nodeID, "203.0.113.9:8080")
if err := h.repo.UpsertNftRuleBinding(repo.NftRuleBindingInput{
ForwardID: forward.ID,
NodeID: fixture.nodeID,
InPort: 20000,
Protocols: "tcp,udp",
TargetAddr: "203.0.113.9:8080",
RuleHash: "hash-a",
Status: runtimenft.StatusApplied,
}, now); err != nil {
t.Fatalf("seed nft binding: %v", err)
}
userTunnelID := mustHandlerCount(t, h, `SELECT id FROM user_tunnel WHERE user_id = 1 AND tunnel_id = ?`, tunnelID)
return nftablesCollectionFixture{
handler: h,
nodeID: fixture.nodeID,
forwardID: forward.ID,
userTunnelID: userTunnelID,
}
}
func mustCollectionSSHConfig(t *testing.T, h *Handler, nodeID int64) *model.NodeSSHConfig {
t.Helper()
cfg, err := h.repo.GetNodeSSHConfig(nodeID)
if err != nil {
t.Fatalf("load ssh config: %v", err)
}
return cfg
}
func mustHandlerCount(t *testing.T, h *Handler, query string, args ...interface{}) int64 {
t.Helper()
var value int64
if err := h.repo.DB().Raw(query, args...).Row().Scan(&value); err != nil {
t.Fatalf("query %q failed: %v", query, err)
}
return value
}
+440
View File
@@ -0,0 +1,440 @@
package handler
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"net/http"
"net/url"
"os"
"strings"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
"go-backend/internal/security"
"go-backend/internal/store/model"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
const passkeyTTL = 2 * time.Minute
type passkeyCeremony struct {
userID int64
kind string
origin string
session webauthn.SessionData
}
type passkeyUser struct {
id int64
name string
credentials []webauthn.Credential
}
func (u passkeyUser) WebAuthnID() []byte {
id := make([]byte, 8)
binary.BigEndian.PutUint64(id, uint64(u.id))
return id
}
func (u passkeyUser) WebAuthnName() string { return u.name }
func (u passkeyUser) WebAuthnDisplayName() string { return u.name }
func (u passkeyUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
func passkeyConfig() (*webauthn.WebAuthn, string) {
raw := strings.TrimSpace(os.Getenv("FLVX_WEBAUTHN_ORIGIN"))
u, err := url.Parse(raw)
if err != nil || u == nil || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
return nil, ""
}
host := u.Hostname()
if host == "" || strings.ContainsAny(host, " /\\") || (u.Scheme != "https" && !(u.Scheme == "http" && (host == "localhost" || host == "127.0.0.1"))) {
return nil, ""
}
origin := u.Scheme + "://" + u.Host
wa, err := webauthn.New(&webauthn.Config{
RPID: host,
RPDisplayName: "FLVX",
RPOrigins: []string{origin},
AuthenticatorSelection: protocol.AuthenticatorSelection{UserVerification: protocol.VerificationRequired, ResidentKey: protocol.ResidentKeyRequirementRequired},
Timeouts: webauthn.TimeoutsConfig{
Login: webauthn.TimeoutConfig{Enforce: true, Timeout: passkeyTTL},
Registration: webauthn.TimeoutConfig{Enforce: true, Timeout: passkeyTTL},
},
})
if err != nil {
return nil, ""
}
return wa, origin
}
func (h *Handler) passkeyStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
wa, _ := passkeyConfig()
response.WriteJSON(w, response.OK(map[string]bool{"enabled": wa != nil}))
}
func (h *Handler) putPasskeyCeremony(c passkeyCeremony) (string, bool) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", false
}
id := base64.RawURLEncoding.EncodeToString(buf)
h.passkeyMu.Lock()
defer h.passkeyMu.Unlock()
for k, v := range h.passkeyPending {
if time.Now().After(v.session.Expires) {
delete(h.passkeyPending, k)
}
}
if len(h.passkeyPending) >= 1000 {
return "", false
}
h.passkeyPending[id] = c
return id, true
}
func (h *Handler) takePasskeyCeremony(id, kind, origin string, userID int64) (webauthn.SessionData, bool) {
h.passkeyMu.Lock()
c, ok := h.passkeyPending[id]
delete(h.passkeyPending, id)
h.passkeyMu.Unlock()
if !ok || c.kind != kind || c.origin != origin || c.userID != userID || time.Now().After(c.session.Expires) {
return webauthn.SessionData{}, false
}
return c.session, true
}
func (h *Handler) takePasskeyLoginCeremony(id, origin string) (passkeyCeremony, bool) {
h.passkeyMu.Lock()
c, ok := h.passkeyPending[id]
delete(h.passkeyPending, id)
h.passkeyMu.Unlock()
if !ok || c.kind != "login-discoverable" || c.origin != origin || time.Now().After(c.session.Expires) {
return passkeyCeremony{}, false
}
return c, true
}
func (h *Handler) loadPasskeyUser(userID int64) (passkeyUser, error) {
user, err := h.repo.GetUserByID(userID)
if err != nil || user == nil || user.Status != 1 {
return passkeyUser{}, errInvalidPasskey
}
rows, err := h.repo.ListPasskeys(userID)
if err != nil {
return passkeyUser{}, err
}
u := passkeyUser{id: user.ID, name: user.User}
for _, row := range rows {
var credential webauthn.Credential
if err := json.Unmarshal([]byte(row.CredentialJSON), &credential); err != nil {
return passkeyUser{}, err
}
u.credentials = append(u.credentials, credential)
}
return u, nil
}
var errInvalidPasskey = &passkeyError{}
type passkeyError struct{}
func (*passkeyError) Error() string { return "invalid passkey user" }
func passkeyBody(r *http.Request, out interface{}) bool {
return json.NewDecoder(http.MaxBytesReader(nil, r.Body, 64*1024)).Decode(out) == nil
}
func (h *Handler) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
wa, origin := passkeyConfig()
if wa == nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥未配置"))
return
}
userID, err := userIDFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return
}
var req struct {
Password string `json:"password"`
}
if !passkeyBody(r, &req) {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
user, err := h.repo.GetUserByID(userID)
if err != nil || user == nil || user.Status != 1 {
response.WriteJSON(w, response.ErrDefault("账号不可用"))
return
}
if ok, _ := security.VerifyPassword(user.Pwd, req.Password); !ok {
response.WriteJSON(w, response.ErrDefault("当前密码错误"))
return
}
u, err := h.loadPasskeyUser(userID)
if err != nil {
response.WriteJSON(w, response.ErrDefault("无法读取通行证密钥"))
return
}
options, session, err := wa.BeginRegistration(u, webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired))
if err != nil {
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
return
}
id, ok := h.putPasskeyCeremony(passkeyCeremony{userID: userID, kind: "register", origin: origin, session: *session})
if !ok {
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
return
}
response.WriteJSON(w, response.OK(map[string]interface{}{"sessionId": id, "options": options}))
}
type passkeyFinishRequest struct {
SessionID string `json:"sessionId"`
Credential json.RawMessage `json:"credential"`
Name string `json:"name"`
}
func (h *Handler) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
wa, origin := passkeyConfig()
if wa == nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥未配置"))
return
}
userID, err := userIDFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return
}
var req passkeyFinishRequest
if !passkeyBody(r, &req) {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
session, ok := h.takePasskeyCeremony(req.SessionID, "register", origin, userID)
if !ok {
response.WriteJSON(w, response.ErrDefault("通行证密钥挑战已过期"))
return
}
u, err := h.loadPasskeyUser(userID)
if err != nil {
response.WriteJSON(w, response.ErrDefault("账号不可用"))
return
}
credential, err := wa.FinishRegistration(u, session, credentialRequest(r, req.Credential))
if err != nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
return
}
credentialJSON, err := json.Marshal(credential)
if err != nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥保存失败"))
return
}
name := strings.TrimSpace(req.Name)
if len([]rune(name)) > 100 {
name = string([]rune(name)[:100])
}
if name == "" {
name = "Passkey"
}
err = h.repo.CreatePasskey(&model.Passkey{ID: base64.RawURLEncoding.EncodeToString(credential.ID), UserID: userID, Name: name, CredentialJSON: string(credentialJSON), CreatedAt: time.Now().UnixMilli()})
if err != nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥保存失败"))
return
}
response.WriteJSON(w, response.OK(nil))
}
func credentialRequest(original *http.Request, body []byte) *http.Request {
r := original.Clone(original.Context())
r.Body = http.NoBody
if len(body) > 0 {
r.Body = ioNopCloser{bytes.NewReader(body)}
}
return r
}
type ioNopCloser struct{ *bytes.Reader }
func (ioNopCloser) Close() error { return nil }
func (h *Handler) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
wa, origin := passkeyConfig()
if wa == nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥未配置"))
return
}
var req struct {
Username string `json:"username"`
}
if !passkeyBody(r, &req) || strings.TrimSpace(req.Username) != "" {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
options, session, err := wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
if err != nil {
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
return
}
id, ok := h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: origin, session: *session})
if !ok {
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
return
}
response.WriteJSON(w, response.OK(map[string]interface{}{"sessionId": id, "options": options}))
}
func (h *Handler) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
wa, origin := passkeyConfig()
if wa == nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥未配置"))
return
}
var req passkeyFinishRequest
if !passkeyBody(r, &req) {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
pending, ok := h.takePasskeyLoginCeremony(req.SessionID, origin)
if !ok {
response.WriteJSON(w, response.ErrDefault("通行证密钥挑战已过期"))
return
}
resolved, credential, err := wa.FinishPasskeyLogin(func(rawID, userHandle []byte) (webauthn.User, error) {
// The credential ID determines ownership. A supplied userHandle cannot select another account.
if len(rawID) == 0 || len(userHandle) != 8 {
return nil, errInvalidPasskey
}
row, lookupErr := h.repo.GetPasskeyByID(base64.RawURLEncoding.EncodeToString(rawID))
if lookupErr != nil || row == nil {
return nil, errInvalidPasskey
}
owner, loadErr := h.loadPasskeyUser(row.UserID)
if loadErr != nil || !bytes.Equal(userHandle, owner.WebAuthnID()) {
return nil, errInvalidPasskey
}
return owner, nil
}, pending.session, credentialRequest(r, req.Credential))
var u passkeyUser
if err == nil {
var valid bool
u, valid = resolved.(passkeyUser)
if !valid {
err = errInvalidPasskey
}
}
if err != nil || credential == nil || credential.Authenticator.CloneWarning {
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
return
}
credentialID := base64.RawURLEncoding.EncodeToString(credential.ID)
stored, err := h.repo.GetPasskey(u.id, credentialID)
if err != nil || stored == nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
return
}
updated, err := json.Marshal(credential)
if err != nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
return
}
ok, err = h.repo.UpdatePasskeyCredential(u.id, credentialID, stored.CredentialJSON, string(updated), time.Now().UnixMilli())
if err != nil || !ok {
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
return
}
user, err := h.repo.GetUserByID(u.id)
if err != nil || user == nil || user.Status != 1 {
response.WriteJSON(w, response.ErrDefault("账号不可用"))
return
}
token, err := auth.GenerateTokenAt(user.ID, user.User, user.RoleID, h.jwtSecret, time.Now())
if err != nil {
response.WriteJSON(w, response.ErrDefault("登录失败"))
return
}
response.WriteJSON(w, response.OK(map[string]interface{}{"token": token, "name": user.User, "role_id": user.RoleID, "requirePasswordChange": user.User == "admin_user"}))
}
func (h *Handler) passkeyList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
userID, err := userIDFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return
}
rows, err := h.repo.ListPasskeys(userID)
if err != nil {
response.WriteJSON(w, response.ErrDefault("获取通行证密钥失败"))
return
}
out := make([]map[string]interface{}, 0, len(rows))
for _, row := range rows {
out = append(out, map[string]interface{}{"id": row.ID, "name": row.Name, "createdAt": row.CreatedAt, "lastUsedAt": row.LastUsedAt})
}
response.WriteJSON(w, response.OK(out))
}
func (h *Handler) passkeyDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
userID, err := userIDFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return
}
var req struct {
ID string `json:"id"`
Password string `json:"password"`
}
if !passkeyBody(r, &req) || req.ID == "" {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
user, err := h.repo.GetUserByID(userID)
if err != nil || user == nil || user.Status != 1 {
response.WriteJSON(w, response.ErrDefault("账号不可用"))
return
}
if ok, _ := security.VerifyPassword(user.Pwd, req.Password); !ok {
response.WriteJSON(w, response.ErrDefault("当前密码错误"))
return
}
deleted, err := h.repo.DeletePasskey(userID, req.ID)
if err != nil || !deleted {
response.WriteJSON(w, response.ErrDefault("通行证密钥不存在"))
return
}
response.WriteJSON(w, response.OK(nil))
}
@@ -0,0 +1,56 @@
package handler
import (
"testing"
"time"
"github.com/go-webauthn/webauthn/webauthn"
)
func TestPasskeyChallengeExpiresAndIsSingleUse(t *testing.T) {
h := &Handler{passkeyPending: make(map[string]passkeyCeremony)}
id, ok := h.putPasskeyCeremony(passkeyCeremony{userID: 7, kind: "register", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(time.Minute)}})
if !ok {
t.Fatal("could not create challenge")
}
if _, ok := h.takePasskeyCeremony(id, "register", "https://panel.example.test", 7); !ok {
t.Fatal("valid challenge was rejected")
}
if _, ok := h.takePasskeyCeremony(id, "register", "https://panel.example.test", 7); ok {
t.Fatal("challenge was reusable")
}
id, ok = h.putPasskeyCeremony(passkeyCeremony{userID: 7, kind: "register", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(-time.Second)}})
if !ok {
t.Fatal("could not create expired challenge")
}
if _, ok := h.takePasskeyCeremony(id, "register", "https://panel.example.test", 7); ok {
t.Fatal("expired challenge was accepted")
}
id, ok = h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(time.Minute)}})
if !ok {
t.Fatal("could not create discoverable challenge")
}
if _, ok := h.takePasskeyLoginCeremony(id, "https://wrong.example.test"); ok {
t.Fatal("wrong origin was accepted")
}
if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); ok {
t.Fatal("failed discoverable challenge was reusable")
}
id, ok = h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(time.Minute)}})
if !ok {
t.Fatal("could not create discoverable challenge")
}
if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); !ok {
t.Fatal("valid discoverable challenge was rejected")
}
if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); ok {
t.Fatal("discoverable challenge was reusable")
}
id, ok = h.putPasskeyCeremony(passkeyCeremony{kind: "login-discoverable", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(-time.Second)}})
if !ok {
t.Fatal("could not create expired discoverable challenge")
}
if _, ok := h.takePasskeyLoginCeremony(id, "https://panel.example.test"); ok {
t.Fatal("expired discoverable challenge was accepted")
}
}
@@ -0,0 +1,160 @@
package handler
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-backend/internal/http/response"
"go-backend/internal/store/repo"
)
func TestPeerShareRestrictedShareAllowsOnlyAuthenticatedCleanup(t *testing.T) {
for _, state := range []string{"disabled", "expired", "over-quota"} {
t.Run(state, func(t *testing.T) {
agent := newCleanupAgent(t)
runtime := roleRuntimeFixture(t, agent, "exit")
changes := map[string]interface{}{"allowed_ips": "203.0.113.10", "allowed_domains": "owner.example"}
switch state {
case "disabled":
changes["is_active"] = 0
case "expired":
changes["expiry_time"] = time.Now().Add(-time.Hour).UnixMilli()
case "over-quota":
changes["max_bandwidth"] = 1
changes["current_flow"] = 2
}
if err := agent.h.repo.DB().Model(&repo.PeerShare{}).Where("id = ?", runtime.ShareID).Updates(changes).Error; err != nil {
t.Fatal(err)
}
tests := []struct {
command string
allowed bool
}{
{"release-role", true}, {"DeleteService", true}, {"DeleteChains", true}, {"DeleteLimiters", true}, {"DeleteCLimiters", true},
{"AddService", false}, {"UpdateService", false}, {"ResumeService", false}, {"PauseService", false}, {"DeleteEverything", false},
}
for _, test := range tests {
t.Run(test.command, func(t *testing.T) {
path := "/api/v1/federation/runtime/command"
body := fmt.Sprintf(`{"commandType":%q,"data":{"services":["70_1_0_tcp"]}}`, test.command)
if test.command == "release-role" {
path = "/api/v1/federation/runtime/release-role"
body = `{"reservationId":"role-reservation"}`
}
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("Authorization", "Bearer role-recovery-token")
req.Header.Set("X-Panel-Domain", "owner.example")
req.RemoteAddr = "203.0.113.10:12345"
reached := false
next := func(w http.ResponseWriter, r *http.Request) {
reached = true
received, err := io.ReadAll(r.Body)
if err != nil || string(received) != body {
t.Errorf("auth consumed request body: %s %v", received, err)
}
response.WriteJSON(w, response.OKEmpty())
}
agent.h.authPeer(next)(httptest.NewRecorder(), req)
if reached != test.allowed {
t.Fatalf("restricted %s %s allowed=%t want=%t", state, test.command, reached, test.allowed)
}
})
}
for _, invalid := range []string{"token", "domain", "ip"} {
t.Run("reject-"+invalid, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/runtime/release-role", strings.NewReader(`{"reservationId":"role-reservation"}`))
req.Header.Set("Authorization", "Bearer role-recovery-token")
req.Header.Set("X-Panel-Domain", "owner.example")
req.RemoteAddr = "203.0.113.10:12345"
switch invalid {
case "token":
req.Header.Set("Authorization", "Bearer wrong-token")
case "domain":
req.Header.Set("X-Panel-Domain", "other.example")
case "ip":
req.RemoteAddr = "198.51.100.1:12345"
}
reached := false
agent.h.authPeer(func(http.ResponseWriter, *http.Request) { reached = true })(httptest.NewRecorder(), req)
if reached {
t.Fatalf("cleanup bypassed %s authentication", invalid)
}
})
}
// Exercise the actual release handler through auth, not only the gate.
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/runtime/release-role", strings.NewReader(`{"reservationId":"role-reservation"}`))
req.Header.Set("Authorization", "Bearer role-recovery-token")
req.Header.Set("X-Panel-Domain", "owner.example")
req.RemoteAddr = "203.0.113.10:12345"
res := httptest.NewRecorder()
agent.h.authPeer(agent.h.federationRuntimeReleaseRole)(res, req)
var result response.R
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil || result.Code != 0 {
t.Fatalf("authenticated cleanup did not complete: %s %v", res.Body.String(), err)
}
stored, err := agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
if err != nil || stored.Status != 0 {
t.Fatalf("cleanup did not release runtime: %+v %v", stored, err)
}
})
}
}
func TestPeerShareOldReleaseIdentityCannotDeleteReusedReservation(t *testing.T) {
agent := newCleanupAgent(t)
old := roleRuntimeFixture(t, agent, "exit")
if err := agent.h.releasePeerShareRuntime(old); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/runtime/reserve-port", strings.NewReader(`{"resourceKey":"role-resource","requestedPort":31000,"protocol":"tls"}`))
req.Header.Set("Authorization", "Bearer role-recovery-token")
res := httptest.NewRecorder()
agent.h.federationRuntimeReservePort(res, req)
var result response.R
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil || result.Code != 0 {
t.Fatalf("new generation reserve failed: %s %v", res.Body.String(), err)
}
fresh, err := agent.h.repo.GetPeerShareRuntimeByID(old.ID)
if err != nil {
t.Fatal(err)
}
if fresh.ReservationID == old.ReservationID {
t.Fatal("reservation identity was reused")
}
if code := roleRuntimeRequest(t, agent.h, fmt.Sprintf(`{"reservationId":%q,"role":"exit"}`, fresh.ReservationID), false); code != 0 {
t.Fatal("new generation apply failed")
}
fresh, err = agent.h.repo.GetPeerShareRuntimeByID(old.ID)
if err != nil {
t.Fatal(err)
}
if fresh.BindingID == old.BindingID || fresh.BindingID == "" {
t.Fatal("binding identity was reused")
}
deletesBefore := len(agent.commandsOfType("DeleteService"))
for _, body := range []string{
fmt.Sprintf(`{"bindingId":%q,"reservationId":%q,"resourceKey":"role-resource"}`, old.BindingID, old.ReservationID),
fmt.Sprintf(`{"reservationId":%q,"resourceKey":"role-resource"}`, old.ReservationID),
} {
if code := roleRuntimeRequest(t, agent.h, body, true); code != 0 {
t.Fatal("obsolete release should acknowledge completion")
}
}
// Also cover an old lookup snapshot waiting behind reservation renewal.
if err := agent.h.releasePeerShareRuntime(old); err != nil {
t.Fatal(err)
}
after, err := agent.h.repo.GetPeerShareRuntimeByID(old.ID)
if err != nil || after.Status != 1 || after.BindingID != fresh.BindingID {
t.Fatalf("old cleanup released new generation: %+v %v", after, err)
}
if len(agent.commandsOfType("DeleteService")) != deletesBefore {
t.Fatal("old cleanup sent deletion for new generation")
}
}
@@ -0,0 +1,197 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
"go-backend/internal/store/repo"
)
// Serialize desired-state changes with reconnect reconciliation. In particular,
// a stale reconnect snapshot must never revive an acknowledged release.
var peerRoleRuntimeMu sync.Mutex
// applyPeerShareRoleRuntime requires peerRoleRuntimeMu. Persist the validated
// desired configuration before creating resources, so failed or interrupted
// commands remain recoverable rather than producing untracked listeners.
func (h *Handler) applyPeerShareRoleRuntime(runtime *repo.PeerShareRuntime) error {
if runtime.ReleasePending != 0 || runtime.Status != 1 {
return errors.New("runtime is being released")
}
if runtime.Role != "middle" && runtime.Role != "exit" {
return errors.New("invalid runtime role")
}
node, err := h.getNodeRecord(runtime.NodeID)
if err != nil {
return err
}
credentials, err := decodeRuntimeRelayAuth(runtime)
if err != nil {
return err
}
var targets []federationRuntimeTarget
if strings.TrimSpace(runtime.Target) != "" {
if err := json.Unmarshal([]byte(runtime.Target), &targets); err != nil {
return err
}
}
if err := validateFederationRelayAuth(runtime.RelayAuth, credentials, targets); err != nil {
return err
}
var chain map[string]interface{}
if runtime.Role == "middle" {
chain, err = buildFederationMiddleChainConfig(runtime.ChainName, runtime.ID, runtime.Protocol, runtime.Strategy, targets, node.InterfaceName)
if err != nil {
return err
}
}
service := buildFederationServiceConfig(runtime.ServiceName, fmt.Sprintf("%s:%d", node.TCPListenAddr, runtime.Port), runtime.Protocol, runtime.Role, runtime.ChainName, len(targets), node.InterfaceName, credentials)
runtime.Applied = 0
runtime.UpdatedTime = time.Now().UnixMilli()
if err := h.repo.UpdatePeerShareRuntime(runtime); err != nil {
return err
}
if h.wsServer == nil {
return errors.New("node command transport unavailable")
}
if chain != nil {
if _, err := h.sendNodeCommand(runtime.NodeID, "UpdateChains", updateChainPayload(runtime.ChainName, chain), false, false); err != nil {
return err
}
}
// UpdateService and UpdateChains are upserts, including on an empty agent.
if _, err := h.sendNodeCommand(runtime.NodeID, "UpdateService", []map[string]interface{}{service}, false, false); err != nil {
return err
}
runtime.Applied = 1
runtime.UpdatedTime = time.Now().UnixMilli()
return h.repo.UpdatePeerShareRuntime(runtime)
}
func (h *Handler) releasePeerShareRuntime(runtime *repo.PeerShareRuntime) error {
peerRoleRuntimeMu.Lock()
defer peerRoleRuntimeMu.Unlock()
if runtime == nil {
return nil
}
current, err := h.repo.GetPeerShareRuntimeByID(runtime.ID)
if err != nil {
return err
}
if current != nil && (current.ReservationID != runtime.ReservationID || current.BindingID != runtime.BindingID) {
// A completed reservation may have been reused while this release
// waited for the mutation lock. Never release the new generation.
return nil
}
return h.releasePeerShareRuntimeLocked(current)
}
func (h *Handler) releasePeerShareRuntimeLocked(runtime *repo.PeerShareRuntime) error {
if runtime == nil || runtime.Status == 0 {
return nil
}
if err := h.repo.SetPeerShareRuntimeReleasePending(runtime.ID); err != nil {
return err
}
runtime.ReleasePending = 1
if runtime.Role == "forward" {
if _, _, scoped := parsePeerShareServiceName(runtime.ServiceName); scoped {
if err := h.releasePeerShareForwardRuntimeResources(runtime); err != nil {
return err
}
return h.repo.CompletePeerShareRuntimeRelease(runtime.ID)
}
// Older agents used unscoped names. Do not delete a name shared by
// another reservation or by a local forward during migration.
owners, err := h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(runtime.NodeID, runtime.ServiceName)
if err != nil {
return err
}
for _, owner := range owners {
if owner.ShareID != runtime.ShareID {
return fmt.Errorf("legacy runtime %q has ambiguous ownership", runtime.ServiceName)
}
}
if forwardID, _, _, ok := parseFlowServiceIDs(normalizeForwardRuntimeServiceName(runtime.ServiceName)); ok {
local, err := h.repo.GetForwardRecord(forwardID)
if err != nil {
return err
}
if local != nil {
return fmt.Errorf("legacy runtime %q collides with a local forward", runtime.ServiceName)
}
}
}
// ServiceName is saved before apply; Applied=0 can therefore mean an
// unacknowledged command, and must not bypass deletion.
if strings.TrimSpace(runtime.ServiceName) != "" || strings.TrimSpace(runtime.ChainName) != "" {
if h.wsServer == nil {
return errors.New("node command transport unavailable")
}
if strings.TrimSpace(runtime.ServiceName) != "" {
names := []string{runtime.ServiceName}
if runtime.Role == "forward" {
names = append(names, runtime.ServiceName+"_tcp", runtime.ServiceName+"_udp")
}
if _, err := h.sendNodeCommand(runtime.NodeID, "DeleteService", map[string]interface{}{"services": names}, false, true); err != nil {
return err
}
}
if strings.TrimSpace(runtime.ChainName) != "" {
if _, err := h.sendNodeCommand(runtime.NodeID, "DeleteChains", map[string]interface{}{"chain": runtime.ChainName}, false, true); err != nil {
return err
}
}
}
return h.repo.CompletePeerShareRuntimeRelease(runtime.ID)
}
func (h *Handler) reconcilePeerShareRoleRuntimesOnNode(nodeID int64) error {
return h.reconcilePeerShareRoleRuntimes(nodeID, false)
}
// Maintenance retries only unfinished desired state. Replaying acknowledged
// services while an unrelated operation is failing can interrupt live traffic.
func (h *Handler) retryPendingPeerShareRoleRuntimesOnNode(nodeID int64) error {
return h.reconcilePeerShareRoleRuntimes(nodeID, true)
}
func (h *Handler) reconcilePeerShareRoleRuntimes(nodeID int64, pendingOnly bool) error {
peerRoleRuntimeMu.Lock()
defer peerRoleRuntimeMu.Unlock()
runtimes, err := h.repo.ListActivePeerShareRuntimesByNode(nodeID)
if err != nil {
return err
}
var reconcileErr error
for i := range runtimes {
runtime := &runtimes[i]
if pendingOnly && runtime.Applied == 1 && runtime.ReleasePending == 0 {
continue
}
if runtime.ReleasePending != 0 {
reconcileErr = errors.Join(reconcileErr, h.releasePeerShareRuntimeLocked(runtime))
continue
}
if runtime.Role != "middle" && runtime.Role != "exit" {
continue
}
share, err := h.repo.GetPeerShare(runtime.ShareID)
if err != nil {
reconcileErr = errors.Join(reconcileErr, err)
continue
}
if share == nil || share.IsActive != 1 || (share.ExpiryTime > 0 && share.ExpiryTime <= time.Now().UnixMilli()) || isPeerShareFlowExceeded(share) {
reconcileErr = errors.Join(reconcileErr, h.releasePeerShareRuntimeLocked(runtime))
continue
}
if err := h.applyPeerShareRoleRuntime(runtime); err != nil {
reconcileErr = errors.Join(reconcileErr, fmt.Errorf("shared runtime %d: %w", runtime.ID, err))
}
}
return reconcileErr
}
@@ -0,0 +1,358 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"go-backend/internal/store/repo"
"go-backend/internal/ws"
"gorm.io/gorm"
)
func roleRuntimeFixture(t *testing.T, agent *cleanupAgent, role string) *repo.PeerShareRuntime {
t.Helper()
now := time.Now().UnixMilli()
share := &repo.PeerShare{Name: "role-recovery", NodeID: 1, Token: "role-recovery-token", PortRangeStart: 31000, PortRangeEnd: 31010, IsActive: 1, CreatedTime: now, UpdatedTime: now}
if err := agent.h.repo.CreatePeerShare(share); err != nil {
t.Fatal(err)
}
runtime := &repo.PeerShareRuntime{ShareID: share.ID, NodeID: 1, ReservationID: "role-reservation", ResourceKey: "role-resource", BindingID: "333", Role: role, ServiceName: "fed_svc_333", Protocol: "tls", Strategy: "round", Port: 31000, Applied: 1, Status: 1, CreatedTime: now, UpdatedTime: now}
if role == "middle" {
runtime.ChainName = "fed_chain_333"
runtime.Target = `[{"host":"127.0.0.1","port":32000,"protocol":"tls"}]`
}
if err := agent.h.repo.CreatePeerShareRuntime(runtime); err != nil {
t.Fatal(err)
}
// Runtime-generated names use the provider's globally unique runtime ID.
runtime.BindingID = fmt.Sprint(runtime.ID)
runtime.ServiceName = fmt.Sprintf("fed_svc_%d", runtime.ID)
if role == "middle" {
runtime.ChainName = federationRuntimeChainName(runtime.BindingID)
}
if err := agent.h.repo.UpdatePeerShareRuntime(runtime); err != nil {
t.Fatal(err)
}
return runtime
}
func roleRuntimeRequest(t *testing.T, h *Handler, body string, release bool) int {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/runtime", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer role-recovery-token")
res := httptest.NewRecorder()
if release {
h.federationRuntimeReleaseRole(res, req)
} else {
h.federationRuntimeApplyRole(res, req)
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
t.Logf("runtime response: %s", res.Body.String())
return result.Code
}
func TestPeerShareAppliedRuntimeRepairsEmptyAgent(t *testing.T) {
for _, role := range []string{"exit", "middle"} {
t.Run(role, func(t *testing.T) {
agent := newCleanupAgent(t)
roleRuntimeFixture(t, agent, role)
if !agent.h.redeployNodeRuntimeAfterUpgrade(1) {
t.Fatal("reconnect reconciliation failed")
}
if got := len(agent.commandsOfType("UpdateService")); got != 1 {
t.Fatalf("reconnect did not restore service: %d", got)
}
targets := ""
if role == "middle" {
targets = `,"targets":[{"host":"127.0.0.1","port":32000,"protocol":"tls"}]`
}
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation","role":"`+role+`","protocol":"tls"`+targets+`}`, false); code != 0 {
t.Fatalf("apply failed: %d", code)
}
if got := len(agent.commandsOfType("UpdateService")); got != 2 {
t.Fatalf("Applied=1 skipped service repair: %d", got)
}
if role == "middle" {
if got := len(agent.commandsOfType("UpdateChains")); got != 2 {
t.Fatalf("missing middle chains: %d", got)
}
agent.mu.Lock()
defer agent.mu.Unlock()
chainSeen := false
for _, cmd := range agent.commands {
if cmd.Type == "UpdateChains" {
chainSeen = true
}
if cmd.Type == "UpdateService" && !chainSeen {
t.Fatal("service started before chain")
}
}
}
})
}
}
func TestPeerShareReleaseOfflineKeepsPortAndReconnectDeletes(t *testing.T) {
agent := newCleanupAgent(t)
runtime := roleRuntimeFixture(t, agent, "middle")
liveServer := agent.h.wsServer
agent.h.wsServer = ws.NewServer(agent.h.repo, "offline-test")
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation"}`, true); code == 0 {
t.Fatal("offline release reported success")
}
stored, err := agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
if err != nil || stored.Status != 1 || stored.ReleasePending != 1 {
t.Fatalf("pending release lost: %+v err=%v", stored, err)
}
occupied, err := agent.h.repo.ExistsActivePeerShareRuntimeOnNodePort(1, 31000)
if err != nil || !occupied {
t.Fatalf("pending release freed occupied port: %t %v", occupied, err)
}
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation","role":"middle","targets":[{"host":"127.0.0.1","port":32000}]}`, false); code == 0 {
t.Fatal("pending release was revived by apply")
}
agent.h.wsServer = liveServer
if !agent.h.redeployNodeRuntimeAfterUpgrade(1) {
t.Fatal("reconnect cleanup failed")
}
stored, err = agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
if err != nil || stored.Status != 0 || stored.Applied != 0 || stored.ReleasePending != 0 {
t.Fatalf("release not completed: %+v %v", stored, err)
}
if len(agent.commandsOfType("DeleteService")) != 1 || len(agent.commandsOfType("DeleteChains")) != 1 {
t.Fatal("reconnect did not delete both service and chain")
}
if len(agent.commandsOfType("UpdateService")) != 0 || len(agent.commandsOfType("UpdateChains")) != 0 {
t.Fatal("reconnect revived pending release")
}
occupied, err = agent.h.repo.ExistsActivePeerShareRuntimeOnNodePort(1, 31000)
if err != nil || occupied {
t.Fatalf("acknowledged release still occupies port: %t %v", occupied, err)
}
}
func TestPeerShareApplyPersistsBeforeCommand(t *testing.T) {
var failDesiredWrite atomic.Bool
agent := newCleanupAgent(t, func(r *repo.Repository) {
if err := r.DB().Callback().Update().Before("gorm:update").Register("test:fail-role-desired-write", func(tx *gorm.DB) {
if failDesiredWrite.Load() && tx.Statement.Table == "peer_share_runtime" {
tx.AddError(errors.New("desired-state write failed"))
}
}); err != nil {
t.Fatal(err)
}
})
runtime := roleRuntimeFixture(t, agent, "exit")
failDesiredWrite.Store(true)
// Only the atomic flag changes while the WebSocket is running; GORM's
// callback registry stays immutable through node-status teardown writes.
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation","role":"exit"}`, false); code == 0 {
t.Fatal("failed ownership persistence reported success")
}
if len(agent.commandsOfType("UpdateService")) != 0 {
t.Fatal("service started before ownership persisted")
}
stored, err := agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
if err != nil || stored.Applied != 1 {
t.Fatalf("old runtime changed on failed persistence: %+v %v", stored, err)
}
}
func TestPeerShareApplyInvalidMiddleKeepsDesiredConfig(t *testing.T) {
agent := newCleanupAgent(t)
runtime := roleRuntimeFixture(t, agent, "middle")
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation","role":"middle","targets":[]}`, false); code == 0 {
t.Fatal("invalid middle targets accepted")
}
stored, err := agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
if err != nil || stored.Target != runtime.Target || stored.Applied != 1 {
t.Fatalf("invalid update changed desired runtime: %+v %v", stored, err)
}
if len(agent.commandsOfType("UpdateService"))+len(agent.commandsOfType("UpdateChains")) != 0 {
t.Fatal("invalid config sent to agent")
}
}
func TestPeerShareUnacknowledgedApplyRecoversOrReleases(t *testing.T) {
for _, release := range []bool{false, true} {
t.Run(fmt.Sprintf("release=%t", release), func(t *testing.T) {
agent := newCleanupAgent(t)
runtime := roleRuntimeFixture(t, agent, "middle")
liveServer := agent.h.wsServer
agent.h.wsServer = ws.NewServer(agent.h.repo, "offline-test")
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation","role":"middle","targets":[{"host":"127.0.0.1","port":32001,"protocol":"tls"}]}`, false); code == 0 {
t.Fatal("offline apply reported success")
}
stored, err := agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
if err != nil || stored.Applied != 0 || !strings.Contains(stored.Target, "32001") || stored.ServiceName == "" {
t.Fatalf("unacknowledged desired state lost: %+v %v", stored, err)
}
if release {
if code := roleRuntimeRequest(t, agent.h, `{"reservationId":"role-reservation"}`, true); code == 0 {
t.Fatal("unacknowledged listener was freed offline")
}
}
agent.h.wsServer = liveServer
if !agent.h.redeployNodeRuntimeAfterUpgrade(1) {
t.Fatal("reconnect failed")
}
stored, err = agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
if err != nil {
t.Fatal(err)
}
if release {
if stored.Status != 0 || len(agent.commandsOfType("DeleteService")) != 1 || len(agent.commandsOfType("UpdateService")) != 0 {
t.Fatalf("unacknowledged apply revived after release: %+v", stored)
}
} else {
if stored.Status != 1 || stored.Applied != 1 || len(agent.commandsOfType("UpdateService")) != 1 {
t.Fatalf("unacknowledged desired state not recovered: %+v", stored)
}
}
})
}
}
func TestPeerShareDeleteOfflineRetainsDisabledShare(t *testing.T) {
agent := newCleanupAgent(t)
runtime := roleRuntimeFixture(t, agent, "exit")
liveServer := agent.h.wsServer
agent.h.wsServer = ws.NewServer(agent.h.repo, "offline-test")
req := httptest.NewRequest(http.MethodPost, "/share/delete", strings.NewReader(fmt.Sprintf(`{"id":%d}`, runtime.ShareID)))
res := httptest.NewRecorder()
agent.h.federationShareDelete(res, req)
var result struct {
Code int `json:"code"`
}
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Code == 0 {
t.Fatal("offline share delete reported success")
}
share, err := agent.h.repo.GetPeerShare(runtime.ShareID)
if err != nil || share == nil || share.IsActive != 0 {
t.Fatalf("cleanup retry record lost or still accepts allocations: %+v %v", share, err)
}
occupied, err := agent.h.repo.ExistsActivePeerShareRuntimeOnNodePort(1, 31000)
if err != nil || !occupied {
t.Fatal("offline share deletion released its port")
}
agent.h.wsServer = liveServer
if !agent.h.redeployNodeRuntimeAfterUpgrade(1) {
t.Fatal("disabled share cleanup did not retry")
}
if len(agent.commandsOfType("DeleteService")) != 1 || len(agent.commandsOfType("UpdateService")) != 0 {
t.Fatal("disabled share was recreated")
}
}
func TestPeerShareLegacyReleaseWithoutLocalCollisionDeletesFamily(t *testing.T) {
agent := newCleanupAgent(t)
runtime := roleRuntimeFixture(t, agent, "forward")
runtime.ServiceName = "70_1_0"
if err := agent.h.repo.UpdatePeerShareRuntime(runtime); err != nil {
t.Fatal(err)
}
if err := agent.h.releasePeerShareRuntime(runtime); err != nil {
t.Fatal(err)
}
commands := agent.commandsOfType("DeleteService")
if len(commands) != 1 {
t.Fatalf("expected one family cleanup, got %d", len(commands))
}
var payload struct {
Services []string `json:"services"`
}
if err := json.Unmarshal(commands[0].Data, &payload); err != nil {
t.Fatal(err)
}
got := strings.Join(payload.Services, ",")
if got != "70_1_0,70_1_0_tcp,70_1_0_udp" {
t.Fatalf("incomplete legacy cleanup: %s", got)
}
stored, err := agent.h.repo.GetPeerShareRuntimeByID(runtime.ID)
if err != nil || stored.Status != 0 || stored.ReleasePending != 0 {
t.Fatalf("legacy release incomplete: %+v %v", stored, err)
}
}
func TestPeerSharePendingRoleRetryDoesNotReplaySuccessfulServices(t *testing.T) {
agent := newCleanupAgent(t)
runtime := roleRuntimeFixture(t, agent, "exit")
if err := agent.h.retryPendingPeerShareRoleRuntimesOnNode(1); err != nil {
t.Fatal(err)
}
if len(agent.commandsOfType("UpdateService")) != 0 {
t.Fatal("maintenance replayed an acknowledged service")
}
runtime.Applied = 0
if err := agent.h.repo.UpdatePeerShareRuntime(runtime); err != nil {
t.Fatal(err)
}
if err := agent.h.retryPendingPeerShareRoleRuntimesOnNode(1); err != nil {
t.Fatal(err)
}
if len(agent.commandsOfType("UpdateService")) != 1 {
t.Fatal("maintenance did not retry unfinished service")
}
if err := agent.h.retryPendingPeerShareRoleRuntimesOnNode(1); err != nil {
t.Fatal(err)
}
if len(agent.commandsOfType("UpdateService")) != 1 {
t.Fatal("maintenance repeated a successful retry")
}
}
func TestPeerShareResourceFailureDoesNotBlockRoleOrLocalRecovery(t *testing.T) {
agent := newCleanupAgent(t)
runtime := roleRuntimeFixture(t, agent, "exit")
if err := agent.h.repo.SavePeerShareResources([]repo.PeerShareResource{{ShareID: runtime.ShareID, NodeID: 1, Kind: "chain", OriginalName: "broken", RuntimeName: "broken", Config: "invalid-json", DesiredState: "active", Applied: 0, UpdatedTime: time.Now().UnixMilli()}}); err != nil {
t.Fatal(err)
}
var localQueries atomic.Int32
if err := agent.h.repo.DB().Callback().Query().Before("gorm:query").Register("test:observe-local-recovery", func(tx *gorm.DB) {
if tx.Statement.Table == "chain_tunnel" || tx.Statement.Table == "forward_port" {
localQueries.Add(1)
}
}); err != nil {
t.Fatal(err)
}
if agent.h.redeployNodeRuntimeAfterUpgrade(1) {
t.Fatal("invalid shared resource was reported recovered")
}
if len(agent.commandsOfType("UpdateService")) != 1 {
t.Fatal("resource failure blocked independent role service recovery")
}
if localQueries.Load() < 2 {
t.Fatal("shared failure blocked independent local recovery")
}
// A shared failure is owned by pending maintenance, without full-redeploy
// timers that would periodically restart healthy listeners.
agent.h.onNodeOnline(1)
agent.h.upgradeMu.Lock()
_, fullQueued := agent.h.nodeOnlineRedeployQueued[1]
_, localQueued := agent.h.nodeLocalRuntimeRetryQueued[1]
agent.h.upgradeMu.Unlock()
if fullQueued || localQueued {
t.Fatal("shared-only failure queued a full or local redeploy")
}
before := len(agent.commandsOfType("UpdateService"))
agent.h.retryNodeLocalRuntime(1)
if len(agent.commandsOfType("UpdateService")) != before {
t.Fatal("local retry replayed shared listeners")
}
}
@@ -0,0 +1,125 @@
package handler
import (
"encoding/json"
"fmt"
"go-backend/internal/http/client"
"go-backend/internal/relaysecurity"
"go-backend/internal/store/repo"
"gorm.io/gorm"
)
func (h *Handler) resolveTunnelRelayAuth(tx *gorm.DB, state *tunnelCreateState, create bool) error {
if state.Type != 2 || state.RelayAuthMode == relaysecurity.Legacy {
return nil
}
if state.RelayAuthMode != relaysecurity.Required {
return fmt.Errorf("unsupported relay authentication mode")
}
for i := range state.OutNodes {
c, err := h.repo.RelayEndpointCredentials(tx, state.TunnelID, state.OutNodes[i].NodeID, 3, create)
if err != nil {
return err
}
state.OutNodes[i].RelayAuth = c
}
for hop := range state.ChainHops {
for i := range state.ChainHops[hop] {
c, err := h.repo.RelayEndpointCredentials(tx, state.TunnelID, state.ChainHops[hop][i].NodeID, 2, create)
if err != nil {
return err
}
state.ChainHops[hop][i].RelayAuth = c
}
}
return nil
}
func (h *Handler) resolveBestExitRelayAuth(tunnelID int64, targets []tunnelRuntimeNode) error {
tunnel, err := h.repo.GetTunnelRecord(tunnelID)
if err != nil {
return err
}
if tunnel == nil {
return fmt.Errorf("tunnel not found")
}
return h.resolveTunnelRelayAuth(nil, &tunnelCreateState{TunnelID: tunnelID, Type: tunnel.Type, RelayAuthMode: tunnel.RelayAuthMode, OutNodes: targets}, false)
}
func relayAuthVersion(c *relaysecurity.Credentials) int {
if c == nil {
return 0
}
return c.Version
}
func decodePersistedRelayAuth(raw string) (*relaysecurity.Credentials, error) {
if raw == "" {
return nil, nil
}
var c relaysecurity.Credentials
if err := json.Unmarshal([]byte(raw), &c); err != nil {
return nil, fmt.Errorf("invalid persisted relay authentication")
}
if err := c.Validate(); err != nil {
return nil, err
}
return &c, nil
}
func validateFederationRelayAuth(persisted string, c *relaysecurity.Credentials, targets []federationRuntimeTarget) error {
old, err := decodePersistedRelayAuth(persisted)
if err != nil {
return err
}
if old != nil && (c == nil || *old != *c) {
return fmt.Errorf("authenticated relay cannot be downgraded or rotated by reapply")
}
if c != nil {
if err := c.Validate(); err != nil {
return err
}
for _, target := range targets {
if err := target.RelayAuth.Validate(); err != nil {
return err
}
}
} else {
for _, target := range targets {
if target.RelayAuth != nil {
if err := target.RelayAuth.Validate(); err != nil {
return err
}
}
}
}
return nil
}
func (h *Handler) preflightFederationRelayAuth(state *tunnelCreateState, domain string) error {
if state.RelayAuthMode != relaysecurity.Required || state.Type != 2 {
return nil
}
for _, node := range state.Nodes {
if node == nil || node.IsRemote != 1 {
continue
}
info, err := client.NewFederationClient().Connect(node.RemoteURL, node.RemoteToken, domain)
if err != nil {
return fmt.Errorf("relay authentication capability check failed for remote node %d", node.ID)
}
if info.RelayAuthVersion != relaysecurity.Version {
return fmt.Errorf("remote node %d does not support required relay authentication", node.ID)
}
}
return nil
}
func decodeRuntimeRelayAuth(runtime *repo.PeerShareRuntime) (*relaysecurity.Credentials, error) {
c, err := decodePersistedRelayAuth(runtime.RelayAuth)
if err != nil {
return nil, err
}
if runtime.RelayAuthVersion != relayAuthVersion(c) {
return nil, fmt.Errorf("persisted relay authentication version or credentials unavailable")
}
return c, nil
}
@@ -3,6 +3,7 @@ package handler
import (
"fmt"
"net"
"net/netip"
"strings"
)
@@ -75,6 +76,12 @@ func IsValidNodeAddress(addr string) error {
if strings.ContainsAny(addr, "/?") {
return fmt.Errorf("address must not contain path or query parameters")
}
// A bare IPv6 literal contains multiple colons, so net.SplitHostPort treats
// it as a malformed host:port pair. Accept IP literals before attempting
// host:port parsing; netip also handles scoped IPv6 addresses.
if _, err := netip.ParseAddr(addr); err == nil {
return nil
}
_, _, err := net.SplitHostPort(addr)
if err != nil {
@@ -0,0 +1,46 @@
package handler
import "testing"
func TestIssue515IsValidNodeAddressAcceptsBareIPv6(t *testing.T) {
for _, addr := range []string{
"2001:db8::1",
"::1",
"fe80::1%eth0",
} {
t.Run(addr, func(t *testing.T) {
if err := IsValidNodeAddress(addr); err != nil {
t.Fatalf("expected bare IPv6 address %q to be accepted: %v", addr, err)
}
})
}
}
func TestIsValidNodeAddressKeepsExistingAddressForms(t *testing.T) {
for _, addr := range []string{
"203.0.113.10",
"node.example.com",
"node.example.com:6365",
"[2001:db8::1]:6365",
} {
t.Run(addr, func(t *testing.T) {
if err := IsValidNodeAddress(addr); err != nil {
t.Fatalf("expected node address %q to be accepted: %v", addr, err)
}
})
}
}
func TestIsValidNodeAddressRejectsURLComponents(t *testing.T) {
for _, addr := range []string{
"https://node.example.com",
"node.example.com/path",
"node.example.com?transport=tcp",
} {
t.Run(addr, func(t *testing.T) {
if err := IsValidNodeAddress(addr); err == nil {
t.Fatalf("expected node address %q to be rejected", addr)
}
})
}
}
@@ -0,0 +1,474 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"go-backend/internal/http/response"
"go-backend/internal/panelupgrade"
)
const (
panelDeployDirEnv = "PANEL_DEPLOY_DIR"
panelBackendContainerEnv = "PANEL_BACKEND_CONTAINER"
defaultPanelDeployDir = "/opt/flvx-panel"
defaultPanelBackendName = "flux-panel-backend"
dockerSocketPath = "/var/run/docker.sock"
maxSystemUpgradeComposeAssetBytes = 1 << 20
systemUpgradeMessage = "升级 helper 已启动,面板服务将短暂重启"
systemUpgradeConflictError = "已有面板升级任务执行中"
)
var safeBackendContainerPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
var enableIPv6ComposePattern = regexp.MustCompile(`(?im)^\s*enable_ipv6\s*:\s*['"]?true['"]?\s*(?:#.*)?$`)
var systemUpgradeReleaseBaseURL = githubHTMLBase
var systemUpgradeAPIBaseURL = githubAPIBase
var systemUpgradeHTTPGet = func(client *http.Client, url string) (*http.Response, error) {
return client.Get(url)
}
type systemUpgradeExecutor struct {
deployDir string
backendContainer string
}
type systemUpgradeCapabilityData struct {
Capable bool `json:"capable"`
Reasons []string `json:"reasons"`
DeployDir string `json:"deployDir"`
BackendContainer string `json:"backendContainer"`
}
type systemUpgradeReleaseData struct {
Version string `json:"version"`
Name string `json:"name"`
PublishedAt string `json:"publishedAt"`
Prerelease bool `json:"prerelease"`
Channel string `json:"channel"`
}
type systemUpgradeVersionData struct {
CurrentVersion string `json:"currentVersion"`
LatestVersion string `json:"latestVersion"`
HasUpdate bool `json:"hasUpdate"`
Channel string `json:"channel"`
Reason string `json:"reason,omitempty"`
Capability systemUpgradeCapabilityData `json:"capability"`
}
type systemUpgradeCheckData struct {
CurrentVersion string `json:"currentVersion"`
LatestVersion string `json:"latestVersion"`
HasUpdate bool `json:"hasUpdate"`
Channel string `json:"channel"`
Capability systemUpgradeCapabilityData `json:"capability"`
Releases []systemUpgradeReleaseData `json:"releases"`
}
type systemUpgradeRunData struct {
JobID string `json:"jobId"`
Version string `json:"version"`
Channel string `json:"channel"`
ComposeAsset string `json:"composeAsset"`
HelperContainer string `json:"helperContainer"`
BackendImageID string `json:"backendImageId"`
Message string `json:"message"`
}
type systemUpgradeRequest struct {
Version string `json:"version"`
Channel string `json:"channel"`
}
func newSystemUpgradeExecutor() *systemUpgradeExecutor {
deployDir := strings.TrimSpace(os.Getenv(panelDeployDirEnv))
if deployDir == "" {
deployDir = defaultPanelDeployDir
}
backendContainer := strings.TrimSpace(os.Getenv(panelBackendContainerEnv))
if backendContainer == "" {
backendContainer = defaultPanelBackendName
}
return &systemUpgradeExecutor{deployDir: deployDir, backendContainer: backendContainer}
}
func currentPanelVersion() string {
version := strings.TrimSpace(os.Getenv("FLUX_VERSION"))
if version == "" {
return "dev"
}
return version
}
func validateBackendContainerName(value string) error {
if value == "" {
return fmt.Errorf("backend container name is empty")
}
if !safeBackendContainerPattern.MatchString(value) {
return fmt.Errorf("unsafe backend container name: %s", value)
}
return nil
}
func validateUpgradeVersion(value string) error { return panelupgrade.ValidateVersion(value) }
func (e *systemUpgradeExecutor) composePath() string {
return filepath.Join(e.deployDir, "docker-compose.yml")
}
func (e *systemUpgradeExecutor) envPath() string { return filepath.Join(e.deployDir, ".env") }
func (e *systemUpgradeExecutor) capability(ctx context.Context) systemUpgradeCapabilityData {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
reasons := make([]string, 0)
if !filepath.IsAbs(e.deployDir) {
reasons = append(reasons, "部署目录必须是绝对路径")
}
if err := validateBackendContainerName(e.backendContainer); err != nil {
reasons = append(reasons, err.Error())
}
if err := exec.CommandContext(ctx, "docker", "--version").Run(); err != nil {
reasons = append(reasons, "docker CLI不可用")
}
if info, err := os.Stat(dockerSocketPath); err != nil {
reasons = append(reasons, "docker socket不可用: "+err.Error())
} else if info.IsDir() {
reasons = append(reasons, "docker socket路径不是文件")
}
if info, err := os.Stat(e.composePath()); err != nil {
reasons = append(reasons, "部署docker-compose.yml不可用: "+err.Error())
} else if info.IsDir() {
reasons = append(reasons, "部署docker-compose.yml不是文件")
}
if info, err := os.Stat(e.envPath()); err != nil {
reasons = append(reasons, "部署.env不可用: "+err.Error())
} else if info.IsDir() {
reasons = append(reasons, "部署.env不是文件")
}
if err := exec.CommandContext(ctx, "docker", "compose", "version").Run(); err != nil {
reasons = append(reasons, "docker compose不可用")
}
if _, err := (panelupgrade.Manager{Dir: e.deployDir, Backend: e.backendContainer}).Identity(ctx); err != nil {
reasons = append(reasons, err.Error())
}
return systemUpgradeCapabilityData{
Capable: len(reasons) == 0,
Reasons: reasons,
DeployDir: e.deployDir,
BackendContainer: e.backendContainer,
}
}
func (e *systemUpgradeExecutor) selectComposeAsset(current []byte) string {
if enableIPv6ComposePattern.Match(current) {
return "docker-compose-v6.yml"
}
return "docker-compose-v4.yml"
}
func (h *Handler) buildSystemUpgradeDownloadURL(version, filename string) string {
enabled, proxyURL := h.getGithubProxyConfig()
base := fmt.Sprintf("%s/%s/releases/download/%s/%s", strings.TrimRight(systemUpgradeReleaseBaseURL, "/"), githubRepo, version, filename)
if enabled {
return fmt.Sprintf("%s/%s", proxyURL, base)
}
return base
}
func (h *Handler) fetchSystemUpgradeReleases(perPage int) ([]githubRelease, error) {
if perPage <= 0 {
perPage = 20
}
client := &http.Client{Timeout: 15 * time.Second}
url := fmt.Sprintf("%s/repos/%s/releases?per_page=%d", strings.TrimRight(systemUpgradeAPIBaseURL, "/"), githubRepo, perPage)
if enabled, proxyURL := h.getGithubProxyConfig(); enabled {
url = fmt.Sprintf("%s/%s", proxyURL, url)
}
resp, err := systemUpgradeHTTPGet(client, url)
if err != nil {
return nil, fmt.Errorf("请求GitHub API失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
}
var releases []githubRelease
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, fmt.Errorf("解析GitHub API响应失败: %v", err)
}
return releases, nil
}
func (h *Handler) resolveSystemUpgradeLatestReleaseByChannel(channel string) (string, error) {
normalizedChannel := normalizeReleaseChannel(channel)
releases, err := h.fetchSystemUpgradeReleases(50)
if err != nil {
return "", err
}
for _, r := range releases {
if r.Draft {
continue
}
tag := strings.TrimSpace(r.TagName)
if tag == "" {
continue
}
if releaseChannelFromTag(tag) == normalizedChannel {
return tag, nil
}
}
return "", fmt.Errorf("未找到%s版本号", releaseChannelLabel(normalizedChannel))
}
func (h *Handler) downloadReleaseAsset(version, filename string) ([]byte, error) {
url := h.buildSystemUpgradeDownloadURL(version, filename)
client := &http.Client{Timeout: 60 * time.Second}
resp, err := systemUpgradeHTTPGet(client, url)
if err != nil {
return nil, fmt.Errorf("下载%s失败: %v", filename, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("下载%s返回 %d: %s", filename, resp.StatusCode, strings.TrimSpace(string(body)))
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxSystemUpgradeComposeAssetBytes+1))
if err != nil {
return nil, fmt.Errorf("读取%s失败: %v", filename, err)
}
if len(body) > maxSystemUpgradeComposeAssetBytes {
return nil, fmt.Errorf("下载%s过大", filename)
}
if len(bytes.TrimSpace(body)) == 0 {
return nil, fmt.Errorf("下载%s内容为空", filename)
}
return body, nil
}
func releasesForChannel(releases []githubRelease, channel string) []systemUpgradeReleaseData {
channel = normalizeReleaseChannel(channel)
items := make([]systemUpgradeReleaseData, 0, len(releases))
for _, r := range releases {
if r.Draft {
continue
}
tag := strings.TrimSpace(r.TagName)
if tag == "" {
continue
}
itemChannel := releaseChannelFromTag(tag)
if itemChannel != channel {
continue
}
items = append(items, systemUpgradeReleaseData{
Version: tag,
Name: r.Name,
PublishedAt: r.PublishedAt,
Prerelease: itemChannel == releaseChannelDev,
Channel: itemChannel,
})
}
return items
}
func decodeSystemUpgradeRequest(r *http.Request, req *systemUpgradeRequest) error {
defer r.Body.Close()
body, err := io.ReadAll(io.LimitReader(r.Body, 4097))
if err != nil {
return err
}
if len(body) > 4096 {
return fmt.Errorf("request too large")
}
if len(bytes.TrimSpace(body)) == 0 {
return nil
}
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.DisallowUnknownFields()
if err := decoder.Decode(req); err != nil {
return err
}
var extra any
if decoder.Decode(&extra) != io.EOF {
return fmt.Errorf("unexpected trailing JSON")
}
return nil
}
func systemUpgradeVersionResponse(current, channel, latest string, lookupErr error, capability systemUpgradeCapabilityData) systemUpgradeVersionData {
data := systemUpgradeVersionData{
CurrentVersion: current,
LatestVersion: latest,
HasUpdate: latest != "" && latest != current,
Channel: channel,
Capability: capability,
}
if lookupErr != nil {
data.LatestVersion = ""
data.HasUpdate = false
data.Reason = lookupErr.Error()
}
return data
}
func (h *Handler) systemVersion(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
channel := releaseChannelStable
current := currentPanelVersion()
exec := newSystemUpgradeExecutor()
capability := exec.capability(r.Context())
latest, err := h.resolveSystemUpgradeLatestReleaseByChannel(channel)
response.WriteJSON(w, response.OK(systemUpgradeVersionResponse(current, channel, latest, err, capability)))
}
func (h *Handler) systemCheckUpdates(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req systemUpgradeRequest
if err := decodeSystemUpgradeRequest(r, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
channel := normalizeReleaseChannel(req.Channel)
current := currentPanelVersion()
exec := newSystemUpgradeExecutor()
capability := exec.capability(r.Context())
githubReleases, err := h.fetchSystemUpgradeReleases(50)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err)))
return
}
releases := releasesForChannel(githubReleases, channel)
latest := ""
if len(releases) > 0 {
latest = releases[0].Version
}
response.WriteJSON(w, response.OK(systemUpgradeCheckData{
CurrentVersion: current,
LatestVersion: latest,
HasUpdate: latest != "" && latest != current,
Channel: channel,
Capability: capability,
Releases: releases,
}))
}
func (h *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.systemUpgradeMu.TryLock() {
response.WriteJSON(w, response.ErrDefault(systemUpgradeConflictError))
return
}
defer h.systemUpgradeMu.Unlock()
var req systemUpgradeRequest
if err := decodeSystemUpgradeRequest(r, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
channel := normalizeReleaseChannel(req.Channel)
exec := newSystemUpgradeExecutor()
capability := exec.capability(r.Context())
if !capability.Capable {
response.WriteJSON(w, response.ErrDefault("当前环境不支持面板自升级: "+strings.Join(capability.Reasons, "; ")))
return
}
version := strings.TrimSpace(req.Version)
if version == "" {
var err error
version, err = h.resolveSystemUpgradeLatestReleaseByChannel(channel)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
return
}
}
if err := validateUpgradeVersion(version); err != nil {
response.WriteJSON(w, response.ErrDefault("版本号格式无效"))
return
}
composeData, err := os.ReadFile(exec.composePath())
if err != nil {
response.WriteJSON(w, response.ErrDefault("读取部署配置失败"))
return
}
composeAsset := exec.selectComposeAsset(composeData)
newCompose, err := h.downloadReleaseAsset(version, composeAsset)
if err != nil {
response.WriteJSON(w, response.ErrDefault("下载目标 Compose 失败"))
return
}
// A detached helper must not be canceled when the browser disconnects.
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
job, imageID, err := (panelupgrade.Manager{Dir: exec.deployDir, Backend: exec.backendContainer}).Start(ctx, version, newCompose)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
response.WriteJSON(w, response.OK(systemUpgradeRunData{JobID: job.ID, Version: version, Channel: channel, ComposeAsset: composeAsset, HelperContainer: job.HelperContainer, BackendImageID: imageID, Message: systemUpgradeMessage}))
}
func (h *Handler) systemUpgradeStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req struct {
JobID string `json:"jobId"`
}
defer r.Body.Close()
decoder := json.NewDecoder(io.LimitReader(r.Body, 4097))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&req); err != nil && err != io.EOF {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
var extra any
if decoder.Decode(&extra) != io.EOF {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
e := newSystemUpgradeExecutor()
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
job, err := (panelupgrade.Manager{Dir: e.deployDir, Backend: e.backendContainer}).Status(ctx, req.JobID)
if err != nil {
response.WriteJSON(w, response.ErrDefault("无法读取升级任务状态"))
return
}
response.WriteJSON(w, response.OK(struct {
CurrentVersion string `json:"currentVersion"`
Job *panelupgrade.Job `json:"job"`
}{currentPanelVersion(), job}))
}
@@ -0,0 +1,241 @@
package handler
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"go-backend/internal/store/repo"
)
func TestSelectComposeAssetUsesIPv6Template(t *testing.T) {
exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"}
compose := []byte("networks:\n gost-network:\n enable_ipv6: true\n")
if got := exec.selectComposeAsset(compose); got != "docker-compose-v6.yml" {
t.Fatalf("selectComposeAsset() = %q, want %q", got, "docker-compose-v6.yml")
}
}
func TestDownloadReleaseAssetUsesGithubProxyWhenEnabled(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "test.db")
repoStore, err := repo.Open(dbPath)
if err != nil {
t.Fatalf("repo.Open() error = %v", err)
}
defer repoStore.Close()
h := &Handler{repo: repoStore}
originalBase := systemUpgradeReleaseBaseURL
systemUpgradeReleaseBaseURL = "https://example.invalid"
t.Cleanup(func() { systemUpgradeReleaseBaseURL = originalBase })
originalGet := systemUpgradeHTTPGet
defer func() { systemUpgradeHTTPGet = originalGet }()
var gotURL string
systemUpgradeHTTPGet = func(client *http.Client, url string) (*http.Response, error) {
gotURL = url
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("services:\n backend:\n image: test\n")),
}, nil
}
now := time.Now().UnixMilli()
if err := repoStore.UpsertConfig("github_proxy_enabled", "true", now); err != nil {
t.Fatalf("UpsertConfig() github_proxy_enabled error = %v", err)
}
if err := repoStore.UpsertConfig("github_proxy_url", "https://proxy.example.com", now); err != nil {
t.Fatalf("UpsertConfig() github_proxy_url error = %v", err)
}
data, err := h.downloadReleaseAsset("2.1.9", "docker-compose-v4.yml")
if err != nil {
t.Fatalf("downloadReleaseAsset() error = %v", err)
}
if !strings.Contains(string(data), "backend") {
t.Fatalf("downloadReleaseAsset() data = %q, want compose data", string(data))
}
wantURL := "https://proxy.example.com/https://example.invalid/Sagit-chu/flvx/releases/download/2.1.9/docker-compose-v4.yml"
if gotURL != wantURL {
t.Fatalf("download URL = %q, want %q", gotURL, wantURL)
}
}
func TestDownloadReleaseAssetRejectsOversizedBody(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "test.db")
repoStore, err := repo.Open(dbPath)
if err != nil {
t.Fatalf("repo.Open() error = %v", err)
}
defer repoStore.Close()
now := time.Now().UnixMilli()
if err := repoStore.UpsertConfig("github_proxy_enabled", "false", now); err != nil {
t.Fatalf("UpsertConfig() github_proxy_enabled error = %v", err)
}
originalBase := systemUpgradeReleaseBaseURL
systemUpgradeReleaseBaseURL = "https://example.invalid"
t.Cleanup(func() { systemUpgradeReleaseBaseURL = originalBase })
originalGet := systemUpgradeHTTPGet
defer func() { systemUpgradeHTTPGet = originalGet }()
systemUpgradeHTTPGet = func(client *http.Client, url string) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(bytes.Repeat([]byte("a"), maxSystemUpgradeComposeAssetBytes+1))),
}, nil
}
h := &Handler{repo: repoStore}
_, err = h.downloadReleaseAsset("2.1.9", "docker-compose-v4.yml")
if err == nil || !strings.Contains(err.Error(), "过大") {
t.Fatalf("downloadReleaseAsset() error = %v, want oversized error", err)
}
}
func TestSelectComposeAssetUsesIPv6TemplateForYAMLVariants(t *testing.T) {
exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"}
for _, compose := range [][]byte{
[]byte("networks:\n gost-network:\n enable_ipv6:true\n"),
[]byte("networks:\n gost-network:\n enable_ipv6: True\n"),
[]byte("networks:\n gost-network:\n enable_ipv6: \"true\"\n"),
[]byte("networks:\n gost-network:\n enable_ipv6: 'true'\n"),
[]byte("networks:\n gost-network:\n enable_ipv6: true # comment\n"),
} {
if got := exec.selectComposeAsset(compose); got != "docker-compose-v6.yml" {
t.Fatalf("selectComposeAsset(%q) = %q, want %q", string(compose), got, "docker-compose-v6.yml")
}
}
}
func TestSelectComposeAssetFallsBackToIPv4Template(t *testing.T) {
exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"}
compose := []byte("services:\n backend:\n image: test\n")
if got := exec.selectComposeAsset(compose); got != "docker-compose-v4.yml" {
t.Fatalf("selectComposeAsset() = %q, want %q", got, "docker-compose-v4.yml")
}
}
func TestValidateBackendContainerNameRejectsUnsafeValue(t *testing.T) {
if err := validateBackendContainerName("flux-panel-backend;rm -rf /"); err == nil {
t.Fatal("expected unsafe container name to fail validation")
}
}
func TestSystemVersionRejectsWrongMethod(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodGet, "/api/v1/system/version", nil)
rr := httptest.NewRecorder()
h.systemVersion(rr, req)
if !strings.Contains(rr.Body.String(), "请求失败") {
t.Fatalf("expected wrong-method response, got %s", rr.Body.String())
}
}
func TestSystemUpgradeRejectsConcurrentRequests(t *testing.T) {
h := &Handler{}
h.systemUpgradeMu.Lock()
defer h.systemUpgradeMu.Unlock()
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/upgrade", strings.NewReader(`{"channel":"stable"}`))
rr := httptest.NewRecorder()
h.systemUpgrade(rr, req)
if !strings.Contains(rr.Body.String(), systemUpgradeConflictError) {
t.Fatalf("expected conflict message, got %s", rr.Body.String())
}
}
func TestSystemUpgradeFailsFastBeforeMutatingFiles(t *testing.T) {
dir := t.TempDir()
composePath := filepath.Join(dir, "docker-compose.yml")
envPath := filepath.Join(dir, ".env")
if err := os.WriteFile(composePath, []byte("services:\n backend:\n image: test\n"), 0o644); err != nil {
t.Fatalf("WriteFile() compose error = %v", err)
}
if err := os.WriteFile(envPath, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o600); err != nil {
t.Fatalf("WriteFile() env error = %v", err)
}
fakeDockerDir := t.TempDir()
fakeDockerPath := filepath.Join(fakeDockerDir, "docker")
fakeDockerScript := "#!/bin/sh\ncase \"$1\" in\n --version)\n echo 'Docker version 27.0.0'\n exit 0\n ;;&\n compose)\n if [ \"$2\" = version ]; then\n echo 'Docker Compose version v2.33.0'\n exit 0\n fi\n exit 0\n ;;&\n inspect)\n echo 'No such object: flux-panel-backend' >&2\n exit 1\n ;;&\n *)\n exit 0\n ;;&\n esac\n"
if err := os.WriteFile(fakeDockerPath, []byte(fakeDockerScript), 0o755); err != nil {
t.Fatalf("WriteFile() fake docker error = %v", err)
}
t.Setenv("PATH", fakeDockerDir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv(panelDeployDirEnv, dir)
t.Setenv(panelBackendContainerEnv, "flux-panel-backend")
h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/upgrade", strings.NewReader(`{"channel":"stable"}`))
rr := httptest.NewRecorder()
h.systemUpgrade(rr, req)
if !strings.Contains(rr.Body.String(), "当前环境不支持面板自升级") {
t.Fatalf("expected fail-fast capability error, got %s", rr.Body.String())
}
if _, err := os.Stat(composePath + ".upgrade.bak"); !os.IsNotExist(err) {
t.Fatalf("expected no compose backup, got err=%v", err)
}
if _, err := os.Stat(envPath + ".upgrade.bak"); !os.IsNotExist(err) {
t.Fatalf("expected no env backup, got err=%v", err)
}
composeData, err := os.ReadFile(composePath)
if err != nil {
t.Fatalf("ReadFile() compose error = %v", err)
}
if string(composeData) != "services:\n backend:\n image: test\n" {
t.Fatalf("compose mutated unexpectedly: %q", string(composeData))
}
envData, err := os.ReadFile(envPath)
if err != nil {
t.Fatalf("ReadFile() env error = %v", err)
}
if string(envData) != "FLUX_VERSION=2.1.8\nJWT_SECRET=test\n" {
t.Fatalf("env mutated unexpectedly: %q", string(envData))
}
}
func TestDecodeSystemUpgradeRequestRejectsTruncatedJSON(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/check-updates", strings.NewReader(`{"channel":"stable"`))
var payload systemUpgradeRequest
if err := decodeSystemUpgradeRequest(req, &payload); err == nil {
t.Fatal("expected truncated JSON to be rejected")
}
}
func TestDecodeSystemUpgradeRequestAllowsEmptyBody(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/check-updates", strings.NewReader(""))
var payload systemUpgradeRequest
if err := decodeSystemUpgradeRequest(req, &payload); err != nil {
t.Fatalf("expected empty body to be accepted, got %v", err)
}
}
func TestSystemUpgradeVersionDataSurfacesLookupFailureReason(t *testing.T) {
data, err := json.Marshal(systemUpgradeVersionData{Reason: "GitHub unavailable"})
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
if !strings.Contains(string(data), `"reason":"GitHub unavailable"`) {
t.Fatalf("expected reason field in JSON, got %s", string(data))
}
}
@@ -0,0 +1,28 @@
package handler
import (
"fmt"
"math"
"strconv"
)
// flowMiB is optional so older clients can keep sending the GB-based flow field.
// A positive value takes precedence and preserves sub-GB limits exactly.
func parseTrafficLimit(req map[string]interface{}, defaultGB int64) (flowGB, flowMiB int64, err error) {
flowGB = asInt64(req["flow"], defaultGB)
if flowGB < 0 {
return 0, 0, fmt.Errorf("流量限制不能小于0")
}
raw, present := req["flowMiB"]
if !present {
return flowGB, 0, nil
}
flowMiB, err = strconv.ParseInt(asString(raw), 10, 64)
if err != nil || flowMiB < 0 || flowMiB > math.MaxInt64/bytesPerMiB {
return 0, 0, fmt.Errorf("流量限制超出范围")
}
if flowMiB > 0 {
flowGB = (flowMiB-1)/1024 + 1
}
return flowGB, flowMiB, nil
}
@@ -0,0 +1,39 @@
package handler
import (
"testing"
"time"
)
func TestTrafficLimitMiBOverridesLegacyGB(t *testing.T) {
flowGB, flowMiB, err := parseTrafficLimit(map[string]interface{}{
"flow": float64(1), "flowMiB": float64(500),
}, 100)
if err != nil || flowGB != 1 || flowMiB != 500 {
t.Fatalf("parseTrafficLimit = (%d, %d, %v), want (1, 500, nil)", flowGB, flowMiB, err)
}
limit := flowLimitBytes(flowGB, flowMiB)
if limit != 500*bytesPerMiB {
t.Fatalf("limit = %d, want %d", limit, 500*bytesPerMiB)
}
policy := &userTunnelPolicy{Flow: flowGB, FlowMiB: flowMiB, InFlow: limit - 1, Status: 1}
if shouldPauseUserTunnel(policy, time.Now().UnixMilli()) {
t.Fatal("policy paused before reaching 500 MiB")
}
policy.InFlow = limit
if !shouldPauseUserTunnel(policy, time.Now().UnixMilli()) {
t.Fatal("policy did not pause at 500 MiB")
}
}
func TestTrafficLimitLegacyAndInvalidValues(t *testing.T) {
flowGB, flowMiB, err := parseTrafficLimit(map[string]interface{}{"flow": float64(2)}, 100)
if err != nil || flowGB != 2 || flowMiB != 0 || flowLimitBytes(flowGB, flowMiB) != 2*bytesPerGB {
t.Fatalf("legacy GB limit changed: (%d, %d, %v)", flowGB, flowMiB, err)
}
for _, value := range []interface{}{"1.5", -1, "999999999999999999999"} {
if _, _, err := parseTrafficLimit(map[string]interface{}{"flowMiB": value}, 100); err == nil {
t.Fatalf("accepted invalid flowMiB %v", value)
}
}
}
@@ -152,7 +152,11 @@ func evaluateBestExitOwner(owner chainNodeRecord, exits []chainNodeRecord, nodes
ownerNode := nodes[owner.NodeID]
for _, exit := range exits {
exitNode := nodes[exit.NodeID]
if exitNode == nil {
if !isTunnelProbeNodeOnline(ownerNode) {
scores = append(scores, failedBestExitCandidate(owner.NodeID, exit, "owner node offline"))
continue
}
if !isTunnelProbeNodeOnline(exitNode) {
scores = append(scores, failedBestExitCandidate(owner.NodeID, exit, "exit node unavailable"))
continue
}
@@ -358,9 +358,9 @@ func TestEvaluateBestExitOwnerScoresAllCandidates(t *testing.T) {
{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: "[::]"},
10: {ID: 10, Status: 1, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
30: {ID: 30, Status: 1, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30", TCPListenAddr: "[::]"},
31: {ID: 31, Status: 1, ServerIP: "10.0.0.31", ServerIPv4: "10.0.0.31", TCPListenAddr: "[::]"},
}
pinger := func(nodeID int64, ip string, port int, _ diagnosisExecOptions) (float64, float64, error) {
switch {
@@ -387,12 +387,30 @@ func TestEvaluateBestExitOwnerScoresAllCandidates(t *testing.T) {
}
}
func TestEvaluateBestExitOwnerSkipsOfflineCandidate(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, Status: 1, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10"},
30: {ID: 30, Status: 0, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30"},
}
ping := func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
t.Fatalf("offline best-exit candidate should not be probed: node=%d target=%s:%d", nodeID, ip, port)
return 0, 100, nil
}
scores := evaluateBestExitOwner(owner, exits, nodes, "", diagnosisExecOptions{}, defaultTunnelProbeTarget(), ping)
if len(scores) != 1 || scores[0].Success {
t.Fatalf("expected one failed offline candidate, got %+v", scores)
}
}
func TestEvaluateBestExitOwnerUsesConfiguredPublicProbeTarget(t *testing.T) {
owner := chainNodeRecord{NodeID: 10, NodeName: "entry-a"}
exits := []chainNodeRecord{{NodeID: 30, NodeName: "exit-a", Port: 30001}}
nodes := map[int64]*nodeRecord{
10: {ID: 10, Name: "entry-a", ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10"},
30: {ID: 30, Name: "exit-a", ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30"},
10: {ID: 10, Name: "entry-a", Status: 1, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10"},
30: {ID: 30, Name: "exit-a", Status: 1, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30"},
}
target := tunnelProbeTarget{Host: "speed.example.com", Port: 8443}
var calls []string
@@ -419,8 +437,8 @@ func TestEvaluateBestExitOwnerMarksCandidateFailedWhenOwnerToExitFails(t *testin
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: "[::]"},
10: {ID: 10, Status: 1, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
30: {ID: 30, Status: 1, 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
@@ -436,8 +454,8 @@ func TestEvaluateBestExitOwnerMarksCandidateFailedWhenTargetResolutionFails(t *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: "[::]"},
10: {ID: 10, Name: "entry", Status: 1, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
30: {ID: 30, Name: "exit-v6", Status: 1, 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)
@@ -371,6 +371,8 @@ func summarizeTunnelDeleteRuleFailures(failures []batchFailureDetail) string {
}
func (h *Handler) processTunnelDeleteWithForwards(tunnelID int64, action string, targetTunnelID int64) (tunnelDeleteWithForwardsResult, []batchFailureDetail, error) {
unlock := h.lockTunnelLifecycle(tunnelID, targetTunnelID)
defer unlock()
preview, err := h.buildTunnelDeletePreview(tunnelID)
if err != nil {
return tunnelDeleteWithForwardsResult{}, nil, err
@@ -431,9 +433,14 @@ func (h *Handler) processTunnelDeleteReplaceAction(tunnelID, targetTunnelID int6
}
if err := h.deleteTunnelAndCleanup(tunnelID); err != nil {
h.rollbackTunnelForwardMigrationPlans(plans)
_ = h.redeployTunnelAndForwards(tunnelID)
return tunnelDeleteWithForwardsResult{}, nil, err
var pending *tunnelDeletionCleanupPendingError
if errors.As(err, &pending) {
warnings = append(warnings, err.Error())
} else {
h.rollbackTunnelForwardMigrationPlans(plans)
_ = h.redeployTunnelAndForwardsLocked(tunnelID)
return tunnelDeleteWithForwardsResult{}, nil, err
}
}
result.MigratedCount = len(plans)
@@ -639,8 +646,9 @@ func (h *Handler) rollbackTunnelForwardMigrationPlans(plans []tunnelForwardMigra
}
func (h *Handler) deleteTunnelAndCleanup(tunnelID int64) error {
h.cleanupTunnelRuntime(tunnelID)
h.cleanupFederationRuntime(tunnelID)
if err := h.cleanupFederationRuntime(tunnelID); err != nil {
return err
}
if err := h.deleteTunnelByID(tunnelID); err != nil {
return err
}
@@ -0,0 +1,153 @@
package handler
import (
"database/sql"
"errors"
"go-backend/internal/http/response"
"gorm.io/gorm"
"slices"
"sort"
"sync"
)
type tunnelLifecycleLock struct {
mu sync.Mutex
users int
}
// lockTunnelLifecycle serializes snapshots, persistence and node acknowledgements.
// Multiple tunnels (forward migration) are locked in ID order to avoid deadlocks.
// Callers must not acquire these locks recursively or hold a database transaction.
func (h *Handler) lockTunnelLifecycle(ids ...int64) func() {
unique := make(map[int64]bool, len(ids))
ordered := make([]int64, 0, len(ids))
for _, id := range ids {
if id > 0 && !unique[id] {
unique[id] = true
ordered = append(ordered, id)
}
}
sort.Slice(ordered, func(i, j int) bool { return ordered[i] < ordered[j] })
locks := make([]*tunnelLifecycleLock, 0, len(ordered))
for _, id := range ordered {
h.tunnelLifecycleMu.Lock()
if h.tunnelLifecycleLocks == nil {
h.tunnelLifecycleLocks = make(map[int64]*tunnelLifecycleLock)
}
lock := h.tunnelLifecycleLocks[id]
if lock == nil {
lock = &tunnelLifecycleLock{}
h.tunnelLifecycleLocks[id] = lock
}
lock.users++
h.tunnelLifecycleMu.Unlock()
lock.mu.Lock()
locks = append(locks, lock)
}
return func() {
for i := len(locks) - 1; i >= 0; i-- {
h.tunnelLifecycleMu.Lock()
locks[i].mu.Unlock()
locks[i].users--
if locks[i].users == 0 {
delete(h.tunnelLifecycleLocks, ordered[i])
}
h.tunnelLifecycleMu.Unlock()
}
}
}
// Probes run without the lifecycle lock. Recheck their topology under the lock
// so a late result cannot restore a removed middle hop or deleted tunnel.
func (h *Handler) validateBestExitSnapshot(tunnelID, ownerNodeID int64, exits []chainNodeRecord, ipPreference string) error {
tunnel, err := h.getTunnelRecord(tunnelID)
if err != nil {
return err
}
if tunnel.Type != 2 || h.repo.GetTunnelIPPreference(tunnelID) != ipPreference {
return errors.New("best exit topology changed; retry next probe")
}
rows, err := h.listChainNodesForTunnel(tunnelID)
if err != nil {
return err
}
ins, hops, currentExits := splitChainNodeGroups(rows)
if !slices.Equal(exits, currentExits) {
return errors.New("best exit targets changed; retry next probe")
}
for _, owner := range bestExitChainOwners(ins, hops) {
if owner.NodeID == ownerNodeID {
return nil
}
}
return errors.New("best exit owner changed; retry next probe")
}
// A newly allocated ID is not visible yet. Claim it without waiting while the
// insert transaction is open: an overlapping request for that ID must not make
// creation wait on a lifecycle lock whose owner is waiting for this transaction.
func (h *Handler) tryLockNewTunnelLifecycle(id int64) (func(), bool) {
h.tunnelLifecycleMu.Lock()
defer h.tunnelLifecycleMu.Unlock()
if h.tunnelLifecycleLocks == nil {
h.tunnelLifecycleLocks = make(map[int64]*tunnelLifecycleLock)
}
if h.tunnelLifecycleLocks[id] != nil {
return nil, false
}
lock := &tunnelLifecycleLock{users: 1}
lock.mu.Lock()
h.tunnelLifecycleLocks[id] = lock
return func() {
h.tunnelLifecycleMu.Lock()
defer h.tunnelLifecycleMu.Unlock()
lock.mu.Unlock()
lock.users--
if lock.users == 0 {
delete(h.tunnelLifecycleLocks, id)
}
}, true
}
// Pending is not successful application. HTTP create/update retain their
// accepted-config contract, while redeploy returns this error to its batch result.
type tunnelRuntimePendingError struct{ cause error }
func (e *tunnelRuntimePendingError) Error() string { return "运行时待同步: " + e.cause.Error() }
func isTunnelRuntimePending(err error) bool {
var pending *tunnelRuntimePendingError
return errors.As(err, &pending)
}
func tunnelRuntimePendingResponse(tunnelID int64, err error) response.R {
message := "配置已保存,部分节点运行时待同步:" + err.Error()
out := response.OK(map[string]interface{}{"runtimeStatus": "pending", "tunnelId": tunnelID, "message": message})
out.Msg = message
return out
}
// Reserve locally allocated ports with the initial desired state, before any
// remote acknowledgment. Remote ports remain unallocated until their provider
// returns; the second transaction replaces these rows with the final bindings.
func (h *Handler) persistPreparedTunnelChainsTx(tx *gorm.DB, state *tunnelCreateState) error {
save := func(node tunnelRuntimeNode, kind string, inx int) error {
return h.repo.CreateChainTunnelTx(tx, state.TunnelID, kind, node.NodeID, sql.NullInt64{Int64: int64(node.Port), Valid: node.Port > 0}, defaultString(node.Strategy, "round"), inx, defaultString(node.Protocol, "tls"), node.ConnectIP)
}
for i, node := range state.InNodes {
if err := save(node, "1", i+1); err != nil {
return err
}
}
for i, node := range state.OutNodes {
if err := save(node, "3", i+1); err != nil {
return err
}
}
for i, hop := range state.ChainHops {
for _, node := range hop {
if err := save(node, "2", i+1); err != nil {
return err
}
}
}
return nil
}
@@ -3,6 +3,8 @@ package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"sync"
"sync/atomic"
@@ -13,7 +15,6 @@ import (
)
const (
tunnelQualityProbeInterval = 1 * time.Second
tunnelQualityProbeTimeout = 8 * time.Second
tunnelQualityPingTimeoutMs = 5000
tunnelQualityPruneInterval = 10 * time.Minute
@@ -31,6 +32,26 @@ type TunnelQualityHop struct {
TargetPort int `json:"targetPort,omitempty"`
}
type TunnelQualityCandidateHop struct {
TunnelQualityHop
FromRole string `json:"fromRole"`
ToRole string `json:"toRole"`
HopIndex int `json:"hopIndex"`
Selected bool `json:"selected"`
ErrorMessage string `json:"errorMessage,omitempty"`
}
type tunnelQualityChainDetails struct {
PrimaryPath []TunnelQualityHop `json:"primaryPath,omitempty"`
CandidateHops []TunnelQualityCandidateHop `json:"candidateHops,omitempty"`
}
type tunnelQualityCandidateGroup struct {
role string
roleIndex int
nodes []chainNodeRecord
}
// tunnelQualitySnapshot is the in-memory latest probe result for a tunnel.
type tunnelQualitySnapshot struct {
TunnelID int64 `json:"tunnelId"`
@@ -56,7 +77,7 @@ type tunnelQualityProber struct {
cache sync.Map // tunnelID (int64) → *tunnelQualitySnapshot
ctx context.Context
cancel context.CancelFunc
interval time.Duration
wake chan struct{}
lastPrune int64
probing int32 // atomic flag: 1 = probeAll running, 0 = idle
probeNode bestExitProbeFunc
@@ -65,8 +86,8 @@ type tunnelQualityProber struct {
// newTunnelQualityProber creates a new prober (not yet running).
func newTunnelQualityProber(h *Handler) *tunnelQualityProber {
return &tunnelQualityProber{
handler: h,
interval: tunnelQualityProbeInterval,
handler: h,
wake: make(chan struct{}, 1),
}
}
@@ -86,6 +107,16 @@ func (p *tunnelQualityProber) Stop() {
p.cancel()
}
func (p *tunnelQualityProber) NotifyConfigChanged() {
if p == nil || p.wake == nil {
return
}
select {
case p.wake <- struct{}{}:
default:
}
}
// GetAll returns all cached quality snapshots (latest per tunnel).
func (p *tunnelQualityProber) GetAll() []tunnelQualitySnapshot {
var items []tunnelQualitySnapshot
@@ -109,20 +140,44 @@ func (p *tunnelQualityProber) loop() {
// Run once immediately
p.probeAll()
ticker := time.NewTicker(p.interval)
defer ticker.Stop()
for {
timer := time.NewTimer(p.probeInterval())
select {
case <-p.ctx.Done():
stopAndDrainTunnelQualityTimer(timer)
return
case <-ticker.C:
case <-p.wake:
stopAndDrainTunnelQualityTimer(timer)
continue
case <-timer.C:
p.probeAll()
p.maybePrune()
}
}
}
func stopAndDrainTunnelQualityTimer(timer *time.Timer) {
if timer == nil || timer.Stop() {
return
}
select {
case <-timer.C:
default:
}
}
func (p *tunnelQualityProber) probeInterval() time.Duration {
if p == nil || p.handler == nil || p.handler.repo == nil {
return time.Duration(monitoring.DefaultTunnelQualityProbeIntervalSec) * time.Second
}
cfg, err := p.handler.repo.GetConfigsByNames([]string{monitoring.ConfigTunnelQualityProbeIntervalSec})
if err != nil {
return time.Duration(monitoring.DefaultTunnelQualityProbeIntervalSec) * time.Second
}
seconds := monitoring.TunnelQualityProbeIntervalSecondsFromConfigMap(cfg)
return time.Duration(seconds) * time.Second
}
func (p *tunnelQualityProber) isEnabled() bool {
if p == nil || p.handler == nil {
return true
@@ -246,15 +301,28 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
options := diagnosisExecOptions{
commandTimeout: tunnelQualityProbeTimeout,
pingTimeoutMS: tunnelQualityPingTimeoutMs,
pingCount: 1,
timeoutMessage: "探测超时",
}
p.probeBestExitOwners(tunnelID, inNodes, midNodesGrouped, outNodes, ipPreference, options, probeTarget)
roundPinger := newBestExitRoundPinger(p.pingNode)
p.probeBestExitOwners(tunnelID, inNodes, midNodesGrouped, outNodes, ipPreference, options, probeTarget, roundPinger)
entry, _, entryOnline := p.firstOnlineChainNode(inNodes)
exit, _, exitOnline := p.firstOnlineChainNode(outNodes)
selectedNodeIDs := make(map[string]int64, 2+len(midNodesGrouped))
if entryOnline {
selectedNodeIDs[tunnelQualityGroupKey("entry", 0)] = entry.NodeID
}
if exitOnline {
selectedNodeIDs[tunnelQualityGroupKey("exit", 0)] = exit.NodeID
}
var primaryHops []TunnelQualityHop
switch tunnel.Type {
case 1:
// Port forwarding: entry → public probe target only.
if len(inNodes) > 0 {
lat, loss, err := p.pingNode(inNodes[0].NodeID, probeTarget.Host, probeTarget.Port, options)
if entryOnline {
lat, loss, err := roundPinger(entry.NodeID, probeTarget.Host, probeTarget.Port, options)
if err == nil {
snap.ExitToBingLatency = lat
snap.ExitToBingLoss = loss
@@ -262,24 +330,42 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
} else {
snap.ErrorMessage = err.Error()
}
} else {
snap.ErrorMessage = "入口节点均不在线"
}
case 2:
// Tunnel forwarding: entry → exit + exit → Bing
probeOK := true
if len(inNodes) > 0 && len(outNodes) > 0 {
var hops []TunnelQualityHop
if !entryOnline {
probeOK = false
snap.ErrorMessage = "入口节点均不在线"
snap.EntryToExitLatency = -1
snap.EntryToExitLoss = 100
} else if !exitOnline {
probeOK = false
snap.ErrorMessage = "出口节点均不在线"
snap.EntryToExitLatency = -1
snap.EntryToExitLoss = 100
} else {
var totalLat float64
remainingSuccessProb := 1.0
nodesInPath := make([]chainNodeRecord, 0, 2+len(midNodesGrouped))
nodesInPath = append(nodesInPath, inNodes[0])
for _, midGroup := range midNodesGrouped {
if len(midGroup) > 0 {
nodesInPath = append(nodesInPath, midGroup[0])
nodesInPath = append(nodesInPath, entry)
for midIndex, midGroup := range midNodesGrouped {
mid, _, online := p.firstOnlineChainNode(midGroup)
if !online {
probeOK = false
snap.ErrorMessage = "中间节点组均不在线"
break
}
nodesInPath = append(nodesInPath, mid)
selectedNodeIDs[tunnelQualityGroupKey("middle", midIndex)] = mid.NodeID
}
if probeOK {
nodesInPath = append(nodesInPath, exit)
}
nodesInPath = append(nodesInPath, outNodes[0])
for i := 0; i < len(nodesInPath)-1; i++ {
source := nodesInPath[i]
@@ -293,12 +379,12 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
}
targetNode, nodeErr := h.getNodeRecord(target.NodeID)
if nodeErr != nil || targetNode == nil {
if nodeErr != nil || !isTunnelProbeNodeOnline(targetNode) {
snap.ErrorMessage = "节点 " + target.NodeName + " 不可用"
probeOK = false
hop.Latency = -1
hop.Loss = 100
hops = append(hops, hop)
primaryHops = append(primaryHops, hop)
break
}
@@ -309,25 +395,25 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
probeOK = false
hop.Latency = -1
hop.Loss = 100
hops = append(hops, hop)
primaryHops = append(primaryHops, hop)
break
}
hop.TargetIP = targetIP
hop.TargetPort = targetPort
lat, loss, err := p.pingNode(source.NodeID, targetIP, targetPort, options)
lat, loss, err := roundPinger(source.NodeID, targetIP, targetPort, options)
if err == nil {
hop.Latency = lat
hop.Loss = loss
totalLat += lat
remainingSuccessProb *= (1.0 - loss/100.0)
hops = append(hops, hop)
primaryHops = append(primaryHops, hop)
} else {
probeOK = false
hop.Latency = -1
hop.Loss = 100
hops = append(hops, hop)
primaryHops = append(primaryHops, hop)
if snap.ErrorMessage == "" {
snap.ErrorMessage = err.Error()
}
@@ -342,17 +428,11 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
snap.EntryToExitLatency = -1
snap.EntryToExitLoss = 100
}
if len(hops) > 0 {
if b, err := json.Marshal(hops); err == nil {
snap.ChainDetails = string(b)
}
}
}
// Exit → Bing
if len(outNodes) > 0 {
lat, loss, err := p.pingNode(outNodes[0].NodeID, probeTarget.Host, probeTarget.Port, options)
if exitOnline {
lat, loss, err := roundPinger(exit.NodeID, probeTarget.Host, probeTarget.Port, options)
if err == nil {
snap.ExitToBingLatency = lat
snap.ExitToBingLoss = loss
@@ -367,8 +447,8 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
snap.Success = probeOK
default:
// Unknown type: entry → public probe target.
if len(inNodes) > 0 {
lat, loss, err := p.pingNode(inNodes[0].NodeID, probeTarget.Host, probeTarget.Port, options)
if entryOnline {
lat, loss, err := roundPinger(entry.NodeID, probeTarget.Host, probeTarget.Port, options)
if err == nil {
snap.ExitToBingLatency = lat
snap.ExitToBingLoss = loss
@@ -376,13 +456,215 @@ func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
} else {
snap.ErrorMessage = err.Error()
}
} else {
snap.ErrorMessage = "入口节点均不在线"
}
}
candidateHops := p.probeTunnelCandidateHops(
tunnel.Type,
inNodes,
midNodesGrouped,
outNodes,
selectedNodeIDs,
ipPreference,
options,
probeTarget,
roundPinger,
)
if len(primaryHops) > 0 || len(candidateHops) > 0 {
details := tunnelQualityChainDetails{
PrimaryPath: primaryHops,
CandidateHops: candidateHops,
}
if b, err := json.Marshal(details); err == nil {
snap.ChainDetails = string(b)
}
}
p.storeResult(snap)
}
func (p *tunnelQualityProber) probeBestExitOwners(tunnelID int64, inNodes []chainNodeRecord, chainHops [][]chainNodeRecord, outNodes []chainNodeRecord, ipPreference string, options diagnosisExecOptions, probeTarget tunnelProbeTarget) {
func tunnelQualityGroupKey(role string, index int) string {
return fmt.Sprintf("%s:%d", role, index)
}
func (p *tunnelQualityProber) probeTunnelCandidateHops(
tunnelType int,
inNodes []chainNodeRecord,
chainHops [][]chainNodeRecord,
outNodes []chainNodeRecord,
selectedNodeIDs map[string]int64,
ipPreference string,
options diagnosisExecOptions,
probeTarget tunnelProbeTarget,
ping bestExitProbeFunc,
) []TunnelQualityCandidateHop {
if p == nil || p.handler == nil || ping == nil {
return nil
}
if tunnelType != 2 {
return p.probePublicTargetCandidates("entry", 0, inNodes, selectedNodeIDs, options, probeTarget, ping)
}
groups := make([]tunnelQualityCandidateGroup, 0, 2+len(chainHops))
groups = append(groups, tunnelQualityCandidateGroup{role: "entry", roleIndex: 0, nodes: inNodes})
for i, hop := range chainHops {
groups = append(groups, tunnelQualityCandidateGroup{role: "middle", roleIndex: i, nodes: hop})
}
groups = append(groups, tunnelQualityCandidateGroup{role: "exit", roleIndex: 0, nodes: outNodes})
var items []TunnelQualityCandidateHop
for i := 0; i < len(groups)-1; i++ {
items = append(items, p.probeCandidateGroupLinks(
groups[i],
groups[i+1],
i,
selectedNodeIDs,
ipPreference,
options,
ping,
)...)
}
items = append(items, p.probePublicTargetCandidates(
"exit",
0,
outNodes,
selectedNodeIDs,
options,
probeTarget,
ping,
)...)
return items
}
func (p *tunnelQualityProber) probeCandidateGroupLinks(
fromGroup tunnelQualityCandidateGroup,
toGroup tunnelQualityCandidateGroup,
hopIndex int,
selectedNodeIDs map[string]int64,
ipPreference string,
options diagnosisExecOptions,
ping bestExitProbeFunc,
) []TunnelQualityCandidateHop {
items := make([]TunnelQualityCandidateHop, 0, len(fromGroup.nodes)*len(toGroup.nodes))
for _, source := range fromGroup.nodes {
for _, target := range toGroup.nodes {
item := TunnelQualityCandidateHop{
TunnelQualityHop: TunnelQualityHop{
FromNodeID: source.NodeID,
FromNodeName: source.NodeName,
ToNodeID: target.NodeID,
ToNodeName: target.NodeName,
Latency: -1,
Loss: 100,
},
FromRole: fromGroup.role,
ToRole: toGroup.role,
HopIndex: hopIndex,
Selected: selectedNodeIDs[tunnelQualityGroupKey(fromGroup.role, fromGroup.roleIndex)] == source.NodeID &&
selectedNodeIDs[tunnelQualityGroupKey(toGroup.role, toGroup.roleIndex)] == target.NodeID,
}
sourceNode, sourceErr := p.handler.getNodeRecord(source.NodeID)
if sourceErr != nil || !isTunnelProbeNodeOnline(sourceNode) {
item.ErrorMessage = "来源节点不在线"
items = append(items, item)
continue
}
targetNode, targetErr := p.handler.getNodeRecord(target.NodeID)
if targetErr != nil || !isTunnelProbeNodeOnline(targetNode) {
item.ErrorMessage = "目标节点不在线"
items = append(items, item)
continue
}
targetIP, targetPort, resolveErr := resolveChainProbeTarget(sourceNode, targetNode, target.Port, ipPreference, target.ConnectIP)
if resolveErr != nil {
item.ErrorMessage = resolveErr.Error()
items = append(items, item)
continue
}
item.TargetIP = targetIP
item.TargetPort = targetPort
latency, loss, probeErr := ping(source.NodeID, targetIP, targetPort, options)
if probeErr != nil {
item.ErrorMessage = probeErr.Error()
items = append(items, item)
continue
}
item.Latency = latency
item.Loss = loss
items = append(items, item)
}
}
return items
}
func (p *tunnelQualityProber) probePublicTargetCandidates(
fromRole string,
fromIndex int,
nodes []chainNodeRecord,
selectedNodeIDs map[string]int64,
options diagnosisExecOptions,
probeTarget tunnelProbeTarget,
ping bestExitProbeFunc,
) []TunnelQualityCandidateHop {
items := make([]TunnelQualityCandidateHop, 0, len(nodes))
for _, source := range nodes {
item := TunnelQualityCandidateHop{
TunnelQualityHop: TunnelQualityHop{
FromNodeID: source.NodeID,
FromNodeName: source.NodeName,
ToNodeName: formatTunnelProbeTarget(probeTarget),
Latency: -1,
Loss: 100,
TargetIP: probeTarget.Host,
TargetPort: probeTarget.Port,
},
FromRole: fromRole,
ToRole: "target",
HopIndex: fromIndex,
Selected: selectedNodeIDs[tunnelQualityGroupKey(fromRole, fromIndex)] == source.NodeID,
}
sourceNode, sourceErr := p.handler.getNodeRecord(source.NodeID)
if sourceErr != nil || !isTunnelProbeNodeOnline(sourceNode) {
item.ErrorMessage = "来源节点不在线"
items = append(items, item)
continue
}
latency, loss, probeErr := ping(source.NodeID, probeTarget.Host, probeTarget.Port, options)
if probeErr != nil {
item.ErrorMessage = probeErr.Error()
items = append(items, item)
continue
}
item.Latency = latency
item.Loss = loss
items = append(items, item)
}
return items
}
func isTunnelProbeNodeOnline(node *nodeRecord) bool {
return node != nil && (node.IsRemote == 1 || node.Status == 1)
}
func (p *tunnelQualityProber) firstOnlineChainNode(nodes []chainNodeRecord) (chainNodeRecord, *nodeRecord, bool) {
if p == nil || p.handler == nil {
return chainNodeRecord{}, nil, false
}
for _, candidate := range nodes {
node, err := p.handler.getNodeRecord(candidate.NodeID)
if err == nil && isTunnelProbeNodeOnline(node) {
return candidate, node, true
}
}
return chainNodeRecord{}, nil, false
}
func (p *tunnelQualityProber) probeBestExitOwners(tunnelID int64, inNodes []chainNodeRecord, chainHops [][]chainNodeRecord, outNodes []chainNodeRecord, ipPreference string, options diagnosisExecOptions, probeTarget tunnelProbeTarget, roundPinger bestExitProbeFunc) {
if p == nil || p.handler == nil || p.handler.bestExit == nil || len(outNodes) <= 1 {
return
}
@@ -404,9 +686,6 @@ func (p *tunnelQualityProber) probeBestExitOwners(tunnelID int64, inNodes []chai
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.pingNode)
for _, owner := range owners {
if nodeMap[owner.NodeID] == nil {
continue
@@ -444,6 +723,9 @@ func (p *tunnelQualityProber) tcpPingNode(nodeID int64, ip string, port int, opt
if nodeErr != nil {
return 0, 100, nodeErr
}
if !isTunnelProbeNodeOnline(node) {
return 0, 100, errors.New("节点不在线")
}
var pingData map[string]interface{}
var pingErr error
@@ -1,6 +1,7 @@
package handler
import (
"encoding/json"
"fmt"
"slices"
"testing"
@@ -26,6 +27,9 @@ func TestTunnelQualityProberUsesConfiguredProbeTarget(t *testing.T) {
p := newTunnelQualityProber(h)
var calls []string
p.probeNode = func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
if options.pingCount != 1 {
t.Fatalf("expected real-time quality probe count 1, got %d", options.pingCount)
}
calls = append(calls, fmt.Sprintf("%d|%s|%d", nodeID, ip, port))
return 10, 0, nil
}
@@ -46,6 +50,114 @@ func TestTunnelQualityProberUsesConfiguredProbeTarget(t *testing.T) {
}
}
func TestTunnelQualityProberSkipsAllOfflineExits(t *testing.T) {
h := setupProbeTargetTunnelHandler(t)
seedQualityForwardTunnel(t, h, 81, []int{0, 0, 0})
p := newTunnelQualityProber(h)
probeCalls := 0
p.probeNode = func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
probeCalls++
return 0, 100, fmt.Errorf("unexpected probe node=%d target=%s:%d", nodeID, ip, port)
}
p.probeTunnel(81)
if probeCalls != 0 {
t.Fatalf("expected no TCP probes when all exits are offline, got %d", probeCalls)
}
snaps := p.GetAll()
if len(snaps) != 1 {
t.Fatalf("expected one quality snapshot, got %+v", snaps)
}
if snaps[0].Success || snaps[0].ErrorMessage != "出口节点均不在线" {
t.Fatalf("expected offline exit snapshot, got %+v", snaps[0])
}
if snaps[0].EntryToExitLoss != 100 {
t.Fatalf("expected 100%% entry-to-exit loss, got %+v", snaps[0])
}
}
func TestTunnelQualityProberUsesOnlineBackupExit(t *testing.T) {
h := setupProbeTargetTunnelHandler(t)
seedQualityForwardTunnel(t, h, 82, []int{0, 1})
p := newTunnelQualityProber(h)
var calls []string
p.probeNode = func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
if options.pingCount != 1 {
t.Fatalf("expected real-time quality probe count 1, got %d", options.pingCount)
}
calls = append(calls, fmt.Sprintf("%d|%s|%d", nodeID, ip, port))
return 10, 0, nil
}
p.probeTunnel(82)
if slices.Contains(calls, "10|10.0.0.30|30030") {
t.Fatalf("did not expect probe to offline primary exit, calls=%+v", calls)
}
if !slices.Contains(calls, "10|10.0.0.31|30031") {
t.Fatalf("expected entry probe to online backup exit, calls=%+v", calls)
}
if !slices.Contains(calls, "31|www.bing.com|443") {
t.Fatalf("expected public probe from online backup exit, calls=%+v", calls)
}
snaps := p.GetAll()
if len(snaps) != 1 || !snaps[0].Success {
t.Fatalf("expected successful backup exit snapshot, got %+v", snaps)
}
}
func TestTunnelQualityProberReportsAllExitCandidateLatencies(t *testing.T) {
h := setupProbeTargetTunnelHandler(t)
seedQualityForwardTunnel(t, h, 83, []int{1, 1})
p := newTunnelQualityProber(h)
p.probeNode = func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
switch fmt.Sprintf("%d|%s|%d", nodeID, ip, port) {
case "10|10.0.0.30|30030":
return 20, 0, nil
case "10|10.0.0.31|30031":
return 35, 0, nil
case "30|www.bing.com|443":
return 50, 0, nil
case "31|www.bing.com|443":
return 65, 0, nil
default:
return 0, 100, fmt.Errorf("unexpected probe node=%d target=%s:%d", nodeID, ip, port)
}
}
p.probeTunnel(83)
snaps := p.GetAll()
if len(snaps) != 1 {
t.Fatalf("expected one quality snapshot, got %+v", snaps)
}
if snaps[0].EntryToExitLatency != 20 || snaps[0].ExitToBingLatency != 50 {
t.Fatalf("expected primary path metrics to remain unchanged, got %+v", snaps[0])
}
var details tunnelQualityChainDetails
if err := json.Unmarshal([]byte(snaps[0].ChainDetails), &details); err != nil {
t.Fatalf("decode chain details: %v", err)
}
assertCandidateHop := func(fromID, toID int64, latency float64, selected bool) {
t.Helper()
for _, hop := range details.CandidateHops {
if hop.FromNodeID == fromID && hop.ToNodeID == toID {
if hop.Latency != latency || hop.Selected != selected || hop.ErrorMessage != "" {
t.Fatalf("unexpected candidate hop: %+v", hop)
}
return
}
}
t.Fatalf("candidate hop %d -> %d not found in %+v", fromID, toID, details.CandidateHops)
}
assertCandidateHop(10, 30, 20, true)
assertCandidateHop(10, 31, 35, false)
assertCandidateHop(30, 0, 50, true)
assertCandidateHop(31, 0, 65, false)
}
func TestTunnelQualityProberStoresProbeTargetWhenChainIncomplete(t *testing.T) {
h := setupProbeTargetTunnelHandler(t)
seedProbeTargetTunnel(t, h, 78, "quality-target-incomplete", "speed.example.com", 8443)
@@ -67,3 +179,69 @@ func TestTunnelQualityProberStoresProbeTargetWhenChainIncomplete(t *testing.T) {
t.Fatalf("unexpected snapshot target metadata: %+v", snaps[0])
}
}
func TestTunnelQualityProberUsesConfiguredInterval(t *testing.T) {
h := setupProbeTargetTunnelHandler(t)
if err := h.repo.UpsertConfig("monitor_tunnel_quality_interval_sec", "15", time.Now().UnixMilli()); err != nil {
t.Fatalf("upsert interval config: %v", err)
}
p := newTunnelQualityProber(h)
if got := p.probeInterval(); got != 15*time.Second {
t.Fatalf("probe interval = %s, want 15s", got)
}
}
func TestTunnelQualityProberConfigNotificationIsCoalesced(t *testing.T) {
p := newTunnelQualityProber(nil)
p.NotifyConfigChanged()
p.NotifyConfigChanged()
if got := len(p.wake); got != 1 {
t.Fatalf("wake notifications = %d, want 1", got)
}
}
func TestNormalizeTunnelQualityProbeIntervalConfigValue(t *testing.T) {
got, err := normalizeAndValidateConfigValue("monitor_tunnel_quality_interval_sec", " 15 ")
if err != nil || got != "15" {
t.Fatalf("normalize interval = %q, %v", got, err)
}
if _, err := normalizeAndValidateConfigValue("monitor_tunnel_quality_interval_sec", "0"); err == nil {
t.Fatalf("expected invalid interval to be rejected")
}
}
func seedQualityForwardTunnel(t *testing.T, h *Handler, tunnelID int64, exitStatuses []int) {
t.Helper()
now := time.Now().UnixMilli()
if err := h.repo.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, inx, ip_preference, probe_target_host, probe_target_port)
VALUES(?, ?, 1, 2, 'tls', 1, ?, ?, 1, ?, '', '', 0)
`, tunnelID, fmt.Sprintf("quality-forward-%d", tunnelID), now, now, tunnelID).Error; err != nil {
t.Fatalf("insert forwarding tunnel: %v", err)
}
if err := h.repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, '1', 10, 30001, 'fifo', 1, 'tls')
`, tunnelID).Error; err != nil {
t.Fatalf("insert entry chain: %v", err)
}
for i, status := range exitStatuses {
nodeID := int64(30 + i)
port := 30030 + i
ip := fmt.Sprintf("10.0.0.%d", nodeID)
if err := h.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(?, ?, ?, ?, ?, '', '30000-30100', '', 'v1', 1, 1, 1, ?, ?, ?, '[::]', '[::]', 0)
`, nodeID, fmt.Sprintf("exit-%d", i+1), fmt.Sprintf("exit-secret-%d", i+1), ip, ip, now, now, status).Error; err != nil {
t.Fatalf("insert exit node %d: %v", nodeID, err)
}
if err := h.repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, '3', ?, ?, 'fifo', ?, 'tls')
`, tunnelID, nodeID, port, i+1).Error; err != nil {
t.Fatalf("insert exit chain %d: %v", nodeID, err)
}
}
}
@@ -0,0 +1,105 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"time"
"go-backend/internal/http/response"
)
type redeployProgress struct {
Total int `json:"total"`
Completed int `json:"completed"`
Success int `json:"success"`
Failed int `json:"failed"`
}
type redeployItemResult struct {
ID int64 `json:"id"`
Name string `json:"name"`
Success bool `json:"success"`
Reason string `json:"reason,omitempty"`
}
// tunnelBatchRedeployStream reports completed tunnels, not estimated runtime.
// A disconnected caller may have an in-flight mutation whose result it did not
// receive. Finish that mutation, but never start another or automatically retry.
func (h *Handler) tunnelBatchRedeployStream(w http.ResponseWriter, r *http.Request) {
ids := idsFromBody(r, w)
if ids == nil {
return
}
// idsFromBody sorts IDs; ignore duplicate selections so each tunnel runs once.
unique := ids[:0]
for _, id := range ids {
if len(unique) == 0 || unique[len(unique)-1] != id {
unique = append(unique, id)
}
}
ids = unique
if len(ids) == 0 {
response.WriteJSON(w, response.ErrDefault("ids不能为空"))
return
}
_, err := prepareDiagnosisStreamResponse(w)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
encoder := json.NewEncoder(w)
controller := http.NewResponseController(w)
emit := func(kind string, data interface{}) bool {
if r.Context().Err() != nil {
return false
}
// The server's WriteTimeout is an absolute request deadline. Renew only
// this stream's bounded write window as progress is emitted, so a long
// batch is not cut off despite making steady progress. Recorders and
// other non-network writers may not support deadlines.
if err := controller.SetWriteDeadline(time.Now().Add(2 * time.Minute)); err != nil && !errors.Is(err, http.ErrNotSupported) {
return false
}
event := diagnosisStreamEvent{Type: kind, Data: data, TS: time.Now().UnixMilli()}
if err := encoder.Encode(event); err != nil {
return false
}
// Buffered writes can succeed while the actual socket flush fails.
// Do not start a mutation after its processing event failed to flush.
return controller.Flush() == nil
}
progress := redeployProgress{Total: len(ids)}
result := batchOperationResult{}
if !emit("start", progress) {
return
}
for _, id := range ids {
if r.Context().Err() != nil {
return
}
name, _ := h.repo.GetTunnelName(id)
if !emit("processing", map[string]interface{}{"id": id, "name": name, "progress": progress}) {
return
}
if r.Context().Err() != nil {
return
}
item := redeployItemResult{ID: id, Name: name}
if err := h.redeployTunnelConfiguration(id); err != nil {
result.FailCount++
result.Failures = appendBatchFailure(result.Failures, id, name, err)
item.Reason = result.Failures[len(result.Failures)-1].Reason
progress.Failed++
} else {
result.SuccessCount++
item.Success = true
progress.Success++
}
progress.Completed++
if !emit("item", map[string]interface{}{"result": item, "progress": progress}) {
return
}
}
emit("done", map[string]interface{}{"progress": progress, "result": result})
}
@@ -0,0 +1,248 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"go-backend/internal/http/middleware"
runtimenft "go-backend/internal/runtime/nftables"
)
type blockingRedeployManager struct {
fakeNftablesManager
entered chan struct{}
release chan struct{}
once sync.Once
}
func (m *blockingRedeployManager) Reconcile(ctx context.Context, cfg runtimenft.SSHConfig, plan runtimenft.NodePlan) (runtimenft.ApplyResult, error) {
m.once.Do(func() { close(m.entered); <-m.release })
return m.fakeNftablesManager.Reconcile(ctx, cfg, plan)
}
func TestTunnelRedeployStreamFlushesBeforeSlowMutationAndReportsMixedResults(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
seedNftablesSSHConfig(t, h, fixture.nodeID)
id := seedTunnelForNftables(t, h, "stream-tunnel", fixture.nodeID)
manager := &blockingRedeployManager{entered: make(chan struct{}), release: make(chan struct{})}
h.nftablesManager = manager
var releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(manager.release) }) }
defer release()
server := httptest.NewServer(http.HandlerFunc(h.tunnelBatchRedeployStream))
defer server.Close()
client := &http.Client{Timeout: 5 * time.Second}
response, err := client.Post(server.URL, "application/json", strings.NewReader(fmt.Sprintf(`{"ids":[%d,999999,%d]}`, id, id)))
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
decoder := json.NewDecoder(response.Body)
read := func(want string) map[string]interface{} {
t.Helper()
var event struct {
Type string
Data map[string]interface{}
}
if err := decoder.Decode(&event); err != nil {
t.Fatal(err)
}
if event.Type != want {
t.Fatalf("expected %s, got %s", want, event.Type)
}
return event.Data
}
if start := read("start"); start["total"] != float64(2) {
t.Fatalf("duplicate IDs not deduplicated: %+v", start)
}
processing := read("processing")
if processing["name"] != "stream-tunnel" || processing["id"] != float64(id) {
t.Fatalf("wrong current tunnel: %+v", processing)
}
select {
case <-manager.entered:
case <-time.After(2 * time.Second):
t.Fatal("did not start mutation")
}
// Both events arrived over HTTP while the actual mutation is still blocked.
release()
item := read("item")
if item["result"].(map[string]interface{})["success"] != true || item["progress"].(map[string]interface{})["completed"] != float64(1) {
t.Fatalf("wrong success result: %+v", item)
}
read("processing")
item = read("item")
if item["result"].(map[string]interface{})["success"] != false || item["result"].(map[string]interface{})["reason"] == "" {
t.Fatalf("wrong failure result: %+v", item)
}
done := read("done")
progress := done["progress"].(map[string]interface{})
result := done["result"].(map[string]interface{})
if progress["completed"] != float64(2) || progress["success"] != float64(1) || progress["failed"] != float64(1) || result["successCount"] != float64(1) || result["failCount"] != float64(1) {
t.Fatalf("incorrect final result: %+v", done)
}
if manager.reconcileCount() != 1 {
t.Fatalf("expected one execution, got %d", manager.reconcileCount())
}
if err := decoder.Decode(new(interface{})); err != io.EOF {
t.Fatalf("expected EOF after done, got %v", err)
}
}
func TestTunnelRedeployStreamStopsAfterDisconnect(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
seedNftablesSSHConfig(t, h, fixture.nodeID)
first := seedTunnelForNftables(t, h, "first", fixture.nodeID)
second := seedTunnelForNftables(t, h, "second", fixture.nodeID)
manager := &blockingRedeployManager{entered: make(chan struct{}), release: make(chan struct{})}
h.nftablesManager = manager
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy/stream", strings.NewReader(fmt.Sprintf(`{"ids":[%d,%d]}`, first, second))).WithContext(ctx)
res := httptest.NewRecorder()
finished := make(chan struct{})
go func() { defer close(finished); h.tunnelBatchRedeployStream(res, req) }()
select {
case <-manager.entered:
case <-time.After(2 * time.Second):
close(manager.release)
t.Fatal("mutation did not start")
}
cancel()
close(manager.release)
select {
case <-finished:
case <-time.After(2 * time.Second):
t.Fatal("handler did not stop")
}
if manager.reconcileCount() != 1 {
t.Fatalf("started another mutation after disconnect: %d", manager.reconcileCount())
}
if strings.Contains(res.Body.String(), `"type":"done"`) {
t.Fatal("disconnected stream claimed completion")
}
}
type failedRedeployWriter struct{ *httptest.ResponseRecorder }
func (w failedRedeployWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }
func TestTunnelRedeployStreamDoesNotMutateWhenInitialWriteFails(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
seedNftablesSSHConfig(t, h, fixture.nodeID)
id := seedTunnelForNftables(t, h, "stream-tunnel", fixture.nodeID)
manager := &fakeNftablesManager{}
h.nftablesManager = manager
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(fmt.Sprintf(`{"ids":[%d]}`, id)))
h.tunnelBatchRedeployStream(failedRedeployWriter{httptest.NewRecorder()}, req)
if manager.reconcileCount() != 0 {
t.Fatal("mutation ran despite failed progress stream")
}
}
type delayedRedeployManager struct{ fakeNftablesManager }
func (m *delayedRedeployManager) Reconcile(ctx context.Context, cfg runtimenft.SSHConfig, plan runtimenft.NodePlan) (runtimenft.ApplyResult, error) {
time.Sleep(100 * time.Millisecond)
return m.fakeNftablesManager.Reconcile(ctx, cfg, plan)
}
type shortRedeployDeadlineWriter struct {
http.ResponseWriter
deadlineCalls *atomic.Int32
}
func (w shortRedeployDeadlineWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
func (w shortRedeployDeadlineWriter) SetWriteDeadline(deadline time.Time) error {
remaining := time.Until(deadline)
if remaining < time.Minute || remaining > 2*time.Minute {
return fmt.Errorf("expected bounded two-minute write window, got %v", remaining)
}
w.deadlineCalls.Add(1)
// Shorten the production idle window for a real, fast timeout regression.
return http.NewResponseController(w.ResponseWriter).SetWriteDeadline(time.Now().Add(200 * time.Millisecond))
}
func TestTunnelRedeployStreamRenewsDeadlineThroughRequestLog(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
seedNftablesSSHConfig(t, h, fixture.nodeID)
ids := make([]int64, 3)
for i := range ids {
ids[i] = seedTunnelForNftables(t, h, fmt.Sprintf("slow-%d", i), fixture.nodeID)
}
manager := &delayedRedeployManager{}
h.nftablesManager = manager
var deadlineCalls atomic.Int32
logged := middleware.RequestLog(http.HandlerFunc(h.tunnelBatchRedeployStream))
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logged.ServeHTTP(shortRedeployDeadlineWriter{w, &deadlineCalls}, r)
}))
server.Config.WriteTimeout = 50 * time.Millisecond
server.Start()
defer server.Close()
body, _ := json.Marshal(map[string]interface{}{"ids": ids})
client := &http.Client{Timeout: 5 * time.Second}
response, err := client.Post(server.URL, "application/json", strings.NewReader(string(body)))
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
payload, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("stream timed out across batch: %v (body %s)", err, payload)
}
if !strings.Contains(string(payload), `"type":"done"`) {
t.Fatalf("batch did not finish: %s", payload)
}
if manager.reconcileCount() != 3 {
t.Fatalf("wrong execution count: %d", manager.reconcileCount())
}
if deadlineCalls.Load() != 8 {
t.Fatalf("each of 8 events must renew its deadline, got %d", deadlineCalls.Load())
}
}
type flushFailureRedeployWriter struct {
*httptest.ResponseRecorder
flushes int
}
func (w *flushFailureRedeployWriter) FlushError() error {
w.flushes++
if w.flushes == 2 {
return io.ErrClosedPipe
}
w.ResponseRecorder.Flush()
return nil
}
func TestTunnelRedeployStreamStopsBeforeMutationOnProcessingFlushError(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
seedNftablesSSHConfig(t, h, fixture.nodeID)
id := seedTunnelForNftables(t, h, "flush-error", fixture.nodeID)
manager := &fakeNftablesManager{}
h.nftablesManager = manager
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(fmt.Sprintf(`{"ids":[%d]}`, id)))
writer := &flushFailureRedeployWriter{ResponseRecorder: httptest.NewRecorder()}
middleware.RequestLog(http.HandlerFunc(h.tunnelBatchRedeployStream)).ServeHTTP(writer, req)
if writer.flushes != 2 {
t.Fatalf("expected start and processing flush, got %d", writer.flushes)
}
if manager.reconcileCount() != 0 {
t.Fatal("mutation ran after failed processing flush")
}
}
@@ -0,0 +1,180 @@
package handler
import (
"errors"
"fmt"
"go-backend/internal/store/model"
"gorm.io/gorm"
)
func tunnelStateRuntimeRows(state *tunnelCreateState) []chainNodeRecord {
if state.Type != 2 {
return nil
}
var rows []chainNodeRecord
for _, n := range state.InNodes {
rows = append(rows, chainNodeRecord{NodeID: n.NodeID, ChainType: 1})
}
for _, hop := range state.ChainHops {
for _, n := range hop {
rows = append(rows, chainNodeRecord{NodeID: n.NodeID, ChainType: 2, Port: n.Port})
}
}
for _, n := range state.OutNodes {
rows = append(rows, chainNodeRecord{NodeID: n.NodeID, ChainType: 3, Port: n.Port})
}
return rows
}
func (h *Handler) queueObsoleteTunnelRuntimeTx(tx *gorm.DB, tunnelID int64, oldRows, newRows []chainNodeRecord) error {
var items []model.TunnelRuntimeCleanup
for _, nodeID := range removedTunnelRuntimeNodeIDs(oldRows, newRows, tunnelRuntimeNeedsService) {
port := 0
for _, row := range oldRows {
if row.NodeID == nodeID {
port = row.Port
break
}
}
items = append(items, model.TunnelRuntimeCleanup{TunnelID: tunnelID, NodeID: nodeID, Kind: "service", Port: port})
}
for _, old := range oldRows {
if !tunnelRuntimeNeedsService(old) {
continue
}
for _, current := range newRows {
if current.NodeID == old.NodeID && tunnelRuntimeNeedsService(current) && (current.Port != old.Port || current.ChainType != old.ChainType) {
items = append(items, model.TunnelRuntimeCleanup{TunnelID: tunnelID, NodeID: old.NodeID, Kind: "service", Port: old.Port})
}
}
}
for _, nodeID := range removedTunnelRuntimeNodeIDs(oldRows, newRows, tunnelRuntimeNeedsChain) {
items = append(items, model.TunnelRuntimeCleanup{TunnelID: tunnelID, NodeID: nodeID, Kind: "chain"})
}
return h.repo.QueueTunnelRuntimeCleanupTx(tx, items)
}
// Caller holds the tunnel lifecycle lock. Recheck current desired roles before
// every retry so a delayed deletion cannot remove a re-added runtime.
func (h *Handler) retryTunnelRuntimeCleanupLocked(tunnelID int64) error {
items, err := h.repo.ListTunnelRuntimeCleanup(tunnelID)
if err != nil {
return err
}
if len(items) == 0 {
return nil
}
tunnel, err := h.repo.GetTunnelRecord(tunnelID)
if err != nil {
return err
}
var rows []chainNodeRecord
if tunnel != nil && tunnel.Type == 2 {
rows, err = h.listChainNodesForTunnel(tunnelID)
if err != nil {
return err
}
}
var failures []error
liveServices := map[int64]bool{}
for _, item := range items {
desired := false
for _, row := range rows {
if row.NodeID == item.NodeID && ((item.Kind == "service" && tunnelRuntimeNeedsService(row)) || (item.Kind == "chain" && tunnelRuntimeNeedsChain(row))) {
desired = true
break
}
}
if desired && item.Kind == "service" {
// Desired state does not prove that the old receiver was replaced.
// Keep this barrier (even at the same port) and protect its old
// chain until acknowledgeTunnelServiceReplacements observes ACK.
liveServices[item.NodeID] = true
continue
}
if !desired {
if item.Kind == "chain" && liveServices[item.NodeID] {
continue
}
var commandErr error
if item.Kind == "service" {
_, commandErr = h.sendNodeCommand(item.NodeID, "DeleteService", map[string]interface{}{"services": tunnelRuntimeServiceNames(tunnelID)}, false, true)
} else {
_, commandErr = h.sendNodeCommand(item.NodeID, "DeleteChains", map[string]interface{}{"chain": fmt.Sprintf("chains_%d", tunnelID)}, false, true)
}
if commandErr != nil {
if item.Kind == "service" {
liveServices[item.NodeID] = true
}
failures = append(failures, fmt.Errorf("节点 %d 旧运行时清理失败: %w", item.NodeID, commandErr))
continue
}
}
if err := h.repo.DeleteTunnelRuntimeCleanup(item.ID); err != nil {
if item.Kind == "service" {
liveServices[item.NodeID] = true
}
failures = append(failures, err)
}
}
return errors.Join(failures...)
}
func (h *Handler) acknowledgeTunnelServiceReplacements(tunnelID int64, nodeIDs []int64) error {
if len(nodeIDs) == 0 {
return nil
}
items, err := h.repo.ListTunnelRuntimeCleanup(tunnelID)
if err != nil {
return err
}
for _, item := range items {
if item.Kind == "service" {
for _, nodeID := range nodeIDs {
if item.NodeID == nodeID {
if err := h.repo.DeleteTunnelRuntimeCleanup(item.ID); err != nil {
return err
}
break
}
}
}
}
return nil
}
func (h *Handler) retryPendingTunnelRuntimeCleanup() error {
return h.retryPendingTunnelRuntimeCleanupOnNode(0)
}
func (h *Handler) retryPendingTunnelRuntimeCleanupOnNode(nodeID int64) error {
items, err := h.repo.ListTunnelRuntimeCleanup(0)
if err != nil {
return err
}
seen := map[int64]bool{}
var failures []error
for _, item := range items {
if nodeID > 0 && item.NodeID != nodeID {
continue
}
if seen[item.TunnelID] {
continue
}
seen[item.TunnelID] = true
unlock := h.lockTunnelLifecycle(item.TunnelID)
err := h.retryTunnelRuntimeCleanupLocked(item.TunnelID)
unlock()
if err != nil {
failures = append(failures, err)
}
}
return errors.Join(failures...)
}
// The desired tunnel has already been deleted; callers must not roll back a
// completed forward migration merely because asynchronous cleanup is pending.
type tunnelDeletionCleanupPendingError struct{ cause error }
func (e *tunnelDeletionCleanupPendingError) Error() string {
return "隧道配置已删除,运行时清理待重试: " + e.cause.Error()
}
func (e *tunnelDeletionCleanupPendingError) Unwrap() error { return e.cause }
+97 -7
View File
@@ -398,15 +398,83 @@ func (h *Handler) consumeNodePendingUpgradeRedeploy(nodeID int64) bool {
}
func (h *Handler) onNodeOnline(nodeID int64) {
if h == nil || h.repo == nil || h.wsServer == nil {
return
}
if node, err := h.getNodeRecord(nodeID); err == nil && (node == nil || node.Status != 1) {
return // The next connection will resume any pending reconciliation.
}
if !h.startNodeOnlineRedeploy(nodeID, time.Now()) {
return
}
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)
// A fresh agent needs a full restore. Shared failures remain persisted and
// are retried by maintenance without restarting acknowledged services.
if err := h.retryPendingTunnelRuntimeCleanupOnNode(nodeID); err != nil {
fmt.Printf("reconnect obsolete tunnel cleanup remains pending: %v\n", err)
}
h.reconcileSharedNodeRuntime(nodeID)
if !h.redeployLocalNodeRuntime(nodeID) {
h.scheduleNodeLocalRuntimeRetry(nodeID)
} else {
h.clearNodeLocalRuntimeRetry(nodeID)
}
}
// Local retries are separate from reconnect reconciliation: a failed local
// forward must not cause every shared listener to be replayed every 30 seconds.
func (h *Handler) scheduleNodeLocalRuntimeRetry(nodeID int64) {
h.upgradeMu.Lock()
defer h.upgradeMu.Unlock()
if h.nodeLocalRuntimeRetryQueued == nil {
h.nodeLocalRuntimeRetryQueued = make(map[int64]struct{})
}
if _, queued := h.nodeLocalRuntimeRetryQueued[nodeID]; queued {
return
}
h.nodeLocalRuntimeRetryQueued[nodeID] = struct{}{}
time.AfterFunc(nodeOnlineRedeployCooldown, func() {
h.upgradeMu.Lock()
_, queued := h.nodeLocalRuntimeRetryQueued[nodeID]
delete(h.nodeLocalRuntimeRetryQueued, nodeID)
h.upgradeMu.Unlock()
if !queued {
return
}
h.retryNodeLocalRuntime(nodeID)
})
}
func (h *Handler) clearNodeLocalRuntimeRetry(nodeID int64) {
h.upgradeMu.Lock()
delete(h.nodeLocalRuntimeRetryQueued, nodeID)
h.upgradeMu.Unlock()
}
func (h *Handler) retryNodeLocalRuntime(nodeID int64) {
if h == nil || h.repo == nil || h.wsServer == nil {
return
}
if node, err := h.getNodeRecord(nodeID); err == nil && (node == nil || node.Status != 1) {
return
}
h.upgradeMu.Lock()
if _, inFlight := h.nodeOnlineRedeploying[nodeID]; inFlight {
h.upgradeMu.Unlock()
h.scheduleNodeLocalRuntimeRetry(nodeID)
return
}
if h.nodeOnlineRedeploying == nil {
h.nodeOnlineRedeploying = make(map[int64]struct{})
}
h.nodeOnlineRedeploying[nodeID] = struct{}{}
h.upgradeMu.Unlock()
defer h.finishNodeOnlineRedeploy(nodeID)
if !h.redeployLocalNodeRuntime(nodeID) {
h.scheduleNodeLocalRuntimeRetry(nodeID)
} else {
h.clearNodeLocalRuntimeRetry(nodeID)
}
}
@@ -503,6 +571,26 @@ func (h *Handler) finishNodeOnlineRedeploy(nodeID int64) {
}
func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) bool {
cleanupOK := h.retryPendingTunnelRuntimeCleanupOnNode(nodeID) == nil
sharedOK := h.reconcileSharedNodeRuntime(nodeID)
localOK := h.redeployLocalNodeRuntime(nodeID)
return cleanupOK && sharedOK && localOK
}
func (h *Handler) reconcileSharedNodeRuntime(nodeID int64) bool {
succeeded := true
if err := h.reconcilePeerShareResourcesOnNode(nodeID); err != nil {
fmt.Printf("reconnect shared resource reconciliation failed on node %d: %v\n", nodeID, err)
succeeded = false
}
if err := h.reconcilePeerShareRoleRuntimesOnNode(nodeID); err != nil {
fmt.Printf("reconnect shared role reconciliation failed on node %d: %v\n", nodeID, err)
succeeded = false
}
return succeeded
}
func (h *Handler) redeployLocalNodeRuntime(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)
@@ -567,6 +655,7 @@ func (h *Handler) retryFailedRedeploys(nodeID int64, tunnelFailed map[int64]stru
return true
}
permanentFailure := false
const maxRetries = 3
baseDelay := time.Second
@@ -580,7 +669,8 @@ func (h *Handler) retryFailedRedeploys(nodeID int64, tunnelFailed map[int64]stru
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
permanentFailure = true
delete(tunnelFailed, tunnelID) // Preserve failure while avoiding immediate retries.
} else {
fmt.Printf("post-upgrade redeploy retry: tunnel %d still failing on node %d (attempt %d): %v\n", tunnelID, nodeID, attempt, err)
}
@@ -596,7 +686,7 @@ func (h *Handler) retryFailedRedeploys(nodeID int64, tunnelFailed map[int64]stru
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
permanentFailure = true // Keep reconciliation pending for a later reconnect.
} 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)
@@ -606,7 +696,7 @@ func (h *Handler) retryFailedRedeploys(nodeID int64, tunnelFailed map[int64]stru
if len(tunnelFailed) == 0 && len(failedForwards) == 0 {
fmt.Printf("post-upgrade redeploy retry: all items recovered on node %d\n", nodeID)
return true
return !permanentFailure
}
}
@@ -12,7 +12,7 @@ func TestStartNodeOnlineRedeploySkipsRecentReconnects(t *testing.T) {
nodeOnlineRedeployQueued: map[int64]struct{}{},
nodeOnlineRedeploying: map[int64]struct{}{},
}
now := time.Unix(1_777_176_720, 0)
now := time.Now()
if !h.startNodeOnlineRedeploy(54, now) {
t.Fatalf("expected first reconnect to redeploy")
@@ -34,7 +34,7 @@ func TestStartNodeOnlineRedeployAllowsPendingUpgradeDuringCooldown(t *testing.T)
nodeOnlineRedeployQueued: map[int64]struct{}{},
nodeOnlineRedeploying: map[int64]struct{}{},
}
now := time.Unix(1_777_176_720, 0)
now := time.Now()
if !h.startNodeOnlineRedeploy(54, now) {
t.Fatalf("expected first reconnect to redeploy")
@@ -57,7 +57,7 @@ func TestStartNodeOnlineRedeployQueuesCooldownReconnect(t *testing.T) {
nodeOnlineRedeployQueued: map[int64]struct{}{},
nodeOnlineRedeploying: map[int64]struct{}{},
}
now := time.Unix(1_777_176_720, 0)
now := time.Now()
if !h.startNodeOnlineRedeploy(54, now) {
t.Fatalf("expected first reconnect to redeploy")
@@ -67,7 +67,10 @@ func TestStartNodeOnlineRedeployQueuesCooldownReconnect(t *testing.T) {
if h.startNodeOnlineRedeploy(54, now.Add(5*time.Second)) {
t.Fatalf("expected cooldown reconnect to skip immediate redeploy")
}
if _, queued := h.nodeOnlineRedeployQueued[54]; !queued {
h.upgradeMu.Lock()
_, queued := h.nodeOnlineRedeployQueued[54]
h.upgradeMu.Unlock()
if !queued {
t.Fatalf("expected cooldown reconnect to queue a follow-up redeploy")
}
}
@@ -79,7 +82,7 @@ func TestStartNodeOnlineRedeployKeepsPendingUpgradeWhileInFlight(t *testing.T) {
nodeOnlineRedeployQueued: map[int64]struct{}{},
nodeOnlineRedeploying: map[int64]struct{}{},
}
now := time.Unix(1_777_176_720, 0)
now := time.Now()
if !h.startNodeOnlineRedeploy(54, now) {
t.Fatalf("expected first reconnect to redeploy")
@@ -96,7 +99,7 @@ func TestStartNodeOnlineRedeployKeepsPendingUpgradeWhileInFlight(t *testing.T) {
}
func TestNextNodeOnlineRedeployFireAtDefersExpiredInFlightReconnect(t *testing.T) {
now := time.Unix(1_777_176_720, 0)
now := time.Now()
last := now.Add(-nodeOnlineRedeployCooldown - 5*time.Second)
fireAt, start := nextNodeOnlineRedeployFireAt(last, now, false, true)
@@ -0,0 +1,527 @@
package handler
import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"go-backend/internal/http/response"
"go-backend/internal/store/model"
"go-backend/internal/ws"
)
func TestUserUpdateDisablesPermissionsAndRunningForwards(t *testing.T) {
for _, password := range []string{"", "replacement-password"} {
t.Run(fmt.Sprintf("password_changed=%t", password != ""), func(t *testing.T) {
a := newCleanupAgent(t)
now := time.Now().UnixMilli()
for _, userID := range []int64{2, 3} {
if err := a.h.repo.DB().Create(&model.User{ID: userID, User: fmt.Sprintf("user%d", userID), Pwd: "pwd", RoleID: 1, Status: 1, ExpTime: now + 86400000, Flow: 100, Num: 10}).Error; err != nil {
t.Fatal(err)
}
}
if err := a.h.repo.DB().Create(&model.Tunnel{ID: 1, Name: "test", Type: 1, Protocol: "tls", Status: 1, Flow: 1}).Error; err != nil {
t.Fatal(err)
}
for _, permission := range []model.UserTunnel{
{ID: 10, UserID: 2, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
{ID: 11, UserID: 2, TunnelID: 2, Status: 0, Flow: 100, Num: 10},
{ID: 12, UserID: 3, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
} {
if err := a.h.repo.DB().Create(&permission).Error; err != nil {
t.Fatal(err)
}
}
for _, forward := range []model.Forward{
{ID: 20, UserID: 2, UserName: "user2", Name: "running", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 1},
{ID: 21, UserID: 2, UserName: "user2", Name: "paused", TunnelID: 1, RemoteAddr: "127.0.0.1:81", Status: 0},
{ID: 22, UserID: 3, UserName: "user3", Name: "other-user", TunnelID: 1, RemoteAddr: "127.0.0.1:82", Status: 1},
} {
if err := a.h.repo.DB().Create(&forward).Error; err != nil {
t.Fatal(err)
}
if err := a.h.repo.DB().Create(&model.ForwardPort{ForwardID: forward.ID, NodeID: 1, Port: 31000 + int(forward.ID)}).Error; err != nil {
t.Fatal(err)
}
}
update := func(status int) {
t.Helper()
payload, err := json.Marshal(map[string]interface{}{"id": 2, "user": "user2", "status": status, "pwd": password, "flow": 100, "num": 10, "expTime": now + 86400000})
if err != nil {
t.Fatal(err)
}
res := httptest.NewRecorder()
a.h.userUpdate(res, httptest.NewRequest(http.MethodPost, "/api/v1/user/update", bytes.NewReader(payload)))
var result response.R
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Code != 0 {
t.Fatalf("update user: %d %s", result.Code, result.Msg)
}
}
update(1)
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user_tunnel WHERE id = 10"); got != 1 {
t.Errorf("ordinary account update disabled an enabled permission: %d", got)
}
if len(a.commandsOfType("PauseService")) != 0 {
t.Error("ordinary account update paused services")
}
update(0)
for _, check := range []struct {
table string
id int64
want int
}{{"user", 2, 0}, {"user_tunnel", 10, 0}, {"user_tunnel", 11, 0}, {"user_tunnel", 12, 1}, {"forward", 20, 0}, {"forward", 21, 0}, {"forward", 22, 1}} {
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM "+check.table+" WHERE id = ?", check.id); got != check.want {
t.Errorf("%s %d status = %d, want %d", check.table, check.id, got, check.want)
}
}
commands := a.commandsOfType("PauseService")
if len(commands) == 0 {
t.Error("running forward never received PauseService")
}
for _, command := range commands {
var data struct {
Services []string `json:"services"`
}
if err := json.Unmarshal(command.Data, &data); err != nil {
t.Fatal(err)
}
for _, name := range data.Services {
if !strings.HasPrefix(name, "20_2_") {
t.Errorf("paused unrelated service %q", name)
}
}
}
update(0)
if len(a.commandsOfType("PauseService")) != len(commands) {
t.Error("repeated disable replayed pauses for already paused forwards")
}
// Account edits and re-enabling must not grant disabled permissions or
// resume forwards that an administrator previously paused.
update(1)
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user_tunnel WHERE id = 10"); got != 0 {
t.Errorf("account re-enable unexpectedly granted a disabled permission: %d", got)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user_tunnel WHERE id = 11"); got != 0 {
t.Errorf("re-enabled independently disabled permission: %d", got)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 21"); got != 0 {
t.Errorf("resumed previously paused forward: %d", got)
}
if len(a.commandsOfType("ResumeService")) != 0 {
t.Error("account re-enable unexpectedly resumed services")
}
})
}
}
func TestUserDisableMultiEntryNeverRecreatesMissingServices(t *testing.T) {
for _, secondNodeState := range []string{"missing", "offline", "timeout"} {
t.Run(secondNodeState, func(t *testing.T) {
a := newCleanupAgent(t)
var remoteMu sync.Mutex
remoteServices := make(map[string]bool)
var remoteCommands []string
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var command struct {
Type string `json:"commandType"`
Data json.RawMessage `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&command); err != nil {
t.Error(err)
return
}
remoteMu.Lock()
defer remoteMu.Unlock()
remoteCommands = append(remoteCommands, command.Type)
switch command.Type {
case "UpdateService", "AddService":
var services []struct {
Name string `json:"name"`
}
if err := json.Unmarshal(command.Data, &services); err != nil {
t.Error(err)
return
}
for _, service := range services {
remoteServices[service.Name] = true
}
case "PauseService":
if secondNodeState != "missing" {
message := "节点不在线"
if secondNodeState == "timeout" {
message = "等待节点响应超时"
}
response.WriteJSON(w, response.ErrDefault(message))
return
}
var data struct {
Services []string `json:"services"`
}
if err := json.Unmarshal(command.Data, &data); err != nil {
t.Error(err)
return
}
for _, name := range data.Services {
if _, exists := remoteServices[name]; !exists {
response.WriteJSON(w, response.OK(map[string]interface{}{"success": false, "message": "service not found"}))
return
}
remoteServices[name] = false
}
}
response.WriteJSON(w, response.OK(map[string]interface{}{"success": true, "type": command.Type}))
}))
defer remote.Close()
now := time.Now().UnixMilli()
userID, err := a.h.repo.CreateUser("multi-entry-disable", "pwd", 1, now+86400000, 100, 1, 10, 1, 0, now)
if err != nil {
t.Fatal(err)
}
for _, value := range []interface{}{
&model.Node{ID: 2, Name: "second-entry", Secret: "test", ServerIP: "127.0.0.1", Status: 1, IsRemote: 1, RemoteURL: sql.NullString{String: remote.URL, Valid: true}, RemoteToken: sql.NullString{String: "test-token", Valid: true}},
&model.Tunnel{ID: 1, Name: "multi-entry", Type: 1, Status: 1, Protocol: "tls", Flow: 1},
&model.ChainTunnel{TunnelID: 1, NodeID: 1, ChainType: "1"},
&model.ChainTunnel{TunnelID: 1, NodeID: 2, ChainType: "1"},
&model.UserTunnel{ID: 10, UserID: userID, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
&model.Forward{ID: 20, UserID: userID, UserName: "multi-entry-disable", Name: "running", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 1},
&model.ForwardPort{ForwardID: 20, NodeID: 1, Port: 31020},
&model.ForwardPort{ForwardID: 20, NodeID: 2, Port: 31020},
} {
if err := a.h.repo.DB().Create(value).Error; err != nil {
t.Fatal(err)
}
}
res := postJSONToHandler(t, a.h.userUpdate, map[string]interface{}{"id": userID, "user": "multi-entry-disable", "status": 0})
var out response.R
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
wantStatus := 1
if secondNodeState == "missing" {
wantStatus = 0
if out.Code != 0 {
t.Errorf("missing service should count as stopped: %s", out.Msg)
}
} else if out.Code == 0 {
t.Error("unconfirmed second entry pause returned success")
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != wantStatus {
t.Errorf("forward status = %d, want %d", got, wantStatus)
}
a.mu.Lock()
commands := append([]cleanupAgentCommand(nil), a.commands...)
a.mu.Unlock()
firstEntryRunning := true
for _, command := range commands {
switch command.Type {
case "PauseService":
firstEntryRunning = false
case "UpdateService", "AddService", "ResumeService":
firstEntryRunning = true
t.Errorf("disable sent %s to already paused entry A", command.Type)
}
}
if firstEntryRunning {
t.Error("entry A is running after disable")
}
remoteMu.Lock()
defer remoteMu.Unlock()
if len(remoteCommands) == 0 {
t.Error("entry B was never checked")
}
for _, command := range remoteCommands {
if command != "PauseService" {
t.Errorf("disable sent %s to entry B", command)
}
}
for name, running := range remoteServices {
if running {
t.Errorf("entry B service %s is still running", name)
}
}
})
}
}
func TestUserDisableContinuesAcrossNodesAndForwards(t *testing.T) {
for _, failure := range []string{"节点不在线", "等待节点响应超时", "pause rejected"} {
for _, failedNodeFirst := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/failed_node_first=%t", failure, failedNodeFirst), func(t *testing.T) {
a := newCleanupAgent(t)
var mu sync.Mutex
recovered := false
attempts := make(map[string]int)
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var command struct {
Type string `json:"commandType"`
}
if err := json.NewDecoder(r.Body).Decode(&command); err != nil {
t.Error(err)
return
}
if command.Type != "PauseService" {
t.Errorf("disable sent %s", command.Type)
}
mu.Lock()
attempts[r.Header.Get("Authorization")]++
isRecovered := recovered
mu.Unlock()
if !isRecovered {
response.WriteJSON(w, response.ErrDefault(failure))
return
}
response.WriteJSON(w, response.OK(map[string]interface{}{"success": true, "type": command.Type}))
}))
defer remote.Close()
now := time.Now().UnixMilli()
userID, err := a.h.repo.CreateUser("continue-disable", "pwd", 1, now+86400000, 100, 1, 10, 1, 0, now)
if err != nil {
t.Fatal(err)
}
for _, value := range []interface{}{
&model.Tunnel{ID: 1, Name: "multi-entry", Type: 2, Status: 1, Flow: 1},
&model.Node{ID: 4, Name: "offline-middle", Secret: "test", ServerIP: "127.0.0.1", Status: 0},
&model.ChainTunnel{TunnelID: 1, NodeID: 4, ChainType: "2"},
&model.UserTunnel{ID: 10, UserID: userID, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
&model.Forward{ID: 20, UserID: userID, UserName: "continue-disable", Name: "mixed-nodes", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 1},
&model.Forward{ID: 21, UserID: userID, UserName: "continue-disable", Name: "healthy-forward", TunnelID: 1, RemoteAddr: "127.0.0.1:81", Status: 1},
} {
if err := a.h.repo.DB().Create(value).Error; err != nil {
t.Fatal(err)
}
}
for _, nodeID := range []int64{2, 3} {
node := model.Node{ID: nodeID, Name: fmt.Sprintf("failed-node-%d", nodeID), Secret: "test", ServerIP: "127.0.0.1", Status: 1, IsRemote: 1, RemoteURL: sql.NullString{String: remote.URL, Valid: true}, RemoteToken: sql.NullString{String: fmt.Sprintf("test-node-%d", nodeID), Valid: true}}
if err := a.h.repo.DB().Create(&node).Error; err != nil {
t.Fatal(err)
}
}
nodeOrder := []int64{1, 2, 3}
if failedNodeFirst {
nodeOrder = []int64{2, 1, 3}
}
for _, nodeID := range nodeOrder {
if err := a.h.repo.DB().Create(&model.ForwardPort{ForwardID: 20, NodeID: nodeID, Port: 31020}).Error; err != nil {
t.Fatal(err)
}
if err := a.h.repo.DB().Create(&model.ChainTunnel{TunnelID: 1, NodeID: nodeID, ChainType: "1"}).Error; err != nil {
t.Fatal(err)
}
}
if err := a.h.repo.DB().Create(&model.ForwardPort{ForwardID: 21, NodeID: 1, Port: 31021}).Error; err != nil {
t.Fatal(err)
}
update := func() response.R {
t.Helper()
res := postJSONToHandler(t, a.h.userUpdate, map[string]interface{}{"id": userID, "user": "continue-disable", "status": 0})
var out response.R
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
out := update()
if out.Code == 0 {
t.Error("partial node failure reported complete success")
}
state, ok := out.Data.(map[string]interface{})
if !ok || state["userDisabled"] != true {
t.Errorf("partial response did not tell the UI the account is disabled: %#v", out.Data)
}
for _, nodeID := range []int64{2, 3} {
if !strings.Contains(out.Msg, fmt.Sprintf("failed-node-%d", nodeID)) || !strings.Contains(out.Msg, fmt.Sprintf("ID %d", nodeID)) {
t.Errorf("failure response did not identify node %d: %s", nodeID, out.Msg)
}
mu.Lock()
count := attempts[fmt.Sprintf("Bearer test-node-%d", nodeID)]
mu.Unlock()
if count == 0 {
t.Errorf("failure on another node prevented node %d from being attempted", nodeID)
}
}
paused := make(map[string]bool)
for _, command := range a.commandsOfType("PauseService") {
var data struct {
Services []string `json:"services"`
}
if err := json.Unmarshal(command.Data, &data); err != nil {
t.Fatal(err)
}
for _, name := range data.Services {
paused[strings.Split(name, "_")[0]] = true
}
}
for _, id := range []string{"20", "21"} {
if !paused[id] {
t.Errorf("healthy node never paused forward %s", id)
}
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != 1 {
t.Errorf("unconfirmed forward status = %d", got)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 21"); got != 0 {
t.Errorf("healthy later forward was not paused: %d", got)
}
mu.Lock()
recovered = true
mu.Unlock()
if retry := update(); retry.Code != 0 {
t.Errorf("retry after recovery failed: %s", retry.Msg)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != 0 {
t.Errorf("retry did not confirm all nodes: %d", got)
}
for _, kind := range []string{"UpdateService", "AddService", "ResumeService"} {
if len(a.commandsOfType(kind)) != 0 {
t.Errorf("disable sent %s", kind)
}
}
})
}
}
}
func TestUserDisableRemovesNftablesRuleOnlyAfterConfirmedApply(t *testing.T) {
fixture := setupNftablesHandler(t)
h := fixture.handler
t.Cleanup(func() { _ = h.repo.Close() })
seedNftablesSSHConfig(t, h, fixture.nodeID)
tunnelID := seedTunnelForNftables(t, h, "disable-nft", fixture.nodeID)
forward := seedForwardForNftables(t, h, tunnelID, fixture.nodeID, "203.0.113.9:8080")
now := time.Now().UnixMilli()
userID, err := h.repo.CreateUser("disable-nft", "pwd", 1, now+86400000, 100, 1, 10, 1, 0, now)
if err != nil {
t.Fatal(err)
}
if err := h.repo.DB().Model(&model.Forward{}).Where("id = ?", forward.ID).Update("user_id", userID).Error; err != nil {
t.Fatal(err)
}
if err := h.repo.DB().Create(&model.UserTunnel{UserID: userID, TunnelID: tunnelID, Status: 1, Flow: 100, Num: 10}).Error; err != nil {
t.Fatal(err)
}
otherForwardID := forward.ID + 100
for _, value := range []interface{}{
&model.Forward{ID: otherForwardID, UserID: 1, UserName: "admin", Name: "other-user", TunnelID: tunnelID, RemoteAddr: "203.0.113.9:8081", Status: 1},
&model.ForwardPort{ForwardID: otherForwardID, NodeID: fixture.nodeID, Port: 20001},
} {
if err := h.repo.DB().Create(value).Error; err != nil {
t.Fatal(err)
}
}
manager := &fakeNftablesManager{reconcileErr: errors.New("ssh unavailable")}
h.nftablesManager = manager
update := func() response.R {
t.Helper()
res := postJSONToHandler(t, h.userUpdate, map[string]interface{}{"id": userID, "user": "disable-nft", "status": 0})
var out response.R
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
if out := update(); out.Code == 0 {
t.Error("failed SSH apply returned success")
}
if got := mustQueryInt(t, h.repo, "SELECT status FROM forward WHERE id = ?", forward.ID); got != 1 {
t.Errorf("unconfirmed nftables pause marked completed: %d", got)
}
if manager.reconcileCount() != 1 || len(manager.lastPlan.Rules) != 1 || manager.lastPlan.Rules[0].ForwardID != otherForwardID {
t.Fatalf("did not attempt to remove disabled user's nftables rule: %+v", manager.lastPlan)
}
manager.reconcileErr = nil
if out := update(); out.Code != 0 {
t.Fatalf("retry nftables disable: %s", out.Msg)
}
if got := mustQueryInt(t, h.repo, "SELECT status FROM forward WHERE id = ?", forward.ID); got != 0 {
t.Errorf("confirmed nftables pause was not stored: %d", got)
}
if manager.reconcileCount() != 2 || len(manager.lastPlan.Rules) != 1 || manager.lastPlan.Rules[0].ForwardID != otherForwardID {
t.Errorf("retry did not apply a plan without disabled forward: %+v", manager.lastPlan)
}
if got := mustQueryInt(t, h.repo, "SELECT status FROM forward WHERE id = ?", otherForwardID); got != 1 {
t.Errorf("disabled another user's nftables forward: %d", got)
}
}
func TestUserDisableReportsPauseFailureAndCanRetry(t *testing.T) {
for _, offline := range []bool{false, true} {
t.Run(fmt.Sprintf("offline=%t", offline), func(t *testing.T) {
a := newCleanupAgent(t)
now := time.Now().UnixMilli()
userID, err := a.h.repo.CreateUser("disable-retry", "pwd", 1, now+86400000, 100, 1, 10, 1, 0, now)
if err != nil {
t.Fatal(err)
}
for _, value := range []interface{}{
&model.Tunnel{ID: 1, Name: "test", Type: 1, Status: 1, Flow: 1},
&model.UserTunnel{ID: 10, UserID: userID, TunnelID: 1, Status: 1, Flow: 100, Num: 10},
&model.Forward{ID: 20, UserID: userID, UserName: "disable-retry", Name: "running", TunnelID: 1, RemoteAddr: "127.0.0.1:80", Status: 1},
&model.ForwardPort{ForwardID: 20, NodeID: 1, Port: 31020},
} {
if err := a.h.repo.DB().Create(value).Error; err != nil {
t.Fatal(err)
}
}
onlineServer := a.h.wsServer
if offline {
a.h.wsServer = ws.NewServer(a.h.repo, "test-jwt-secret")
} else {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
response.WriteJSON(w, response.ErrDefault("pause rejected"))
}))
defer ts.Close()
if err := a.h.repo.DB().Model(&model.Node{}).Where("id = 1").Updates(map[string]interface{}{"is_remote": 1, "remote_url": ts.URL, "remote_token": "test-token"}).Error; err != nil {
t.Fatal(err)
}
}
update := func() response.R {
t.Helper()
res := postJSONToHandler(t, a.h.userUpdate, map[string]interface{}{"id": userID, "user": "disable-retry", "status": 0})
var out response.R
if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
if out := update(); out.Code == 0 {
t.Error("pause failure returned success")
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user WHERE id = ?", userID); got != 0 {
t.Errorf("failed pause restored account access: %d", got)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM user_tunnel WHERE id = 10"); got != 0 {
t.Errorf("failed pause restored tunnel access: %d", got)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != 1 {
t.Errorf("unconfirmed pause marked completed, status = %d", got)
}
a.h.wsServer = onlineServer
if err := a.h.repo.DB().Model(&model.Node{}).Where("id = 1").Update("is_remote", 0).Error; err != nil {
t.Fatal(err)
}
if out := update(); out.Code != 0 {
t.Fatalf("retry failed: %s", out.Msg)
}
if got := mustQueryInt(t, a.h.repo, "SELECT status FROM forward WHERE id = 20"); got != 0 {
t.Errorf("retry did not pause forward: %d", got)
}
if len(a.commandsOfType("PauseService")) == 0 {
t.Error("retry never sent PauseService")
}
})
}
}
+44 -3
View File
@@ -3,6 +3,7 @@ package middleware
import (
"context"
"net/http"
"strconv"
"strings"
"go-backend/internal/auth"
@@ -14,7 +15,8 @@ type contextKey string
const ClaimsContextKey contextKey = "claims"
type AuthOptions struct {
JWTSecret string
JWTSecret string
GetUserAuthState func(userID int64) (*auth.UserAuthState, error)
}
func JWT(opts AuthOptions) func(http.Handler) http.Handler {
@@ -32,16 +34,45 @@ func JWT(opts AuthOptions) func(http.Handler) http.Handler {
token := strings.TrimSpace(r.Header.Get("Authorization"))
if token == "" {
if allowsOptionalAuth(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return
}
claims, ok := auth.ValidateToken(token, opts.JWTSecret)
if !ok {
if allowsOptionalAuth(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
return
}
if opts.GetUserAuthState != nil {
userID, err := strconv.ParseInt(claims.Sub, 10, 64)
if err != nil {
if allowsOptionalAuth(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
return
}
state, err := opts.GetUserAuthState(userID)
if err != nil || state == nil || state.Status != 1 || state.RoleID != claims.RoleID || claims.IatMs <= state.PasswordChangedAt {
if allowsOptionalAuth(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
return
}
}
if requiresAdmin(r.URL.Path) && claims.RoleID != 0 {
response.WriteJSON(w, response.Err(403, "权限不足,仅管理员可操作"))
return
@@ -69,6 +100,10 @@ func RequireAdmin(next http.Handler) http.Handler {
})
}
func allowsOptionalAuth(path string) bool {
return path == "/api/v1/config/get"
}
func shouldSkip(path string) bool {
switch {
case strings.HasPrefix(path, "/flow/"):
@@ -78,9 +113,13 @@ func shouldSkip(path string) bool {
case strings.HasPrefix(path, "/api/v1/captcha/"):
return true
case path == "/api/v1/config/get":
return true
return false
case path == "/api/v1/user/login":
return true
case path == "/api/v1/user/passkey/status" || path == "/api/v1/user/passkey/login/begin" || path == "/api/v1/user/passkey/login/finish":
return true
case path == "/api/v1/public/config/get":
return true
case path == "/api/v1/federation/connect":
return true
case path == "/api/v1/federation/tunnel/create":
@@ -141,10 +180,12 @@ func requiresAdmin(path string) bool {
}
switch path {
case "/api/v1/user/create", "/api/v1/user/list", "/api/v1/user/update", "/api/v1/user/delete", "/api/v1/user/reset":
case "/api/v1/user/update-order", "/api/v1/user/create", "/api/v1/user/list", "/api/v1/user/update", "/api/v1/user/delete", "/api/v1/user/reset":
return true
case "/api/v1/config/update", "/api/v1/config/update-single":
return true
case "/api/v1/license/activate":
return true
case "/api/v1/announcement/update":
return true
default:
@@ -0,0 +1,276 @@
package middleware
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
func TestJWTRejectsPasswordChangedToken(t *testing.T) {
secret := "unit-test-secret"
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
claims, err := auth.ParseClaims(token, secret)
if err != nil {
t.Fatalf("parse claims: %v", err)
}
wrapped := JWT(AuthOptions{
JWTSecret: secret,
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
return &auth.UserAuthState{ID: userID, RoleID: 0, Status: 1, PasswordChangedAt: claims.IatMs + 1}, nil
},
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OK("pass"))
}))
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
wrapped.ServeHTTP(res, req)
assertAuthDenied(t, res)
}
func TestJWTAcceptsCurrentUserState(t *testing.T) {
secret := "unit-test-secret"
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
claims, err := auth.ParseClaims(token, secret)
if err != nil {
t.Fatalf("parse claims: %v", err)
}
wrapped := JWT(AuthOptions{
JWTSecret: secret,
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
return &auth.UserAuthState{ID: userID, RoleID: 0, Status: 1, PasswordChangedAt: claims.IatMs - 1}, nil
},
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OK("pass"))
}))
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
wrapped.ServeHTTP(res, req)
assertCode(t, res, 0)
}
func TestJWTRejectsDisabledUserToken(t *testing.T) {
secret := "unit-test-secret"
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
wrapped := JWT(AuthOptions{
JWTSecret: secret,
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
return &auth.UserAuthState{ID: userID, RoleID: 0, Status: 0, PasswordChangedAt: time.Now().Unix()}, nil
},
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OK("pass"))
}))
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
wrapped.ServeHTTP(res, req)
assertAuthDenied(t, res)
}
func TestJWTRejectsPasswordChangedAtSameMillisecond(t *testing.T) {
secret := "unit-test-secret"
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
claims, err := auth.ParseClaims(token, secret)
if err != nil {
t.Fatalf("parse claims: %v", err)
}
wrapped := JWT(AuthOptions{
JWTSecret: secret,
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
return &auth.UserAuthState{ID: userID, RoleID: 0, Status: 1, PasswordChangedAt: claims.IatMs}, nil
},
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OK("pass"))
}))
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
wrapped.ServeHTTP(res, req)
assertAuthDenied(t, res)
}
func TestJWTRejectsRoleMismatch(t *testing.T) {
secret := "unit-test-secret"
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
wrapped := JWT(AuthOptions{
JWTSecret: secret,
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
return &auth.UserAuthState{ID: userID, RoleID: 1, Status: 1, PasswordChangedAt: 0}, nil
},
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OK("pass"))
}))
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
wrapped.ServeHTTP(res, req)
assertAuthDenied(t, res)
}
func TestJWTRejectsMissingUserState(t *testing.T) {
secret := "unit-test-secret"
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
wrapped := JWT(AuthOptions{
JWTSecret: secret,
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
return nil, nil
},
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OK("pass"))
}))
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
wrapped.ServeHTTP(res, req)
assertAuthDenied(t, res)
}
func TestJWTRejectsAuthStateLookupError(t *testing.T) {
secret := "unit-test-secret"
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
wrapped := JWT(AuthOptions{
JWTSecret: secret,
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
return nil, errors.New("boom")
},
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OK("pass"))
}))
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
wrapped.ServeHTTP(res, req)
assertAuthDenied(t, res)
}
func TestShouldSkipDoesNotBypassConfigGet(t *testing.T) {
if shouldSkip("/api/v1/config/get") {
t.Fatal("expected /api/v1/config/get to require auth")
}
}
func TestShouldSkipBypassesPublicConfigGet(t *testing.T) {
if !shouldSkip("/api/v1/public/config/get") {
t.Fatal("expected /api/v1/public/config/get to remain public")
}
}
func TestLicenseActivateRequiresAdmin(t *testing.T) {
if !requiresAdmin("/api/v1/license/activate") {
t.Fatal("expected license activation to require admin")
}
}
func TestJWTRejectsNonAdminLicenseActivation(t *testing.T) {
secret := "unit-test-secret"
token, err := auth.GenerateToken(2, "regular_user", 1, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
claims, err := auth.ParseClaims(token, secret)
if err != nil {
t.Fatalf("parse claims: %v", err)
}
wrapped := JWT(AuthOptions{
JWTSecret: secret,
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
return &auth.UserAuthState{ID: userID, RoleID: 1, Status: 1, PasswordChangedAt: claims.IatMs - 1}, nil
},
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OK("pass"))
}))
req := httptest.NewRequest(http.MethodPost, "/api/v1/license/activate", nil)
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
wrapped.ServeHTTP(res, req)
assertCodeMsg(t, res, 403, "权限不足,仅管理员可操作")
}
func TestJWTExpiresAfterSevenDays(t *testing.T) {
secret := "unit-test-secret"
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
claims, err := auth.ParseClaims(token, secret)
if err != nil {
t.Fatalf("parse claims: %v", err)
}
if got := claims.Exp - claims.Iat; got != int64(7*24*time.Hour/time.Second) {
t.Fatalf("expected 7 day token lifetime, got %d seconds", got)
}
if claims.IatMs <= 0 {
t.Fatalf("expected millisecond issuance time to be populated, got %d", claims.IatMs)
}
}
func assertCode(t *testing.T, rec *httptest.ResponseRecorder, expected int) {
t.Helper()
var out response.R
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != expected {
t.Fatalf("expected code %d, got %d", expected, out.Code)
}
}
func assertAuthDenied(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
assertCodeMsg(t, rec, 401, "无效的token或token已过期")
}
func assertCodeMsg(t *testing.T, rec *httptest.ResponseRecorder, expectedCode int, expectedMsg string) {
t.Helper()
var out response.R
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != expectedCode || out.Msg != expectedMsg {
t.Fatalf("expected (%d,%q), got (%d,%q)", expectedCode, expectedMsg, out.Code, out.Msg)
}
}
@@ -14,6 +14,11 @@ type statusWriter struct {
status int
}
// Unwrap preserves ResponseController features such as per-response deadlines.
func (w *statusWriter) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
@@ -27,10 +32,12 @@ func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return hj.Hijack()
}
func (w *statusWriter) FlushError() error {
return http.NewResponseController(w.ResponseWriter).Flush()
}
func (w *statusWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
_ = w.FlushError()
}
func (w *statusWriter) ReadFrom(r io.Reader) (int64, error) {
+1 -1
View File
@@ -13,7 +13,7 @@ func NewRouter(h *handler.Handler, jwtSecret string) http.Handler {
mux.Handle("/system-info", h.WebSocketHandler())
wrapped := middleware.Recover(mux)
wrapped = middleware.JWT(middleware.AuthOptions{JWTSecret: jwtSecret})(wrapped)
wrapped = middleware.JWT(middleware.AuthOptions{JWTSecret: jwtSecret, GetUserAuthState: h.GetUserAuthState})(wrapped)
wrapped = middleware.RequestLog(wrapped)
wrapped = middleware.CORS(wrapped)
return wrapped
+127 -21
View File
@@ -6,24 +6,53 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
var AccountID string
type KeygenClient struct {
AccountID string
Token string
BaseURL string
HTTPClient *http.Client
}
type APIError struct {
Operation string
StatusCode int
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("keygen %s failed: status %d, response: %s", e.Operation, e.StatusCode, e.Body)
}
func (e *APIError) HasCode(code string) bool {
return e != nil && hasKeygenErrorCode([]byte(e.Body), code)
}
const defaultAPIBaseURL = "https://api.keygen.sh/v1"
func NewKeygenClient(accountID, token string) *KeygenClient {
return &KeygenClient{
AccountID: accountID,
Token: token,
AccountID: accountID,
Token: token,
BaseURL: defaultAPIBaseURL,
HTTPClient: &http.Client{Timeout: 10 * time.Second},
}
}
func (c *KeygenClient) apiURL(path string) string {
baseURL := strings.TrimRight(c.BaseURL, "/")
if baseURL == "" {
baseURL = defaultAPIBaseURL
}
return fmt.Sprintf("%s/accounts/%s/%s", baseURL, c.AccountID, strings.TrimLeft(path, "/"))
}
type ValidateResponse struct {
Meta struct {
Valid bool `json:"valid"`
@@ -35,6 +64,7 @@ type ValidateResponse struct {
Expiry string `json:"expiry"`
} `json:"attributes"`
} `json:"data"`
MachineID string `json:"-"`
}
type ActivateMachineRequest struct {
@@ -54,8 +84,37 @@ type ActivateMachineRequest struct {
} `json:"data"`
}
type keygenErrorResponse struct {
Errors []struct {
Code string `json:"code"`
} `json:"errors"`
}
type MachineResponse struct {
Data struct {
ID string `json:"id"`
} `json:"data"`
}
func hasKeygenErrorCode(body []byte, code string) bool {
var resp keygenErrorResponse
if err := json.Unmarshal(body, &resp); err != nil {
return false
}
for _, item := range resp.Errors {
if strings.EqualFold(strings.TrimSpace(item.Code), code) {
return true
}
}
return false
}
func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string) (*ValidateResponse, error) {
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/licenses/actions/validate-key", c.AccountID)
return c.ValidateKeyWithMachine(key, fingerprint, "")
}
func (c *KeygenClient) ValidateKeyWithMachine(key, fingerprint, machineID string) (*ValidateResponse, error) {
url := c.apiURL("licenses/actions/validate-key")
meta := map[string]interface{}{
"key": key,
@@ -66,6 +125,14 @@ func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string
"fingerprint": fingerprint,
}
}
if machineID != "" {
scope, _ := meta["scope"].(map[string]interface{})
if scope == nil {
scope = make(map[string]interface{})
meta["scope"] = scope
}
scope["machine"] = machineID
}
reqBody := map[string]interface{}{
"meta": meta,
@@ -91,7 +158,8 @@ func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
return nil, &APIError{Operation: "validate license", StatusCode: resp.StatusCode, Body: string(body)}
}
var valResp ValidateResponse
@@ -102,8 +170,44 @@ func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string
return &valResp, nil
}
func (c *KeygenClient) GetMachineID(fingerprint string) (string, error) {
machineURL := c.apiURL("machines/" + url.PathEscape(fingerprint))
req, err := http.NewRequest(http.MethodGet, machineURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/vnd.api+json")
if c.Token != "" {
if !strings.HasPrefix(c.Token, "Bearer ") && !strings.HasPrefix(c.Token, "License ") {
req.Header.Set("Authorization", "License "+c.Token)
} else {
req.Header.Set("Authorization", c.Token)
}
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", &APIError{Operation: "retrieve machine", StatusCode: resp.StatusCode, Body: string(body)}
}
var machineResp MachineResponse
if err := json.NewDecoder(resp.Body).Decode(&machineResp); err != nil {
return "", err
}
machineID := strings.TrimSpace(machineResp.Data.ID)
if machineID == "" {
return "", fmt.Errorf("failed to retrieve machine: empty machine id")
}
return machineID, nil
}
func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/licenses/actions/validate-key", c.AccountID)
url := c.apiURL("licenses/actions/validate-key")
reqBody := map[string]interface{}{
"meta": map[string]string{
@@ -130,7 +234,8 @@ func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
return nil, &APIError{Operation: "validate license", StatusCode: resp.StatusCode, Body: string(body)}
}
var valResp ValidateResponse
@@ -141,8 +246,8 @@ func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
return &valResp, nil
}
func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) error {
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/machines", c.AccountID)
func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) (string, error) {
url := c.apiURL("machines")
var reqBody ActivateMachineRequest
reqBody.Data.Type = "machines"
@@ -165,23 +270,24 @@ func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) error {
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
return nil
}
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusConflict || resp.StatusCode == http.StatusUnprocessableEntity {
if strings.Contains(string(body), "FINGERPRINT_TAKEN") || strings.Contains(string(body), "MACHINE_LIMIT_EXCEEDED") {
// Machine already registered to this license or limit reached because it's already us.
// The subsequent ValidateKey check will determine if the existing machine is actually us.
return nil
var machineResp MachineResponse
if json.Unmarshal(body, &machineResp) == nil {
return strings.TrimSpace(machineResp.Data.ID), nil
}
return "", nil
}
return fmt.Errorf("failed to activate machine: status %d, response: %s", resp.StatusCode, string(body))
}
if resp.StatusCode == http.StatusUnprocessableEntity && hasKeygenErrorCode(body, "FINGERPRINT_TAKEN") {
// Machine activation is idempotent. Keygen scopes fingerprint uniqueness
// to the target license, so this means the same machine is already bound.
return "", nil
}
return "", &APIError{Operation: "activate machine", StatusCode: resp.StatusCode, Body: string(body)}
}
+104
View File
@@ -0,0 +1,104 @@
package license
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestValidateKeyWithMachineSendsFingerprintAndMachineScopes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Meta struct {
Scope map[string]string `json:"scope"`
} `json:"meta"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
if body.Meta.Scope["fingerprint"] != "fingerprint" {
t.Fatalf("fingerprint scope = %q", body.Meta.Scope["fingerprint"])
}
if body.Meta.Scope["machine"] != "machine-id" {
t.Fatalf("machine scope = %q", body.Meta.Scope["machine"])
}
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{}}}`)
}))
defer server.Close()
client := NewKeygenClient("account-id", "")
client.BaseURL = server.URL
validation, err := client.ValidateKeyWithMachine("license-key", "fingerprint", "machine-id")
if err != nil || !validation.Meta.Valid {
t.Fatalf("ValidateKeyWithMachine() validation=%+v err=%v", validation, err)
}
}
func TestGetMachineIDRetrievesMachineByFingerprint(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/machines/fingerprint") {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
if r.Header.Get("Authorization") != "License license-key" {
t.Fatalf("authorization = %q", r.Header.Get("Authorization"))
}
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
}))
defer server.Close()
client := NewKeygenClient("account-id", "license-key")
client.BaseURL = server.URL
machineID, err := client.GetMachineID("fingerprint")
if err != nil || machineID != "machine-id" {
t.Fatalf("GetMachineID() = %q, %v", machineID, err)
}
}
func TestActivateMachineTreatsFingerprintTakenAsIdempotent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = fmt.Fprint(w, `{"errors":[{"code":"FINGERPRINT_TAKEN"},{"code":"MACHINE_LIMIT_EXCEEDED"}]}`)
}))
defer server.Close()
client := NewKeygenClient("account-id", "license-key")
client.BaseURL = server.URL
if machineID, err := client.ActivateMachine("license-id", "fingerprint"); err != nil || machineID != "" {
t.Fatalf("ActivateMachine() error = %v, want idempotent success", err)
}
}
func TestActivateMachineReturnsCreatedMachineID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
}))
defer server.Close()
client := NewKeygenClient("account-id", "license-key")
client.BaseURL = server.URL
machineID, err := client.ActivateMachine("license-id", "fingerprint")
if err != nil || machineID != "machine-id" {
t.Fatalf("ActivateMachine() = %q, %v", machineID, err)
}
}
func TestActivateMachineRejectsMachineLimitWithoutFingerprintTaken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = fmt.Fprint(w, `{"errors":[{"code":"MACHINE_LIMIT_EXCEEDED"}]}`)
}))
defer server.Close()
client := NewKeygenClient("account-id", "license-key")
client.BaseURL = server.URL
_, err := client.ActivateMachine("license-id", "fingerprint")
if err == nil || !strings.Contains(err.Error(), "MACHINE_LIMIT_EXCEEDED") {
t.Fatalf("ActivateMachine() error = %v, want machine limit failure", err)
}
}
@@ -0,0 +1,52 @@
package monitoring
import (
"fmt"
"strconv"
"strings"
)
const (
ConfigTunnelQualityProbeIntervalSec = "monitor_tunnel_quality_interval_sec"
DefaultTunnelQualityProbeIntervalSec = 1
MinTunnelQualityProbeIntervalSec = 1
MaxTunnelQualityProbeIntervalSec = 3600
)
func TunnelQualityProbeIntervalSecondsFromConfigMap(cfg map[string]string) int {
if cfg == nil {
return DefaultTunnelQualityProbeIntervalSec
}
seconds, err := parseTunnelQualityProbeIntervalSeconds(cfg[ConfigTunnelQualityProbeIntervalSec])
if err != nil {
return DefaultTunnelQualityProbeIntervalSec
}
return seconds
}
func NormalizeTunnelQualityProbeIntervalSeconds(value string) (string, error) {
seconds, err := parseTunnelQualityProbeIntervalSeconds(value)
if err != nil {
return "", err
}
return strconv.Itoa(seconds), nil
}
func parseTunnelQualityProbeIntervalSeconds(value string) (int, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return 0, fmt.Errorf("隧道质量探测间隔不能为空")
}
seconds, err := strconv.Atoi(trimmed)
if err != nil {
return 0, fmt.Errorf("隧道质量探测间隔必须是整数")
}
if seconds < MinTunnelQualityProbeIntervalSec || seconds > MaxTunnelQualityProbeIntervalSec {
return 0, fmt.Errorf(
"隧道质量探测间隔必须在 %d 到 %d 秒之间",
MinTunnelQualityProbeIntervalSec,
MaxTunnelQualityProbeIntervalSec,
)
}
return seconds, nil
}
@@ -0,0 +1,37 @@
package monitoring
import "testing"
func TestTunnelQualityProbeIntervalSecondsFromConfigMap(t *testing.T) {
tests := []struct {
name string
cfg map[string]string
want int
}{
{name: "missing config", cfg: nil, want: DefaultTunnelQualityProbeIntervalSec},
{name: "configured", cfg: map[string]string{ConfigTunnelQualityProbeIntervalSec: "15"}, want: 15},
{name: "invalid", cfg: map[string]string{ConfigTunnelQualityProbeIntervalSec: "0"}, want: DefaultTunnelQualityProbeIntervalSec},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := TunnelQualityProbeIntervalSecondsFromConfigMap(tt.cfg); got != tt.want {
t.Fatalf("interval = %d, want %d", got, tt.want)
}
})
}
}
func TestNormalizeTunnelQualityProbeIntervalSeconds(t *testing.T) {
for _, value := range []string{"1", "15", "3600"} {
if got, err := NormalizeTunnelQualityProbeIntervalSeconds(value); err != nil || got != value {
t.Fatalf("normalize %q = %q, %v", value, got, err)
}
}
for _, value := range []string{"", "0", "3601", "1.5", "abc"} {
if got, err := NormalizeTunnelQualityProbeIntervalSeconds(value); err == nil {
t.Fatalf("normalize %q unexpectedly succeeded with %q", value, got)
}
}
}
@@ -0,0 +1,66 @@
# Panel upgrade safety and recovery
The helper is a subcommand in the **currently running image**, dispatched before
application/database initialization. This fix must first be installed through the
normal administrator deployment procedure on older releases lacking the helper.
It cannot retroactively repair an already-running legacy upgrade helper.
Supported deployments have one `docker-compose.yml`, the original Compose project
labels, and a writable host bind mount containing that file and `.env`. The
helper mounts that directory at its **host absolute path** and passes explicit
project, project-directory, env-file and compose-file options. Multiple Compose
files, mismatched mounts/resources, or changed database settings fail closed.
Jobs are under `<deployment>/.panel-upgrades/<jobId>/` (directory 0700, files
0600). `*.original` contains immutable per-job configuration snapshots, not a live
database backup. `events.log` contains safe phase messages only. Legacy
`.upgrade.bak` files and `upgrade.log` are never changed. Helper containers remain
for diagnosis; they are intentionally not automatically removed. Operators may
remove completed helpers after retaining the job's files.
The `latest` pointer plus filesystem lock prevents simultaneous jobs across API
processes and backend replacement. Failed validation/pull leaves live files
untouched. A failed configuration application attempts to restore both files
before any recreate. Once `restarting` is recorded, failures require recovery:
no automatic database rollback, image rollback, or container rollback is safe.
The helper has a 30 minute overall deadline, including a maximum five minute
post-recreate verification window. Docker client timeout during recreation can
leave daemon work in flight; it is always treated as uncertain, not retried.
## Operator recovery after an interrupted/uncertain job
There is intentionally no remotely callable “clear lock” endpoint. Do not delete
job directories or rewrite status JSON to force another attempt.
1. Work on the Docker host and identify the job from `latest`, `status.json` and
`plan.json`. Preserve the entire job directory, live Compose/env files, and
container inspection information in a private location. These files can
contain credentials. Never paste `.env` or raw Compose config into public logs.
2. Restore Docker daemon access if necessary. Inspect the exact helper container
named by the job. If it is running, let it finish or explicitly stop it as an
administrator. Verify it has stopped. If launch was uncertain, establish that
no matching helper exists and no Docker create/up operation remains in flight.
Do not clear the job while any of these facts are uncertain.
3. Inspect the original project's backend and frontend container IDs, images,
mounts and health. Check the actual database and application logs privately.
After any attempted recreation, assume migrations may already have occurred.
Preserve a current database snapshot before further manual deployment work.
Use the normal documented deployment procedure to bring both services to a
consistent, healthy state. Do not copy an old database over the current one or
blindly redeploy an older image. Configuration snapshots alone cannot undo a
schema migration.
4. Only after verifying no helper/daemon work remains and both services and their
database are consistent, archive the `latest` pointer under a unique filename
in `.panel-upgrades/` (for example `resolved-<jobId>.pointer`). Do this while
the API is stopped, or hold an exclusive OS flock on `.panel-upgrades/lock`,
so no other start can race this manual recovery. Keep the job directory and
archived pointer. This permits a new job without changing recovery evidence.
5. Restart the API if stopped and reload the browser settings page so it discovers
the current `latest` task instead of following the resolved task ID. No current
job is expected. A fresh start creates a new ID and new backups, never
overwriting old evidence.
A lost helper, unavailable daemon, or stale status is displayed as
`recovery_required` while retaining the active lock. This is intentionally a
conservative derived status, so a helper that is still completing work can report
its actual durable result without racing a status request.
+311
View File
@@ -0,0 +1,311 @@
package panelupgrade
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"time"
)
const helperTimeout = 30 * time.Minute
var verificationTimeout = 5 * time.Minute
type composeConfig struct {
FullServices map[string]map[string]any `json:"-"`
Services map[string]struct {
Image string `json:"image"`
ContainerName string `json:"container_name"`
Volumes []map[string]any `json:"volumes"`
Environment map[string]string `json:"environment"`
} `json:"services"`
Volumes map[string]any `json:"volumes"`
Networks map[string]any `json:"networks"`
}
func composeArgs(p Plan, dir string, args ...string) []string {
return append([]string{"compose", "--project-name", p.Project, "--project-directory", p.HostDir, "--env-file", filepath.Join(dir, ".env"), "-f", filepath.Join(dir, "docker-compose.yml")}, args...)
}
func config(ctx context.Context, p Plan, dir string) (composeConfig, error) {
var c composeConfig
b, e := docker(ctx, composeArgs(p, dir, "config", "--format", "json")...)
if e != nil {
return c, e
}
e = json.Unmarshal(b, &c)
if e == nil {
var raw struct {
Services map[string]map[string]any `json:"services"`
}
e = json.Unmarshal(b, &raw)
c.FullServices = raw.Services
}
return c, e
}
func validateConfig(old, next composeConfig, p Plan) error {
// Compare complete normalized services, not a projection that can silently drop
// host bindings, commands, security options, networks or dependency changes.
if old.FullServices == nil || next.FullServices == nil {
return errors.New("missing full Compose configuration")
}
raw, _ := json.Marshal(old.FullServices)
var expected map[string]map[string]any
if json.Unmarshal(raw, &expected) != nil {
return errors.New("invalid configuration")
}
for _, service := range []string{"backend", "frontend"} {
if expected[service] == nil || next.FullServices[service] == nil {
return errors.New("missing service")
}
expected[service]["image"] = next.FullServices[service]["image"]
}
if env, ok := expected["backend"]["environment"].(map[string]any); ok {
env["FLUX_VERSION"] = p.Job.TargetVersion
} else {
return errors.New("missing backend environment")
}
if !reflect.DeepEqual(expected, next.FullServices) {
return errors.New("unsupported service configuration change; manual upgrade required")
}
for service, image := range map[string]string{"backend": "ghcr.io/sagit-chu/flux-panel-backend:", "frontend": "ghcr.io/sagit-chu/vite-frontend:"} {
a, ok := old.Services[service]
b, present := next.Services[service]
if !ok || !present || b.Image != image+p.Job.TargetVersion || a.ContainerName != b.ContainerName || !reflect.DeepEqual(a.Volumes, b.Volumes) {
return errors.New("service image, container identity or persistent mounts do not match the supported deployment")
}
}
if next.Services["backend"].ContainerName != p.Backend || next.Services["backend"].Environment["FLUX_VERSION"] != p.Job.TargetVersion {
return errors.New("backend identity/version mismatch")
}
for service := range next.Services {
if service != "backend" && service != "frontend" && service != "postgres" {
return errors.New("unsupported service")
}
}
for key, value := range old.Services["backend"].Environment {
if key != "FLUX_VERSION" && next.Services["backend"].Environment[key] != value {
return errors.New("backend environment changed; manual upgrade required")
}
}
for key, value := range next.Services["backend"].Environment {
if key != "FLUX_VERSION" && old.Services["backend"].Environment[key] != value {
return errors.New("backend environment changed; manual upgrade required")
}
}
// Database configuration and shared resource identities are not upgrade targets.
if !reflect.DeepEqual(old.Services["postgres"], next.Services["postgres"]) || !reflect.DeepEqual(old.Volumes, next.Volumes) || !reflect.DeepEqual(old.Networks, next.Networks) {
return errors.New("database or shared resource configuration changed; manual upgrade required")
}
return nil
}
// validateLive proves the normalized source configuration still describes the running
// backend. In particular, Compose must not attach a fresh database volume by accident.
func validateLive(ctx context.Context, p Plan, c composeConfig) error {
live, err := inspect(ctx, p.Backend)
if err != nil {
return err
}
if live.Image != p.Image || !live.State.Running || live.Config.Labels["com.docker.compose.project"] != p.Project || live.Config.Labels["com.docker.compose.service"] != "backend" {
return errors.New("running backend changed")
}
if len(live.Mounts) != len(c.Services["backend"].Volumes) {
return errors.New("unexpected live mounts; manual upgrade required")
}
for _, v := range c.Services["backend"].Volumes {
typ, _ := v["type"].(string)
src, _ := v["source"].(string)
dst, _ := v["target"].(string)
if typ == "volume" {
spec, ok := c.Volumes[src].(map[string]any)
if !ok {
return errors.New("unknown volume")
}
src, _ = spec["name"].(string)
}
if src == "" || dst == "" || (typ != "volume" && typ != "bind") {
return errors.New("unsupported mount")
}
found := false
for _, mount := range live.Mounts {
actual := mount.Source
if typ == "volume" {
actual = mount.Name
}
if mount.Type == typ && mount.Destination == dst && actual == src && mount.RW != (v["read_only"] == true) {
found = true
}
}
if !found {
return errors.New("live persistent mount does not match Compose")
}
}
env := map[string]string{}
for _, v := range live.Config.Env {
parts := strings.SplitN(v, "=", 2)
if len(parts) == 2 {
env[parts[0]] = parts[1]
}
}
for _, key := range []string{"DB_TYPE", "DB_PATH", "DATABASE_URL", "PANEL_DEPLOY_DIR", "PANEL_BACKEND_CONTAINER"} {
if env[key] != c.Services["backend"].Environment[key] {
return errors.New("live database/deployment environment does not match Compose")
}
}
return nil
}
// RunHelper runs before app/database initialization and does not depend on the old backend process.
func RunHelper(planPath string) error {
b, e := os.ReadFile(planPath)
if e != nil {
return e
}
var p Plan
if e = json.Unmarshal(b, &p); e != nil {
return e
}
if !idPattern.MatchString(p.Job.ID) || ValidateVersion(p.Job.TargetVersion) != nil || !projectPattern.MatchString(p.Project) {
return errors.New("invalid plan")
}
m := Manager{Dir: p.HostDir, Backend: p.Backend}
dir := m.jobDir(p.Job.ID)
if filepath.Clean(planPath) != filepath.Join(dir, "plan.json") {
return errors.New("invalid plan location")
}
// Reject duplicate helper invocations without affecting the live job.
f, e := os.OpenFile(filepath.Join(dir, "helper.started"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if e != nil {
return e
}
f.Close()
j, e := m.read(p.Job.ID)
if e != nil {
return e
}
if !j.Active {
return errors.New("job is not active")
}
ctx, cancel := context.WithTimeout(context.Background(), helperTimeout)
defer cancel()
fail := func(msg string) error {
if e := m.write(j, "failed", msg); e != nil {
return e
}
return errors.New(msg)
}
recovery := func(msg string) error {
m.write(j, "recovery_required", msg+";请管理员核对容器、数据库和任务备份后人工恢复,禁止自动回滚或重复升级")
return errors.New(msg)
}
if e = m.write(j, "preparing", "校验部署配置和持久化资源"); e != nil {
return e
}
old, e := config(ctx, p, p.HostDir)
if e != nil {
return fail("现有 Compose 配置校验失败,线上配置未变更")
}
next, e := config(ctx, p, dir)
if e != nil {
return fail("目标 Compose 配置校验失败,线上配置未变更")
}
if validateConfig(old, next, p) != nil {
return fail("目标镜像、服务或持久化资源不匹配,需手动升级")
}
if validateLive(ctx, p, old) != nil {
return fail("运行中后端与部署配置不一致,需手动升级")
}
if e = m.write(j, "pulling", "正在拉取并检查目标镜像,线上配置尚未变更"); e != nil {
return e
}
if _, e = docker(ctx, composeArgs(p, dir, "pull", "backend", "frontend")...); e != nil {
return fail("镜像拉取失败或超时,线上配置未变更")
}
targets := map[string]string{}
for _, service := range []string{"backend", "frontend"} {
out, err := docker(ctx, "image", "inspect", "--format", "{{.Id}}", next.Services[service].Image)
if err != nil || !strings.HasPrefix(strings.TrimSpace(string(out)), "sha256:") {
return fail("目标镜像校验失败,线上配置未变更")
}
targets[service] = strings.TrimSpace(string(out))
}
if validateLive(ctx, p, old) != nil {
return fail("拉取期间运行中后端发生变化,已安全取消")
}
// Detect edits since snapshots before replacing either live file.
for _, name := range []string{"docker-compose.yml", ".env"} {
live, err := os.ReadFile(filepath.Join(p.HostDir, name))
if err != nil {
return fail("读取线上配置失败")
}
backup, err := os.ReadFile(filepath.Join(dir, name+".original"))
if err != nil || string(live) != string(backup) {
return fail("线上配置已被修改,本任务已安全取消")
}
}
if e = m.write(j, "applying", "正在应用已校验配置"); e != nil {
return e
}
for _, name := range []string{"docker-compose.yml", ".env"} {
data, err := os.ReadFile(filepath.Join(dir, name))
if err == nil {
err = atomic(filepath.Join(p.HostDir, name), data)
}
if err != nil {
for _, restore := range []string{"docker-compose.yml", ".env"} {
original, re := os.ReadFile(filepath.Join(dir, restore+".original"))
if re != nil || atomic(filepath.Join(p.HostDir, restore), original) != nil {
return recovery("配置恢复失败,尚未执行容器重建")
}
}
return fail("应用配置失败,已恢复原配置,未执行容器重建")
}
}
// Persist the point of no automatic rollback BEFORE Docker can recreate any service.
if e = m.write(j, "restarting", "正在重建面板服务"); e != nil {
return recovery("无法记录重建状态,已停止执行")
}
if _, e = docker(ctx, composeArgs(p, p.HostDir, "up", "-d", "--no-deps", "--force-recreate", "--pull", "never", "backend", "frontend")...); e != nil {
return recovery("容器重建失败或超时,可能已部分生效")
}
if e = m.write(j, "verifying", "正在验证服务版本及健康状态"); e != nil {
return e
}
deadline := time.Now().Add(verificationTimeout)
for time.Now().Before(deadline) && ctx.Err() == nil {
ready := true
for _, service := range []string{"backend", "frontend"} {
c, err := inspect(ctx, next.Services[service].ContainerName)
if err != nil || !c.State.Running || c.Image != targets[service] || c.Config.Labels["com.docker.compose.project"] != p.Project || c.Config.Labels["com.docker.compose.service"] != service {
ready = false
continue
}
if c.State.Health != nil && c.State.Health.Status != "healthy" {
ready = false
}
if service == "backend" && c.State.Health == nil {
ready = false
}
}
if ready { // Frontend has no Docker healthcheck in the stock template.
probeCtx, probeCancel := context.WithTimeout(ctx, 10*time.Second)
_, probeErr := docker(probeCtx, "exec", next.Services["frontend"].ContainerName, "wget", "-q", "-T", "5", "-O", "/dev/null", "http://127.0.0.1:80/")
probeCancel()
ready = probeErr == nil
}
if ready {
return m.write(j, "succeeded", "升级完成,目标镜像和服务健康状态已验证")
}
select {
case <-ctx.Done():
case <-time.After(3 * time.Second):
}
}
return recovery("升级后服务健康检查失败或超时")
}
+310
View File
@@ -0,0 +1,310 @@
// Package panelupgrade implements durable, conservative Docker Compose upgrades.
package panelupgrade
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"syscall"
"time"
)
var versionPattern = regexp.MustCompile(`^v?[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9][A-Za-z0-9.-]*)?$`)
var idPattern = regexp.MustCompile(`^[a-f0-9]{32}$`)
var projectPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
func ValidateVersion(v string) error {
if !versionPattern.MatchString(v) || len(v) > 128 {
return errors.New("invalid release version")
}
return nil
}
type Job struct {
ID string `json:"id"`
TargetVersion string `json:"targetVersion"`
Stage string `json:"stage"`
Active bool `json:"active"`
RequiresRecovery bool `json:"requiresRecovery"`
Message string `json:"message"`
StartedAt int64 `json:"startedAt"`
UpdatedAt int64 `json:"updatedAt"`
HelperContainer string `json:"helperContainer,omitempty"`
BackupDir string `json:"backupDir,omitempty"`
}
type Plan struct {
Job Job
DeployDir, HostDir, Project, Backend, Image string
}
type Manager struct{ Dir, Backend string }
func (m Manager) root() string { return filepath.Join(m.Dir, ".panel-upgrades") }
func (m Manager) jobDir(id string) string { return filepath.Join(m.root(), id) }
func atomic(path string, b []byte) error {
f, e := os.CreateTemp(filepath.Dir(path), ".write-")
if e != nil {
return e
}
n := f.Name()
defer os.Remove(n)
if e = f.Chmod(0600); e == nil {
_, e = f.Write(b)
}
if e == nil {
e = f.Sync()
}
ce := f.Close()
if e == nil {
e = ce
}
if e != nil {
return e
}
if e = os.Rename(n, path); e != nil {
return e
}
d, e := os.Open(filepath.Dir(path))
if e != nil {
return e
}
defer d.Close()
return d.Sync()
}
func save(path string, v any) error {
b, e := json.Marshal(v)
if e != nil {
return e
}
return atomic(path, b)
}
func (m Manager) lock() (func(), error) {
if e := os.MkdirAll(m.root(), 0700); e != nil {
return nil, e
}
f, e := os.OpenFile(filepath.Join(m.root(), "lock"), os.O_CREATE|os.O_RDWR, 0600)
if e != nil {
return nil, e
}
if e = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); e != nil {
f.Close()
return nil, errors.New("another upgrade operation is in progress")
}
return func() { syscall.Flock(int(f.Fd()), syscall.LOCK_UN); f.Close() }, nil
}
func docker(ctx context.Context, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.WaitDelay = 2 * time.Second
b, e := cmd.Output()
if e != nil {
return nil, errors.New("Docker operation failed (details suppressed to protect credentials)")
}
return b, nil
}
type container struct {
Image string
Config struct {
Labels map[string]string
Env []string
}
State struct {
Running bool
Health *struct{ Status string }
}
Mounts []struct {
Type, Source, Destination, Name string
RW bool
}
}
func inspect(ctx context.Context, name string) (container, error) {
var c []container
b, e := docker(ctx, "inspect", name)
if e != nil {
return container{}, e
}
if json.Unmarshal(b, &c) != nil || len(c) != 1 {
return container{}, errors.New("invalid Docker inspection")
}
return c[0], nil
}
func (m Manager) Identity(ctx context.Context) (Plan, error) {
p := Plan{DeployDir: m.Dir, Backend: m.Backend}
if !filepath.IsAbs(m.Dir) || filepath.Clean(m.Dir) != m.Dir {
return p, errors.New("deployment directory must be an absolute clean path")
}
c, e := inspect(ctx, m.Backend)
if e != nil {
return p, e
}
p.Image = c.Image
p.Project = c.Config.Labels["com.docker.compose.project"]
if !c.State.Running || !projectPattern.MatchString(p.Project) || c.Config.Labels["com.docker.compose.service"] != "backend" || !strings.HasPrefix(p.Image, "sha256:") {
return p, errors.New("cannot establish original Compose identity")
}
for _, v := range c.Mounts {
if v.Destination == m.Dir && v.Type == "bind" && v.RW {
p.HostDir = v.Source
}
}
if p.HostDir == "/" || !filepath.IsAbs(p.HostDir) || strings.ContainsAny(p.HostDir, ",\n\r") || filepath.Clean(p.HostDir) != p.HostDir {
return p, errors.New("deployment directory must be a writable host bind mount")
}
for _, protected := range []string{"/app/paneld", "/usr/local/bin/docker", "/usr/local/libexec/docker/cli-plugins/docker-compose", "/var/run/docker.sock", "/bin", "/lib", "/etc", "/proc", "/sys", "/dev"} {
if p.HostDir == protected || strings.HasPrefix(protected, p.HostDir+"/") || strings.HasPrefix(p.HostDir, protected+"/") {
return p, errors.New("host deployment mount would shadow helper runtime paths")
}
}
if c.Config.Labels["com.docker.compose.project.working_dir"] != p.HostDir || c.Config.Labels["com.docker.compose.project.config_files"] != filepath.Join(p.HostDir, "docker-compose.yml") {
return p, errors.New("custom or multiple Compose paths require manual upgrade")
}
return p, nil
}
func (m Manager) read(id string) (*Job, error) {
if !idPattern.MatchString(id) {
return nil, errors.New("invalid job ID")
}
b, e := os.ReadFile(filepath.Join(m.jobDir(id), "status.json"))
if e != nil {
return nil, e
}
var j Job
e = json.Unmarshal(b, &j)
return &j, e
}
func (m Manager) Status(ctx context.Context, id string) (*Job, error) {
if id == "" {
b, e := os.ReadFile(filepath.Join(m.root(), "latest"))
if os.IsNotExist(e) {
return nil, nil
}
if e != nil {
return nil, e
}
id = string(b)
}
j, e := m.read(id)
if e != nil {
return nil, e
}
if !j.Active || time.Now().UnixMilli()-j.UpdatedAt < 90000 {
return j, nil
}
// An unavailable daemon, missing helper, or uncertain launch must never release the task lock.
c, e := inspect(ctx, j.HelperContainer)
if e == nil && c.State.Running {
return j, nil
}
j.RequiresRecovery = true
j.Message = "升级 helper 已停止或无法确认状态。请管理员检查任务备份和容器状态;在确认安全前禁止再次升级。"
// This is a derived view: do not race the helper's durable state writes.
j.Stage = "recovery_required"
return j, nil
}
func (m Manager) write(j *Job, stage, msg string) error {
j.Stage = stage
j.Message = msg
j.UpdatedAt = time.Now().UnixMilli()
j.Active = stage != "succeeded" && stage != "failed"
j.RequiresRecovery = stage == "recovery_required"
if e := save(filepath.Join(m.jobDir(j.ID), "status.json"), j); e != nil {
return e
}
f, e := os.OpenFile(filepath.Join(m.jobDir(j.ID), "events.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
if e != nil {
return e
}
defer f.Close()
_, e = fmt.Fprintf(f, "%d %s %s\n", j.UpdatedAt, stage, msg)
return e
}
func (m Manager) Start(ctx context.Context, version string, compose []byte) (*Job, string, error) {
if e := ValidateVersion(version); e != nil {
return nil, "", e
}
unlock, e := m.lock()
if e != nil {
return nil, "", e
}
defer unlock()
old, e := m.Status(ctx, "")
if e != nil {
return nil, "", e
}
if old != nil && (old.Active || old.RequiresRecovery) {
return old, "", errors.New("已有面板升级任务执行中或需要人工恢复")
}
p, e := m.Identity(ctx)
if e != nil {
return nil, "", e
}
b := make([]byte, 16)
if _, e = rand.Read(b); e != nil {
return nil, "", e
}
id := hex.EncodeToString(b)
dir := m.jobDir(id)
if e = os.Mkdir(dir, 0700); e != nil {
return nil, "", e
}
j := Job{ID: id, TargetVersion: version, StartedAt: time.Now().UnixMilli(), HelperContainer: "flvx-upgrade-" + id, BackupDir: filepath.Join(p.HostDir, ".panel-upgrades", id)}
p.Job = j
if e = m.write(&j, "preparing", "正在准备独立升级任务"); e != nil {
return nil, "", e
}
if e = atomic(filepath.Join(m.root(), "latest"), []byte(id)); e != nil {
return nil, "", e
}
fail := func(err error) (*Job, string, error) {
m.write(&j, "failed", "准备失败,线上配置未变更")
return &j, p.Image, err
}
for _, name := range []string{"docker-compose.yml", ".env"} {
data, err := os.ReadFile(filepath.Join(m.Dir, name))
if err != nil {
return fail(err)
}
if err = atomic(filepath.Join(dir, name+".original"), data); err != nil {
return fail(err)
}
}
env, e := os.ReadFile(filepath.Join(dir, ".env.original"))
if e != nil {
return fail(e)
}
lines := strings.Split(string(env), "\n")
for i, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "FLUX_VERSION=") || strings.HasPrefix(strings.TrimSpace(line), "export FLUX_VERSION=") {
lines[i] = ""
}
}
env = []byte(strings.Join(lines, "\n") + "\nFLUX_VERSION=" + version + "\n")
if e = atomic(filepath.Join(dir, ".env"), env); e != nil {
return fail(e)
}
if e = atomic(filepath.Join(dir, "docker-compose.yml"), compose); e != nil {
return fail(e)
}
p.Job = j
if e = save(filepath.Join(dir, "plan.json"), p); e != nil {
return fail(e)
}
// No --rm: an exited helper is evidence needed after a daemon/client timeout.
args := []string{"run", "-d", "--name", j.HelperContainer, "--label", "io.flvx.upgrade-job=" + id, "--mount", "type=bind,src=" + p.HostDir + ",dst=" + p.HostDir, "--mount", "type=bind,src=/var/run/docker.sock,dst=/var/run/docker.sock", "--entrypoint", "/app/paneld", p.Image, "panel-upgrade-helper", filepath.Join(p.HostDir, ".panel-upgrades", id, "plan.json")}
if _, e = docker(ctx, args...); e != nil {
c, ie := inspect(ctx, j.HelperContainer)
if ie != nil || !c.State.Running {
return &j, p.Image, errors.New("helper 启动结果不确定;任务已保留,请查询升级状态,勿重复启动")
}
}
return &j, p.Image, nil
}
@@ -0,0 +1,302 @@
package panelupgrade
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func fakeDocker(t *testing.T, body string) {
t.Helper()
d := t.TempDir()
if e := os.WriteFile(filepath.Join(d, "docker"), []byte("#!/bin/sh\n"+body), 0700); e != nil {
t.Fatal(e)
}
t.Setenv("PATH", d+":"+os.Getenv("PATH"))
}
func fixture(t *testing.T) (Manager, Plan) {
t.Helper()
dir := t.TempDir()
m := Manager{Dir: dir, Backend: "flux-panel-backend"}
id := strings.Repeat("a", 32)
os.MkdirAll(m.jobDir(id), 0700)
j := Job{ID: id, TargetVersion: "2.5.0", HelperContainer: "helper", StartedAt: time.Now().UnixMilli()}
m.write(&j, "preparing", "ready")
atomic(filepath.Join(m.root(), "latest"), []byte(id))
p := Plan{Job: j, HostDir: dir, DeployDir: "/opt/flvx-panel", Project: "original-project", Backend: m.Backend, Image: "sha256:old"}
save(filepath.Join(m.jobDir(id), "plan.json"), p)
for _, n := range []string{".env", "docker-compose.yml"} {
atomic(filepath.Join(dir, n), []byte("original"))
atomic(filepath.Join(m.jobDir(id), n+".original"), []byte("original"))
atomic(filepath.Join(m.jobDir(id), n), []byte("target"))
}
return m, p
}
func TestVersionRejectsComposeAndPathInjection(t *testing.T) {
for _, v := range []string{"latest", "../v1", "1.2.3\nSECRET=x", "1.2.3${TOKEN}", "1.2.3 x", "1.2.3;id"} {
if ValidateVersion(v) == nil {
t.Fatal(v)
}
}
for _, v := range []string{"2.5.0-rc10", "v2.5.0", "2.1.9-beta6"} {
if ValidateVersion(v) != nil {
t.Fatal(v)
}
}
}
func TestPersistentLockAcrossManagers(t *testing.T) {
m, p := fixture(t)
fakeDocker(t, "exit 1\n")
_, _, e := m.Start(context.Background(), "2.5.0", nil)
if e == nil {
t.Fatal("accepted concurrent job")
}
b, _ := os.ReadFile(filepath.Join(m.root(), "latest"))
if string(b) != p.Job.ID {
t.Fatal("changed active job")
}
}
func TestUncertainHelperNeverUnlocks(t *testing.T) {
m, p := fixture(t)
j, _ := m.read(p.Job.ID)
j.UpdatedAt = 1
save(filepath.Join(m.jobDir(j.ID), "status.json"), j)
fakeDocker(t, "exit 1\n")
j, e := m.Status(context.Background(), "")
if e != nil || !j.Active || !j.RequiresRecovery || j.Stage != "recovery_required" {
t.Fatalf("%+v %v", j, e)
}
if _, _, e = m.Start(context.Background(), "2.5.0", nil); e == nil {
t.Fatal("uncertain launch unlocked")
}
}
func configs(p Plan) (string, string) {
old := composeConfig{}
json.Unmarshal([]byte(`{"services":{"backend":{"image":"old","container_name":"flux-panel-backend","environment":{"FLUX_VERSION":"old"}},"frontend":{"image":"old","container_name":"vite-frontend"}}}`), &old)
a, _ := json.Marshal(old)
next := old
next.Services = make(map[string]struct {
Image string `json:"image"`
ContainerName string `json:"container_name"`
Volumes []map[string]any `json:"volumes"`
Environment map[string]string `json:"environment"`
})
for k, v := range old.Services {
next.Services[k] = v
}
v := next.Services["backend"]
v.Image = "ghcr.io/sagit-chu/flux-panel-backend:" + p.Job.TargetVersion
v.Environment = map[string]string{"FLUX_VERSION": p.Job.TargetVersion}
next.Services["backend"] = v
v = next.Services["frontend"]
v.Image = "ghcr.io/sagit-chu/vite-frontend:" + p.Job.TargetVersion
next.Services["frontend"] = v
b, _ := json.Marshal(next)
return string(a), string(b)
}
func TestHelperFailureBoundaries(t *testing.T) {
for _, stage := range []string{"config", "pull", "apply", "up", "health", "success"} {
t.Run(stage, func(t *testing.T) {
m, p := fixture(t)
old, next := configs(p)
dir := m.jobDir(p.Job.ID)
if stage == "apply" {
os.Remove(filepath.Join(dir, ".env"))
os.Mkdir(filepath.Join(dir, ".env"), 0700)
}
if stage == "health" {
before := verificationTimeout
verificationTimeout = 0
t.Cleanup(func() { verificationTimeout = before })
}
script := `case "$*" in
*"config --format json"*)
case "$*" in *"` + p.Job.ID + `"*) echo '` + next + `';; *) echo '` + old + `';; esac
;;
*"pull backend frontend"*) `
if stage == "pull" {
script += "echo SECRET >&2; exit 1"
} else {
script += "exit 0"
}
script += `;;
"image inspect"*) echo sha256:target;;
*"up -d"*) `
if stage == "up" {
script += "exit 1"
} else {
script += "touch \"" + filepath.Join(dir, "recreated") + "\"; exit 0"
}
script += `;;
"inspect flux-panel-backend") if [ -f "` + filepath.Join(dir, "recreated") + `" ]; then IMG=sha256:target; else IMG=sha256:old; fi; echo '[{"Image":"'"$IMG"'","State":{"Running":true,"Health":{"Status":"healthy"}},"Config":{"Labels":{"com.docker.compose.project":"original-project","com.docker.compose.service":"backend"}}}]';;
"inspect vite-frontend") echo '[{"Image":"sha256:target","State":{"Running":true},"Config":{"Labels":{"com.docker.compose.project":"original-project","com.docker.compose.service":"frontend"}}}]';;
"exec vite-frontend wget"*) exit 0;;
*) exit 1;; esac
`
if stage == "config" {
script = "echo SECRET >&2;exit 1\n"
}
fakeDocker(t, script)
e := RunHelper(filepath.Join(dir, "plan.json"))
j, _ := m.read(p.Job.ID)
want := "failed"
if stage == "up" || stage == "health" {
want = "recovery_required"
}
if stage == "success" {
want = "succeeded"
if e != nil {
t.Fatal(e)
}
}
if j.Stage != want {
t.Fatalf("stage=%s error=%v", j.Stage, e)
}
for _, n := range []string{".env", "docker-compose.yml"} {
b, _ := os.ReadFile(filepath.Join(m.Dir, n))
expected := "original"
if stage == "up" || stage == "success" || stage == "health" {
expected = "target"
}
if string(b) != expected {
t.Fatalf("%s mutated unexpectedly", n)
}
b, _ = os.ReadFile(filepath.Join(dir, n+".original"))
if string(b) != "original" {
t.Fatal("backup overwritten")
}
}
b, _ := os.ReadFile(filepath.Join(dir, "events.log"))
if strings.Contains(string(b), "SECRET") {
t.Fatal("secret leaked")
}
})
}
}
func TestComposeUsesOriginalHostPathAndProject(t *testing.T) {
p := Plan{Project: "my-project", HostDir: "/srv/panel"}
s := strings.Join(composeArgs(p, "/srv/panel/.panel-upgrades/job", "pull", "backend", "frontend"), " ")
if !strings.Contains(s, "--project-name my-project --project-directory /srv/panel") || strings.Contains(s, "remove-orphans") {
t.Fatal(s)
}
}
func TestStatusWithoutJobNeedsNoDocker(t *testing.T) {
fakeDocker(t, "exit 99\n")
j, e := (Manager{Dir: t.TempDir()}).Status(context.Background(), "")
if e != nil || j != nil {
t.Fatalf("%v %v", j, e)
}
}
func TestFullConfigRejectsUnintendedChanges(t *testing.T) {
for _, field := range []string{"ports", "network_mode", "command", "entrypoint", "privileged", "networks"} {
t.Run(field, func(t *testing.T) {
_, p := fixture(t)
oldJSON, nextJSON := configs(p)
var old, next composeConfig
json.Unmarshal([]byte(oldJSON), &old)
json.Unmarshal([]byte(nextJSON), &next)
var a, b struct {
Services map[string]map[string]any `json:"services"`
}
json.Unmarshal([]byte(oldJSON), &a)
json.Unmarshal([]byte(nextJSON), &b)
old.FullServices = a.Services
next.FullServices = b.Services
old.FullServices["frontend"][field] = "private-custom-setting"
if validateConfig(old, next, p) == nil {
t.Fatal("accepted service setting removal: " + field)
}
})
}
}
func TestLiveRejectsShadowMount(t *testing.T) {
_, p := fixture(t)
fakeDocker(t, `echo '[{"Image":"sha256:old","State":{"Running":true},"Config":{"Labels":{"com.docker.compose.project":"original-project","com.docker.compose.service":"backend"}},"Mounts":[{"Type":"bind","Source":"/srv/db/gost.db","Destination":"/app/data/gost.db","RW":true}]}]'`)
oldJSON, _ := configs(p)
var old composeConfig
json.Unmarshal([]byte(oldJSON), &old)
if validateLive(context.Background(), p, old) == nil {
t.Fatal("accepted undeclared shadow mount")
}
}
func identityJSON(dir, host, project, files string) string {
c := map[string]any{"Image": "sha256:old", "State": map[string]any{"Running": true}, "Config": map[string]any{"Labels": map[string]string{"com.docker.compose.project": project, "com.docker.compose.service": "backend", "com.docker.compose.project.working_dir": host, "com.docker.compose.project.config_files": files}}, "Mounts": []map[string]any{{"Type": "bind", "Source": host, "Destination": dir, "RW": true}}}
b, _ := json.Marshal([]any{c})
return string(b)
}
func TestStartUsesHostMountAndKeepsUniqueBackups(t *testing.T) {
dir := t.TempDir()
m := Manager{Dir: dir, Backend: "backend"}
for _, n := range []string{".env", "docker-compose.yml", ".env.upgrade.bak"} {
os.WriteFile(filepath.Join(dir, n), []byte("original-secret"), 0600)
}
args := filepath.Join(t.TempDir(), "args")
fakeDocker(t, `case "$1" in inspect) echo '`+identityJSON(dir, "/srv/original-panel", "original-project", "/srv/original-panel/docker-compose.yml")+`';; run) printf '%s\n' "$@" > '`+args+`'; echo container-id;; *) exit 1;; esac`)
j, _, e := m.Start(context.Background(), "2.5.0-rc10", []byte("target"))
if e != nil {
t.Fatal(e)
}
b, _ := os.ReadFile(args)
s := string(b)
for _, expected := range []string{"type=bind,src=/srv/original-panel,dst=/srv/original-panel", "/app/paneld", "sha256:old", "panel-upgrade-helper"} {
if !strings.Contains(s, expected) {
t.Fatal(s)
}
}
if strings.Contains(s, "--rm") || strings.Contains(s, "--volumes-from") {
t.Fatal(s)
}
first := j.ID
m.write(j, "failed", "safe failure")
j, _, e = m.Start(context.Background(), "2.5.1", []byte("target2"))
if e != nil || j.ID == first {
t.Fatalf("%v %v", j, e)
}
for _, n := range []string{".env", "docker-compose.yml", ".env.upgrade.bak"} {
b, _ := os.ReadFile(filepath.Join(dir, n))
if string(b) != "original-secret" {
t.Fatal("live or legacy overwritten")
}
}
b, _ = os.ReadFile(filepath.Join(m.jobDir(first), ".env.original"))
if string(b) != "original-secret" {
t.Fatal("old job backup changed")
}
}
func TestIdentityFailsClosed(t *testing.T) {
for _, host := range []string{"/", "/app", "/usr", "/var/run", "/proc", "relative", "/srv/bad,host"} {
t.Run(host, func(t *testing.T) {
dir := t.TempDir()
fakeDocker(t, "echo '"+identityJSON(dir, host, "project", filepath.Join(host, "docker-compose.yml"))+"'\n")
if _, e := (Manager{Dir: dir, Backend: "backend"}).Identity(context.Background()); e == nil {
t.Fatal(host)
}
})
}
}
func TestUncertainRunIsNotRetriedOrUnlocked(t *testing.T) {
dir := t.TempDir()
m := Manager{Dir: dir, Backend: "backend"}
for _, n := range []string{".env", "docker-compose.yml"} {
os.WriteFile(filepath.Join(dir, n), []byte("original"), 0600)
}
calls := filepath.Join(t.TempDir(), "runs")
fakeDocker(t, `case "$*" in "inspect backend") echo '`+identityJSON(dir, "/srv/panel", "project", "/srv/panel/docker-compose.yml")+`';; run*) echo run >> '`+calls+`'; exit 1;; *) exit 1;; esac`)
j, _, e := m.Start(context.Background(), "2.5.0", []byte("target"))
if e == nil || j == nil || !j.Active {
t.Fatalf("%v %v", j, e)
}
_, _, e = m.Start(context.Background(), "2.5.0", nil)
if e == nil {
t.Fatal("retry accepted")
}
b, _ := os.ReadFile(calls)
if string(b) != "run\n" {
t.Fatal("retried uncertain docker run")
}
}
+41
View File
@@ -0,0 +1,41 @@
// Package relaysecurity defines the versioned, endpoint-scoped relay contract.
// It authenticates relay clients, not the confidentiality of their transport.
package relaysecurity
import (
"crypto/rand"
"encoding/hex"
"errors"
"strings"
)
const Version = 1
const Required = "required"
const Legacy = "legacy"
type Credentials struct {
Version int `json:"version"`
Username string `json:"username"`
Password string `json:"password"`
}
func New() (*Credentials, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return nil, errors.New("relay credential randomness unavailable")
}
u := make([]byte, 12)
if _, err := rand.Read(u); err != nil {
return nil, errors.New("relay credential randomness unavailable")
}
return &Credentials{Version: Version, Username: "relay-" + hex.EncodeToString(u), Password: hex.EncodeToString(b)}, nil
}
func (c *Credentials) Validate() error {
if c == nil || c.Version != Version || strings.TrimSpace(c.Username) == "" || len(c.Username) > 255 || len(c.Password) < 32 || len(c.Password) > 255 {
return errors.New("invalid required relay authentication")
}
return nil
}
func (c *Credentials) Config() map[string]interface{} {
return map[string]interface{}{"username": c.Username, "password": c.Password}
}
@@ -0,0 +1,125 @@
package nftables
import (
"encoding/json"
"strconv"
"strings"
)
type CounterSample struct {
ForwardID int64
Direction string
Protocol string
Bytes uint64
Packets uint64
}
func ParseCounterComment(comment string) (CounterSample, bool) {
parts := strings.Split(comment, " ")
if len(parts) != 4 || parts[0] != "flvx" {
return CounterSample{}, false
}
if !strings.HasPrefix(parts[1], "forward:") {
return CounterSample{}, false
}
forwardText := strings.TrimPrefix(parts[1], "forward:")
forwardID, err := strconv.ParseInt(forwardText, 10, 64)
if err != nil || forwardID <= 0 {
return CounterSample{}, false
}
direction := parts[2]
if direction != CounterDirectionToTarget && direction != CounterDirectionFromTarget {
return CounterSample{}, false
}
protocol := parts[3]
if protocol != "tcp" && protocol != "udp" {
return CounterSample{}, false
}
return CounterSample{
ForwardID: forwardID,
Direction: direction,
Protocol: protocol,
}, true
}
func ParseCounterSamples(raw []byte) ([]CounterSample, error) {
var doc nftListTable
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, err
}
samples := make([]CounterSample, 0)
for _, item := range doc.Nftables {
ruleRaw, ok := item["rule"]
if !ok {
continue
}
var rule nftCounterRule
if err := json.Unmarshal(ruleRaw, &rule); err != nil {
return nil, err
}
if rule.Table != "flvx" || rule.Chain != "forward" {
continue
}
sample, ok, err := parseCounterRule(rule)
if err != nil {
return nil, err
}
if !ok {
continue
}
samples = append(samples, sample)
}
return samples, nil
}
type nftListTable struct {
Nftables []map[string]json.RawMessage `json:"nftables"`
}
type nftCounterRule struct {
Table string `json:"table"`
Chain string `json:"chain"`
Comment string `json:"comment"`
Expr []map[string]json.RawMessage `json:"expr"`
}
type nftCounter struct {
Bytes uint64 `json:"bytes"`
Packets uint64 `json:"packets"`
}
func parseCounterRule(rule nftCounterRule) (CounterSample, bool, error) {
var (
counter nftCounter
hasCounter bool
comment = rule.Comment
)
for _, expr := range rule.Expr {
if rawCounter, ok := expr["counter"]; ok {
if err := json.Unmarshal(rawCounter, &counter); err != nil {
return CounterSample{}, false, err
}
hasCounter = true
continue
}
if rawComment, ok := expr["comment"]; ok && strings.TrimSpace(comment) == "" {
if err := json.Unmarshal(rawComment, &comment); err != nil {
return CounterSample{}, false, err
}
}
}
if !hasCounter {
return CounterSample{}, false, nil
}
sample, ok := ParseCounterComment(comment)
if !ok {
return CounterSample{}, false, nil
}
sample.Bytes = counter.Bytes
sample.Packets = counter.Packets
return sample, true, nil
}

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