mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
Compare commits
376 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9aa13c4dfb | |||
| 4a8c400944 | |||
| 18e7ec94a8 | |||
| 02f2a1c8b3 | |||
| 681a0bef48 | |||
| 67bf5be0f2 | |||
| efb613b0b5 | |||
| 8124e59de5 | |||
| ac8c293ff3 | |||
| 4e38b73cac | |||
| 8f336377f6 | |||
| 5f78dd66fc | |||
| f2ee939006 | |||
| 23d2060742 | |||
| bb0da0b769 | |||
| e51af4be1f | |||
| a82f3a75b0 | |||
| 7d07fe08b7 | |||
| 375877b223 | |||
| 004daeadb6 | |||
| 3bcb80d7a2 | |||
| 5ff9621227 | |||
| 84db9711bc | |||
| 2f97e892d5 | |||
| 17fd1e4ad4 | |||
| e56dd898ef | |||
| 06bb8b3b04 | |||
| 42a775c3bb | |||
| 05c3b5842e | |||
| 149e10ee66 | |||
| e194813f3b | |||
| f1cad30f44 | |||
| 2e05df288b | |||
| 3e5bb8fc0b | |||
| d1e3c59537 | |||
| 8b8ebb6092 | |||
| 0195a2a01b | |||
| ad9b336fb9 | |||
| 30d9552207 | |||
| 5e96a8de72 | |||
| 69faeaa9a6 | |||
| e8bfe52104 | |||
| 9767cc3247 | |||
| e8a7f999c8 | |||
| 4d4f5f8b1f | |||
| d2a425d761 | |||
| 673d38a089 | |||
| 2e8c0530a9 | |||
| 32ee511eac | |||
| f410640862 | |||
| 6427b830ea | |||
| 5e7bf3ba5c | |||
| 27d6691232 | |||
| cc4b8a916a | |||
| d9dd5131b2 | |||
| a98c9f4f59 | |||
| 5cb935e0e5 | |||
| 6f59e4be0c | |||
| 647446a2a2 | |||
| 42d6249af5 | |||
| de9ab51def | |||
| a2ec08f033 | |||
| f8809d73fb | |||
| 413081f72a | |||
| e5339a8072 | |||
| fbb4d82a44 | |||
| 508a37a84c | |||
| d60655045a | |||
| 31ef861504 | |||
| f1bdb2e2ef | |||
| 61b71a11c7 | |||
| 4c69ff491d | |||
| 0ad4904e20 | |||
| bd30b61018 | |||
| e0dd70a054 | |||
| 4966a8aad1 | |||
| 3e11549370 | |||
| addf83a249 | |||
| c3e35fd416 | |||
| 775dfe19f1 | |||
| db3b2f651b | |||
| 669323f926 | |||
| 7202b69e4e | |||
| 31977a62e6 | |||
| 87479c2ac1 | |||
| ffda0fb71a | |||
| 9c0e7341c3 | |||
| 1db5452be9 | |||
| c10f894afd | |||
| 7fb75baa73 | |||
| 15e6cd69eb | |||
| f6eb88d75e | |||
| f45b580984 | |||
| 4f50c47550 | |||
| 2e1d75dc36 | |||
| f496f58a4d | |||
| 32474bec20 | |||
| 581cda7edc | |||
| 96aebb8d61 | |||
| 735fd40786 | |||
| a3b0bf4898 | |||
| 9703e4a081 | |||
| a43653f252 | |||
| 348900de01 | |||
| b93c259fac | |||
| 2e3d5c9249 | |||
| c8c1841058 | |||
| 1c596fae4b | |||
| 2ff52e3275 | |||
| 7efb49bdab | |||
| a00b20abf3 | |||
| 1450b25475 | |||
| b815be54b8 | |||
| 75edeb9afa | |||
| 7c54192055 | |||
| 7ba68778c1 | |||
| 7b736b2e60 | |||
| ef613c1518 | |||
| b62df6ffa3 | |||
| be9d8773ce | |||
| 1c10347357 | |||
| 5bd21e2ac1 | |||
| e38335973d | |||
| 95929bf82e | |||
| 9cf9f4f1f7 | |||
| ae8dbdd77f | |||
| 05bd6a686d | |||
| b8193417f5 | |||
| 15e4508be4 | |||
| 634c6cd620 | |||
| 4eaecb289b | |||
| 98a9e5c666 | |||
| d244920dd4 | |||
| 77e4387b35 | |||
| 7a40ddb1ef | |||
| d33814e18c | |||
| cf51b305b0 | |||
| 9ffeb83753 | |||
| 2f40cf29d4 | |||
| a92eb168aa | |||
| de21a55f37 | |||
| b01dbdb6e5 | |||
| a645cc699b | |||
| 528f912aac | |||
| 8bf30a157f | |||
| 58abba7fc0 | |||
| d8cd4b404c | |||
| 9e979aa82a | |||
| 5caaaf6092 | |||
| f23d1c2afd | |||
| 5e00cbf131 | |||
| 975948dcf6 | |||
| a9eac6d01f | |||
| 6e8406f439 | |||
| db3577afa9 | |||
| 7285717e34 | |||
| de6911f219 | |||
| e5ce0501a2 | |||
| 25a87e25c5 | |||
| a628f31859 | |||
| aae138a8cf | |||
| d2645589da | |||
| 6684a3426b | |||
| 7a8595ec87 | |||
| 06f76d918f | |||
| feb357ff17 | |||
| 34581e0d18 | |||
| 61c5b5e759 | |||
| c8eb780c67 | |||
| 4bdfa50b0c | |||
| 21008ccb43 | |||
| 362d327bf9 | |||
| 9a650fcc8f | |||
| 804a5a29ea | |||
| 6189fe23f1 | |||
| 7ba90e8696 | |||
| 0eed74fe10 | |||
| 466cc65069 | |||
| 9c41410f17 | |||
| bc71c524e0 | |||
| f46b2b4d86 | |||
| 9f17d63cdc | |||
| 92f8ec47db | |||
| a97484cd9b | |||
| ee6bc8c50e | |||
| c94ab84ab9 | |||
| 84a03215f4 | |||
| def93749eb | |||
| 945a1c0dfc | |||
| d752e096a3 | |||
| 880a3b81b0 | |||
| 191aface2e | |||
| e121dadb90 | |||
| bafcfbde3a | |||
| 98c463c62b | |||
| daf34d0f6c | |||
| bb505d461d | |||
| 00be0ac31e | |||
| fc5624a190 | |||
| 42ae3457b5 | |||
| 088027da7b | |||
| d37adee5df | |||
| c147e52d72 | |||
| d483258eef | |||
| 79c28103d5 | |||
| f36bf1437c | |||
| 4ad3aa2c06 | |||
| 357a4b165e | |||
| a15be253f5 | |||
| 572d1c16a6 | |||
| 39e22c07de | |||
| ca24573803 | |||
| 0cb3263a2e | |||
| fb2189c924 | |||
| 1383174b31 | |||
| c95bde7055 | |||
| 57f5e3a1a3 | |||
| 66ad52c199 | |||
| 2081dc9658 | |||
| b93255df3d | |||
| e1aef8700e | |||
| d5b3a39774 | |||
| 022e9e3807 | |||
| d333d463f6 | |||
| abc9f21ab9 | |||
| d216567c02 | |||
| 45bfd35a20 | |||
| 5a1b72387d | |||
| 66de566a00 | |||
| 18c2da7c7e | |||
| c4d807f1c4 | |||
| d1460ab9c7 | |||
| 9189c68800 | |||
| efbdabceca | |||
| 6e5a71f489 | |||
| e5c57f81ad | |||
| 0b1609c6cb | |||
| a10c68ef20 | |||
| 9aedeab406 | |||
| 137c34e3f5 | |||
| 25d29c305f | |||
| 9dcf9a1a43 | |||
| 2308b25bcf | |||
| b6c2159614 | |||
| 30c96a280d | |||
| 12c50df6a7 | |||
| c5124a01e6 | |||
| 6d57b49595 | |||
| d12c5bf2e1 | |||
| 42701e6c01 | |||
| d6c17aee79 | |||
| 17f8a06704 | |||
| e7b777890e | |||
| 5b03ce87ff | |||
| 2aebb9ed5e | |||
| e209fc689a | |||
| f7bcb13f75 | |||
| 3d1a8c8963 | |||
| d82c099c7f | |||
| 2dfcad6154 | |||
| ba7e3c9893 | |||
| d4622903b2 | |||
| 66be07750f | |||
| a982c663d2 | |||
| 98b4d78b4d | |||
| 45d7970177 | |||
| 1b4500202a | |||
| 9a9e83dda0 | |||
| e5e22baf43 | |||
| 961c06655a | |||
| 8dc31383e0 | |||
| 184ac3c3e5 | |||
| 77dbd719ed | |||
| 04ce125416 | |||
| fd5cfc2a40 | |||
| 4e4193e0b0 | |||
| 3f80278dd4 | |||
| 6abe3e7713 | |||
| 47c05c3d02 | |||
| d05c8a2ea4 | |||
| e00e41bb64 | |||
| 5271efec1e | |||
| 2d39cb3005 | |||
| 3e52c8eace | |||
| 7808d57a79 | |||
| 46bc4ca6e4 | |||
| 28e66ab172 | |||
| f19bccec4c | |||
| e37d6cf666 | |||
| 177c2bc35f | |||
| 76c0978763 | |||
| fd1168d855 | |||
| 92c9590c1a | |||
| 2afb1d275a | |||
| 880cd4cac5 | |||
| a69a0f040b | |||
| cf6294a77d | |||
| 524ee4cd95 | |||
| c049ceaacf | |||
| 3424221176 | |||
| 5a9715eb26 | |||
| 1b79213aed | |||
| c0d71125f4 | |||
| f01c0481cd | |||
| f227ffddc3 | |||
| c4f14f985e | |||
| 3b294c6b9e | |||
| 641aa66afc | |||
| fea1bf52f3 | |||
| a72d84fa76 | |||
| 146821ebba | |||
| 0191f29cf1 | |||
| 149a841a49 | |||
| 229ae9e454 | |||
| ae8a3db3df | |||
| 51660c413e | |||
| 2d2ca389e3 | |||
| 3799729706 | |||
| 8628c35802 | |||
| acea5ea76c | |||
| 8652380da1 | |||
| dd206ced14 | |||
| f720b92f53 | |||
| f879a58bb4 | |||
| b11283d488 | |||
| 70f8dfeac1 | |||
| 37005a1954 | |||
| b55e056316 | |||
| b3b7f5e56d | |||
| d6ff6ea500 | |||
| 9ed875b7ef | |||
| 6387ce1816 | |||
| 219067a27c | |||
| 33678477aa | |||
| e33fbda8f4 | |||
| 62cb75f669 | |||
| f3260637db | |||
| e43c6bf840 | |||
| 025d0a3322 | |||
| 2dcfe15f00 | |||
| ecaae178b6 | |||
| 46a8ceff6a | |||
| 7bfa1251c4 | |||
| cd09305a85 | |||
| b5f8a491b3 | |||
| e4acd855e0 | |||
| 275cfdc65a | |||
| 9d323ce930 | |||
| 1733948a1b | |||
| cedcaebd1f | |||
| b32133f81a | |||
| ff57bca505 | |||
| cdb2914dbf | |||
| b56d0a28e7 | |||
| bdfc704f95 | |||
| 9223892ca5 | |||
| 6f205df37c | |||
| 04266165df | |||
| dbd5773717 | |||
| 6c4d44e7a7 | |||
| 07b8d73956 | |||
| 71a6a60077 | |||
| fe33028934 | |||
| 01da4bd283 | |||
| a0b975b62a | |||
| f4e56d091e | |||
| 0a5335c1ca | |||
| d40e97d73b | |||
| 73bf672e62 | |||
| 297f526a92 | |||
| 2b854d3172 | |||
| c184a75f22 | |||
| 8a5bfa5aa8 | |||
| 421f18d4da | |||
| 5133e6f039 | |||
| 311840b29b |
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Task",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code post-task"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "TodoWrite",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code post-todo"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Task",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code pre-task"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code session-end"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code session-start"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code stop"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "entire hooks claude-code user-prompt-submit"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"deny": [
|
||||
"Read(./.entire/metadata/**)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,43 @@ jobs:
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
|
||||
backend-postgres-contract:
|
||||
name: Go Backend PostgreSQL Contract
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
env:
|
||||
POSTGRES_USER: flux_test
|
||||
POSTGRES_PASSWORD: flux_test_pass
|
||||
POSTGRES_DB: flux_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U flux_test -d flux_test"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
defaults:
|
||||
run:
|
||||
working-directory: go-backend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
cache-dependency-path: go-backend/go.sum
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Run PostgreSQL contract test
|
||||
env:
|
||||
FLVX_POSTGRES_TEST_DSN: 'postgres://flux_test:flux_test_pass@127.0.0.1:5432/flux_test?sslmode=disable'
|
||||
run: go test ./tests/contract -run TestPostgresNodeCreateRepairsMissingIDDefaultContract -count=1
|
||||
|
||||
agent:
|
||||
name: Build Agent
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -112,6 +112,12 @@ jobs:
|
||||
upx --best --lzma gost-amd64
|
||||
upx --best --lzma gost-arm64
|
||||
|
||||
- name: Generate SHA256 checksums
|
||||
working-directory: ./go-gost
|
||||
run: |
|
||||
sha256sum gost-amd64 > gost-amd64.sha256
|
||||
sha256sum gost-arm64 > gost-arm64.sha256
|
||||
|
||||
- name: Upload GOST AMD64 artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -124,6 +130,18 @@ jobs:
|
||||
name: gost-binary-arm64
|
||||
path: ./go-gost/gost-arm64
|
||||
|
||||
- name: Upload GOST AMD64 checksum artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gost-checksum-amd64
|
||||
path: ./go-gost/gost-amd64.sha256
|
||||
|
||||
- name: Upload GOST ARM64 checksum artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gost-checksum-arm64
|
||||
path: ./go-gost/gost-arm64.sha256
|
||||
|
||||
build-vite:
|
||||
name: Build & Push Vite Frontend
|
||||
needs: check-version
|
||||
@@ -238,7 +256,20 @@ jobs:
|
||||
name: gost-binary-arm64
|
||||
path: ./artifacts/arm64
|
||||
|
||||
- name: Download GOST AMD64 checksum
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: gost-checksum-amd64
|
||||
path: ./artifacts/
|
||||
|
||||
- name: Download GOST ARM64 checksum
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: gost-checksum-arm64
|
||||
path: ./artifacts/
|
||||
|
||||
- name: Prepare release files
|
||||
|
||||
run: |
|
||||
VERSION="${{ needs.check-version.outputs.version }}"
|
||||
OWNER="${{ needs.check-version.outputs.image_owner }}"
|
||||
@@ -331,6 +362,10 @@ jobs:
|
||||
gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber
|
||||
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
|
||||
|
||||
echo "📤 上传 GOST 校验文件..."
|
||||
gh release upload "${VERSION}" ./artifacts/gost-amd64.sha256 --clobber
|
||||
gh release upload "${VERSION}" ./artifacts/gost-arm64.sha256 --clobber
|
||||
|
||||
echo "📤 上传安装脚本..."
|
||||
gh release upload "${VERSION}" ./artifacts/install.sh --clobber
|
||||
gh release upload "${VERSION}" ./artifacts/panel_install.sh --clobber
|
||||
@@ -363,6 +398,18 @@ jobs:
|
||||
name: gost-binary-arm64
|
||||
path: ./artifacts/arm64
|
||||
|
||||
- name: Download GOST AMD64 checksum
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: gost-checksum-amd64
|
||||
path: ./artifacts/
|
||||
|
||||
- name: Download GOST ARM64 checksum
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: gost-checksum-arm64
|
||||
path: ./artifacts/
|
||||
|
||||
- name: Rename binaries
|
||||
run: |
|
||||
mv ./artifacts/amd64/gost-amd64 ./artifacts/gost-amd64
|
||||
@@ -379,4 +426,9 @@ jobs:
|
||||
gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber
|
||||
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
|
||||
|
||||
echo "📤 上传 GOST 校验文件..."
|
||||
gh release upload "${VERSION}" ./artifacts/gost-amd64.sha256 --clobber
|
||||
gh release upload "${VERSION}" ./artifacts/gost-arm64.sha256 --clobber
|
||||
|
||||
echo "✅ GOST 二进制文件更新完成"
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Publish Skill to npm
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
VERSION=$(node -p "require('./skills/flvx-api/package.json').version")
|
||||
else
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Publishing skill version: $VERSION"
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: skills/flvx-api
|
||||
run: npm publish --provenance --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: github.event_name == 'push'
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
name: Skill v${{ steps.version.outputs.version }}
|
||||
generate_release_notes: true
|
||||
files: skills/flvx-api/package.json
|
||||
@@ -177,6 +177,7 @@ build/
|
||||
*.dylib
|
||||
your_app.exe
|
||||
go-backend/paneld
|
||||
go-gost/gost
|
||||
|
||||
# Go 测试二进制文件
|
||||
*.test
|
||||
@@ -258,7 +259,15 @@ gitee/
|
||||
doraemon.jks
|
||||
device.id
|
||||
commit.sh
|
||||
.opencode/
|
||||
analysis/
|
||||
sql/
|
||||
!go-backend/internal/store/sqlite/sql/
|
||||
!go-backend/internal/store/sqlite/sql/schema.sql
|
||||
!go-backend/internal/store/sqlite/sql/data.sql
|
||||
!go-backend/internal/store/postgres/sql/
|
||||
!go-backend/internal/store/postgres/sql/schema.sql
|
||||
!go-backend/internal/store/postgres/sql/data.sql
|
||||
go-backend/gost.db-shm
|
||||
.gitignore
|
||||
go-backend/gost.db-wal
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Issue #211: 转发自定义监听IP / 隧道指定连接IP
|
||||
|
||||
## 需求总结
|
||||
1. **节点**: 高级配置增加"额外IP地址"字段(逗号分隔)
|
||||
2. **转发**: 创建/编辑时可指定入口监听IP
|
||||
3. **隧道**: 配置出口节点时可指定连接IP
|
||||
|
||||
---
|
||||
|
||||
## 任务清单
|
||||
|
||||
### 后端
|
||||
- [x] 1. 数据模型扩展 - Node/ForwardPort/ChainTunnel 增加字段
|
||||
- [x] 2. Repository - CreateNode/UpdateNode 处理 extraIPs
|
||||
- [x] 3. Repository - resolveForwardIngress 使用 forward_port.in_ip
|
||||
- [x] 4. Repository - GetNodeAllIPs 辅助函数(返回节点所有可用IP)
|
||||
- [x] 5. Handler - 转发创建/更新处理 inIp 参数
|
||||
- [x] 6. Handler - 隧道出口节点处理 connectIp 参数
|
||||
- [x] 7. Handler - 节点API返回 extraIPs 字段
|
||||
|
||||
### 前端
|
||||
- [x] 8. 节点编辑页 - 高级配置增加"额外IP"输入
|
||||
- [x] 9. 转发编辑弹窗 - 增加"监听IP"下拉选择
|
||||
- [x] 10. 隧道配置页 - 出口节点增加"连接IP"输入
|
||||
|
||||
---
|
||||
|
||||
## 完成进度
|
||||
- 开始时间: 2026-03-02
|
||||
- 完成时间: 2026-03-02
|
||||
- 完成任务: 10/10
|
||||
- 后端完成: ✅
|
||||
- 前端完成: ✅
|
||||
@@ -1,24 +1,27 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Mon Feb 02 2026
|
||||
**Commit:** 7ca01ab
|
||||
**Branch:** beta
|
||||
**Generated:** Thu Feb 26 2026
|
||||
**Commit:** 21008cc
|
||||
**Branch:** main
|
||||
**Tag:** 2.1.5-rc15
|
||||
|
||||
## OVERVIEW
|
||||
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
|
||||
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite/PostgreSQL) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
./
|
||||
├── go-gost/ # Go forwarding agent (forked gost + local x/)
|
||||
│ └── x/ # Local fork of github.com/go-gost/x (replace => ./x)
|
||||
├── go-backend/ # Go Admin API (SQLite, net/http)
|
||||
├── vite-frontend/ # React/Vite dashboard (HeroUI + Tailwind)
|
||||
├── go-backend/ # Go Admin API (GORM + SQLite/PostgreSQL, net/http)
|
||||
│ └── tests/contract/ # Integration/contract tests
|
||||
├── vite-frontend/ # React/Vite dashboard (shadcn bridge + Tailwind v4)
|
||||
│ └── src/shadcn-bridge/heroui/ # HeroUI-compatible facade
|
||||
├── docker-compose-v4.yml # Panel deploy (IPv4-only bridge)
|
||||
├── docker-compose-v6.yml # Panel deploy (IPv6-enabled bridge)
|
||||
├── panel_install.sh # Panel installer/upgrader (downloads compose)
|
||||
├── install.sh # Node installer/upgrader (downloads gost binary)
|
||||
└── .github/workflows/ # CI: build/push images + release artifacts
|
||||
└── .github/workflows/ # CI: build/test + Docker push + release artifacts
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
@@ -28,10 +31,15 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
|
||||
| **Deploy (IPv6)** | `docker-compose-v6.yml` | Same as v4 + IPv6-enabled bridge |
|
||||
| **Panel install** | `panel_install.sh` | Picks v4/v6, generates `JWT_SECRET`, downloads compose |
|
||||
| **Node install** | `install.sh` | Installs `/etc/flux_agent/flux_agent` + writes `config.json`/`gost.json` + systemd `flux_agent.service` |
|
||||
| **Admin API** | `go-backend/` | Go Admin API (SQLite) |
|
||||
| **Web UI** | `vite-frontend/` | React/Vite dashboard (HeroUI + Tailwind) |
|
||||
| **Admin API** | `go-backend/` | Go Admin API (SQLite/PostgreSQL) |
|
||||
| **Web UI** | `vite-frontend/` | React/Vite dashboard (shadcn bridge + Tailwind v4) |
|
||||
| **UI Compatibility** | `vite-frontend/src/shadcn-bridge/heroui/` | HeroUI-compatible API wrappers backed by shadcn/radix |
|
||||
| **Theme Tokens** | `vite-frontend/src/styles/tailwind-theme.pcss` | Tailwind v4 `@theme inline` semantic color mapping |
|
||||
| **Go Agent** | `go-gost/` | Forwarding agent (forked gost + local x/) |
|
||||
| **Go Core** | `go-gost/x/` | Handlers/listeners/dialers + management API |
|
||||
| **Repository Layer** | `go-backend/internal/store/repo/` | GORM data access (repository.go 83k LOC) |
|
||||
| **Contract Tests** | `go-backend/tests/contract/` | Integration tests for auth, federation, tunnels |
|
||||
| **CI Workflows** | `.github/workflows/` | ci-build.yml, docker-build.yml, deploy-docs.yml |
|
||||
|
||||
## CODE MAP
|
||||
| Symbol | Type | Location | Role |
|
||||
@@ -40,14 +48,29 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
|
||||
| `main` | Func | `go-backend/cmd/paneld/main.go` | Backend Entry |
|
||||
| `App` | Component | `vite-frontend/src/App.tsx` | Frontend Entry |
|
||||
| `main` | Func | `go-gost/main.go` | Agent Entry |
|
||||
|
||||
| `Repository` | Struct | `go-backend/internal/store/repo/repository.go` | Data Access Layer |
|
||||
| `Handler` | Struct | `go-backend/internal/http/handler/handler.go` | HTTP Handlers |
|
||||
| `websocket_reporter` | Func | `go-gost/x/socket/websocket_reporter.go` | Panel Telemetry |
|
||||
|
||||
## CONVENTIONS
|
||||
- `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `go-backend/`.
|
||||
- `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
|
||||
- **Skills & MCP**: Always prefer using available skills (via `skill` tool) and MCP tools when applicable. Check for relevant skills before implementing from scratch.
|
||||
- **Auth**: `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `go-backend/`.
|
||||
- **Module Fork**: `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
|
||||
- **Encryption**: Agent-to-panel communication uses AES encryption with node `secret` as PSK.
|
||||
- **API Envelope**: All REST responses follow `{code, msg, data, ts}` structure (code 0 = success).
|
||||
- **Frontend UI Layer**: Import UI primitives from `src/shadcn-bridge/heroui/*` (legacy-compatible facade), not direct `@heroui/*` packages.
|
||||
- **Tailwind v4 Semantic Colors**: `src/styles/globals.css` must import `src/styles/tailwind-theme.pcss`; removing it breaks semantic classes like `bg-primary`, `text-foreground`, and `border-input`.
|
||||
- **Go Versions**: `go-backend` uses Go 1.24, `go-gost` uses Go 1.23, `go-gost/x` uses Go 1.22.
|
||||
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
- Do not edit generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
|
||||
- **DO NOT EDIT** generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
|
||||
- **DO NOT ADD** `Bearer` prefix to Authorization header - expects raw JWT token.
|
||||
- **DO NOT MODIFY** `install.sh` or `panel_install.sh` locally - CI overwrites these on release.
|
||||
- **DO NOT** let backend handlers call `repo.DB()` directly — add a Repository method instead.
|
||||
- **DO NOT ADD** frontend tests - project has no test infrastructure (Vitest/Jest not configured).
|
||||
- **DO NOT REINTRODUCE** `@heroui/*` or `@nextui-org/*` dependencies; migration is now shadcn bridge-based.
|
||||
- **DO NOT** use `type:jsonb` or `type:serial` in GORM tags (SQLite incompatible).
|
||||
- **DO NOT** omit `TableName()` on new models — GORM pluralizes by default.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
@@ -63,8 +86,38 @@ docker compose -f docker-compose-v6.yml up -d
|
||||
(cd go-backend && make build)
|
||||
(cd vite-frontend && npm run dev)
|
||||
(cd go-gost && go run .)
|
||||
|
||||
# Testing
|
||||
(cd go-backend && go test ./...)
|
||||
(cd go-backend && go test ./tests/contract/...)
|
||||
```
|
||||
|
||||
## UNIQUE STYLES
|
||||
- **Flat Monorepo**: Language-prefixed dirs (`go-backend`, `go-gost`, `vite-frontend`) instead of `apps/`/`libs/`.
|
||||
- **Asymmetric Go Layout**: `go-backend` follows `cmd/<app>/main.go` while `go-gost` uses `root/main.go`.
|
||||
- **Frontend Hybrid Mode**: `App.tsx` detects "H5 mode" (mobile WebView) vs desktop, dictating layout strategy.
|
||||
- **Experimental Bundler**: `vite-frontend` uses `rolldown-vite` (Rust-based) instead of standard Vite.
|
||||
- **Non-minified Builds**: `vite.config.ts` sets `minify: false`, `treeshake: false` for debugging.
|
||||
|
||||
## NOTES
|
||||
- LSP servers are not installed in this environment (gopls/jdtls/typescript-language-server); rely on grep-based navigation.
|
||||
- LSP servers are not installed in this environment (gopls/typescript-language-server); rely on grep-based navigation.
|
||||
- `vite-frontend/vite.config.ts` sets `minify: false` and disables treeshake; expect larger bundles.
|
||||
- `vite-frontend` uses `rolldown-vite` (experimental Rust bundler) instead of standard Vite.
|
||||
- Install scripts (`install.sh`, `panel_install.sh`) self-delete after execution - common pattern in one-liner installs.
|
||||
- CI uses UPX compression (`--best --lzma`) on Go binaries before release.
|
||||
- CI dynamically injects `PINNED_VERSION` into install scripts and docker-compose files during releases.
|
||||
- `panel_install.sh` auto-detects IPv6 and modifies `/etc/docker/daemon.json` to enable IPv6 bridge.
|
||||
- Download proxy `https://gcode.hostcentral.cc/` used for GitHub downloads in China/restricted environments.
|
||||
- Backend has contract tests in `go-backend/tests/contract/` - frontend has no test infrastructure.
|
||||
- `analysis/3x-ui/` contains a separate git repo for reference/comparison - not part of FLVX core.
|
||||
- CI workflows: `ci-build.yml` (build check), `docker-build.yml` (multi-arch images + release), `deploy-docs.yml` (MkDocs).
|
||||
- PostgreSQL migration supported via `panel_install.sh` menu option using pgloader.
|
||||
- Repository layer is large: `repository.go` (83k LOC), `repository_mutations.go` (43k LOC).
|
||||
- Button visual parity relies on `vite-frontend/src/shadcn-bridge/heroui/button.tsx` color mapping + `vite-frontend/src/styles/tailwind-theme.pcss` token export.
|
||||
|
||||
## PLAN DOCUMENT RULE
|
||||
- Every new implementation plan must have a dedicated Markdown plan document.
|
||||
- Store plan documents under `plans/`.
|
||||
- Use an incrementing numeric prefix and a short plan-summary name: `NNN-<plan-summary>.md` (for example, `001-auth-refactor.md`, `002-federation-api-cleanup.md`).
|
||||
- The numeric prefix must increase by 1 for each new plan.
|
||||
- In each plan document, keep a task checklist and mark each task as completed immediately after finishing it.
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# 限速功能重构实施计划
|
||||
|
||||
## 一、需求概述
|
||||
|
||||
**原始需求**: 限速功能当前绑定到具体隧道,需要改为不绑定隧道,创建限速后可以自由在隧道上限速,也可以在转发上限速。
|
||||
|
||||
**核心变更**:
|
||||
1. 限速规则(SpeedLimit)与隧道的绑定关系改为可选
|
||||
2. 转发(Forward)支持独立的限速规则
|
||||
|
||||
---
|
||||
|
||||
## 二、实施计划清单
|
||||
|
||||
### 2.0 计划状态(审计更新:2026-02-26)
|
||||
|
||||
- 总体状态:**进行中(未验收通过)**
|
||||
- 已完成:模型、仓储查询、限速 CRUD、控制面优先级、限速页与类型改造、编译与测试通过
|
||||
- 未完成:**Forward 独立限速写入链路**(前端表单 -> API handler -> repository 落库 `forward.speed_id`)
|
||||
|
||||
### 2.1 后端模型层 (Model)
|
||||
|
||||
| 序号 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| M1 | SpeedLimit.TunnelID 改为 sql.NullInt64 (可空) | `go-backend/internal/store/model/model.go` | ✅ 完成 |
|
||||
| M2 | SpeedLimit.TunnelName 改为 sql.NullString (可空) | `go-backend/internal/store/model/model.go` | ✅ 完成 |
|
||||
| M3 | Forward 添加 SpeedID sql.NullInt64 字段 | `go-backend/internal/store/model/model.go` | ✅ 完成 |
|
||||
| M4 | ForwardRecord 添加 SpeedID sql.NullInt64 字段 | `go-backend/internal/store/model/model.go` | ✅ 完成 |
|
||||
| M5 | SpeedLimitBackup.TunnelID 改为指针类型 | `go-backend/internal/store/model/model.go` | ✅ 完成 |
|
||||
| M6 | ForwardBackup 添加 SpeedID *int64 字段 | `go-backend/internal/store/model/model.go` | ✅ 完成 |
|
||||
|
||||
### 2.2 后端仓储层 (Repository)
|
||||
|
||||
| 序号 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| R1 | ListSpeedLimits() 返回可空 tunnelId/tunnelName | `go-backend/internal/store/repo/repository.go` | ✅ 完成 |
|
||||
| R2 | ListForwards() 返回 speedId 字段 | `go-backend/internal/store/repo/repository.go` | ✅ 完成 |
|
||||
| R3 | CreateSpeedLimit() 参数 tunnelID 改为 *int64 | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
|
||||
| R4 | UpdateSpeedLimit() 参数 tunnelID 改为 *int64 | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
|
||||
| R5 | GetSpeedLimitTunnelID() 返回 sql.NullInt64 | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
|
||||
| R6 | exportSpeedLimits() 处理可空字段 | `go-backend/internal/store/repo/repository.go` | ✅ 完成 |
|
||||
| R7 | importSpeedLimits() 处理可空字段 | `go-backend/internal/store/repo/repository.go` | ✅ 完成 |
|
||||
| R8 | GetSpeedLimitSpeed() 新增方法 | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 |
|
||||
| R9 | ListForwardsByTunnel() 返回 SpeedID | `go-backend/internal/store/repo/repository_control.go` | ✅ 完成 |
|
||||
| R10 | ListActiveForwardsByUser() 返回 SpeedID | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 |
|
||||
| R11 | ListActiveForwardsByUserTunnel() 返回 SpeedID | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 |
|
||||
| R12 | GetForwardRecord() 返回 SpeedID | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 |
|
||||
|
||||
### 2.3 后端处理器层 (Handler)
|
||||
|
||||
| 序号 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| H1 | speedLimitCreate 处理可选 tunnelId | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
|
||||
| H2 | speedLimitUpdate 处理可选 tunnelId | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
|
||||
| H3 | speedLimitDelete 处理可空 tunnelID | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
|
||||
|
||||
### 2.4 后端控制平面 (Control Plane)
|
||||
|
||||
| 序号 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| C1 | syncForwardServices 优先使用 Forward.SpeedID | `go-backend/internal/http/handler/control_plane.go` | ✅ 完成 |
|
||||
| C2 | 回退到 UserTunnel 的 speed limit | `go-backend/internal/http/handler/control_plane.go` | ✅ 完成 |
|
||||
|
||||
### 2.5 前端类型定义 (TypeScript Types)
|
||||
|
||||
| 序号 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| T1 | SpeedLimitApiItem.tunnelId 改为可选 | `vite-frontend/src/api/types.ts` | ✅ 完成 |
|
||||
| T2 | ForwardApiItem 添加 speedId 字段 | `vite-frontend/src/api/types.ts` | ✅ 完成 |
|
||||
| T3 | ForwardMutationPayload 添加 speedId 字段 | `vite-frontend/src/api/types.ts` | ✅ 完成 |
|
||||
| T4 | SpeedLimitMutationPayload.tunnelId 改为可选 | `vite-frontend/src/api/types.ts` | ✅ 完成 |
|
||||
|
||||
### 2.6 前端页面组件
|
||||
|
||||
| 序号 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| F1 | SpeedLimitRule 接口更新 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
|
||||
| F2 | SpeedLimitForm 接口更新 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
|
||||
| F3 | validateForm 移除 tunnelId 必填校验 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
|
||||
| F4 | Select 组件改为可选 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
|
||||
| F5 | 显示"未绑定"状态 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
|
||||
|
||||
### 2.7 编译验证
|
||||
|
||||
| 序号 | 任务 | 状态 |
|
||||
|------|------|------|
|
||||
| B1 | Go 后端编译通过 | ✅ 完成 |
|
||||
| B2 | TypeScript 类型检查通过 | ✅ 完成 |
|
||||
| B3 | `go test ./...` 全量通过 | ✅ 完成 |
|
||||
| B4 | `go test ./tests/contract/... -run SpeedLimit` 通过 | ✅ 完成 |
|
||||
|
||||
### 2.8 Forward 独立限速写入链路补全(新增)
|
||||
|
||||
| 序号 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| N1 | forwardCreate 支持接收并校验可选 speedId,写入 Forward.SpeedID | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
|
||||
| N2 | forwardUpdate 支持更新/清空 speedId,并触发服务重下发 | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
|
||||
| N3 | CreateForwardTx 支持落库 speed_id | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
|
||||
| N4 | UpdateForward 支持更新 speed_id | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
|
||||
| N5 | Forward 页面新增限速选择并透传 speedId | `vite-frontend/src/pages/forward.tsx` | ✅ 完成 |
|
||||
| N6 | Forward 相关契约测试补充 speedId 写入/清空断言 | `go-backend/tests/contract/forward_contract_test.go` | ✅ 完成 |
|
||||
|
||||
---
|
||||
|
||||
## 三、优先级说明
|
||||
|
||||
限速规则应用优先级:
|
||||
1. **Forward.SpeedID** - 转发级别的限速 (最高优先)
|
||||
2. **UserTunnel.SpeedID** - 用户隧道权限级别的限速 (回退)
|
||||
|
||||
---
|
||||
|
||||
## 四、数据库兼容性
|
||||
|
||||
- SpeedLimit 表: `tunnel_id` 和 `tunnel_name` 字段改为可空 (GORM AutoMigrate 自动处理)
|
||||
- Forward 表: 新增 `speed_id` 可空字段 (GORM AutoMigrate 自动处理)
|
||||
|
||||
---
|
||||
|
||||
## 五、验证检查项
|
||||
|
||||
### 5.1 功能验证(审计后)
|
||||
|
||||
- [x] 创建不限速规则的限速 (不绑定隧道)
|
||||
- [x] 创建绑定隧道的限速 (兼容旧逻辑)
|
||||
- [x] 编辑限速规则,切换隧道绑定状态
|
||||
- [ ] 删除限速规则
|
||||
- [ ] 转发列表正确显示 speedId
|
||||
|
||||
### 5.2 API 验证(审计后)
|
||||
|
||||
- [x] GET /api/speed-limit/list 返回可选 tunnelId
|
||||
- [x] POST /api/speed-limit/create 接受可选 tunnelId
|
||||
- [x] POST /api/speed-limit/update 接受可选 tunnelId
|
||||
- [ ] GET /api/forward/list 返回 speedId
|
||||
|
||||
### 5.3 兼容性验证(审计后)
|
||||
|
||||
- [x] 现有绑定隧道的限速规则继续正常工作
|
||||
- [ ] 现有 UserTunnel 的限速继续正常工作
|
||||
- [ ] 备份/恢复功能正常
|
||||
|
||||
### 5.4 Forward 独立限速闭环验证(新增)
|
||||
|
||||
- [x] POST /api/forward/create 接受 speedId 并写入 `forward.speed_id`
|
||||
- [x] POST /api/forward/update 可更新/清空 speedId
|
||||
- [x] Forward 表单可选择限速并提交 speedId
|
||||
- [ ] `syncForwardServices` 实际使用 Forward.SpeedID 而非仅回退 UserTunnel.SpeedID
|
||||
@@ -42,6 +42,79 @@ curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/panel_in
|
||||
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
||||
```
|
||||
|
||||
#### PostgreSQL 部署(Docker Compose)
|
||||
|
||||
安装脚本会根据环境自动下载对应的 Compose 配置并保存为 `docker-compose.yml`。默认仍使用 SQLite,切换到 PostgreSQL 只需要配置环境变量。
|
||||
|
||||
1) 在 `docker-compose` 同目录创建或修改 `.env`:
|
||||
|
||||
```bash
|
||||
JWT_SECRET=replace_with_your_secret
|
||||
BACKEND_PORT=6365
|
||||
FRONTEND_PORT=6366
|
||||
|
||||
DB_TYPE=postgres
|
||||
DATABASE_URL=postgres://flux_panel:replace_with_strong_password@postgres:5432/flux_panel?sslmode=disable
|
||||
|
||||
POSTGRES_DB=flux_panel
|
||||
POSTGRES_USER=flux_panel
|
||||
POSTGRES_PASSWORD=replace_with_strong_password
|
||||
```
|
||||
|
||||
> 📌 使用安装脚本部署时,`POSTGRES_PASSWORD` 会自动随机生成并写入 `.env`。
|
||||
|
||||
2) 启动服务:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
3) 如果你想继续使用 SQLite,保留 `DB_TYPE=sqlite`(或不设置 `DB_TYPE`)即可。
|
||||
|
||||
#### 从 SQLite 迁移到 PostgreSQL
|
||||
|
||||
如果你是通过 `panel_install.sh` 安装面板,推荐直接使用脚本菜单一键迁移:
|
||||
|
||||
```bash
|
||||
./panel_install.sh
|
||||
# 选择 4. 迁移到 PostgreSQL
|
||||
```
|
||||
|
||||
脚本会自动完成 SQLite 备份、PostgreSQL 启动、`pgloader` 导入、`.env` 中 `DB_TYPE`/`DATABASE_URL` 更新,并重启服务。
|
||||
|
||||
如果你希望手动迁移,以下示例基于 Docker Volume `sqlite_data`(项目默认配置)与 `pgloader`:
|
||||
|
||||
1) 停止服务并备份 SQLite 数据:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker run --rm -v sqlite_data:/data -v "$(pwd)":/backup alpine sh -c "cp /data/gost.db /backup/gost.db.bak"
|
||||
```
|
||||
|
||||
2) 仅启动 PostgreSQL:
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres
|
||||
```
|
||||
|
||||
3) 使用 `pgloader` 迁移:
|
||||
|
||||
```bash
|
||||
source .env
|
||||
docker run --rm --network gost-network -v sqlite_data:/sqlite dimitri/pgloader:latest pgloader /sqlite/gost.db "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}"
|
||||
```
|
||||
|
||||
4) 切换后端到 PostgreSQL 并启动:
|
||||
|
||||
```bash
|
||||
source .env
|
||||
export DB_TYPE=postgres
|
||||
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable"
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
5) 迁移完成后,登录面板检查用户、隧道、转发、节点数据是否正确。
|
||||
|
||||
#### 默认管理员账号
|
||||
|
||||
- **账号**: admin_user
|
||||
@@ -56,27 +129,28 @@ curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/install.
|
||||
- **License**: Apache License 2.0
|
||||
|
||||
## Modifications
|
||||
The following major changes and additions have been made in this fork (FLVX):
|
||||
This fork (FLVX) is no longer a light patch on top of the upstream project. It has been deeply reworked, with both backend and frontend rebuilt around a Go-based architecture.
|
||||
|
||||
### 1. Backend Architecture (Replaced)
|
||||
- **Removed**: The original `springboot-backend/` (Java/Spring Boot) has been entirely removed.
|
||||
- **Added**: A new `go-backend/` (Go/SQLite) implementation replaces the original backend.
|
||||
### 1. Backend (Rewritten)
|
||||
- **Removed**: The original `springboot-backend/` (Java/Spring Boot) implementation.
|
||||
- **Added**: A fully rewritten `go-backend/` service (Go), including updated data and API handling for panel management.
|
||||
|
||||
### 2. Forwarding Agent (Modified)
|
||||
- **Modified**: `go-gost/` - Modified forwarding agent wrapper.
|
||||
- **Modified**: `go-gost/x/` - Modified local fork of the `gost` extensions library.
|
||||
### 2. Frontend (Reworked)
|
||||
- **Reworked**: `vite-frontend/` has been substantially rebuilt to match the new backend contract and current UI layer architecture.
|
||||
- **Updated**: Dashboard pages/components and interaction flows for the current React/Vite stack.
|
||||
|
||||
### 3. Frontend (Modified)
|
||||
- **Modified**: `vite-frontend/` - Significant updates to the React/Vite dashboard to compatible with the new Go backend, including UI/UX improvements (HeroUI + Tailwind).
|
||||
### 3. Forwarding Stack (Modified)
|
||||
- **Modified**: `go-gost/` forwarding agent wrapper.
|
||||
- **Modified**: `go-gost/x/` local fork of `github.com/go-gost/x`.
|
||||
|
||||
### 4. Mobile Applications (Removed)
|
||||
- **Removed**: `android-app/` - Source code for the Android client.
|
||||
- **Removed**: `ios-app/` - Source code for the iOS client.
|
||||
### 4. Mobile Clients (Removed)
|
||||
- **Removed**: `android-app/` source code.
|
||||
- **Removed**: `ios-app/` source code.
|
||||
|
||||
### 5. Infrastructure & Scripts
|
||||
- **Modified**: `docker-compose-v4.yml`, `docker-compose-v6.yml` (Updated for Go backend).
|
||||
- **Modified**: `install.sh`, `panel_install.sh` (Updated installation logic).
|
||||
- **Added**: `AGENTS.md` (Project documentation).
|
||||
### 5. Deployment & Project Infrastructure
|
||||
- **Updated**: Docker deployment templates and installer output flow (IPv4/IPv6 compose variants).
|
||||
- **Updated**: Release installation scripts (`install.sh`, `panel_install.sh`) and supporting automation.
|
||||
- **Added/Updated**: Project-level engineering documentation (for example `AGENTS.md`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
# AI Skill 使用指南
|
||||
|
||||
让大模型直接操作 FLVX 面板的技能包。支持 OpenCode、OpenClaw、Claude Code 等工具。
|
||||
|
||||
## 安装
|
||||
|
||||
### 方式 1: npm (推荐)
|
||||
|
||||
```bash
|
||||
npm install -g @flvx/skill-api
|
||||
```
|
||||
|
||||
postinstall 脚本会自动链接到 `~/.agents/skills/flvx-api/`。
|
||||
|
||||
### 方式 2: 手动链接
|
||||
|
||||
```bash
|
||||
# 从 FLVX 源码
|
||||
cd /path/to/flvx
|
||||
mkdir -p ~/.agents/skills
|
||||
ln -sf $(pwd)/skills/flvx-api ~/.agents/skills/
|
||||
|
||||
# 或从 GitHub
|
||||
git clone https://github.com/Sagit-chu/flvx.git
|
||||
cd flvx
|
||||
ln -sf $(pwd)/skills/flvx-api ~/.agents/skills/
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
设置环境变量:
|
||||
|
||||
```bash
|
||||
export FLVX_BASE_URL="https://your-panel.example.com"
|
||||
export FLVX_USERNAME="admin"
|
||||
export FLVX_PASSWORD="your-password"
|
||||
```
|
||||
|
||||
或使用凭证文件:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.flvx
|
||||
cat > ~/.flvx/.env << 'EOF'
|
||||
export FLVX_BASE_URL="https://panel.example.com"
|
||||
export FLVX_USERNAME="admin"
|
||||
export FLVX_PASSWORD="your-password"
|
||||
EOF
|
||||
chmod 600 ~/.flvx/.env
|
||||
source ~/.flvx/.env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工具接入方法
|
||||
|
||||
### OpenCode
|
||||
|
||||
OpenCode 是命令行 AI 编程助手,支持通过 skills 扩展能力。
|
||||
|
||||
**安装 skill:**
|
||||
```bash
|
||||
npm install -g @flvx/skill-api
|
||||
```
|
||||
|
||||
**使用:**
|
||||
```bash
|
||||
export FLVX_BASE_URL="https://panel.example.com"
|
||||
export FLVX_USERNAME="admin"
|
||||
export FLVX_PASSWORD="your-password"
|
||||
|
||||
opencode
|
||||
```
|
||||
|
||||
**示例对话:**
|
||||
```
|
||||
你: 查看我的转发列表
|
||||
你: 创建一个转发到 192.168.1.100:80 使用隧道 1
|
||||
你: 检查节点状态
|
||||
你: 查看流量使用情况
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### OpenClaw
|
||||
|
||||
OpenClaw 同样支持 skills 机制。
|
||||
|
||||
**安装 skill:**
|
||||
```bash
|
||||
npm install -g @flvx/skill-api
|
||||
|
||||
# 或手动链接
|
||||
mkdir -p ~/.openclaw/skills
|
||||
ln -sf /path/to/flvx/skills/flvx-api ~/.openclaw/skills/flvx-api
|
||||
```
|
||||
|
||||
**使用:**
|
||||
```bash
|
||||
openclaw
|
||||
|
||||
>>> 查看所有节点状态
|
||||
>>> 给用户 alice 分配 50GB 流量
|
||||
>>> 导出系统备份
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Claude Code
|
||||
|
||||
Claude Code 是 Anthropic 官方的命令行工具,支持通过 CLAUDE.md 扩展。
|
||||
|
||||
#### 方式 1: 项目级 CLAUDE.md
|
||||
|
||||
在项目根目录创建 `CLAUDE.md`:
|
||||
|
||||
```markdown
|
||||
# FLVX API Skill
|
||||
|
||||
你可以通过 REST API 操作 FLVX 面板。
|
||||
|
||||
## 环境变量
|
||||
- FLVX_BASE_URL: 面板地址
|
||||
- FLVX_USERNAME: 用户名
|
||||
- FLVX_PASSWORD: 密码
|
||||
|
||||
## 认证规则
|
||||
- Authorization 头使用原始 JWT token,不加 "Bearer " 前缀
|
||||
- 所有 API 使用 POST 方法
|
||||
|
||||
## 常用 API
|
||||
|
||||
### 登录获取 token
|
||||
POST /api/v1/user/login
|
||||
{"username": "...", "password": "..."}
|
||||
|
||||
### 查看转发列表
|
||||
POST /api/v1/forward/list
|
||||
Authorization: <token>
|
||||
{}
|
||||
|
||||
### 创建转发
|
||||
POST /api/v1/forward/create
|
||||
{"name": "xxx", "tunnelId": 1, "remoteAddr": "1.2.3.4:80"}
|
||||
|
||||
### 查看节点
|
||||
POST /api/v1/node/list
|
||||
{}
|
||||
```
|
||||
|
||||
**使用:**
|
||||
```bash
|
||||
cd /path/to/your/project
|
||||
claude
|
||||
```
|
||||
|
||||
#### 方式 2: 全局 CLAUDE.md
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.claude
|
||||
cat > ~/.claude/CLAUDE.md << 'EOF'
|
||||
# FLVX Panel Operations
|
||||
|
||||
使用 FLVX REST API 操作流量转发面板。
|
||||
|
||||
环境变量: FLVX_BASE_URL, FLVX_USERNAME, FLVX_PASSWORD
|
||||
调用方式: curl -X POST "$FLVX_BASE_URL/api/v1/..." -H "Authorization: $TOKEN"
|
||||
注意: Authorization 不要加 Bearer 前缀
|
||||
EOF
|
||||
```
|
||||
|
||||
#### 方式 3: 复制 SKILL.md
|
||||
|
||||
```bash
|
||||
cat ~/.agents/skills/flvx-api/SKILL.md >> ~/.claude/CLAUDE.md
|
||||
```
|
||||
|
||||
**示例对话:**
|
||||
```
|
||||
>>> 帮我查看 FLVX 面板上有哪些节点
|
||||
>>> 创建一个名为 test 的转发,目标地址 10.0.0.1:80
|
||||
>>> 查看我的流量使用情况
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 覆盖
|
||||
|
||||
| 模块 | 操作 |
|
||||
|------|------|
|
||||
| 认证 | 登录、Token 管理 |
|
||||
| 用户 | 增删改查、流量重置、密码 |
|
||||
| 节点 | 增删改查、安装、升级、状态 |
|
||||
| 隧道 | 增删改查、用户分配 |
|
||||
| 转发 | 增删改查、暂停/恢复、诊断 |
|
||||
| 分组 | 用户/隧道分组、权限 |
|
||||
| 限速 | 增删改查 |
|
||||
| 联邦 | 节点共享、远程节点 |
|
||||
| 备份 | 导出/导入 |
|
||||
|
||||
## 安全提示
|
||||
|
||||
- ⚠️ 环境变量在进程列表中可见
|
||||
- 使用 `~/.flvx/.env` 文件并设置 `chmod 600`
|
||||
- 添加 `export HISTIGNORE="*FLVX_PASSWORD*"` 防止密码进入历史记录
|
||||
- Token 仅在会话内存中缓存,不写入磁盘
|
||||
|
||||
## 发布
|
||||
|
||||
维护者可通过以下方式发布新版本:
|
||||
|
||||
```bash
|
||||
# 方式 1: 推送 tag
|
||||
git tag skill-v2.1.6
|
||||
git push --tags
|
||||
|
||||
# 方式 2: GitHub Actions 手动触发
|
||||
# 在 Actions 页面运行 publish-skill workflow
|
||||
```
|
||||
|
||||
需要在 GitHub 仓库设置 `NPM_TOKEN` secret。
|
||||
+16
@@ -22,3 +22,19 @@
|
||||
|
||||
### Q5: IPv6 无法使用?
|
||||
**A**: 面板安装脚本会自动尝试配置 Docker 的 IPv6。如果失败,请手动检查 `/etc/docker/daemon.json` 配置,确保 `ipv6: true` 且分配了正确的 `fixed-cidr-v6` 子网。
|
||||
|
||||
### Q6: 如何切换到 PostgreSQL?
|
||||
**A**: 在 `.env` 文件中设置 `DB_TYPE=postgres`,并让 `DATABASE_URL` 与 `POSTGRES_*` 保持一致,然后执行 `docker compose up -d` 重启服务即可。使用安装脚本部署时,`POSTGRES_PASSWORD` 会自动随机生成并写入 `.env`。详见 [PostgreSQL 数据库指南](./postgresql.md)。
|
||||
|
||||
### Q7: 从 SQLite 迁移到 PostgreSQL 后数据丢失?
|
||||
**A**:
|
||||
1. 确认迁移前已备份 SQLite 文件(`gost.db.bak`)。
|
||||
2. 确认 `pgloader` 命令执行成功,检查其输出是否有报错。
|
||||
3. 确认 `.env` 中 `DATABASE_URL` 的密码与 `POSTGRES_PASSWORD` 一致。
|
||||
4. 详细迁移步骤参考 [PostgreSQL 数据库指南 - 从 SQLite 迁移](./postgresql.md)。
|
||||
|
||||
### Q8: PostgreSQL 容器启动失败?
|
||||
**A**:
|
||||
1. 检查 `POSTGRES_PASSWORD` 是否已设置(不能为空)。
|
||||
2. 查看容器日志:`docker logs flux-panel-postgres`。
|
||||
3. 如果是首次启动后修改了密码,需要删除旧的数据卷重新初始化:`docker volume rm postgres_data`。
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
- [安装部署](./install.md)
|
||||
- [使用指南](./usage.md)
|
||||
- [PostgreSQL 数据库指南](./postgresql.md)
|
||||
- [AI Skill 接入](./ai-skill.md) - 让大模型直接操作面板
|
||||
- [常见问题](./faq.md)
|
||||
|
||||
## 免责声明
|
||||
|
||||
@@ -41,6 +41,8 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_instal
|
||||
1. 安装面板
|
||||
2. 更新面板
|
||||
3. 卸载面板
|
||||
4. 迁移到 PostgreSQL
|
||||
5. 退出
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
# PostgreSQL 数据库指南
|
||||
|
||||
FLVX 默认使用 SQLite 作为数据库,同时也内置了对 PostgreSQL 的完整支持。本文档介绍如何使用 PostgreSQL 部署面板、从 SQLite 迁移以及日常维护。
|
||||
|
||||
## 一、SQLite 与 PostgreSQL 对比
|
||||
|
||||
| 特性 | SQLite | PostgreSQL |
|
||||
|------|--------|------------|
|
||||
| **部署复杂度** | 零配置,开箱即用 | 需要额外的数据库服务 |
|
||||
| **并发性能** | 适合小规模单机使用 | 支持高并发读写 |
|
||||
| **数据规模** | 适合中小规模数据 | 适合大规模数据 |
|
||||
| **备份与恢复** | 直接复制文件 | 支持逻辑备份与物理备份 |
|
||||
| **高可用** | 不支持 | 支持主从复制、流复制 |
|
||||
|
||||
**建议**:如果你只是个人使用或小团队使用,SQLite 完全够用。如果节点多,推荐使用 PostgreSQL。
|
||||
|
||||
---
|
||||
|
||||
## 二、环境变量说明
|
||||
|
||||
以下环境变量用于配置数据库连接,在 `.env` 文件或 Docker Compose `environment` 中设置。
|
||||
|
||||
### 后端服务 (backend) 使用
|
||||
|
||||
| 变量名 | 说明 | 默认值 | 示例 |
|
||||
|--------|------|--------|------|
|
||||
| `DB_TYPE` | 数据库类型,`sqlite` 或 `postgres` | `sqlite` | `postgres` |
|
||||
| `DATABASE_URL` | PostgreSQL 连接字符串(仅 `DB_TYPE=postgres` 时必填) | 空 | `postgres://flux_panel:密码@postgres:5432/flux_panel?sslmode=disable` |
|
||||
| `DB_PATH` | SQLite 数据库文件路径(仅 `DB_TYPE=sqlite` 时使用) | `/app/data/gost.db` | `/app/data/gost.db` |
|
||||
|
||||
### PostgreSQL 容器使用
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `POSTGRES_DB` | 数据库名称 | `flux_panel` |
|
||||
| `POSTGRES_USER` | 数据库用户名 | `flux_panel` |
|
||||
| `POSTGRES_PASSWORD` | 数据库密码 | `flux_panel_change_me` |
|
||||
|
||||
> ⚠️ **安全提示**:生产环境中请务必修改 `POSTGRES_PASSWORD` 为强密码,不要使用默认值!
|
||||
|
||||
---
|
||||
|
||||
## 三、全新部署(Docker Compose + PostgreSQL)
|
||||
|
||||
安装脚本会根据环境自动下载对应的 Compose 配置并保存为 `docker-compose.yml`。默认使用 SQLite,只需配置环境变量即可切换到 PostgreSQL。
|
||||
|
||||
### 1. 创建 `.env` 文件
|
||||
|
||||
在 `docker-compose` 同目录创建 `.env` 文件:
|
||||
|
||||
```bash
|
||||
# 基础配置
|
||||
JWT_SECRET=替换为你的密钥
|
||||
BACKEND_PORT=6365
|
||||
FRONTEND_PORT=6366
|
||||
|
||||
# PostgreSQL 配置
|
||||
DB_TYPE=postgres
|
||||
DATABASE_URL=postgres://flux_panel:替换为强密码@postgres:5432/flux_panel?sslmode=disable
|
||||
|
||||
POSTGRES_DB=flux_panel
|
||||
POSTGRES_USER=flux_panel
|
||||
POSTGRES_PASSWORD=替换为强密码
|
||||
```
|
||||
|
||||
> ⚠️ `DATABASE_URL` 中的密码必须与 `POSTGRES_PASSWORD` 保持一致。
|
||||
|
||||
### 2. 启动服务
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 3. 验证
|
||||
|
||||
```bash
|
||||
# 检查所有容器是否正常运行
|
||||
docker ps
|
||||
|
||||
# 查看后端日志,确认连接 PostgreSQL 成功
|
||||
docker logs flux-panel-backend
|
||||
|
||||
# 查看 PostgreSQL 容器状态
|
||||
docker logs flux-panel-postgres
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、从 SQLite 迁移到 PostgreSQL
|
||||
|
||||
如果你已经在使用 SQLite 并且希望迁移到 PostgreSQL,请按照以下步骤操作。
|
||||
|
||||
### 快速方式:脚本菜单一键迁移(推荐)
|
||||
|
||||
如果你是通过安装脚本部署面板,可直接执行:
|
||||
|
||||
```bash
|
||||
./panel_install.sh
|
||||
# 选择 4. 迁移到 PostgreSQL
|
||||
```
|
||||
|
||||
脚本会自动完成以下操作:
|
||||
- 备份 SQLite 数据到当前目录(`gost.db.bak`)
|
||||
- 启动并等待 PostgreSQL 健康检查通过
|
||||
- 使用 `pgloader` 导入 SQLite 数据
|
||||
- 自动写入 `.env` 的 `DB_TYPE=postgres` 与 `DATABASE_URL`
|
||||
- 重启服务并等待后端健康检查
|
||||
|
||||
### 手动方式:按步骤迁移
|
||||
|
||||
### 1. 备份 SQLite 数据
|
||||
|
||||
```bash
|
||||
# 停止所有服务
|
||||
docker compose down
|
||||
|
||||
# 备份 SQLite 数据文件到当前目录
|
||||
docker run --rm -v sqlite_data:/data -v "$(pwd)":/backup alpine sh -c "cp /data/gost.db /backup/gost.db.bak"
|
||||
```
|
||||
|
||||
### 2. 配置 PostgreSQL 环境变量
|
||||
|
||||
在 `.env` 文件中添加 PostgreSQL 配置(参考上方"环境变量说明")。
|
||||
|
||||
### 3. 仅启动 PostgreSQL
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres
|
||||
```
|
||||
|
||||
等待 PostgreSQL 完全就绪:
|
||||
|
||||
```bash
|
||||
# 检查 PostgreSQL 健康状态
|
||||
docker inspect --format='{{.State.Health.Status}}' flux-panel-postgres
|
||||
# 输出 "healthy" 表示就绪
|
||||
```
|
||||
|
||||
### 4. 使用 pgloader 迁移数据
|
||||
|
||||
```bash
|
||||
source .env
|
||||
docker run --rm \
|
||||
--network gost-network \
|
||||
-v sqlite_data:/sqlite \
|
||||
dimitri/pgloader:latest \
|
||||
pgloader /sqlite/gost.db "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}"
|
||||
```
|
||||
|
||||
> 📌 建议直接从 `.env` 读取 `POSTGRES_USER`、`POSTGRES_PASSWORD`、`POSTGRES_DB`,避免手填密码导致认证失败。
|
||||
|
||||
### 5. 启动全部服务
|
||||
|
||||
```bash
|
||||
source .env
|
||||
export DB_TYPE=postgres
|
||||
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable"
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 6. 验证迁移
|
||||
|
||||
登录面板后,检查以下数据是否完整:
|
||||
- 用户列表和权限
|
||||
- 节点信息和状态
|
||||
- 隧道配置
|
||||
- 转发规则
|
||||
- 流量统计数据
|
||||
|
||||
---
|
||||
|
||||
## 五、独立 PostgreSQL(非 Docker)
|
||||
|
||||
如果你不想使用 Docker Compose 中自带的 PostgreSQL 容器,也可以连接外部的 PostgreSQL 实例。
|
||||
|
||||
### 1. 准备 PostgreSQL
|
||||
|
||||
在目标 PostgreSQL 服务器上创建数据库和用户:
|
||||
|
||||
```sql
|
||||
CREATE USER flux_panel WITH PASSWORD '你的强密码';
|
||||
CREATE DATABASE flux_panel OWNER flux_panel;
|
||||
```
|
||||
|
||||
### 2. 配置连接
|
||||
|
||||
修改 `.env` 文件,将 `DATABASE_URL` 指向外部 PostgreSQL:
|
||||
|
||||
```bash
|
||||
DB_TYPE=postgres
|
||||
DATABASE_URL=postgres://flux_panel:你的强密码@数据库地址:5432/flux_panel?sslmode=disable
|
||||
```
|
||||
|
||||
> 📌 如果 PostgreSQL 在远程服务器且启用了 SSL,请将 `sslmode=disable` 改为 `sslmode=require` 或 `sslmode=verify-full`。
|
||||
|
||||
### 3. 停用内置 PostgreSQL 容器(可选)
|
||||
|
||||
如果使用外部 PostgreSQL,可以在启动时不启动内置的 postgres 服务:
|
||||
|
||||
```bash
|
||||
docker compose up -d backend frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、数据备份与恢复
|
||||
|
||||
### 逻辑备份(pg_dump)
|
||||
|
||||
```bash
|
||||
# 备份(在 Docker 环境下)
|
||||
docker exec flux-panel-postgres pg_dump -U flux_panel flux_panel > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||
|
||||
# 恢复
|
||||
docker exec -i flux-panel-postgres psql -U flux_panel flux_panel < backup_20260101_120000.sql
|
||||
```
|
||||
|
||||
### 定时备份(cron)
|
||||
|
||||
创建备份脚本 `/opt/flvx/backup.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
BACKUP_DIR="/opt/flvx/backups"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
docker exec flux-panel-postgres pg_dump -U flux_panel flux_panel | gzip > "$BACKUP_DIR/flvx_$(date +%Y%m%d_%H%M%S).sql.gz"
|
||||
# 清理 30 天前的备份
|
||||
find "$BACKUP_DIR" -name "flvx_*.sql.gz" -mtime +30 -delete
|
||||
```
|
||||
|
||||
添加 cron 任务(每天凌晨 3 点执行):
|
||||
|
||||
```bash
|
||||
chmod +x /opt/flvx/backup.sh
|
||||
echo "0 3 * * * /opt/flvx/backup.sh" | crontab -
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、常见问题
|
||||
|
||||
### Q: 切换到 PostgreSQL 后启动失败,提示连接被拒绝?
|
||||
|
||||
**A**:
|
||||
1. 确认 PostgreSQL 容器已启动并处于 `healthy` 状态:`docker ps`。
|
||||
2. 确认 `DATABASE_URL` 中的主机名、端口、用户名、密码正确。
|
||||
3. 在 Docker Compose 环境下,主机名应为 `postgres`(服务名),而非 `localhost`。
|
||||
|
||||
### Q: pgloader 迁移时报错?
|
||||
|
||||
**A**:
|
||||
1. 确认 PostgreSQL 容器已完全就绪(状态为 `healthy`)。
|
||||
2. 确认 `--network gost-network` 参数正确,使 pgloader 容器与 PostgreSQL 在同一网络中。
|
||||
3. 如果数据库已有表结构,pgloader 可能会报冲突。可以先清空目标数据库后重试。
|
||||
|
||||
### Q: 如何查看当前使用的数据库类型?
|
||||
|
||||
**A**: 查看后端容器的 `DB_TYPE` 环境变量:
|
||||
|
||||
```bash
|
||||
docker exec flux-panel-backend printenv DB_TYPE
|
||||
```
|
||||
|
||||
### Q: 可以同时使用 SQLite 和 PostgreSQL 吗?
|
||||
|
||||
**A**: 不可以。`DB_TYPE` 只能设置为 `sqlite` 或 `postgres` 之一。后端启动时根据此配置连接对应的数据库。
|
||||
|
||||
### Q: PostgreSQL 数据存储在哪里?
|
||||
|
||||
**A**: 在 Docker Compose 部署中,PostgreSQL 数据存储在名为 `postgres_data` 的 Docker Volume 中。可以通过以下命令查看:
|
||||
|
||||
```bash
|
||||
docker volume inspect postgres_data
|
||||
```
|
||||
+31
-5
@@ -7,16 +7,17 @@ services:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
environment:
|
||||
DB_TYPE: ${DB_TYPE:-sqlite}
|
||||
DB_PATH: /app/data/gost.db
|
||||
DATABASE_URL: ${DATABASE_URL:-}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
LOG_DIR: /app/logs
|
||||
SERVER_ADDR: :6365
|
||||
TZ: Asia/Shanghai
|
||||
ports:
|
||||
- "${BACKEND_PORT}:6365"
|
||||
volumes:
|
||||
- backend_logs:/app/logs
|
||||
- sqlite_data:/app/data
|
||||
networks:
|
||||
- gost-network
|
||||
@@ -29,6 +30,30 @@ services:
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: flux-panel-postgres
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-flux_panel}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-flux_panel}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flux_panel_change_me}
|
||||
TZ: Asia/Shanghai
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- gost-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-flux_panel} -d ${POSTGRES_DB:-flux_panel}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
|
||||
frontend:
|
||||
image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}
|
||||
container_name: vite-frontend
|
||||
@@ -37,6 +62,7 @@ services:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
ports:
|
||||
- "${FRONTEND_PORT}:80"
|
||||
depends_on:
|
||||
@@ -50,8 +76,8 @@ volumes:
|
||||
sqlite_data:
|
||||
name: sqlite_data
|
||||
driver: local
|
||||
backend_logs:
|
||||
name: backend_logs
|
||||
postgres_data:
|
||||
name: postgres_data
|
||||
driver: local
|
||||
|
||||
|
||||
@@ -61,4 +87,4 @@ networks:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
- subnet: 172.80.0.0/16
|
||||
|
||||
+31
-5
@@ -7,16 +7,17 @@ services:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
environment:
|
||||
DB_TYPE: ${DB_TYPE:-sqlite}
|
||||
DB_PATH: /app/data/gost.db
|
||||
DATABASE_URL: ${DATABASE_URL:-}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
LOG_DIR: /app/logs
|
||||
SERVER_ADDR: :6365
|
||||
TZ: Asia/Shanghai
|
||||
ports:
|
||||
- "${BACKEND_PORT}:6365"
|
||||
volumes:
|
||||
- backend_logs:/app/logs
|
||||
- sqlite_data:/app/data
|
||||
networks:
|
||||
- gost-network
|
||||
@@ -29,6 +30,30 @@ services:
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: flux-panel-postgres
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-flux_panel}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-flux_panel}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flux_panel_change_me}
|
||||
TZ: Asia/Shanghai
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- gost-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-flux_panel} -d ${POSTGRES_DB:-flux_panel}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
|
||||
frontend:
|
||||
image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}
|
||||
container_name: vite-frontend
|
||||
@@ -37,6 +62,7 @@ services:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
ports:
|
||||
- "${FRONTEND_PORT}:80"
|
||||
depends_on:
|
||||
@@ -50,8 +76,8 @@ volumes:
|
||||
sqlite_data:
|
||||
name: sqlite_data
|
||||
driver: local
|
||||
backend_logs:
|
||||
name: backend_logs
|
||||
postgres_data:
|
||||
name: postgres_data
|
||||
driver: local
|
||||
|
||||
|
||||
@@ -62,5 +88,5 @@ networks:
|
||||
enable_ipv6: true
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
- subnet: 172.80.0.0/16
|
||||
- subnet: fd00:dead:beef::/48
|
||||
|
||||
+32
-12
@@ -1,8 +1,8 @@
|
||||
# GO BACKEND KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Go-based Admin API for FLVX (formerly Flux Panel). Replaces the legacy Spring Boot backend.
|
||||
**Stack:** Go 1.23, net/http (std lib), SQLite (modernc.org/sqlite).
|
||||
Go-based Admin API for FLVX. Replaced legacy Spring Boot backend.
|
||||
**Stack:** Go 1.24, net/http (std lib), GORM + SQLite/PostgreSQL (glebarez/sqlite - CGO-free).
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
@@ -14,12 +14,18 @@ go-backend/
|
||||
│ │ ├── handler/ # API Handlers (User, Tunnel, Node, etc.)
|
||||
│ │ ├── middleware/ # JWT, CORS, Logging, Recover
|
||||
│ │ └── response/ # JSON response helpers
|
||||
│ ├── store/sqlite/ # Data Access Layer (Repository pattern)
|
||||
│ │ ├── repository.go # SQL queries & Struct definitions
|
||||
│ │ └── sql/ # Embedded schema.sql & data.sql
|
||||
│ ├── store/
|
||||
│ │ ├── model/model.go # GORM model structs (single source of truth)
|
||||
│ │ └── repo/ # Data Access Layer (Repository pattern, GORM)
|
||||
│ │ ├── repository.go # Core queries, Open/OpenPostgres, AutoMigrate (83k LOC)
|
||||
│ │ ├── repository_mutations.go # Mutation helpers (user/node/tunnel/forward CRUD, 43k LOC)
|
||||
│ │ ├── repository_federation.go # Federation-specific queries
|
||||
│ │ ├── repository_flow.go # Flow/forward status queries
|
||||
│ │ ├── repository_control.go # Control plane queries
|
||||
│ │ └── repository_groups.go # Group management queries
|
||||
│ └── auth/ # Auth logic
|
||||
├── tests/ # Integration/Contract tests
|
||||
├── Dockerfile # Multi-stage build (alpine)
|
||||
├── tests/contract/ # Integration/contract tests (14 tests)
|
||||
├── Dockerfile # Multi-stage build (golang:1.24-bookworm → debian:bookworm-slim)
|
||||
└── Makefile # Build commands
|
||||
```
|
||||
|
||||
@@ -27,21 +33,35 @@ go-backend/
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| **API Routes** | `go-backend/internal/http/router.go` | Registers handlers to `http.ServeMux` |
|
||||
| **DB Schema** | `go-backend/internal/store/sqlite/sql/schema.sql` | Embedded in binary |
|
||||
| **SQL Queries** | `go-backend/internal/store/sqlite/repository.go` | Raw SQL, no ORM |
|
||||
| **DB Models** | `go-backend/internal/store/model/model.go` | GORM structs with `TableName()` methods |
|
||||
| **Repository** | `go-backend/internal/store/repo/` | GORM-based queries, all DB ops encapsulated |
|
||||
| **Auth Middleware** | `go-backend/internal/http/middleware/jwt.go` | Extracts `Authorization` header |
|
||||
| **WebSocket** | `go-backend/internal/ws/` | Real-time updates (traffic, status) |
|
||||
| **Contract Tests** | `go-backend/tests/contract/` | Integration tests for auth, federation, tunnels |
|
||||
|
||||
## CONVENTIONS
|
||||
- **No ORM**: Uses raw SQL with `database/sql` and `modernc.org/sqlite`.
|
||||
- **GORM ORM**: Uses GORM with `glebarez/sqlite` (CGO-free) and `gorm.io/driver/postgres`.
|
||||
- **AutoMigrate**: Schema created at startup via `autoMigrateAll()` — no hand-written DDL.
|
||||
- **TableName()**: All models define explicit `TableName()` returning singular snake_case names.
|
||||
- **Repository Pattern**: Handlers never access `*gorm.DB` directly — all queries go through `repo.Repository` methods.
|
||||
- **Standard Lib**: Uses `net/http` for routing (Go 1.22+ patterns).
|
||||
- **Auth**: Expects raw JWT in `Authorization` header (no `Bearer` prefix).
|
||||
- **API Envelope**: All responses use `response.R{code, msg, data, ts}` structure.
|
||||
- **Config**: Loaded from environment variables (see `cmd/paneld/main.go`).
|
||||
- **SQLite Constraints**: `MaxOpenConns(1)`, WAL mode, busy_timeout=5000.
|
||||
- **PostgreSQL**: Supported via `DB_TYPE=postgres` and `DATABASE_URL` env vars.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- **DO NOT** let handlers call `repo.DB()` directly — add a Repository method instead.
|
||||
- **DO NOT CHANGE** handler signatures without updating `router.go`.
|
||||
- **DO NOT** use `type:jsonb` or `type:serial` in GORM tags (SQLite incompatible).
|
||||
- **DO NOT** omit `TableName()` on new models — GORM pluralizes by default.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-backend
|
||||
go run ./cmd/paneld
|
||||
go test ./...
|
||||
go run ./cmd/paneld # Default: SERVER_ADDR=:6365
|
||||
go test ./... # Unit tests
|
||||
go test ./tests/contract/... # Contract tests
|
||||
make build
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
FROM golang:1.23-bookworm AS builder
|
||||
FROM golang:1.24-bookworm AS builder
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod ./
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
# 数据库 GORM ORM 迁移计划
|
||||
|
||||
**创建时间:** 2026-02-15
|
||||
**更新时间:** 2026-02-17 (实施:完成 P1 + P2 + P3 + P5(Repo 查询层 + schema 收尾) + 测试/构建收尾)
|
||||
**分支:** main (commit e5e22ba)
|
||||
**状态:** 基本完成(保留 4 处 PG 序列修复 DDL `Exec`)
|
||||
|
||||
---
|
||||
|
||||
## 一、现状分析
|
||||
|
||||
### 1.1 迁移前架构 (已归档)
|
||||
|
||||
项目原使用 `database/sql` + 手写 raw SQL,通过 `internal/store/db.go` 中的运行时 SQL 重写层实现 SQLite/PostgreSQL 双数据库兼容。
|
||||
|
||||
| 组件 | 行数 | 角色 | 当前状态 |
|
||||
|------|------|------|----------|
|
||||
| `store/db.go` | ~520 | SQL 方言重写层 | **已删除** |
|
||||
| `store/sqlite/repository.go` | ~3118 | Repository 查询方法 | **已重写为 store/repo/** |
|
||||
| `handler/mutations.go` | ~3748 | Handler 内直接写 raw SQL | **已迁移到 repo(生产 SQL=0)** |
|
||||
| `handler/handler.go` | ~1283 | 部分方法用 `repo.DB()` | **大部分已迁移** |
|
||||
| `handler/federation.go` | ~若干 | Federation 相关 SQL | **已迁移到 repo** |
|
||||
| `handler/control_plane.go` | ~若干 | 控制面相关 SQL | **已迁移到 repo** |
|
||||
| `handler/flow_policy.go` | ~若干 | 流量策略相关 SQL | **已迁移到 repo** |
|
||||
| `handler/jobs.go` | ~若干 | 后台任务相关 SQL | **已迁移到 repo** |
|
||||
| `store/postgres/` | 目录 | PostgreSQL 专用 schema/data | **已删除** |
|
||||
|
||||
### 1.2 痛点 (迁移目标)
|
||||
|
||||
1. ~~**双 Schema 维护**~~:已通过 AutoMigrate 解决
|
||||
2. ~~**SQL 重写层复杂**~~:db.go 已删除
|
||||
3. ~~**handler 直接写 SQL**~~:`mutations.go` 生产路径 `tx.Exec`/`tx.Raw` 已清零(测试代码除外)
|
||||
4. ~~**无类型安全**~~:repo 业务查询已 GORM 化;剩余 4 处为 PG 序列修复 DDL `Exec`(设计保留)
|
||||
5. ~~**模型定义分散**~~:已集中到 model/model.go
|
||||
|
||||
---
|
||||
|
||||
## 二、方案:引入 GORM ORM(全面重写)
|
||||
|
||||
### 2.1 方案变更说明
|
||||
|
||||
原计划为 **方案 D(扩展现有 DDL 重写层)**,现变更为 **方案 A(GORM 全面重写)**。
|
||||
|
||||
### 2.2 选择 GORM 的理由
|
||||
|
||||
1. Go 生态最成熟的 ORM,社区庞大,文档完善
|
||||
2. 原生支持 SQLite + PostgreSQL 双数据库,自动处理方言差异
|
||||
3. AutoMigrate 消除双 schema 维护,自动处理 AUTOINCREMENT ↔ SERIAL 等
|
||||
4. 类型安全的模型定义,编译期检查字段映射
|
||||
5. 内置事务管理(closure pattern 自动 rollback/commit)
|
||||
6. 自动处理 `"user"` 保留字引号
|
||||
|
||||
### 2.3 GORM 驱动选择
|
||||
|
||||
| 数据库 | 驱动 | 包 | 备注 |
|
||||
|--------|------|-----|------|
|
||||
| SQLite | modernc.org/sqlite (CGO-free) | `github.com/glebarez/sqlite` | 纯 Go,无需 CGO |
|
||||
| PostgreSQL | pgx/v5 | `gorm.io/driver/postgres` | 默认使用 pgx |
|
||||
|
||||
> **注意**:标准 `gorm.io/driver/sqlite` 依赖 CGO,必须使用 `glebarez/sqlite` 包装器。
|
||||
|
||||
### 2.4 核心设计原则
|
||||
|
||||
1. **Model 集中定义**:所有 GORM Model 在 `internal/store/model/` 包中
|
||||
2. **Repository 模式保留**:Repository struct 持有 `*gorm.DB`,对外方法签名尽量不变
|
||||
3. **Handler 不直接操作 DB**:所有数据库操作必须封装在 Repository 方法中
|
||||
4. **AutoMigrate 替代 schema.sql**:启动时自动迁移,不再维护手写 DDL
|
||||
5. **保留 PG 序列修复**:pgloader 迁移场景仍需 `ensurePostgresIDDefaults()`
|
||||
6. **Package 重命名**:`store/sqlite` → `store/repo`
|
||||
|
||||
---
|
||||
|
||||
## 三、Model 设计
|
||||
|
||||
### 3.1 GORM 类型映射
|
||||
|
||||
| Go 类型 | GORM 行为 | PostgreSQL | SQLite |
|
||||
|---------|-----------|------------|--------|
|
||||
| `int64` + `primaryKey` | 自增主键 | `bigserial` | `INTEGER PRIMARY KEY AUTOINCREMENT` |
|
||||
| `int64` | 64位整数 | `bigint` | `integer` (SQLite 自动 64位) |
|
||||
| `int` | 整数 | `integer` | `integer` |
|
||||
| `float64` | 浮点 | `double precision` | `real` |
|
||||
| `string` + `size:100` | 变长字符 | `varchar(100)` | `varchar(100)` |
|
||||
| `string` (无 size) | 文本 | `text` | `text` |
|
||||
| `sql.NullInt64` | 可空整数 | `bigint NULL` | `integer NULL` |
|
||||
| `sql.NullString` | 可空文本 | `text NULL` | `text NULL` |
|
||||
|
||||
### 3.2 表清单(21 张表)
|
||||
|
||||
| 表名 | Model | 特殊处理 |
|
||||
|------|-------|----------|
|
||||
| `user` | `User` | `TableName()` 返回 `"user"` (PG 保留字) |
|
||||
| `forward` | `Forward` | |
|
||||
| `forward_port` | `ForwardPort` | |
|
||||
| `node` | `Node` | |
|
||||
| `speed_limit` | `SpeedLimit` | |
|
||||
| `statistics_flow` | `StatisticsFlow` | |
|
||||
| `tunnel` | `Tunnel` | |
|
||||
| `chain_tunnel` | `ChainTunnel` | |
|
||||
| `user_tunnel` | `UserTunnel` | 复合唯一索引 (user_id, tunnel_id) |
|
||||
| `tunnel_group` | `TunnelGroup` | |
|
||||
| `user_group` | `UserGroup` | |
|
||||
| `tunnel_group_tunnel` | `TunnelGroupTunnel` | 复合唯一索引 |
|
||||
| `user_group_user` | `UserGroupUser` | 复合唯一索引 |
|
||||
| `group_permission` | `GroupPermission` | 复合唯一索引 |
|
||||
| `group_permission_grant` | `GroupPermissionGrant` | 复合唯一索引 |
|
||||
| `vite_config` | `ViteConfig` | name 唯一 |
|
||||
| `peer_share` | `PeerShare` | token 唯一 |
|
||||
| `peer_share_runtime` | `PeerShareRuntime` | reservation_id, resource_key 唯一 |
|
||||
| `federation_tunnel_binding` | `FederationTunnelBinding` | 复合唯一索引 + resource_key 唯一 |
|
||||
| `announcement` | `Announcement` | |
|
||||
| `schema_version` | `SchemaVersion` | |
|
||||
|
||||
---
|
||||
|
||||
## 四、详细实施步骤
|
||||
|
||||
### 阶段 1:基础设施 — 添加依赖 + 定义 Model ✅ 已完成
|
||||
|
||||
| 步骤 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| 1.1 | `go get gorm.io/gorm gorm.io/driver/postgres github.com/glebarez/sqlite` | `go.mod` | ✅ |
|
||||
| 1.2 | 创建 `internal/store/model/model.go`,定义全部 21 个表 Model | 新文件 | ✅ |
|
||||
| 1.3 | 为 `user` 表添加 `TableName()` 处理 PG 保留字 | model.go | ✅ |
|
||||
| 1.4 | 为复合唯一索引的表添加 GORM 索引 tag | model.go | ✅ |
|
||||
| 1.5 | 将 Backup 相关 struct 也迁移到 model/ | model.go | ✅ |
|
||||
| 1.6 | 验证 `go build ./...` 编译通过 | - | ✅ |
|
||||
|
||||
### 阶段 2:GORM DB 初始化 ✅ 已完成
|
||||
|
||||
| 步骤 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| 2.1 | 修改 Repository struct,`*store.DB` → `*gorm.DB` | repository.go | ✅ |
|
||||
| 2.2 | 重写 `Open()` — 用 `glebarez/sqlite` 打开 SQLite | repository.go | ✅ |
|
||||
| 2.3 | 重写 `OpenPostgres()` — 用 `gorm.io/driver/postgres` 打开 PG | repository.go | ✅ |
|
||||
| 2.4 | 用 `db.AutoMigrate()` 替代 `bootstrapSchema()` | repository.go | ✅ |
|
||||
| 2.5 | 实现种子数据逻辑(FirstOrCreate 替代 data.sql) | repository.go | ✅ |
|
||||
| 2.6 | 保留并适配 `ensurePostgresIDDefaults()`(用 `db.Exec()`) | repository.go | ✅ |
|
||||
| 2.7 | 保留并适配 `migrateSchema()` 增量迁移 | repository.go | ✅ |
|
||||
| 2.8 | `DB()` 方法返回 `*gorm.DB` | repository.go | ✅ |
|
||||
| 2.9 | SQLite 连接池设置 `MaxOpenConns(1)` 防锁 | repository.go | ✅ |
|
||||
|
||||
### 阶段 3:重写 repository 查询方法 ⚠️ ~97% 完成
|
||||
|
||||
将所有 raw SQL 查询替换为 GORM 链式调用。
|
||||
|
||||
> **2026-02-16 审计**:基础 CRUD 查询已 GORM 化,但 mutation、JOIN 查询、import/export 仍大量使用 raw SQL。
|
||||
> **2026-02-17 更新**:已完成 `repository_mutations.go`、Import、以及 `repository_federation/control/flow` 查询层 GORM 化;`repository.go` 中 Raw 已清零,当前仅保留 4 处 PG 序列修复 DDL `Exec`。
|
||||
|
||||
| 步骤 | 任务 | 方法数 | 状态 |
|
||||
|------|------|--------|------|
|
||||
| 3.1 | 用户查询:GetUserByUsername, GetUserByID, UsernameExists* 等 | ~5 | ✅ |
|
||||
| 3.2 | 配置查询:GetConfigByName, ListConfigs, UpsertConfig | ~3 | ✅ |
|
||||
| 3.3 | 公告查询:GetAnnouncement, UpsertAnnouncement | ~2 | ✅ |
|
||||
| 3.4 | 节点查询:GetNodeBy*, ListNodes, UpdateNode* | ~6 | ✅ |
|
||||
| 3.5 | 隧道查询:ListTunnels, ListTunnelGroups 等 (含 chain_tunnel 关联) | ~5 | ✅ |
|
||||
| 3.6 | 转发查询:ListForwards, resolveForwardIngress | ~3 | ✅ |
|
||||
| 3.7 | 用户隧道:GetUserPackageTunnels, GetUserPackageForwards | ~3 | ✅ |
|
||||
| 3.8 | 统计/限速:GetStatisticsFlows, ListSpeedLimits, AddFlow | ~4 | ✅ |
|
||||
| 3.9 | 分组查询:ListUserGroups, ListGroupPermissions 等 | ~4 | ✅ |
|
||||
| 3.10 | PeerShare 全部方法 (CRUD + Runtime) | ~15 | ✅ |
|
||||
| 3.11 | FederationTunnelBinding 全部方法 | ~4 | ✅ (Upsert 用 clause.OnConflict) |
|
||||
| 3.12 | Export 全部方法 | ~10 | ✅ |
|
||||
| 3.13 | Import 全部方法 | ~10 | ✅ 已全部改为 GORM `Clauses(clause.OnConflict)`(见 §9.6) |
|
||||
| **3.14** | **repository_mutations.go 全部方法 (~40 个)** | **~40** | **✅ 已全量改为 GORM 链式调用(见 §9.3)** |
|
||||
| **3.15** | **repository_federation.go 查询方法** | **~8** | **✅ 已全部改为 GORM 链式调用** |
|
||||
| **3.16** | **repository_control.go 复杂查询** | **~5** | **✅ 已全部改为 GORM 链式调用** |
|
||||
| **3.17** | **repository_flow.go 查询方法** | **~5** | **✅ 已全部改为 GORM 链式调用** |
|
||||
| **3.18** | **Jobs 查询方法 (repository.go 尾部)** | **~8** | **✅ 已 GORM 化** |
|
||||
|
||||
### 阶段 4:消除 handler 中直接 SQL — 提取为 Repository 方法 ✅ 已完成
|
||||
|
||||
> **2026-02-16 审计**:handler 中的 SQL 已大部分提取到 repo 层,但这些 repo 方法本身仍使用 raw SQL(见阶段 3)。
|
||||
> **2026-02-17 更新**:`mutations.go` 直接 `tx.Exec`/`tx.Raw` 已从 27 处降至 0 处(生产代码),详见 §9.4。
|
||||
|
||||
mutations.go 和其他 handler 文件中大量直接操作 `h.repo.DB()` 执行 raw SQL,需要:
|
||||
1. 将 SQL 逻辑提取为 Repository 方法
|
||||
2. Handler 只调用 Repository 方法
|
||||
|
||||
| 步骤 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| 4.1 | 用户 CRUD:userCreate, userUpdate, userDelete, userResetFlow | mutations.go | ✅ 已提取到 repo 方法 |
|
||||
| 4.2 | 节点 CRUD:nodeCreate, nodeUpdate, nodeDelete, nodeBatch* | mutations.go | ✅ 已提取到 repo 方法 |
|
||||
| 4.3 | 隧道 CRUD:tunnelCreate, tunnelUpdate, tunnelDelete, tunnelBatch* | mutations.go | ✅ tunnelCreate/Update 的 SQL 已下沉 repo |
|
||||
| 4.4 | 转发 CRUD:forwardCreate, forwardUpdate, forwardDelete, forwardBatch* | mutations.go | ✅ 已提取到 repo (CreateForwardTx 等) |
|
||||
| 4.5 | 限速 CRUD:speedLimitCreate, speedLimitUpdate, speedLimitDelete | mutations.go | ✅ 已提取到 repo 方法 |
|
||||
| 4.6 | 分组 CRUD:所有 group* 方法 | mutations.go | ✅ 成员同步/权限管理 SQL 已下沉 repo |
|
||||
| 4.7 | 用户隧道:userTunnelAssign, userTunnelRemove, userTunnelUpdate | mutations.go | ✅ 已提取到 repo 方法 |
|
||||
| 4.8 | handler.go 中的直接 SQL (openAPISubStore 等) | handler.go | ✅ 已迁移(含 nil 检查清理) |
|
||||
| 4.9 | federation.go 中的 raw SQL | federation.go | ✅ 已提取到 repo_federation.go |
|
||||
| 4.10 | control_plane.go 中的 raw SQL | control_plane.go | ✅ 已提取到 repo_control.go |
|
||||
| 4.11 | flow_policy.go 中的 raw SQL | flow_policy.go | ✅ 已提取到 repo_flow.go |
|
||||
| 4.12 | jobs.go 中的 raw SQL | jobs.go | ✅ 已提取到 repo 方法(含 nil 检查清理) |
|
||||
|
||||
### 阶段 5:清理旧代码 ✅ 已完成
|
||||
|
||||
| 步骤 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| 5.1 | 删除 `internal/store/postgres/` 整个目录 | 目录删除 | ✅ |
|
||||
| 5.2 | 删除 `internal/store/sqlite/sql/` 目录 | 目录删除 | ✅ |
|
||||
| 5.3 | 删除 `internal/store/db.go` SQL 重写层 | 文件删除 | ✅ |
|
||||
| 5.4 | 删除 `internal/store/db_test.go` | 文件删除 | ✅ |
|
||||
| 5.5 | 清理 repository.go 中不再需要的 embed 指令 | 清理 | ✅ |
|
||||
|
||||
### 阶段 6:Package 重命名 ✅ 已完成
|
||||
|
||||
| 步骤 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| 6.1 | `internal/store/sqlite/` → `internal/store/repo/` | 目录重命名 | ✅ |
|
||||
| 6.2 | 更新所有 import 路径:`store/sqlite` → `store/repo` (13处) | 全局替换 | ✅ |
|
||||
|
||||
### 阶段 7:测试 + 验证 ⚠️ 部分完成
|
||||
|
||||
| 步骤 | 任务 | 状态 |
|
||||
|------|------|------|
|
||||
| 7.1 | 更新所有现有测试适配 GORM | ✅ 测试已适配 (使用 repo.DB() 做数据准备) |
|
||||
| 7.2 | `go test ./...` 全部通过 | ✅ 已通过(含 `internal/http/handler`、`tests/contract`) |
|
||||
| 7.3 | `make build` 构建成功 | ✅ 已通过 |
|
||||
|
||||
### 阶段 8:文档更新 ✅ 已完成
|
||||
|
||||
| 步骤 | 任务 | 文件 | 状态 |
|
||||
|------|------|------|------|
|
||||
| 8.1 | 更新 `go-backend/AGENTS.md` — 移除 "DO NOT USE ORM",记录 GORM 规范 | AGENTS.md | ✅ |
|
||||
| 8.2 | 更新根 `AGENTS.md` | AGENTS.md | ✅ |
|
||||
| 8.3 | 更新 `handler/AGENTS.md` | AGENTS.md | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 五、GORM 使用规范
|
||||
|
||||
### 5.1 查询模式
|
||||
|
||||
```go
|
||||
// 单条查询 - 未找到返回 nil, nil (保持现有语义)
|
||||
var user model.User
|
||||
err := r.db.Where("id = ?", id).First(&user).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 列表查询
|
||||
var users []model.User
|
||||
err := r.db.Where("role_id != ?", 0).Order("id ASC").Find(&users).Error
|
||||
|
||||
// 创建
|
||||
err := r.db.Create(&user).Error
|
||||
|
||||
// 更新 (部分字段)
|
||||
err := r.db.Model(&model.User{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"user": username, "flow": flow, "updated_time": now,
|
||||
}).Error
|
||||
|
||||
// 事务 (closure pattern - 自动 rollback/commit)
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("user_id = ?", id).Delete(&model.Forward{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("id = ?", id).Delete(&model.User{}).Error
|
||||
})
|
||||
|
||||
// 原生 SQL (仅用于复杂查询和 PG 特有操作)
|
||||
r.db.Exec("SELECT setval(?::regclass, ?, ?)", seqRef, maxID, true)
|
||||
```
|
||||
|
||||
### 5.2 关键注意事项
|
||||
|
||||
1. **user 保留字**:通过 `TableName()` 返回 `"user"`,GORM 自动处理引号
|
||||
2. **SQLite MaxOpenConns**:必须设为 1 防止 "database locked"
|
||||
3. **SQLite WAL 模式**:DSN 中配置 `_pragma=journal_mode(WAL)`
|
||||
4. **不要用 `type:jsonb`**:SQLite 不支持,用 `serializer:json`
|
||||
5. **不要用 `type:serial`**:让 GORM 从 `primaryKey` 自动推断
|
||||
6. **AutoMigrate 在 SQLite 中使用 copy-swap-drop**:大表慎用
|
||||
|
||||
---
|
||||
|
||||
## 六、影响范围
|
||||
|
||||
### 需要修改的文件
|
||||
|
||||
| 文件 | 修改类型 | 描述 | 当前状态 |
|
||||
|------|----------|------|----------|
|
||||
| `go.mod` / `go.sum` | 修改 | 添加 GORM + 驱动依赖 | ✅ |
|
||||
| `internal/store/model/model.go` | **新增** | 全部 21 个 GORM Model | ✅ |
|
||||
| `internal/store/repo/repository.go` | **重写** | 全部查询 GORM 化 | ⚠️ 业务查询已 GORM;仅剩 PG 序列修复 DDL `Exec` 4 处 |
|
||||
| `internal/store/repo/repository_mutations.go` | **重写** | Mutation helpers | ✅ 全量 GORM(Raw=0) |
|
||||
| `internal/store/repo/repository_federation.go` | **重写** | Federation 查询 | ✅ 已 GORM 化(Raw=0) |
|
||||
| `internal/store/repo/repository_control.go` | **重写** | 控制面查询 | ✅ 已 GORM 化(Raw=0) |
|
||||
| `internal/store/repo/repository_flow.go` | **重写** | 流量/转发查询 | ✅ 已 GORM 化(Raw=0) |
|
||||
| `internal/http/handler/mutations.go` | **重写** | 全部 CRUD 提取到 repo | ✅ 生产代码 `tx.Exec/tx.Raw` = 0 |
|
||||
| `internal/http/handler/handler.go` | 修改 | 更新 import、移除直接 SQL | ✅ (仅剩 nil check) |
|
||||
| `internal/http/handler/federation.go` | 修改 | GORM 替代 raw SQL | ✅ |
|
||||
| `internal/http/handler/control_plane.go` | 修改 | GORM 替代 raw SQL | ✅ |
|
||||
| `internal/http/handler/flow_policy.go` | 修改 | GORM 替代 raw SQL | ✅ |
|
||||
| `internal/http/handler/jobs.go` | 修改 | GORM 替代 raw SQL | ✅ (仅剩 nil check) |
|
||||
| `internal/ws/server.go` | 修改 | 更新 import | ✅ |
|
||||
| `internal/app/app.go` | 修改 | 更新 import | ✅ |
|
||||
| `internal/store/postgres/` | **删除** | 不再需要 | ✅ |
|
||||
| `internal/store/db.go` | **删除** | GORM 自动处理方言 | ✅ |
|
||||
| `internal/store/db_test.go` | **删除** | 旧重写层测试 | ✅ |
|
||||
| `internal/store/sqlite/sql/` | **删除** | AutoMigrate 替代 | ✅ |
|
||||
| `tests/contract/*.go` | 修改 | 适配 GORM | ✅ |
|
||||
| `AGENTS.md` (3处) | 更新 | 反映新架构 | ✅ |
|
||||
|
||||
### 不需要修改的文件
|
||||
|
||||
- `internal/http/router.go` — 路由不变
|
||||
- `internal/config/config.go` — 配置不变
|
||||
- `internal/auth/` — 认证不变
|
||||
- `internal/security/` — 加密不变
|
||||
- `internal/http/middleware/` — 中间件不变
|
||||
- `internal/http/response/` — 响应格式不变
|
||||
- `Dockerfile`, `Makefile` — 构建不变
|
||||
|
||||
---
|
||||
|
||||
## 七、风险与缓解
|
||||
|
||||
| 风险 | 可能性 | 影响 | 缓解措施 |
|
||||
|------|--------|------|----------|
|
||||
| GORM AutoMigrate SQLite/PG 行为差异 | 中 | 高 | 先写 Model 验证双数据库 AutoMigrate |
|
||||
| handler 中散落 raw SQL 遗漏 | 中 | 高 | 全局搜索 `.Exec(`, `.Query(`, `.QueryRow(` |
|
||||
| 事务语义变化 | 低 | 中 | 逐方法对比旧代码事务边界 |
|
||||
| 大量代码变更导致回归 | 高 | 高 | 分阶段提交,每阶段 `go test` |
|
||||
| GORM 性能开销 | 低 | 低 | 此场景下可忽略 |
|
||||
| SQLite "database locked" | 中 | 高 | `MaxOpenConns(1)` + WAL 模式 |
|
||||
|
||||
---
|
||||
|
||||
## 八、迁移顺序原则
|
||||
|
||||
1. **先 Model 后查询**:确保 AutoMigrate 双数据库通过
|
||||
2. **先 Repository 后 Handler**:Handler 依赖 Repository
|
||||
3. **先核心后边缘**:User → Node → Tunnel → Forward → 分组 → Federation
|
||||
4. **每步编译**:每完成一组方法确保 `go build ./...` 通过
|
||||
5. **最后清理**:全部重写完成后再删除旧代码和重命名 package
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 九、2026-02-16 审计发现 + 2026-02-17 进展记录
|
||||
|
||||
### 9.1 总体完成度
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 阶段完成数 | 7/8 完成 (1, 2, 4, 5, 6, 7, 8),1/8 部分完成 (3) |
|
||||
| GORM 链式调用 | ~226 处 |
|
||||
| Raw SQL 调用 (`.Exec`/`.Raw`+`.Scan`) | 4 处(生产代码) |
|
||||
| GORM 占比 | ~98% |
|
||||
| Handler 内 `tx.Exec`/`tx.Raw` | 0 处(生产代码) |
|
||||
| `last_insert_rowid()` 生产代码 | 0 处(已消灭) |
|
||||
|
||||
### 9.2 ✅ P0:`last_insert_rowid()`(生产代码)已清零
|
||||
|
||||
`last_insert_rowid()` 已从生产路径移除,创建主键统一改为 `Create(&model)` 自动回填 ID,
|
||||
确保 SQLite / PostgreSQL 双数据库行为一致。
|
||||
|
||||
> 备注:测试代码中的历史 SQL 兼容性用例可在后续测试清理阶段单独处理。
|
||||
|
||||
### 9.3 ✅ P1:`repository_mutations.go` 已全量 GORM 化
|
||||
|
||||
本次已完成 `repository_mutations.go` 的集中清理:
|
||||
|
||||
1. User / Node / Tunnel / Forward / UserTunnel / SpeedLimit / Group / Permission 全部 mutation 方法改为 GORM 链式调用。
|
||||
2. 事务内级联删除统一为 `tx.Where(...).Delete(&Model{})` 模式。
|
||||
3. `ON CONFLICT DO NOTHING` 统一替换为 `Clauses(clause.OnConflict{DoNothing: true})`。
|
||||
4. 保留原有调用语义(含 `sql.ErrNoRows` 行为兼容)并完成 `go build ./...` 验证。
|
||||
|
||||
> 当前 `repository_mutations.go` 中生产代码 `.Raw(`/`.Exec(` 调用已降为 0。
|
||||
|
||||
### 9.4 ✅ P2:Handler `mutations.go` 直接 SQL 已清零
|
||||
|
||||
2026-02-17 本轮静态扫描结果:`mutations.go` **0 处** `tx.Exec`/`tx.Raw`(生产代码)。
|
||||
|
||||
本轮完成下沉到 repo 的逻辑:
|
||||
|
||||
- `tunnelUpdate` 中 `UPDATE tunnel` + `DELETE chain_tunnel`
|
||||
- `isRemoteNodeTx` 查询
|
||||
- `pickNodePortTx` 的 node/chain_tunnel/forward_port 端口占用查询
|
||||
- `replaceTunnelChainsTx` 的 chain_tunnel 写入
|
||||
- 分组成员同步(`tunnel_group_tunnel` / `user_group_user`)
|
||||
- 权限删除与 grant 回收(`group_permission` / `group_permission_grant` / `user_tunnel`)
|
||||
- federation 绑定替换(`federation_tunnel_binding`)
|
||||
|
||||
### 9.5 ✅ P3(部分):已移除 `QueryInt64List` / `QueryPairs` SQL 透传
|
||||
|
||||
- `repository_mutations.go` 中两个 SQL 透传入口已删除。
|
||||
- Handler 已切换为语义化 repo 方法:
|
||||
- `ListUserIDsByUserGroup`
|
||||
- `ListTunnelIDsByTunnelGroup`
|
||||
- `ListGroupPermissionPairsByUserGroup`
|
||||
- `ListGroupPermissionPairsByTunnelGroup`
|
||||
|
||||
### 9.6 ✅ P3:Import 函数已全部 GORM 化
|
||||
|
||||
`repository.go` 中 Import 相关函数已完成迁移:
|
||||
|
||||
- `importUsers`
|
||||
- `importNodes`
|
||||
- `importTunnels`(含 `chain_tunnel` 子项 upsert)
|
||||
- `importForwards`(含 `forward_port` 覆盖写入)
|
||||
- `importUserTunnels`
|
||||
- `importSpeedLimits`
|
||||
- `importTunnelGroups`
|
||||
- `importUserGroups`
|
||||
- `importPermissions`
|
||||
- `importConfigs`(原本已是 GORM)
|
||||
|
||||
迁移后统一采用 `Clauses(clause.OnConflict{Columns: id/name, DoUpdates: ...}).Create(&model)` 模式,
|
||||
保留原 `ON CONFLICT ... DO UPDATE` 语义;Import 区段 `tx.Exec`/`tx.Raw` 已清零。
|
||||
|
||||
### 9.7 ✅ P4:`h.repo.DB() == nil` 检查已清理
|
||||
|
||||
`internal/http/handler/` 下已无 `h.repo.DB()` 直接访问;handler 仅通过语义化 repo 方法进行数据访问。
|
||||
|
||||
### 9.8 ✅ P5:Repository 层 Raw 已收敛(仅保留 PG 序列修复 DDL)
|
||||
|
||||
当前生产代码中 `.Raw()` 已清零;仅剩 `repository.go` 的 4 处 `Exec()`,全部位于 PG 序列修复 DDL:
|
||||
|
||||
- `CREATE SEQUENCE IF NOT EXISTS ...`
|
||||
- `ALTER TABLE ... ALTER COLUMN id SET DEFAULT nextval(...)`
|
||||
- `ALTER SEQUENCE ... OWNED BY ...`
|
||||
- `SELECT setval(...::regclass, ?, ?)`
|
||||
|
||||
以上 4 处属于数据库管理 DDL/序列同步语义,当前保留,不再继续向 GORM 链式调用替换。
|
||||
|
||||
`repository_federation.go` / `repository_control.go` / `repository_flow.go` 已完成 GORM 化(Raw=0)。
|
||||
|
||||
---
|
||||
|
||||
## 十、后续工作优先级
|
||||
|
||||
| 优先级 | 任务 | 影响范围 | 工作量 |
|
||||
|--------|------|----------|--------|
|
||||
| **P0** | ✅ 已完成:生产代码中 `last_insert_rowid()` 清零(测试用例待单独清理) | 6 处生产(已完成) | 完成 |
|
||||
| **P1** | ✅ 已完成:`repository_mutations.go` ~40 方法改为 GORM 链式调用 | 659 行(已完成) | 完成 |
|
||||
| **P2** | ✅ 已完成:`mutations.go` handler 直接 SQL 全部提取为 repo 方法 | mutations.go | 完成 |
|
||||
| **P3** | ✅ 已完成:移除 `QueryInt64List`/`QueryPairs` 透传,切换语义化 repo 方法 | 2 个方法 + 调用方(已完成) | 完成 |
|
||||
| **P3** | ✅ 已完成:Import 函数 Raw SQL 改为 GORM `Clauses(clause.OnConflict{}).Create()` | 9 个函数(已完成) | 完成 |
|
||||
| **P4** | ✅ 已完成:`h.repo.DB() == nil` 检查清理完毕 | 4 处(已完成) | 完成 |
|
||||
| **P5** | ✅ 已完成:repo 查询层 Raw 清零,`repository.go` 保留 4 处 PG 序列修复 DDL `Exec`(设计保留) | repository.go | 完成 |
|
||||
| **P5** | ✅ 已完成:更新 MIGRATION_PLAN.md 状态标记与收尾记录 | 本文件 | 完成 |
|
||||
|
||||
### 10.5 本轮执行记录(2026-02-17,P5 schema 收尾)
|
||||
|
||||
1. 完成 `repository.go` schema 迁移段去 Raw:
|
||||
- `normalizeStrategy` 改为 `Model(...).Where(...).Update(...)`
|
||||
- `ensurePostgresIDDefaults`/`ensurePostgresTableIDDefault` 的 information_schema 查询改为 GORM `Table+Joins+Where+Scan`
|
||||
- `syncPostgresTableIDSequence` 的 `MAX(id)` 查询改为 GORM `Table+Select+Scan`
|
||||
2. 复扫结果:
|
||||
- `repository.go` `.Raw()` = 0
|
||||
- repo 生产路径剩余 `.Exec()` = 4(全部为 PG 序列修复 DDL)
|
||||
3. 验证结果:
|
||||
- `go build ./...` ✅
|
||||
- `go test ./internal/store/repo/...` ✅
|
||||
|
||||
### 10.6 本轮执行记录(2026-02-17,测试/构建收尾)
|
||||
|
||||
1. 修复事务内 SQLite 连接阻塞(`MaxOpenConns(1)` 场景):
|
||||
- 新增 `GetNodeRecordTx` 并在 `prepareTunnelCreateState` 使用事务句柄读取节点。
|
||||
- 新增 `GetNodeRemoteFieldsTx` 并在 `tunnelCreate` 事务内改用事务句柄读取远端字段。
|
||||
- `applyFederationRuntime` 改为显式接收 `localDomain`,避免事务内再次走 `repo.GetConfigByName`。
|
||||
2. 修复 legacy SQLite schema 迁移契约:
|
||||
- 新增 `prepareSQLiteLegacyColumns` 预补齐 `node/tunnel` 关键列。
|
||||
- SQLite 模式下对已存在 `node/tunnel` 表跳过对应 `AutoMigrate` 重建流程,避免 `node__temp.name` 约束失败。
|
||||
3. 验证结果:
|
||||
- `go test ./internal/http/handler/...` ✅
|
||||
- `go test ./tests/contract/...` ✅
|
||||
- `go test ./...` ✅
|
||||
- `go build ./...` ✅
|
||||
- `make build` ✅
|
||||
|
||||
### 10.1 本轮执行记录(2026-02-17,P5 查询层)
|
||||
|
||||
1. 完成 `repository_federation.go` 全量 GORM 化:
|
||||
- `ListRemoteNodes` / `UpdateNodeRemoteConfig`
|
||||
- `ListActiveBindingsForNode` / `GetNodeBasicInfo`
|
||||
- `ListUsedPortsOnNode` / `ListTunnelIDsByNamePrefix` / `NextIndex`
|
||||
2. 完成 `repository_control.go` 全量 GORM 化:
|
||||
- `ListForwardsByTunnel` / `ListForwardPorts` / `GetTunnelOutProtocol`
|
||||
- `ResolveUserTunnelAndLimiter` / `ListChainNodesForTunnel`
|
||||
3. 完成 `repository_flow.go` 全量 GORM 化:
|
||||
- `ListActiveForwardsByUser` / `ListActiveForwardsByUserTunnel`
|
||||
- `GetForwardRecord` / `GetTunnelRecord`
|
||||
4. 复扫结果:
|
||||
- `repository_federation.go` Raw/Exec = 0
|
||||
- `repository_control.go` Raw/Exec = 0
|
||||
- `repository_flow.go` Raw/Exec = 0
|
||||
- repo 生产路径剩余 Raw/Exec = 9(全部在 `repository.go`)
|
||||
5. 验证结果:
|
||||
- `go build ./...` ✅
|
||||
- `go test ./internal/store/repo/...` ✅
|
||||
|
||||
### 10.2 本轮执行记录(2026-02-17)
|
||||
|
||||
1. 完成 P3 Import 9 个函数的 GORM 化(`repository.go`),并保持 `ON CONFLICT` 语义一致。
|
||||
2. 复扫确认:`repository.go` Import 区段 `tx.Exec`/`tx.Raw` 已清零。
|
||||
3. 验证结果:
|
||||
- `go build ./...` ✅(使用显式 `GOMODCACHE/GOPATH/GOCACHE/HOME` 环境)
|
||||
- `go test ./internal/store/repo/...` ✅
|
||||
|
||||
### 10.3 本轮执行记录(2026-02-17,P2 部分)
|
||||
|
||||
1. 将 tunnel 更新/chain 重建路径 SQL 下沉到 `repository_mutations.go`:
|
||||
- 新增 `UpdateTunnelTx`
|
||||
- 新增 `DeleteChainTunnelsByTunnelTx`
|
||||
- 新增 `CreateChainTunnelTx`
|
||||
2. 将 handler 内部 SQL helper 迁移到 repo:
|
||||
- 新增 `IsRemoteNodeTx`
|
||||
- 新增 `PickNodePortTx`
|
||||
- `replaceTunnelChainsTx` 改为 handler 方法并改用 repo 调用,不再直接 SQL
|
||||
3. 复扫结果:`mutations.go` 直接 SQL 从 27 处降至 17 处。
|
||||
4. 验证结果:
|
||||
- `go build ./...` ✅
|
||||
- `go test ./internal/store/repo/...` ✅
|
||||
|
||||
### 10.4 本轮执行记录(2026-02-17,P2 收尾)
|
||||
|
||||
1. 新增并落地事务语义化 repo 方法:
|
||||
- `ReplaceTunnelGroupMembersTx` / `ReplaceUserGroupMembersTx`
|
||||
- `ListUserIDsByUserGroupTx`
|
||||
- `GetGroupPermissionPairByIDTx` / `DeleteGroupPermissionByIDTx`
|
||||
- `RevokeGroupGrantsForRemovedUsersTx` / `RevokeGroupPermissionPairTx`
|
||||
- `ReplaceFederationTunnelBindingsTx`
|
||||
2. 删除 handler 内 SQL helper(`queryInt64ListTx` / `revokeGroupGrantsForRemovedUsersTx` / `revokeGroupPermissionPairTx` / `replaceFederationTunnelBindingsTx`)。
|
||||
3. 复扫确认:`mutations.go` 生产路径 `tx.Exec`/`tx.Raw` = 0。
|
||||
4. 验证结果:
|
||||
- `go build ./...` ✅
|
||||
- `go test ./internal/store/repo/...` ✅
|
||||
|
||||
---
|
||||
|
||||
*本文档将随迁移进展实时更新状态标记。*
|
||||
*最后审计时间:2026-02-17,审计工具:代码静态分析 (grep/AST) + go build/go test 验证*
|
||||
+14
-1
@@ -1,22 +1,35 @@
|
||||
module go-backend
|
||||
|
||||
go 1.23.0
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.4
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.7.3
|
||||
modernc.org/sqlite v1.37.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/glebarez/sqlite v1.11.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
gorm.io/driver/postgres v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.1 // indirect
|
||||
modernc.org/libc v1.65.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
+44
-6
@@ -1,28 +1,66 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
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/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
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.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
|
||||
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
|
||||
@@ -4,28 +4,44 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/config"
|
||||
httpserver "go-backend/internal/http"
|
||||
"go-backend/internal/http/handler"
|
||||
"go-backend/internal/store/sqlite"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
cfg config.Config
|
||||
server *http.Server
|
||||
repo *sqlite.Repository
|
||||
repo *repo.Repository
|
||||
h *handler.Handler
|
||||
}
|
||||
|
||||
func New(cfg config.Config) (*App, error) {
|
||||
repo, err := sqlite.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
var (
|
||||
r *repo.Repository
|
||||
err error
|
||||
)
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(cfg.DBType)) {
|
||||
case "", "sqlite":
|
||||
r, err = repo.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
case "postgres", "postgresql":
|
||||
r, err = repo.OpenPostgres(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open postgres: %w", err)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported DB_TYPE %q", cfg.DBType)
|
||||
}
|
||||
|
||||
h := handler.New(repo, cfg.JWTSecret)
|
||||
h := handler.New(r, cfg.JWTSecret)
|
||||
router := httpserver.NewRouter(h, cfg.JWTSecret)
|
||||
|
||||
s := &http.Server{
|
||||
@@ -33,11 +49,11 @@ func New(cfg config.Config) (*App, error) {
|
||||
Handler: router,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
WriteTimeout: 2 * time.Minute,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
return &App{cfg: cfg, server: s, repo: repo, h: h}, nil
|
||||
return &App{cfg: cfg, server: s, repo: r, h: h}, nil
|
||||
}
|
||||
|
||||
func (a *App) Run() error {
|
||||
|
||||
@@ -3,18 +3,22 @@ package config
|
||||
import "os"
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
DBPath string
|
||||
JWTSecret string
|
||||
LogDir string
|
||||
Addr string
|
||||
DBType string
|
||||
DBPath string
|
||||
DatabaseURL string
|
||||
JWTSecret string
|
||||
LogDir string
|
||||
}
|
||||
|
||||
func FromEnv() Config {
|
||||
cfg := Config{
|
||||
Addr: getEnv("SERVER_ADDR", ":6365"),
|
||||
DBPath: getEnv("DB_PATH", "/app/data/gost.db"),
|
||||
JWTSecret: getEnv("JWT_SECRET", ""),
|
||||
LogDir: getEnv("LOG_DIR", "/app/logs"),
|
||||
Addr: getEnv("SERVER_ADDR", ":6365"),
|
||||
DBType: getEnv("DB_TYPE", "sqlite"),
|
||||
DBPath: getEnv("DB_PATH", "/app/data/gost.db"),
|
||||
DatabaseURL: getEnv("DATABASE_URL", ""),
|
||||
JWTSecret: getEnv("JWT_SECRET", ""),
|
||||
LogDir: getEnv("LOG_DIR", "/app/logs"),
|
||||
}
|
||||
|
||||
return cfg
|
||||
|
||||
@@ -77,6 +77,18 @@ type RuntimeDiagnoseRequest struct {
|
||||
Timeout int `json:"timeout"`
|
||||
}
|
||||
|
||||
type RuntimeNodeCommandRequest struct {
|
||||
CommandType string `json:"commandType"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
type RuntimeNodeCommandResponse struct {
|
||||
Type string `json:"type"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func NewFederationClient() *FederationClient {
|
||||
return &FederationClient{
|
||||
client: &http.Client{
|
||||
@@ -333,3 +345,42 @@ func (c *FederationClient) Diagnose(url, token, localDomain string, reqData Runt
|
||||
|
||||
return res.Data, nil
|
||||
}
|
||||
|
||||
func (c *FederationClient) Command(url, token, localDomain string, reqData RuntimeNodeCommandRequest) (*RuntimeNodeCommandResponse, error) {
|
||||
url = strings.TrimSuffix(url, "/")
|
||||
bodyBytes, _ := json.Marshal(reqData)
|
||||
req, err := http.NewRequest("POST", url+"/api/v1/federation/runtime/command", strings.NewReader(string(bodyBytes)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if localDomain != "" {
|
||||
req.Header.Set("X-Panel-Domain", localDomain)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var res struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data RuntimeNodeCommandResponse `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Code != 0 {
|
||||
return nil, fmt.Errorf("remote api error: %s", res.Msg)
|
||||
}
|
||||
|
||||
return &res.Data, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# BACKEND HTTP HANDLER KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Thu Feb 26 2026
|
||||
|
||||
## OVERVIEW
|
||||
HTTP request handlers for FLVX Admin API. Core business logic layer.
|
||||
**Stack:** Go 1.24, net/http, GORM via Repository pattern.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
handler/
|
||||
├── handler.go # Main Handler struct, login/captcha, job scheduling
|
||||
├── control_plane.go # Node control plane API (add/delete/list)
|
||||
├── federation.go # Federation/cluster sync API
|
||||
├── flow_policy.go # Traffic policy API
|
||||
├── jobs.go # Background job management (sync, cleanup)
|
||||
├── mutations.go # CRUD for users, tunnels, forwards (~3700 LOC)
|
||||
└── upgrade.go # System upgrade API
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| **User/Tunnel CRUD** | `mutations.go` | Largest file; all create/update/delete ops |
|
||||
| **Login/Captcha** | `handler.go` | Login flow, captcha verification |
|
||||
| **Federation Sync** | `federation.go` | Panel-to-panel sync |
|
||||
| **Traffic Policies** | `flow_policy.go` | Flow limiting, quota management |
|
||||
| **Background Jobs** | `jobs.go` | Scheduled sync/cleanup tasks |
|
||||
| **Node Control** | `control_plane.go` | Node add/delete/list operations |
|
||||
|
||||
## CONVENTIONS
|
||||
- Inherits from parent: GORM via Repository pattern, JWT in Authorization header.
|
||||
- Large files expected (`mutations.go` ~3700 LOC - central mutation hub).
|
||||
- Uses `repo.Repository` for DB access via `h.repo.XXX()` methods.
|
||||
- Handlers never call `repo.DB()` directly — all queries go through Repository methods.
|
||||
- Domain-driven file split: one file per functional area (federation, jobs, etc.).
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- Do NOT let handlers call `repo.DB()` directly — add a Repository method instead.
|
||||
- Do NOT change handler signatures without updating router.go.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-backend
|
||||
go test ./internal/http/handler/...
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,11 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestBuildForwardControlServiceNamesPauseResume(t *testing.T) {
|
||||
@@ -42,6 +45,20 @@ func TestBuildForwardServiceBaseCandidatesWithZeroPreferred(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceBaseWithResolvedUserTunnel(t *testing.T) {
|
||||
got := buildForwardServiceBaseWithResolvedUserTunnel(12, 34, 56)
|
||||
if got != "12_34_56" {
|
||||
t.Fatalf("expected 12_34_56, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceBaseWithResolvedUserTunnelFallbackToZero(t *testing.T) {
|
||||
got := buildForwardServiceBaseWithResolvedUserTunnel(12, 34, 0)
|
||||
if got != "12_34_0" {
|
||||
t.Fatalf("expected 12_34_0, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldTryLegacySingleService(t *testing.T) {
|
||||
if !shouldTryLegacySingleService("PauseService") {
|
||||
t.Fatalf("PauseService should require legacy fallback")
|
||||
@@ -53,3 +70,418 @@ func TestShouldTryLegacySingleService(t *testing.T) {
|
||||
t.Fatalf("DeleteService should not require legacy fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSelfHealForwardServiceControl(t *testing.T) {
|
||||
if !shouldSelfHealForwardServiceControl("PauseService") {
|
||||
t.Fatalf("PauseService should trigger self-heal")
|
||||
}
|
||||
if !shouldSelfHealForwardServiceControl(" resumeService ") {
|
||||
t.Fatalf("ResumeService should trigger self-heal")
|
||||
}
|
||||
if shouldSelfHealForwardServiceControl("DeleteService") {
|
||||
t.Fatalf("DeleteService should not trigger self-heal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlForwardServiceCommandHandledOnKnownVariant(t *testing.T) {
|
||||
bases := []string{"12_34_56"}
|
||||
called := make([]string, 0)
|
||||
handled, lastNotFoundErr, err := controlForwardServiceCommand(bases, "PauseService", func(name string) error {
|
||||
called = append(called, name)
|
||||
if name == "12_34_56_udp" {
|
||||
return nil
|
||||
}
|
||||
return errors.New("service " + name + " not found")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !handled {
|
||||
t.Fatalf("expected handled=true")
|
||||
}
|
||||
if lastNotFoundErr != nil {
|
||||
t.Fatalf("expected lastNotFoundErr=nil when handled")
|
||||
}
|
||||
wantCalls := []string{"12_34_56_tcp", "12_34_56_udp", "12_34_56"}
|
||||
if !reflect.DeepEqual(called, wantCalls) {
|
||||
t.Fatalf("expected calls %v, got %v", wantCalls, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlForwardServiceCommandReturnsLastNotFoundWhenAllMissing(t *testing.T) {
|
||||
bases := []string{"12_34_56"}
|
||||
handled, lastNotFoundErr, err := controlForwardServiceCommand(bases, "PauseService", func(name string) error {
|
||||
return errors.New("service " + name + " not found")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if handled {
|
||||
t.Fatalf("expected handled=false")
|
||||
}
|
||||
if lastNotFoundErr == nil {
|
||||
t.Fatalf("expected lastNotFoundErr when all variants are missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteForwardServiceCandidatesSkipsNotFoundUntilLegacyMatch(t *testing.T) {
|
||||
bases := []string{"12_34_56", "12_34_0"}
|
||||
called := make([]string, 0)
|
||||
err := deleteForwardServiceCandidates(bases, func(name string) error {
|
||||
called = append(called, name)
|
||||
if name == "12_34_0" {
|
||||
return nil
|
||||
}
|
||||
return errors.New("service " + name + " not found")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
wantCalls := []string{"12_34_56_tcp", "12_34_56_udp", "12_34_56", "12_34_0_tcp", "12_34_0_udp", "12_34_0"}
|
||||
if !reflect.DeepEqual(called, wantCalls) {
|
||||
t.Fatalf("expected calls %v, got %v", wantCalls, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteForwardServiceCandidatesTreatsAllMissingAsSuccess(t *testing.T) {
|
||||
bases := []string{"12_34_56", "12_34_0"}
|
||||
err := deleteForwardServiceCandidates(bases, func(name string) error {
|
||||
return errors.New("service " + name + " not found")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("all-missing delete should be tolerated, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardServiceBaseCandidatesIncludesResolvedAndLegacyZero(t *testing.T) {
|
||||
bases := buildForwardServiceBaseCandidates(46, 9, 123, []int64{123, 77, 0})
|
||||
want := []string{"46_9_123", "46_9_77", "46_9_0"}
|
||||
if !reflect.DeepEqual(bases, want) {
|
||||
t.Fatalf("expected %v, got %v", want, bases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteForwardServiceBasesOnNodeRetriesLegacyZeroResidue(t *testing.T) {
|
||||
bases := []string{"46_9_123", "46_9_0"}
|
||||
called := make([]string, 0)
|
||||
err := deleteForwardServiceCandidates(bases, func(name string) error {
|
||||
called = append(called, name)
|
||||
if name == "46_9_0_tcp" || name == "46_9_0_udp" {
|
||||
return nil
|
||||
}
|
||||
return errors.New("service " + name + " not found")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"46_9_123_tcp", "46_9_123_udp", "46_9_123", "46_9_0_tcp", "46_9_0_udp", "46_9_0"}
|
||||
if !reflect.DeepEqual(called, want) {
|
||||
t.Fatalf("expected calls %v, got %v", want, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteForwardServiceCandidatesDeletesAllMatchingVariants(t *testing.T) {
|
||||
bases := []string{"57_7_7", "57_7_0"}
|
||||
called := make([]string, 0)
|
||||
err := deleteForwardServiceCandidates(bases, func(name string) error {
|
||||
called = append(called, name)
|
||||
switch name {
|
||||
case "57_7_7_tcp", "57_7_7_udp", "57_7_0_tcp", "57_7_0_udp":
|
||||
return nil
|
||||
default:
|
||||
return errors.New("service " + name + " not found")
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"57_7_7_tcp", "57_7_7_udp", "57_7_7", "57_7_0_tcp", "57_7_0_udp", "57_7_0"}
|
||||
if !reflect.DeepEqual(called, want) {
|
||||
t.Fatalf("expected calls %v, got %v", want, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateForwardPortAvailabilityRejectsOtherForwardOccupancy(t *testing.T) {
|
||||
h := &Handler{repo: nil}
|
||||
node := &nodeRecord{ID: 9, Name: "test-node"}
|
||||
_ = h
|
||||
_ = node
|
||||
|
||||
rawRepo, err := repo.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
h = &Handler{repo: rawRepo}
|
||||
if err := rawRepo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(1, 9, 2000)`).Error; err != nil {
|
||||
t.Fatalf("insert forward port: %v", err)
|
||||
}
|
||||
|
||||
err = h.validateForwardPortAvailability(&nodeRecord{ID: 9, Name: "test-node"}, 2000, 2)
|
||||
if err == nil {
|
||||
t.Fatalf("expected occupancy error")
|
||||
}
|
||||
if err.Error() != "节点 test-node 端口 2000 已被其他转发占用" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
err = h.validateForwardPortAvailability(&nodeRecord{ID: 9, Name: "test-node"}, 2000, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("same forward should be allowed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlForwardServiceCommandReturnsHardError(t *testing.T) {
|
||||
bases := []string{"12_34_56"}
|
||||
handled, lastNotFoundErr, err := controlForwardServiceCommand(bases, "PauseService", func(name string) error {
|
||||
if name == "12_34_56_tcp" {
|
||||
return errors.New("network timeout")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected hard error")
|
||||
}
|
||||
if handled {
|
||||
t.Fatalf("expected handled=false on hard error")
|
||||
}
|
||||
if lastNotFoundErr != nil {
|
||||
t.Fatalf("did not expect not-found error alongside hard error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAlreadyExistsMessage(t *testing.T) {
|
||||
if !isAlreadyExistsMessage("service demo already exists") {
|
||||
t.Fatalf("expected already exists message to be tolerated")
|
||||
}
|
||||
if !isAlreadyExistsMessage("服务已存在") {
|
||||
t.Fatalf("expected Chinese already exists message to be tolerated")
|
||||
}
|
||||
if !isAlreadyExistsMessage("service demo alreadyexists") {
|
||||
t.Fatalf("missing-space alreadyexists should be tolerated")
|
||||
}
|
||||
if isAlreadyExistsMessage("listen tcp [::]:10001: bind: address already in use") {
|
||||
t.Fatalf("address already in use must not be treated as already exists")
|
||||
}
|
||||
if isAlreadyExistsMessage("create service 57_7_7_tcp failed: listen tcp4 0.0.0.0:46222: bind: address alreadyin use") {
|
||||
t.Fatalf("alreadyin-use variant must not be treated as already exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBindAddressInUseError(t *testing.T) {
|
||||
if !isBindAddressInUseError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
|
||||
t.Fatalf("address already in use should be detected")
|
||||
}
|
||||
if !isBindAddressInUseError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
|
||||
t.Fatalf("cannot assign requested address should be detected")
|
||||
}
|
||||
if isBindAddressInUseError(errors.New("service demo already exists")) {
|
||||
t.Fatalf("already exists should not be treated as bind conflict")
|
||||
}
|
||||
if isBindAddressInUseError(nil) {
|
||||
t.Fatalf("nil error should not be treated as bind conflict")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAddressAlreadyInUseError(t *testing.T) {
|
||||
if !isAddressAlreadyInUseError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
|
||||
t.Fatalf("address already in use should be detected")
|
||||
}
|
||||
if !isAddressAlreadyInUseError(errors.New("create service 57_7_7_tcp failed: listen tcp4 0.0.0.0:46222: bind: address alreadyin use")) {
|
||||
t.Fatalf("missing-space alreadyin-use variant should be detected")
|
||||
}
|
||||
if isAddressAlreadyInUseError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
|
||||
t.Fatalf("cannot assign requested address should not be treated as address-in-use")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCannotAssignRequestedAddressError(t *testing.T) {
|
||||
if !isCannotAssignRequestedAddressError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannot assign requested address")) {
|
||||
t.Fatalf("cannot assign requested address should be detected")
|
||||
}
|
||||
if !isCannotAssignRequestedAddressError(errors.New("listen tcp4 13.228.170.187:16765: bind: cannotassignrequestedaddress")) {
|
||||
t.Fatalf("missing-space cannotassignrequestedaddress variant should be detected")
|
||||
}
|
||||
if isCannotAssignRequestedAddressError(errors.New("listen tcp [::]:10001: bind: address already in use")) {
|
||||
t.Fatalf("address already in use should not be treated as cannot-assign")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryTunnelServiceAddWithCleanupRetriesOnAddressInUse(t *testing.T) {
|
||||
addCalls := 0
|
||||
cleanupCalls := 0
|
||||
err := retryTunnelServiceAddWithCleanup(
|
||||
func() error {
|
||||
addCalls++
|
||||
if addCalls == 1 {
|
||||
return errors.New("listen tcp 10.0.0.1:32000: bind: address already in use")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
func() error {
|
||||
cleanupCalls++
|
||||
return nil
|
||||
},
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("expected retry to succeed, got %v", err)
|
||||
}
|
||||
if addCalls != 2 {
|
||||
t.Fatalf("expected 2 add attempts, got %d", addCalls)
|
||||
}
|
||||
if cleanupCalls != 1 {
|
||||
t.Fatalf("expected 1 cleanup attempt, got %d", cleanupCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryTunnelServiceAddWithCleanupSkipsCleanupOnNonBindError(t *testing.T) {
|
||||
addCalls := 0
|
||||
cleanupCalls := 0
|
||||
err := retryTunnelServiceAddWithCleanup(
|
||||
func() error {
|
||||
addCalls++
|
||||
return errors.New("network timeout")
|
||||
},
|
||||
func() error {
|
||||
cleanupCalls++
|
||||
return nil
|
||||
},
|
||||
0,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("expected hard error")
|
||||
}
|
||||
if addCalls != 1 {
|
||||
t.Fatalf("expected 1 add attempt, got %d", addCalls)
|
||||
}
|
||||
if cleanupCalls != 0 {
|
||||
t.Fatalf("expected 0 cleanup attempts, got %d", cleanupCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryTunnelServiceAddWithCleanupReturnsCleanupError(t *testing.T) {
|
||||
cleanupErr := errors.New("delete failed")
|
||||
err := retryTunnelServiceAddWithCleanup(
|
||||
func() error {
|
||||
return errors.New("listen tcp 10.0.0.1:32000: bind: address already in use")
|
||||
},
|
||||
func() error {
|
||||
return cleanupErr
|
||||
},
|
||||
0,
|
||||
)
|
||||
if !errors.Is(err, cleanupErr) {
|
||||
t.Fatalf("expected cleanup error %v, got %v", cleanupErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {
|
||||
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
|
||||
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
|
||||
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22000, "10.9.8.7", nil, false)
|
||||
if len(services) != 2 {
|
||||
t.Fatalf("expected 2 services, got %d", len(services))
|
||||
}
|
||||
for _, svc := range services {
|
||||
addr, _ := svc["addr"].(string)
|
||||
if addr != "10.9.8.7:22000" {
|
||||
t.Fatalf("expected bind IP address 10.9.8.7:22000, got %q", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceConfigs_DefaultListenAddrWhenBindIPEmpty(t *testing.T) {
|
||||
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
|
||||
node := &nodeRecord{TCPListenAddr: "0.0.0.0", UDPListenAddr: "[::]"}
|
||||
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22001, "", nil, false)
|
||||
if len(services) != 2 {
|
||||
t.Fatalf("expected 2 services, got %d", len(services))
|
||||
}
|
||||
tcpAddr, _ := services[0]["addr"].(string)
|
||||
udpAddr, _ := services[1]["addr"].(string)
|
||||
if tcpAddr != "0.0.0.0:22001" {
|
||||
t.Fatalf("expected tcp addr 0.0.0.0:22001, got %q", tcpAddr)
|
||||
}
|
||||
if udpAddr != "[::]:22001" {
|
||||
t.Fatalf("expected udp addr [::]:22001, got %q", udpAddr)
|
||||
}
|
||||
}
|
||||
func TestBuildForwardServiceConfigs_BindIPAlreadyContainsPort(t *testing.T) {
|
||||
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
|
||||
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
|
||||
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 55555, "3.3.3.3:12345", nil, false)
|
||||
if len(services) != 2 {
|
||||
t.Fatalf("expected 2 services, got %d", len(services))
|
||||
}
|
||||
for _, svc := range services {
|
||||
addr, _ := svc["addr"].(string)
|
||||
if addr != "3.3.3.3:12345" {
|
||||
t.Fatalf("expected bind IP with port 3.3.3.3:12345, got %q", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessServerAddress_StripsURLSchemeAndPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "https with path",
|
||||
in: "https://panel.example.com:8443/api/v1",
|
||||
want: "panel.example.com:8443",
|
||||
},
|
||||
{
|
||||
name: "wss with query",
|
||||
in: "wss://panel.example.com:443/system-info?x=1",
|
||||
want: "panel.example.com:443",
|
||||
},
|
||||
{
|
||||
name: "http without port",
|
||||
in: "http://panel.example.com",
|
||||
want: "panel.example.com",
|
||||
},
|
||||
{
|
||||
name: "manual host with trailing path",
|
||||
in: "panel.example.com:8080/path",
|
||||
want: "panel.example.com:8080",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := processServerAddress(tt.in); got != tt.want {
|
||||
t.Fatalf("%s: expected %q, got %q", tt.name, tt.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessServerAddress_NormalizesIPv6(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ipv6 host only",
|
||||
in: "2001:db8::1",
|
||||
want: "[2001:db8::1]",
|
||||
},
|
||||
{
|
||||
name: "ipv6 host and port",
|
||||
in: "https://[2001:db8::1]:8443/path",
|
||||
want: "[2001:db8::1]:8443",
|
||||
},
|
||||
{
|
||||
name: "already bracketed",
|
||||
in: "[2001:db8::2]:9000",
|
||||
want: "[2001:db8::2]:9000",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := processServerAddress(tt.in); got != tt.want {
|
||||
t.Fatalf("%s: expected %q, got %q", tt.name, tt.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func mustLastInsertID(t *testing.T, r *repo.Repository, label string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
if err := r.DB().Raw("SELECT last_insert_rowid()").Row().Scan(&id); err != nil {
|
||||
t.Fatalf("read last_insert_rowid for %s: %v", label, err)
|
||||
}
|
||||
if id <= 0 {
|
||||
t.Fatalf("invalid last_insert_rowid for %s: %d", label, id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func mustQueryInt(t *testing.T, r *repo.Repository, query string, args ...interface{}) int {
|
||||
t.Helper()
|
||||
var v int
|
||||
if err := r.DB().Raw(query, args...).Row().Scan(&v); err != nil {
|
||||
t.Fatalf("query int failed: %v (query=%q)", err, query)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func mustQueryInt64Int64String(t *testing.T, r *repo.Repository, query string, args ...interface{}) (int64, int64, string) {
|
||||
t.Helper()
|
||||
var a int64
|
||||
var b int64
|
||||
var c string
|
||||
if err := r.DB().Raw(query, args...).Row().Scan(&a, &b, &c); err != nil {
|
||||
t.Fatalf("query int64+int64+string failed: %v (query=%q)", err, query)
|
||||
}
|
||||
return a, b, c
|
||||
}
|
||||
|
||||
func mustQueryInt64Int64Int(t *testing.T, r *repo.Repository, query string, args ...interface{}) (int64, int64, int) {
|
||||
t.Helper()
|
||||
var a int64
|
||||
var b int64
|
||||
var c int
|
||||
if err := r.DB().Raw(query, args...).Row().Scan(&a, &b, &c); err != nil {
|
||||
t.Fatalf("query int64+int64+int failed: %v (query=%q)", err, query)
|
||||
}
|
||||
return a, b, c
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
type diagnosisStreamEvent struct {
|
||||
Type string `json:"type"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
TS int64 `json:"ts"`
|
||||
}
|
||||
|
||||
func prepareDiagnosisStreamResponse(w http.ResponseWriter) (http.Flusher, error) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
return nil, errors.New("当前服务不支持流式响应")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
return flusher, nil
|
||||
}
|
||||
|
||||
func writeDiagnosisStreamEvent(encoder *json.Encoder, flusher http.Flusher, eventType string, data interface{}) error {
|
||||
if encoder == nil || flusher == nil {
|
||||
return errors.New("流式响应写入器未初始化")
|
||||
}
|
||||
event := diagnosisStreamEvent{Type: eventType, Data: data, TS: time.Now().UnixMilli()}
|
||||
if err := encoder.Encode(event); err != nil {
|
||||
return err
|
||||
}
|
||||
flusher.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
func summarizeDiagnosisProgress(results []map[string]interface{}) diagnosisProgress {
|
||||
progress := diagnosisProgress{Total: len(results)}
|
||||
for _, item := range results {
|
||||
progress.Completed++
|
||||
if asBool(item["success"], false) {
|
||||
progress.Success++
|
||||
} else {
|
||||
progress.Failed++
|
||||
}
|
||||
}
|
||||
return progress
|
||||
}
|
||||
|
||||
func shouldIgnoreDiagnosisStreamError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return true
|
||||
}
|
||||
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
if strings.Contains(msg, "broken pipe") || strings.Contains(msg, "connection reset by peer") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(msg, "stream already closed") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Handler) streamDiagnosisRuntime(ctx context.Context, cancel context.CancelFunc, w http.ResponseWriter, startPayload map[string]interface{}, workItems []diagnosisWorkItem) error {
|
||||
flusher, err := prepareDiagnosisStreamResponse(w)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoder := json.NewEncoder(w)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"total": len(workItems),
|
||||
"timestamp": time.Now().UnixMilli(),
|
||||
"items": h.buildDiagnosisStreamStartItems(workItems),
|
||||
}
|
||||
for key, value := range startPayload {
|
||||
payload[key] = value
|
||||
}
|
||||
if err := writeDiagnosisStreamEvent(encoder, flusher, "start", payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
streamBroken := false
|
||||
emitter := func(index int, item map[string]interface{}, progress diagnosisProgress) {
|
||||
if streamBroken {
|
||||
return
|
||||
}
|
||||
itemPayload := map[string]interface{}{
|
||||
"index": index,
|
||||
"result": item,
|
||||
"progress": progress,
|
||||
}
|
||||
if err := writeDiagnosisStreamEvent(encoder, flusher, "item", itemPayload); err != nil {
|
||||
streamBroken = true
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results := h.runDiagnosisWorkItems(ctx, workItems, emitter)
|
||||
if streamBroken {
|
||||
return context.Canceled
|
||||
}
|
||||
|
||||
progress := summarizeDiagnosisProgress(results)
|
||||
donePayload := map[string]interface{}{
|
||||
"progress": progress,
|
||||
"timedOut": errors.Is(ctx.Err(), context.DeadlineExceeded),
|
||||
}
|
||||
return writeDiagnosisStreamEvent(encoder, flusher, "done", donePayload)
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelDiagnoseStream(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
id := asInt64FromBodyKey(r, w, "tunnelId")
|
||||
if id <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
tunnelName, tunnelType, workItems, err := h.prepareTunnelDiagnosis(id)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "不完整") {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
startPayload := map[string]interface{}{
|
||||
"tunnelName": tunnelName,
|
||||
"tunnelType": tunnelType,
|
||||
}
|
||||
if err := h.streamDiagnosisRuntime(ctx, cancel, w, startPayload, workItems); err != nil {
|
||||
if shouldIgnoreDiagnosisStreamError(err) {
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "不支持流式响应") {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) forwardDiagnoseStream(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
id := asInt64FromBodyKey(r, w, "forwardId")
|
||||
if id <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
forward, _, _, err := h.resolveForwardAccess(r, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errForwardNotFound) {
|
||||
response.WriteJSON(w, response.ErrDefault("转发不存在"))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
forwardName, workItems, err := h.prepareForwardDiagnosis(forward)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "不存在") || strings.Contains(err.Error(), "不能为空") || strings.Contains(err.Error(), "错误") {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
startPayload := map[string]interface{}{
|
||||
"forwardName": forwardName,
|
||||
}
|
||||
if err := h.streamDiagnosisRuntime(ctx, cancel, w, startPayload, workItems); err != nil {
|
||||
if shouldIgnoreDiagnosisStreamError(err) {
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "不支持流式响应") {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nodeSupportsV4 / nodeSupportsV6
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSelectTunnelDialHost_ConnectIpPriority(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
|
||||
// Empty connectIp should be ignored, IP preference takes effect
|
||||
host, err := selectTunnelDialHost(from, to, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "10.0.0.2" {
|
||||
t.Fatalf("empty connectIp should be ignored (v4 preference applies), got %q", host)
|
||||
}
|
||||
// Non-empty connectIp should override IP preference
|
||||
host, err = selectTunnelDialHost(from, to, "v6", "192.168.0.3")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "192.168.0.3" {
|
||||
t.Fatalf("connectIp should override v6 preference, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTunnelChainServiceConfig_UsesConnectIPForListen(t *testing.T) {
|
||||
node := &nodeRecord{TCPListenAddr: "[::]"}
|
||||
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21000, ConnectIP: "2001:db8::88"}
|
||||
services := buildTunnelChainServiceConfig(99, chain, node, 1)
|
||||
if len(services) != 1 {
|
||||
t.Fatalf("expected 1 service, got %d", len(services))
|
||||
}
|
||||
addr, _ := services[0]["addr"].(string)
|
||||
if addr != "[2001:db8::88]:21000" {
|
||||
t.Fatalf("expected connectIp listen [2001:db8::88]:21000, got %q", addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTunnelChainServiceConfig_FallsBackToNodeListenAddr(t *testing.T) {
|
||||
node := &nodeRecord{TCPListenAddr: "10.8.0.5"}
|
||||
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21002}
|
||||
services := buildTunnelChainServiceConfig(99, chain, node, 1)
|
||||
if len(services) != 1 {
|
||||
t.Fatalf("expected 1 service, got %d", len(services))
|
||||
}
|
||||
addr, _ := services[0]["addr"].(string)
|
||||
if addr != "10.8.0.5:21002" {
|
||||
t.Fatalf("expected node listen addr 10.8.0.5:21002, got %q", addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTunnelChainServiceConfig_DefaultListenAddrWhenConnectIPEmpty(t *testing.T) {
|
||||
node := &nodeRecord{TCPListenAddr: "[::]"}
|
||||
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21001}
|
||||
services := buildTunnelChainServiceConfig(99, chain, node, 1)
|
||||
if len(services) != 1 {
|
||||
t.Fatalf("expected 1 service, got %d", len(services))
|
||||
}
|
||||
addr, _ := services[0]["addr"].(string)
|
||||
if addr != "[::]:21001" {
|
||||
t.Fatalf("expected default listen [::]:21001, got %q", addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTunnelChainServiceConfig_SetsRetriesWhenMultipleCandidates(t *testing.T) {
|
||||
node := &nodeRecord{TCPListenAddr: "[::]"}
|
||||
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21001}
|
||||
services := buildTunnelChainServiceConfig(99, chain, node, 3)
|
||||
if len(services) != 1 {
|
||||
t.Fatalf("expected 1 service, got %d", len(services))
|
||||
}
|
||||
handler, _ := services[0]["handler"].(map[string]interface{})
|
||||
if handler == nil {
|
||||
t.Fatal("expected handler config")
|
||||
}
|
||||
retries, ok := handler["retries"].(int)
|
||||
if !ok {
|
||||
t.Fatal("expected retries to be set when nextHopCandidateCount > 1")
|
||||
}
|
||||
if retries != 2 {
|
||||
t.Fatalf("expected retries=2 (candidates-1), got %d", retries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTunnelChainServiceConfig_NoRetriesWhenSingleCandidate(t *testing.T) {
|
||||
node := &nodeRecord{TCPListenAddr: "[::]"}
|
||||
chain := tunnelRuntimeNode{Protocol: "tls", Port: 21001}
|
||||
services := buildTunnelChainServiceConfig(99, chain, node, 1)
|
||||
if len(services) != 1 {
|
||||
t.Fatalf("expected 1 service, got %d", len(services))
|
||||
}
|
||||
handler, _ := services[0]["handler"].(map[string]interface{})
|
||||
if handler == nil {
|
||||
t.Fatal("expected handler config")
|
||||
}
|
||||
if _, hasRetries := handler["retries"]; hasRetries {
|
||||
t.Fatal("expected no retries when nextHopCandidateCount is 1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV6_Nil(t *testing.T) {
|
||||
if nodeSupportsV6(nil) {
|
||||
t.Fatal("nil node must not support v6")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV4_ExplicitV4(t *testing.T) {
|
||||
n := &nodeRecord{ServerIPv4: "10.0.0.1"}
|
||||
if !nodeSupportsV4(n) {
|
||||
t.Fatal("explicit server_ip_v4 needs support v4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV6_ExplicitV6(t *testing.T) {
|
||||
n := &nodeRecord{ServerIPv6: "2001:db8::1"}
|
||||
if !nodeSupportsV6(n) {
|
||||
t.Fatal("explicit server_ip_v6 needs support v6")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV4_OnlyV6Set(t *testing.T) {
|
||||
n := &nodeRecord{ServerIPv6: "2001:db8::1"}
|
||||
if nodeSupportsV4(n) {
|
||||
t.Fatal("node with only v6 should not support v4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV6_OnlyV4Set(t *testing.T) {
|
||||
n := &nodeRecord{ServerIPv4: "10.0.0.1"}
|
||||
if nodeSupportsV6(n) {
|
||||
t.Fatal("node with only v4 should not support v6")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV4_DualStack(t *testing.T) {
|
||||
n := &nodeRecord{ServerIPv4: "10.0.0.1", ServerIPv6: "2001:db8::1"}
|
||||
if !nodeSupportsV4(n) {
|
||||
t.Fatal("dual-stack node must support v4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV6_DualStack(t *testing.T) {
|
||||
n := &nodeRecord{ServerIPv4: "10.0.0.1", ServerIPv6: "2001:db8::1"}
|
||||
if !nodeSupportsV6(n) {
|
||||
t.Fatal("dual-stack node must support v6")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV4_LegacyV4Only(t *testing.T) {
|
||||
n := &nodeRecord{ServerIP: "192.168.1.1"}
|
||||
if !nodeSupportsV4(n) {
|
||||
t.Fatal("legacy v4 ip in server_ip must support v4")
|
||||
}
|
||||
if nodeSupportsV6(n) {
|
||||
t.Fatal("legacy v4 ip in server_ip should not support v6")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV6_LegacyV6Only(t *testing.T) {
|
||||
n := &nodeRecord{ServerIP: "2001:db8::1"}
|
||||
if !nodeSupportsV6(n) {
|
||||
t.Fatal("legacy v6 ip in server_ip must support v6")
|
||||
}
|
||||
if nodeSupportsV4(n) {
|
||||
t.Fatal("legacy v6 ip in server_ip should not support v4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV4_EmptyNode(t *testing.T) {
|
||||
n := &nodeRecord{}
|
||||
if nodeSupportsV4(n) {
|
||||
t.Fatal("empty node must not support v4")
|
||||
}
|
||||
if nodeSupportsV6(n) {
|
||||
t.Fatal("empty node must not support v6")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSupportsV4_LegacyBracketed(t *testing.T) {
|
||||
n := &nodeRecord{ServerIP: "[::1]"}
|
||||
if nodeSupportsV4(n) {
|
||||
t.Fatal("bracketed ipv6 must not support v4")
|
||||
}
|
||||
if !nodeSupportsV6(n) {
|
||||
t.Fatal("bracketed ipv6 must support v6")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pickNodeAddressV4 / pickNodeAddressV6
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestPickNodeAddressV4_Nil(t *testing.T) {
|
||||
if pickNodeAddressV4(nil) != "" {
|
||||
t.Fatal("nil node must return empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickNodeAddressV6_Nil(t *testing.T) {
|
||||
if pickNodeAddressV6(nil) != "" {
|
||||
t.Fatal("nil node must return empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickNodeAddressV4_PreferExplicit(t *testing.T) {
|
||||
n := &nodeRecord{ServerIPv4: "10.0.0.1", ServerIP: "192.168.0.1"}
|
||||
got := pickNodeAddressV4(n)
|
||||
if got != "10.0.0.1" {
|
||||
t.Fatalf("expected explicit v4 10.0.0.1, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickNodeAddressV4_FallbackLegacy(t *testing.T) {
|
||||
n := &nodeRecord{ServerIP: "192.168.0.1"}
|
||||
got := pickNodeAddressV4(n)
|
||||
if got != "192.168.0.1" {
|
||||
t.Fatalf("expected legacy 192.168.0.1, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickNodeAddressV6_PreferExplicit(t *testing.T) {
|
||||
n := &nodeRecord{ServerIPv6: "2001:db8::1", ServerIP: "::1"}
|
||||
got := pickNodeAddressV6(n)
|
||||
if got != "2001:db8::1" {
|
||||
t.Fatalf("expected explicit v6 2001:db8::1, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickNodeAddressV6_FallbackLegacy(t *testing.T) {
|
||||
n := &nodeRecord{ServerIP: "::1"}
|
||||
got := pickNodeAddressV6(n)
|
||||
if got != "::1" {
|
||||
t.Fatalf("expected legacy ::1, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// selectTunnelDialHost — core IP preference selection logic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func dualStackNode(name, v4, v6 string) *nodeRecord {
|
||||
return &nodeRecord{
|
||||
Name: name,
|
||||
ServerIPv4: v4,
|
||||
ServerIPv6: v6,
|
||||
}
|
||||
}
|
||||
|
||||
func v4OnlyNode(name, v4 string) *nodeRecord {
|
||||
return &nodeRecord{
|
||||
Name: name,
|
||||
ServerIPv4: v4,
|
||||
}
|
||||
}
|
||||
|
||||
func v6OnlyNode(name, v6 string) *nodeRecord {
|
||||
return &nodeRecord{
|
||||
Name: name,
|
||||
ServerIPv6: v6,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_NilNodes(t *testing.T) {
|
||||
_, err := selectTunnelDialHost(nil, nil, "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil nodes")
|
||||
}
|
||||
_, err = selectTunnelDialHost(dualStackNode("a", "1.1.1.1", "::1"), nil, "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil toNode")
|
||||
}
|
||||
_, err = selectTunnelDialHost(nil, dualStackNode("b", "1.1.1.1", "::1"), "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil fromNode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_DualStack_DefaultPreference(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
host, err := selectTunnelDialHost(from, to, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Default prefers v4 when both available
|
||||
if host != "10.0.0.2" {
|
||||
t.Fatalf("default preference should pick v4, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_DualStack_PreferV4(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
host, err := selectTunnelDialHost(from, to, "v4", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "10.0.0.2" {
|
||||
t.Fatalf("v4 preference should pick v4 address, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_DualStack_PreferV6(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
host, err := selectTunnelDialHost(from, to, "v6", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "2001:db8::2" {
|
||||
t.Fatalf("v6 preference should pick v6 address, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_V4Only_PreferV6Fallback(t *testing.T) {
|
||||
from := v4OnlyNode("from", "10.0.0.1")
|
||||
to := v4OnlyNode("to", "10.0.0.2")
|
||||
// User prefers v6, but both nodes are v4-only — should fallback to v4
|
||||
host, err := selectTunnelDialHost(from, to, "v6", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "10.0.0.2" {
|
||||
t.Fatalf("v6 preference on v4-only nodes should fallback to v4, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {
|
||||
from := v6OnlyNode("from", "2001:db8::1")
|
||||
to := v6OnlyNode("to", "2001:db8::2")
|
||||
// User prefers v4, but both nodes are v6-only — should fallback to v6
|
||||
host, err := selectTunnelDialHost(from, to, "v4", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "2001:db8::2" {
|
||||
t.Fatalf("v4 preference on v6-only nodes should fallback to v6, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_Incompatible(t *testing.T) {
|
||||
from := v4OnlyNode("from", "10.0.0.1")
|
||||
to := v6OnlyNode("to", "2001:db8::2")
|
||||
_, err := selectTunnelDialHost(from, to, "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for incompatible nodes (v4-only -> v6-only)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_Incompatible_Reverse(t *testing.T) {
|
||||
from := v6OnlyNode("from", "2001:db8::1")
|
||||
to := v4OnlyNode("to", "10.0.0.2")
|
||||
_, err := selectTunnelDialHost(from, to, "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for incompatible nodes (v6-only -> v4-only)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_WhitespacePreference(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
// Whitespace should be trimmed, treated as "v6"
|
||||
host, err := selectTunnelDialHost(from, to, " v6 ", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "2001:db8::2" {
|
||||
t.Fatalf("trimmed v6 preference should pick v6 address, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_MixedStack_FromDualToV4(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := v4OnlyNode("to", "10.0.0.2")
|
||||
// v6 preferred, but target only has v4 — should succeed with v4
|
||||
host, err := selectTunnelDialHost(from, to, "v6", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "10.0.0.2" {
|
||||
t.Fatalf("should fallback to v4 when target is v4-only, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_MixedStack_FromDualToV6(t *testing.T) {
|
||||
from := dualStackNode("from", "10.0.0.1", "2001:db8::1")
|
||||
to := v6OnlyNode("to", "2001:db8::2")
|
||||
// v4 preferred, but target only has v6 — should succeed with v6
|
||||
host, err := selectTunnelDialHost(from, to, "v4", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "2001:db8::2" {
|
||||
t.Fatalf("should fallback to v6 when target is v6-only, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_MixedStack_FromV4ToDual(t *testing.T) {
|
||||
from := v4OnlyNode("from", "10.0.0.1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
// v6 preferred, but from only has v4 — should use v4 (from can only reach v4 of target)
|
||||
host, err := selectTunnelDialHost(from, to, "v6", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "10.0.0.2" {
|
||||
t.Fatalf("should use v4 when from is v4-only, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTunnelDialHost_MixedStack_FromV6ToDual(t *testing.T) {
|
||||
from := v6OnlyNode("from", "2001:db8::1")
|
||||
to := dualStackNode("to", "10.0.0.2", "2001:db8::2")
|
||||
// v4 preferred, but from only has v6 — should use v6
|
||||
host, err := selectTunnelDialHost(from, to, "v4", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "2001:db8::2" {
|
||||
t.Fatalf("should use v6 when from is v6-only, got %q", host)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nodeDisplayName
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNodeDisplayName_Nil(t *testing.T) {
|
||||
got := nodeDisplayName(nil)
|
||||
if got != "node" {
|
||||
t.Fatalf("nil node display name should be 'node', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeDisplayName_Named(t *testing.T) {
|
||||
n := &nodeRecord{ID: 42, Name: "hk-node"}
|
||||
got := nodeDisplayName(n)
|
||||
if got != "hk-node" {
|
||||
t.Fatalf("expected 'hk-node', got %q", got)
|
||||
}
|
||||
}
|
||||
func TestNodeDisplayName_Unnamed(t *testing.T) {
|
||||
n := &nodeRecord{ID: 42}
|
||||
got := nodeDisplayName(n)
|
||||
if got != "node_42" {
|
||||
t.Fatalf("expected 'node_42', got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/client"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/sqlite"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
type federationTunnelRequest struct {
|
||||
@@ -91,6 +91,11 @@ type federationRuntimeDiagnoseRequest struct {
|
||||
Timeout int `json:"timeout"`
|
||||
}
|
||||
|
||||
type federationRuntimeCommandRequest struct {
|
||||
CommandType string `json:"commandType"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
type peerShareUsedPort struct {
|
||||
RuntimeID int64 `json:"runtimeId"`
|
||||
Port int `json:"port"`
|
||||
@@ -102,7 +107,7 @@ type peerShareUsedPort struct {
|
||||
}
|
||||
|
||||
type peerShareListItem struct {
|
||||
sqlite.PeerShare
|
||||
repo.PeerShare
|
||||
UsedPorts []int `json:"usedPorts"`
|
||||
UsedPortDetails []peerShareUsedPort `json:"usedPortDetails"`
|
||||
ActiveRuntimeNum int `json:"activeRuntimeNum"`
|
||||
@@ -136,6 +141,32 @@ type remoteUsageNodeItem struct {
|
||||
SyncError string `json:"syncError,omitempty"`
|
||||
}
|
||||
|
||||
func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName string, targetCount int, interfaceName string) map[string]interface{} {
|
||||
service := map[string]interface{}{
|
||||
"name": serviceName,
|
||||
"addr": addr,
|
||||
"handler": map[string]interface{}{
|
||||
"type": "relay",
|
||||
},
|
||||
"listener": map[string]interface{}{
|
||||
"type": protocol,
|
||||
},
|
||||
}
|
||||
if isTLSTunnelProtocol(protocol) {
|
||||
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{"nodelay": true}
|
||||
}
|
||||
if role == "middle" {
|
||||
service["handler"].(map[string]interface{})["chain"] = chainName
|
||||
if targetCount > 1 {
|
||||
service["handler"].(map[string]interface{})["retries"] = targetCount - 1
|
||||
}
|
||||
}
|
||||
if role == "exit" && strings.TrimSpace(interfaceName) != "" {
|
||||
service["metadata"] = map[string]interface{}{"interface": interfaceName}
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func (h *Handler) federationShareList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||
@@ -258,7 +289,7 @@ func (h *Handler) federationShareCreate(w http.ResponseWriter, r *http.Request)
|
||||
now := time.Now().UnixMilli()
|
||||
token := randomToken(32)
|
||||
|
||||
share := &sqlite.PeerShare{
|
||||
share := &repo.PeerShare{
|
||||
Name: req.Name,
|
||||
NodeID: req.NodeID,
|
||||
Token: token,
|
||||
@@ -293,13 +324,20 @@ func (h *Handler) federationShareDelete(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
share, _ := h.repo.GetPeerShare(req.ID)
|
||||
|
||||
h.cleanupPeerShareRuntimes(req.ID)
|
||||
h.cleanupFederationTunnels(req.ID)
|
||||
|
||||
if err := h.repo.DeletePeerShare(req.ID); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if share != nil && h.wsServer != nil {
|
||||
h.wsServer.SendCommand(share.NodeID, "reload", nil, time.Second*5)
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
@@ -417,40 +455,25 @@ func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.repo.DB().Query(`
|
||||
SELECT id, name, remote_url, remote_token, remote_config
|
||||
FROM node
|
||||
WHERE is_remote = 1
|
||||
ORDER BY id DESC
|
||||
`)
|
||||
remoteNodes, err := h.repo.ListRemoteNodes()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
fc := client.NewFederationClient()
|
||||
localDomain := h.federationLocalDomain()
|
||||
|
||||
items := make([]remoteUsageNodeItem, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
nodeID int64
|
||||
nodeName string
|
||||
remoteURL sql.NullString
|
||||
remoteToken sql.NullString
|
||||
remoteConfig sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&nodeID, &nodeName, &remoteURL, &remoteToken, &remoteConfig); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
for _, node := range remoteNodes {
|
||||
nodeID := node.ID
|
||||
nodeName := node.Name
|
||||
|
||||
shareID, maxBandwidth, currentFlow, expiryTime, portRangeStart, portRangeEnd := parseRemoteShareUsageConfig(remoteConfig.String)
|
||||
shareID, maxBandwidth, currentFlow, expiryTime, portRangeStart, portRangeEnd := parseRemoteShareUsageConfig(node.RemoteConfig.String)
|
||||
|
||||
var syncError string
|
||||
url := strings.TrimSpace(remoteURL.String)
|
||||
token := strings.TrimSpace(remoteToken.String)
|
||||
url := strings.TrimSpace(node.RemoteURL.String)
|
||||
token := strings.TrimSpace(node.RemoteToken.String)
|
||||
if url != "" && token != "" {
|
||||
info, connectErr := fc.Connect(url, token, localDomain)
|
||||
if connectErr != nil {
|
||||
@@ -471,42 +494,62 @@ func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Reque
|
||||
"portRangeStart": info.PortRangeStart,
|
||||
"portRangeEnd": info.PortRangeEnd,
|
||||
})
|
||||
_, _ = h.repo.DB().Exec(`UPDATE node SET remote_config = ? WHERE id = ?`, string(configData), nodeID)
|
||||
_ = h.repo.UpdateNodeRemoteConfig(nodeID, string(configData))
|
||||
}
|
||||
}
|
||||
|
||||
bindingRows, err := h.repo.DB().Query(`
|
||||
SELECT fb.id, fb.tunnel_id, COALESCE(t.name, ''), fb.chain_type, fb.hop_inx, fb.allocated_port, fb.resource_key, fb.remote_binding_id, fb.updated_time
|
||||
FROM federation_tunnel_binding fb
|
||||
LEFT JOIN tunnel t ON t.id = fb.tunnel_id
|
||||
WHERE fb.node_id = ? AND fb.status = 1
|
||||
ORDER BY fb.allocated_port ASC, fb.id ASC
|
||||
`, nodeID)
|
||||
bindingRows, err := h.repo.ListActiveBindingsForNode(nodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
forwardPortRows, err := h.repo.ListActiveForwardPortsForNode(nodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
usedSet := make(map[int]struct{})
|
||||
bindings := make([]remoteUsageBindingItem, 0)
|
||||
for bindingRows.Next() {
|
||||
var item remoteUsageBindingItem
|
||||
if err := bindingRows.Scan(&item.BindingID, &item.TunnelID, &item.TunnelName, &item.ChainType, &item.HopInx, &item.AllocatedPort, &item.ResourceKey, &item.RemoteBindingID, &item.UpdatedTime); err != nil {
|
||||
_ = bindingRows.Close()
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
bindings = append(bindings, item)
|
||||
if item.AllocatedPort > 0 {
|
||||
usedSet[item.AllocatedPort] = struct{}{}
|
||||
bindings := make([]remoteUsageBindingItem, 0, len(bindingRows)+len(forwardPortRows))
|
||||
for _, b := range bindingRows {
|
||||
bindings = append(bindings, remoteUsageBindingItem{
|
||||
BindingID: b.ID,
|
||||
TunnelID: b.TunnelID,
|
||||
TunnelName: b.TunnelName,
|
||||
ChainType: b.ChainType,
|
||||
HopInx: b.HopInx,
|
||||
AllocatedPort: b.AllocatedPort,
|
||||
ResourceKey: b.ResourceKey,
|
||||
RemoteBindingID: b.RemoteBindingID,
|
||||
UpdatedTime: b.UpdatedTime,
|
||||
})
|
||||
if b.AllocatedPort > 0 {
|
||||
usedSet[b.AllocatedPort] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err := bindingRows.Err(); err != nil {
|
||||
_ = bindingRows.Close()
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
for _, fp := range forwardPortRows {
|
||||
bindings = append(bindings, remoteUsageBindingItem{
|
||||
BindingID: -fp.ForwardID,
|
||||
TunnelID: fp.TunnelID,
|
||||
TunnelName: fp.TunnelName,
|
||||
ChainType: 1,
|
||||
HopInx: 0,
|
||||
AllocatedPort: fp.Port,
|
||||
ResourceKey: fmt.Sprintf("forward:%d", fp.ForwardID),
|
||||
RemoteBindingID: "",
|
||||
UpdatedTime: fp.UpdatedTime,
|
||||
})
|
||||
if fp.Port > 0 {
|
||||
usedSet[fp.Port] = struct{}{}
|
||||
}
|
||||
}
|
||||
_ = bindingRows.Close()
|
||||
|
||||
sort.Slice(bindings, func(i, j int) bool {
|
||||
if bindings[i].AllocatedPort == bindings[j].AllocatedPort {
|
||||
return bindings[i].BindingID < bindings[j].BindingID
|
||||
}
|
||||
return bindings[i].AllocatedPort < bindings[j].AllocatedPort
|
||||
})
|
||||
|
||||
usedPorts := make([]int, 0, len(usedSet))
|
||||
for port := range usedSet {
|
||||
@@ -530,14 +573,32 @@ func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Reque
|
||||
SyncError: syncError,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
}
|
||||
|
||||
func remoteNodePortRange(node *nodeRecord) (int, int) {
|
||||
if node == nil || node.IsRemote != 1 || node.RemoteConfig == "" {
|
||||
return 0, 0
|
||||
}
|
||||
_, _, _, _, portRangeStart, portRangeEnd := parseRemoteShareUsageConfig(node.RemoteConfig)
|
||||
return portRangeStart, portRangeEnd
|
||||
}
|
||||
|
||||
func validateRemoteNodePort(node *nodeRecord, port int) error {
|
||||
if node == nil || node.IsRemote != 1 || port <= 0 {
|
||||
return nil
|
||||
}
|
||||
start, end := remoteNodePortRange(node)
|
||||
if start <= 0 || end <= 0 {
|
||||
return nil
|
||||
}
|
||||
if port < start || port > end {
|
||||
return fmt.Errorf("远程节点端口 %d 超出允许范围 %d-%d", port, start, end)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRemoteShareUsageConfig(raw string) (int64, int64, int64, int64, int, int) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
@@ -604,31 +665,21 @@ func (h *Handler) nodeImport(w http.ResponseWriter, r *http.Request) {
|
||||
portRange = fmt.Sprintf("%d-%d", info.PortRangeStart, info.PortRangeEnd)
|
||||
}
|
||||
|
||||
db := h.repo.DB()
|
||||
inx := nextIndex(db, "node")
|
||||
inx := h.repo.NextIndex("node")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
|
||||
`,
|
||||
if err = h.repo.CreateRemoteNode(
|
||||
fmt.Sprintf("%s (Remote)", info.NodeName),
|
||||
randomToken(16), // Dummy secret
|
||||
randomToken(16),
|
||||
info.ServerIP,
|
||||
"", "", // v4/v6 unknown, use server_ip
|
||||
portRange,
|
||||
"",
|
||||
"",
|
||||
now, now,
|
||||
now,
|
||||
info.Status,
|
||||
"[::]", "[::]",
|
||||
inx,
|
||||
req.RemoteURL,
|
||||
req.Token,
|
||||
string(configBytes),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, "Database error: "+err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -720,11 +771,7 @@ func (h *Handler) federationConnect(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var nodeName string
|
||||
var serverIP string
|
||||
var status int
|
||||
|
||||
err = h.repo.DB().QueryRow("SELECT name, server_ip, status FROM node WHERE id = ?", share.NodeID).Scan(&nodeName, &serverIP, &status)
|
||||
nodeInfo, err := h.repo.GetNodeBasicInfo(share.NodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, "Node not found"))
|
||||
return
|
||||
@@ -734,9 +781,9 @@ func (h *Handler) federationConnect(w http.ResponseWriter, r *http.Request) {
|
||||
"shareId": share.ID,
|
||||
"shareName": share.Name,
|
||||
"nodeId": share.NodeID,
|
||||
"nodeName": nodeName,
|
||||
"serverIp": serverIP,
|
||||
"status": status,
|
||||
"nodeName": nodeInfo.Name,
|
||||
"serverIp": nodeInfo.ServerIP,
|
||||
"status": nodeInfo.Status,
|
||||
"maxBandwidth": share.MaxBandwidth,
|
||||
"currentFlow": share.CurrentFlow,
|
||||
"expiryTime": share.ExpiryTime,
|
||||
@@ -773,46 +820,71 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
tunnelType := 1
|
||||
if strings.ToLower(req.Protocol) == "udp" {
|
||||
tunnelType = 2
|
||||
}
|
||||
|
||||
tx, err := h.repo.DB().Begin()
|
||||
usedPorts, err := h.repo.ListUsedPortsOnNode(share.NodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, port := range usedPorts {
|
||||
if port == req.RemotePort {
|
||||
response.WriteJSON(w, response.Err(403, "Port already in use"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
runtimeOnPort, err := h.repo.GetActiveForwardPeerShareRuntimeByPort(share.ID, req.RemotePort)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if runtimeOnPort != nil {
|
||||
response.WriteJSON(w, response.Err(403, "Port already in use"))
|
||||
return
|
||||
}
|
||||
existsOnNodePort, err := h.repo.ExistsActivePeerShareRuntimeOnNodePort(share.NodeID, req.RemotePort)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if existsOnNodePort {
|
||||
response.WriteJSON(w, response.Err(403, "Port already in use"))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
res, err := tx.Exec(`INSERT INTO tunnel (name, type, protocol, flow, created_time, updated_time, status, in_ip) VALUES (?, ?, ?, 0, ?, ?, 1, ?)`,
|
||||
tunnelID, err := h.repo.CreateFederationTunnel(
|
||||
fmt.Sprintf("Share-%d-Port-%d", share.ID, req.RemotePort),
|
||||
tunnelType,
|
||||
1,
|
||||
req.Protocol,
|
||||
now,
|
||||
now,
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
tunnelID, _ := res.LastInsertId()
|
||||
|
||||
_, err = tx.Exec(`INSERT INTO chain_tunnel (tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES (?, 1, ?, ?, 'fifo', 0, ?)`,
|
||||
tunnelID,
|
||||
share.NodeID,
|
||||
req.RemotePort,
|
||||
req.Protocol,
|
||||
)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
runtime := &repo.PeerShareRuntime{
|
||||
ShareID: share.ID,
|
||||
NodeID: share.NodeID,
|
||||
ReservationID: randomToken(24),
|
||||
ResourceKey: fmt.Sprintf("federation-forward-%d-%d-%d", share.ID, tunnelID, req.RemotePort),
|
||||
BindingID: "",
|
||||
Role: "forward",
|
||||
ChainName: "",
|
||||
ServiceName: "",
|
||||
Protocol: defaultString(req.Protocol, "tcp"),
|
||||
Strategy: "fifo",
|
||||
Port: req.RemotePort,
|
||||
Target: strings.TrimSpace(req.Target),
|
||||
Applied: 0,
|
||||
Status: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}
|
||||
if err := h.repo.CreatePeerShareRuntime(runtime); err != nil {
|
||||
_ = h.deleteTunnelByID(tunnelID)
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -897,7 +969,7 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
runtime := &sqlite.PeerShareRuntime{
|
||||
runtime := &repo.PeerShareRuntime{
|
||||
ShareID: share.ID,
|
||||
NodeID: share.NodeID,
|
||||
ReservationID: randomToken(24),
|
||||
@@ -951,7 +1023,7 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
var runtime *sqlite.PeerShareRuntime
|
||||
var runtime *repo.PeerShareRuntime
|
||||
if strings.TrimSpace(req.ReservationID) != "" {
|
||||
runtime, err = h.repo.GetPeerShareRuntimeByReservationID(share.ID, strings.TrimSpace(req.ReservationID))
|
||||
} else {
|
||||
@@ -979,6 +1051,13 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
if share.PortRangeStart > 0 && share.PortRangeEnd > 0 && runtime.Port > 0 {
|
||||
if runtime.Port < share.PortRangeStart || runtime.Port > share.PortRangeEnd {
|
||||
response.WriteJSON(w, response.Err(403, fmt.Sprintf("port %d out of allowed range %d-%d", runtime.Port, share.PortRangeStart, share.PortRangeEnd)))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
node, err := h.getNodeRecord(share.NodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
@@ -1002,14 +1081,19 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid target"))
|
||||
return
|
||||
}
|
||||
targetProtocol := defaultString(target.Protocol, protocol)
|
||||
connector := map[string]interface{}{
|
||||
"type": "relay",
|
||||
}
|
||||
if isTLSTunnelProtocol(targetProtocol) {
|
||||
connector["metadata"] = map[string]interface{}{"nodelay": true}
|
||||
}
|
||||
nodeItems = append(nodeItems, map[string]interface{}{
|
||||
"name": fmt.Sprintf("node_%d", i+1),
|
||||
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, target.Port)),
|
||||
"connector": map[string]interface{}{
|
||||
"type": "relay",
|
||||
},
|
||||
"name": fmt.Sprintf("node_%d", i+1),
|
||||
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, target.Port)),
|
||||
"connector": connector,
|
||||
"dialer": map[string]interface{}{
|
||||
"type": defaultString(target.Protocol, protocol),
|
||||
"type": targetProtocol,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1038,22 +1122,16 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
}
|
||||
|
||||
service := map[string]interface{}{
|
||||
"name": serviceName,
|
||||
"addr": fmt.Sprintf("%s:%d", node.TCPListenAddr, runtime.Port),
|
||||
"handler": map[string]interface{}{
|
||||
"type": "relay",
|
||||
},
|
||||
"listener": map[string]interface{}{
|
||||
"type": protocol,
|
||||
},
|
||||
}
|
||||
if req.Role == "middle" {
|
||||
service["handler"].(map[string]interface{})["chain"] = chainName
|
||||
}
|
||||
if req.Role == "exit" && strings.TrimSpace(node.InterfaceName) != "" {
|
||||
service["metadata"] = map[string]interface{}{"interface": node.InterfaceName}
|
||||
}
|
||||
targetCount := len(req.Targets)
|
||||
service := buildFederationServiceConfig(
|
||||
serviceName,
|
||||
fmt.Sprintf("%s:%d", node.TCPListenAddr, runtime.Port),
|
||||
protocol,
|
||||
req.Role,
|
||||
chainName,
|
||||
targetCount,
|
||||
node.InterfaceName,
|
||||
)
|
||||
if _, err := h.sendNodeCommand(share.NodeID, "AddService", []map[string]interface{}{service}, true, false); err != nil {
|
||||
if req.Role == "middle" {
|
||||
_, _ = h.sendNodeCommand(share.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
|
||||
@@ -1107,7 +1185,7 @@ func (h *Handler) federationRuntimeReleaseRole(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
var runtime *sqlite.PeerShareRuntime
|
||||
var runtime *repo.PeerShareRuntime
|
||||
if strings.TrimSpace(req.BindingID) != "" {
|
||||
runtime, err = h.repo.GetPeerShareRuntimeByBindingID(share.ID, strings.TrimSpace(req.BindingID))
|
||||
} else if strings.TrimSpace(req.ReservationID) != "" {
|
||||
@@ -1171,16 +1249,20 @@ func (h *Handler) federationRuntimeDiagnose(w http.ResponseWriter, r *http.Reque
|
||||
if req.Count <= 0 {
|
||||
req.Count = 4
|
||||
}
|
||||
if req.Timeout <= 0 {
|
||||
req.Timeout = 5000
|
||||
if req.Timeout <= 0 || req.Timeout > int(diagnosisCommandTimeout/time.Millisecond) {
|
||||
req.Timeout = int(diagnosisCommandTimeout / time.Millisecond)
|
||||
}
|
||||
commandTimeout := time.Duration(req.Timeout) * time.Millisecond
|
||||
if commandTimeout <= 0 || commandTimeout > diagnosisCommandTimeout {
|
||||
commandTimeout = diagnosisCommandTimeout
|
||||
}
|
||||
|
||||
res, err := h.sendNodeCommand(share.NodeID, "TcpPing", map[string]interface{}{
|
||||
res, err := h.sendNodeCommandWithTimeout(share.NodeID, "TcpPing", map[string]interface{}{
|
||||
"ip": req.IP,
|
||||
"port": req.Port,
|
||||
"count": req.Count,
|
||||
"timeout": req.Timeout,
|
||||
}, false, false)
|
||||
}, commandTimeout, false, false)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
@@ -1193,7 +1275,271 @@ func (h *Handler) federationRuntimeDiagnose(w http.ResponseWriter, r *http.Reque
|
||||
response.WriteJSON(w, response.OK(res.Data))
|
||||
}
|
||||
|
||||
func (h *Handler) pickPeerSharePort(share *sqlite.PeerShare, requestedPort int) (int, error) {
|
||||
func (h *Handler) federationRuntimeCommand(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||
return
|
||||
}
|
||||
|
||||
token := extractBearerToken(r)
|
||||
share, err := h.repo.GetPeerShareByToken(token)
|
||||
if err != nil || share == nil {
|
||||
response.WriteJSON(w, response.Err(401, "Unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
var req federationRuntimeCommandRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
|
||||
return
|
||||
}
|
||||
cmd := strings.TrimSpace(req.CommandType)
|
||||
if cmd == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("commandType is required"))
|
||||
return
|
||||
}
|
||||
if !isFederationRuntimeCommandAllowed(cmd) {
|
||||
response.WriteJSON(w, response.ErrDefault("command not allowed"))
|
||||
return
|
||||
}
|
||||
|
||||
if isFederationServiceCommand(cmd) {
|
||||
if err := validateFederationCommandPorts(share, req.Data); err != nil {
|
||||
response.WriteJSON(w, response.Err(403, err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
res, err := h.sendNodeCommand(share.NodeID, cmd, req.Data, false, false)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
if strings.EqualFold(cmd, "addservice") || strings.EqualFold(cmd, "updateservice") {
|
||||
h.bindPeerShareForwardRuntimeServices(share, req.Data)
|
||||
} else if strings.EqualFold(cmd, "deleteservice") {
|
||||
h.releasePeerShareForwardRuntimeServices(share, req.Data)
|
||||
}
|
||||
response.WriteJSON(w, response.OK(res))
|
||||
}
|
||||
|
||||
type federationForwardServiceBinding struct {
|
||||
Name string
|
||||
Port int
|
||||
}
|
||||
|
||||
func extractFederationServiceEntries(data interface{}) []map[string]interface{} {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if entries := asMapSlice(data); len(entries) > 0 {
|
||||
return entries
|
||||
}
|
||||
|
||||
dataMap, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if entries := asMapSlice(dataMap["services"]); len(entries) > 0 {
|
||||
return entries
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseFederationForwardServiceBindings(data interface{}) []federationForwardServiceBinding {
|
||||
serviceList := extractFederationServiceEntries(data)
|
||||
bindings := make([]federationForwardServiceBinding, 0, len(serviceList))
|
||||
for _, svcMap := range serviceList {
|
||||
name := normalizeForwardRuntimeServiceName(asString(svcMap["name"]))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, _, _, ok := parseFlowServiceIDs(name); !ok {
|
||||
continue
|
||||
}
|
||||
addr := strings.TrimSpace(asString(svcMap["addr"]))
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
_, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port <= 0 {
|
||||
continue
|
||||
}
|
||||
bindings = append(bindings, federationForwardServiceBinding{Name: name, Port: port})
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
func parseFederationForwardServiceNamesForRelease(data interface{}) []string {
|
||||
names := make(map[string]struct{})
|
||||
appendName := func(raw string) {
|
||||
name := normalizeForwardRuntimeServiceName(raw)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
if _, _, _, ok := parseFlowServiceIDs(name); !ok {
|
||||
return
|
||||
}
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
|
||||
for _, svcMap := range extractFederationServiceEntries(data) {
|
||||
appendName(asString(svcMap["name"]))
|
||||
}
|
||||
|
||||
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||
for _, item := range asAnySlice(dataMap["services"]) {
|
||||
appendName(asString(item))
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range asAnySlice(data) {
|
||||
appendName(asString(item))
|
||||
}
|
||||
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(names))
|
||||
for name := range names {
|
||||
out = append(out, name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Handler) bindPeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) {
|
||||
if h == nil || h.repo == nil || share == nil {
|
||||
return
|
||||
}
|
||||
bindings := parseFederationForwardServiceBindings(data)
|
||||
if len(bindings) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
for _, binding := range bindings {
|
||||
runtime, err := h.repo.GetActiveForwardPeerShareRuntimeByPort(share.ID, binding.Port)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if runtime == nil {
|
||||
runtime, err = h.repo.GetActiveForwardPeerShareRuntimeByServiceName(share.ID, binding.Name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if runtime == nil {
|
||||
_ = h.repo.CreatePeerShareRuntime(&repo.PeerShareRuntime{
|
||||
ShareID: share.ID,
|
||||
NodeID: share.NodeID,
|
||||
ReservationID: randomToken(24),
|
||||
ResourceKey: fmt.Sprintf("forward-runtime:%d:%s:%d:%s", share.ID, binding.Name, binding.Port, randomToken(8)),
|
||||
BindingID: "",
|
||||
Role: "forward",
|
||||
ChainName: "",
|
||||
ServiceName: binding.Name,
|
||||
Protocol: "tcp",
|
||||
Strategy: "fifo",
|
||||
Port: binding.Port,
|
||||
Target: "",
|
||||
Applied: 1,
|
||||
Status: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if runtime.ServiceName == binding.Name && runtime.Applied == 1 && runtime.Port == binding.Port && runtime.Status == 1 {
|
||||
continue
|
||||
}
|
||||
runtime.ServiceName = binding.Name
|
||||
runtime.Port = binding.Port
|
||||
runtime.Applied = 1
|
||||
runtime.Status = 1
|
||||
runtime.UpdatedTime = now
|
||||
if strings.TrimSpace(runtime.Protocol) == "" {
|
||||
runtime.Protocol = "tcp"
|
||||
}
|
||||
if strings.TrimSpace(runtime.Strategy) == "" {
|
||||
runtime.Strategy = "fifo"
|
||||
}
|
||||
_ = h.repo.UpdatePeerShareRuntime(runtime)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) releasePeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) {
|
||||
if h == nil || h.repo == nil || share == nil {
|
||||
return
|
||||
}
|
||||
names := parseFederationForwardServiceNamesForRelease(data)
|
||||
if len(names) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
for _, name := range names {
|
||||
_ = h.repo.MarkForwardPeerShareRuntimeReleasedByServiceName(share.ID, name, now)
|
||||
}
|
||||
}
|
||||
|
||||
func isFederationRuntimeCommandAllowed(commandType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(commandType)) {
|
||||
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "deletechains", "addlimiters", "updatelimiters", "deletelimiters", "tcpping", "reload":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isFederationServiceCommand(commandType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(commandType)) {
|
||||
case "addservice", "updateservice":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validateFederationCommandPorts(share *repo.PeerShare, data interface{}) error {
|
||||
if share == nil || (share.PortRangeStart <= 0 && share.PortRangeEnd <= 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
serviceList := extractFederationServiceEntries(data)
|
||||
if len(serviceList) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, svcMap := range serviceList {
|
||||
addr := asString(svcMap["addr"])
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
_, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid service address: %s", addr)
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port <= 0 {
|
||||
return fmt.Errorf("invalid port in service address: %s", addr)
|
||||
}
|
||||
if port < share.PortRangeStart || port > share.PortRangeEnd {
|
||||
return fmt.Errorf("port %d out of allowed range %d-%d", port, share.PortRangeStart, share.PortRangeEnd)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) pickPeerSharePort(share *repo.PeerShare, requestedPort int) (int, error) {
|
||||
if share == nil {
|
||||
return 0, fmt.Errorf("share not found")
|
||||
}
|
||||
@@ -1203,29 +1549,13 @@ func (h *Handler) pickPeerSharePort(share *sqlite.PeerShare, requestedPort int)
|
||||
|
||||
used := make(map[int]struct{})
|
||||
|
||||
rows, err := h.repo.DB().Query(`SELECT port FROM chain_tunnel WHERE node_id = ? AND port IS NOT NULL AND port > 0`, share.NodeID)
|
||||
nodePorts, err := h.repo.ListUsedPortsOnNode(share.NodeID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var p sql.NullInt64
|
||||
if scanErr := rows.Scan(&p); scanErr == nil && p.Valid && p.Int64 > 0 {
|
||||
used[int(p.Int64)] = struct{}{}
|
||||
}
|
||||
for _, p := range nodePorts {
|
||||
used[p] = struct{}{}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
rows, err = h.repo.DB().Query(`SELECT port FROM forward_port WHERE node_id = ? AND port > 0`, share.NodeID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var p sql.NullInt64
|
||||
if scanErr := rows.Scan(&p); scanErr == nil && p.Valid && p.Int64 > 0 {
|
||||
used[int(p.Int64)] = struct{}{}
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
ports, err := h.repo.ListActivePeerShareRuntimePorts(share.ID, share.NodeID)
|
||||
if err != nil {
|
||||
@@ -1266,7 +1596,7 @@ func extractBearerToken(r *http.Request) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func isPeerShareFlowExceeded(share *sqlite.PeerShare) bool {
|
||||
func isPeerShareFlowExceeded(share *repo.PeerShare) bool {
|
||||
if share == nil {
|
||||
return false
|
||||
}
|
||||
@@ -1350,7 +1680,7 @@ func parseIPLiteral(raw string) net.IP {
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(value); ip != nil {
|
||||
return ip
|
||||
return normalizeIPAddress(ip)
|
||||
}
|
||||
|
||||
host, _, err := net.SplitHostPort(value)
|
||||
@@ -1362,7 +1692,17 @@ func parseIPLiteral(raw string) net.IP {
|
||||
if host == "" {
|
||||
return nil
|
||||
}
|
||||
return net.ParseIP(host)
|
||||
return normalizeIPAddress(net.ParseIP(host))
|
||||
}
|
||||
|
||||
func normalizeIPAddress(ip net.IP) net.IP {
|
||||
if ip == nil {
|
||||
return nil
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
return v4
|
||||
}
|
||||
return ip.To16()
|
||||
}
|
||||
|
||||
func isTrustedProxyIP(ip net.IP) bool {
|
||||
@@ -1493,3 +1833,18 @@ func (h *Handler) cleanupPeerShareRuntimes(shareID int64) {
|
||||
_ = h.repo.MarkPeerShareRuntimeReleased(runtime.ID, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) cleanupFederationTunnels(shareID int64) {
|
||||
if h == nil || h.repo == nil || shareID <= 0 {
|
||||
return
|
||||
}
|
||||
namePrefix := fmt.Sprintf("Share-%d-Port-", shareID)
|
||||
tunnelIDs, err := h.repo.ListTunnelIDsByNamePrefix(namePrefix)
|
||||
if err != nil || len(tunnelIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, tid := range tunnelIDs {
|
||||
_ = h.deleteTunnelByID(tid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,33 +10,33 @@ import (
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/sqlite"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestPickPeerSharePortUsesRuntimeReservations(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer repo.Close()
|
||||
defer r.Close()
|
||||
|
||||
h := &Handler{repo: repo}
|
||||
h := &Handler{repo: r}
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, ?, ?, ?, ?, ?, ?)`, 1, 2, 1, 3000, "round", 1, "tls"); err != nil {
|
||||
if err := r.DB().Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, ?, ?, ?, ?, ?, ?)`, 1, 2, 1, 3000, "round", 1, "tls").Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, 1, 1, 3001); err != nil {
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, 1, 1, 3001).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 77, 1, "res-1", "rk-1", "b-1", "exit", "", "fed_svc_1", "tls", "round", 3002, "", 1, 1, now, now); err != nil {
|
||||
`, 77, 1, "res-1", "rk-1", "b-1", "exit", "", "fed_svc_1", "tls", "round", 3002, "", 1, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert peer_share_runtime: %v", err)
|
||||
}
|
||||
|
||||
share := &sqlite.PeerShare{
|
||||
share := &repo.PeerShare{
|
||||
ID: 77,
|
||||
NodeID: 1,
|
||||
PortRangeStart: 3000,
|
||||
@@ -56,14 +56,35 @@ func TestPickPeerSharePortUsesRuntimeReservations(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTunnelRuntimeSkipsRemoteNodes(t *testing.T) {
|
||||
h := &Handler{}
|
||||
func TestApplyTunnelRuntimeSkipsRemoteChainAndOutNodes(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "rt-skip.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
h := &Handler{repo: r}
|
||||
now := time.Now().UnixMilli()
|
||||
for _, n := range []struct {
|
||||
id int64
|
||||
name string
|
||||
ip string
|
||||
}{
|
||||
{12, "remote-chain", "10.99.0.2"},
|
||||
{13, "remote-out", "10.99.0.3"},
|
||||
} {
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(id, name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, n.id, n.name, n.name+"-secret", n.ip, n.ip, "", "40000-40010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://remote-peer", "remote-token").Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", n.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
state := &tunnelCreateState{
|
||||
TunnelID: 1,
|
||||
Type: 2,
|
||||
InNodes: []tunnelRuntimeNode{
|
||||
{NodeID: 11, ChainType: 1, Protocol: "tls"},
|
||||
},
|
||||
InNodes: []tunnelRuntimeNode{},
|
||||
ChainHops: [][]tunnelRuntimeNode{
|
||||
{
|
||||
{NodeID: 12, ChainType: 2, Inx: 1, Port: 41000, Protocol: "tls", Strategy: "round"},
|
||||
@@ -73,9 +94,8 @@ func TestApplyTunnelRuntimeSkipsRemoteNodes(t *testing.T) {
|
||||
{NodeID: 13, ChainType: 3, Port: 42000, Protocol: "tls", Strategy: "round"},
|
||||
},
|
||||
Nodes: map[int64]*nodeRecord{
|
||||
11: {ID: 11, Name: "remote-in", IsRemote: 1},
|
||||
12: {ID: 12, Name: "remote-chain", IsRemote: 1},
|
||||
13: {ID: 13, Name: "remote-out", IsRemote: 1},
|
||||
12: {ID: 12, Name: "remote-chain", IsRemote: 1, ServerIPv4: "10.99.0.2"},
|
||||
13: {ID: 13, Name: "remote-out", IsRemote: 1, ServerIPv4: "10.99.0.3"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -84,47 +104,42 @@ func TestApplyTunnelRuntimeSkipsRemoteNodes(t *testing.T) {
|
||||
t.Fatalf("apply runtime: %v", err)
|
||||
}
|
||||
if len(chains) != 0 {
|
||||
t.Fatalf("expected no local chains created, got %d", len(chains))
|
||||
t.Fatalf("expected no local chains for remote-only nodes, got %d", len(chains))
|
||||
}
|
||||
if len(services) != 0 {
|
||||
t.Fatalf("expected no local services created, got %d", len(services))
|
||||
t.Fatalf("expected no local services for remote-only nodes, got %d", len(services))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareTunnelCreateStateRemoteAutoPortDefersToFederation(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer repo.Close()
|
||||
defer r.Close()
|
||||
|
||||
h := &Handler{repo: repo}
|
||||
h := &Handler{repo: r}
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
insertNode := func(name string, status int, portRange string, isRemote int) int64 {
|
||||
res, execErr := repo.DB().Exec(`
|
||||
if execErr := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", "10.0.0.1", "10.0.0.1", "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0, isRemote, "http://peer", "peer-token", `{"shareId":1}`)
|
||||
if execErr != nil {
|
||||
`, name, name+"-secret", "10.0.0.1", "10.0.0.1", "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0, isRemote, "http://peer", "peer-token", `{"shareId":1}`).Error; execErr != nil {
|
||||
t.Fatalf("insert node %s: %v", name, execErr)
|
||||
}
|
||||
id, idErr := res.LastInsertId()
|
||||
if idErr != nil {
|
||||
t.Fatalf("node id %s: %v", name, idErr)
|
||||
}
|
||||
return id
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
entryID := insertNode("entry", 1, "31000-31010", 0)
|
||||
remoteOutID := insertNode("remote-out", 1, "30000", 1)
|
||||
|
||||
if _, err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, 1, remoteOutID, 30000); err != nil {
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, 1, remoteOutID, 30000).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
tx, err := repo.DB().Begin()
|
||||
if err != nil {
|
||||
tx := r.DB().Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatalf("begin tx: %v", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
@@ -152,17 +167,131 @@ func TestPrepareTunnelCreateStateRemoteAutoPortDefersToFederation(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRuntimeReservePortRejectsWhenShareFlowExceeded(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
func TestPrepareTunnelCreateStateAllowsOfflineRemoteMiddleNode(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer repo.Close()
|
||||
defer r.Close()
|
||||
|
||||
h := &Handler{repo: repo}
|
||||
h := &Handler{repo: r}
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||
insertNode := func(name string, status int, portRange string, isRemote int) int64 {
|
||||
if execErr := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", "10.0.0.1", "10.0.0.1", "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0, isRemote, "http://peer", "peer-token", `{"shareId":2}`).Error; execErr != nil {
|
||||
t.Fatalf("insert node %s: %v", name, execErr)
|
||||
}
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
entryID := insertNode("entry-local", 1, "32000-32010", 0)
|
||||
remoteMiddleID := insertNode("middle-remote", 0, "33000-33010", 1)
|
||||
outID := insertNode("out-local", 1, "34000-34010", 0)
|
||||
|
||||
tx := r.DB().Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatalf("begin tx: %v", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
req := map[string]interface{}{
|
||||
"name": "remote-middle-offline-status",
|
||||
"inNodeId": []interface{}{
|
||||
map[string]interface{}{"nodeId": float64(entryID), "protocol": "tls", "strategy": "round"},
|
||||
},
|
||||
"chainNodes": []interface{}{
|
||||
[]interface{}{
|
||||
map[string]interface{}{"nodeId": float64(remoteMiddleID), "protocol": "tls", "strategy": "round", "port": float64(0)},
|
||||
},
|
||||
},
|
||||
"outNodeId": []interface{}{
|
||||
map[string]interface{}{"nodeId": float64(outID), "protocol": "tls", "strategy": "round", "port": float64(0)},
|
||||
},
|
||||
}
|
||||
|
||||
state, err := h.prepareTunnelCreateState(tx, req, 2, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare state should allow offline remote middle node: %v", err)
|
||||
}
|
||||
if len(state.ChainHops) != 1 || len(state.ChainHops[0]) != 1 {
|
||||
t.Fatalf("expected one middle hop node, got %+v", state.ChainHops)
|
||||
}
|
||||
if state.ChainHops[0][0].NodeID != remoteMiddleID {
|
||||
t.Fatalf("expected remote middle node id %d, got %d", remoteMiddleID, state.ChainHops[0][0].NodeID)
|
||||
}
|
||||
if state.Nodes[remoteMiddleID] == nil || state.Nodes[remoteMiddleID].IsRemote != 1 {
|
||||
t.Fatalf("expected remote middle node metadata in state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFederationServiceConfig_MiddleRoleWithMultipleTargets_SetsRetries(t *testing.T) {
|
||||
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 3, "")
|
||||
handler := service["handler"].(map[string]interface{})
|
||||
if handler["chain"] != "chain-next" {
|
||||
t.Fatalf("expected chain 'chain-next', got %v", handler["chain"])
|
||||
}
|
||||
if handler["retries"] != 2 {
|
||||
t.Fatalf("expected retries 2 for 3 targets, got %v", handler["retries"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFederationServiceConfig_MiddleRoleWithSingleTarget_NoRetries(t *testing.T) {
|
||||
service := buildFederationServiceConfig("svc-middle", ":40000", "tls", "middle", "chain-next", 1, "")
|
||||
handler := service["handler"].(map[string]interface{})
|
||||
if handler["chain"] != "chain-next" {
|
||||
t.Fatalf("expected chain 'chain-next', got %v", handler["chain"])
|
||||
}
|
||||
if _, hasRetries := handler["retries"]; hasRetries {
|
||||
t.Fatalf("expected no retries for single target, got %v", handler["retries"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFederationServiceConfig_ExitRole_NoRetriesRegardlessOfTargets(t *testing.T) {
|
||||
service := buildFederationServiceConfig("svc-exit", ":40000", "tls", "exit", "", 3, "eth0")
|
||||
handler := service["handler"].(map[string]interface{})
|
||||
if _, hasChain := handler["chain"]; hasChain {
|
||||
t.Fatalf("expected no chain for exit role, got %v", handler["chain"])
|
||||
}
|
||||
if _, hasRetries := handler["retries"]; hasRetries {
|
||||
t.Fatalf("expected no retries for exit role, got %v", handler["retries"])
|
||||
}
|
||||
metadata := service["metadata"].(map[string]interface{})
|
||||
if metadata["interface"] != "eth0" {
|
||||
t.Fatalf("expected interface 'eth0', got %v", metadata["interface"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFederationServiceConfig_TLSTunnelProtocol_SetsNodelay(t *testing.T) {
|
||||
service := buildFederationServiceConfig("svc-tls", ":40000", "tls", "middle", "chain-next", 2, "")
|
||||
handler := service["handler"].(map[string]interface{})
|
||||
meta := handler["metadata"].(map[string]interface{})
|
||||
if meta["nodelay"] != true {
|
||||
t.Fatalf("expected nodelay=true for TLS protocol, got %v", meta["nodelay"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFederationServiceConfig_NonTLSProtocol_NoNodelay(t *testing.T) {
|
||||
service := buildFederationServiceConfig("svc-tcp", ":40000", "tcp", "middle", "chain-next", 2, "")
|
||||
handler := service["handler"].(map[string]interface{})
|
||||
if _, hasMeta := handler["metadata"]; hasMeta {
|
||||
t.Fatalf("expected no metadata for non-TLS protocol, got %v", handler["metadata"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRuntimeReservePortRejectsWhenShareFlowExceeded(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
h := &Handler{repo: r}
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "limited-share",
|
||||
NodeID: 1,
|
||||
Token: "limited-token",
|
||||
|
||||
@@ -12,30 +12,26 @@ import (
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/sqlite"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestFederationShareCreateRejectsRemoteNode(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
insertRes, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "remote-share-node", "remote-share-secret", "10.10.10.1", "10.10.10.1", "", "20000-20010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://peer.example", "peer-token", `{"shareId":1}`)
|
||||
if err != nil {
|
||||
`, "remote-share-node", "remote-share-secret", "10.10.10.1", "10.10.10.1", "", "20000-20010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://peer.example", "peer-token", `{"shareId":1}`).Error; err != nil {
|
||||
t.Fatalf("insert remote node: %v", err)
|
||||
}
|
||||
remoteNodeID, err := insertRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get remote node id: %v", err)
|
||||
}
|
||||
remoteNodeID := mustLastInsertID(t, r, "remote-share-node")
|
||||
|
||||
body, err := json.Marshal(createPeerShareRequest{
|
||||
Name: "remote-node-share",
|
||||
@@ -70,36 +66,29 @@ func TestFederationShareCreateRejectsRemoteNode(t *testing.T) {
|
||||
t.Fatalf("expected rejection message %q, got %q", "Only local nodes can be shared", payload.Msg)
|
||||
}
|
||||
|
||||
var shareCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share WHERE node_id = ?`, remoteNodeID).Scan(&shareCount); err != nil {
|
||||
t.Fatalf("query peer_share count: %v", err)
|
||||
}
|
||||
shareCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share WHERE node_id = ?`, remoteNodeID)
|
||||
if shareCount != 0 {
|
||||
t.Fatalf("expected no share rows for remote node, got %d", shareCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationShareCreateRejectsInvalidAllowedIPs(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
insertRes, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "local-share-node", "local-share-secret", "10.20.30.40", "10.20.30.40", "", "21000-21010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "")
|
||||
if err != nil {
|
||||
`, "local-share-node", "local-share-secret", "10.20.30.40", "10.20.30.40", "", "21000-21010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
|
||||
t.Fatalf("insert local node: %v", err)
|
||||
}
|
||||
localNodeID, err := insertRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get local node id: %v", err)
|
||||
}
|
||||
localNodeID := mustLastInsertID(t, r, "local-share-node")
|
||||
|
||||
body, err := json.Marshal(createPeerShareRequest{
|
||||
Name: "local-node-share",
|
||||
@@ -135,26 +124,23 @@ func TestFederationShareCreateRejectsInvalidAllowedIPs(t *testing.T) {
|
||||
t.Fatalf("expected invalid IP message, got %q", payload.Msg)
|
||||
}
|
||||
|
||||
var shareCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share WHERE node_id = ?`, localNodeID).Scan(&shareCount); err != nil {
|
||||
t.Fatalf("query peer_share count: %v", err)
|
||||
}
|
||||
shareCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share WHERE node_id = ?`, localNodeID)
|
||||
if shareCount != 0 {
|
||||
t.Fatalf("expected no share rows for node, got %d", shareCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationShareListIncludesRemoteUsedPorts(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "provider-share",
|
||||
NodeID: 9,
|
||||
Token: "share-list-token",
|
||||
@@ -169,12 +155,12 @@ func TestFederationShareListIncludesRemoteUsedPorts(t *testing.T) {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
|
||||
share, err := repo.GetPeerShareByToken("share-list-token")
|
||||
share, err := r.GetPeerShareByToken("share-list-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
@@ -183,7 +169,7 @@ func TestFederationShareListIncludesRemoteUsedPorts(t *testing.T) {
|
||||
share.ID, share.NodeID, "r-1", "rk-1", "b-1", "middle", "fed_chain_1", "fed_svc_1", "tls", "round", 22001, "", 1, 1, now, now,
|
||||
share.ID, share.NodeID, "r-2", "rk-2", "b-2", "exit", "", "fed_svc_2", "tls", "round", 22002, "", 1, 1, now, now,
|
||||
share.ID, share.NodeID, "r-3", "rk-3", "", "", "", "", "tls", "round", 22003, "", 0, 0, now, now,
|
||||
); err != nil {
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert peer_share_runtime rows: %v", err)
|
||||
}
|
||||
|
||||
@@ -238,16 +224,16 @@ func TestFederationShareListIncludesRemoteUsedPorts(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFederationShareDeleteCleansUpRuntimes(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "delete-cleanup-share",
|
||||
NodeID: 99,
|
||||
Token: "delete-cleanup-token",
|
||||
@@ -261,26 +247,23 @@ func TestFederationShareDeleteCleansUpRuntimes(t *testing.T) {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
|
||||
share, err := repo.GetPeerShareByToken("delete-cleanup-token")
|
||||
share, err := r.GetPeerShareByToken("delete-cleanup-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
share.ID, 99, "dc-r1", "dc-rk1", "dc-b1", "exit", "", "fed_svc_dc1", "tls", "round", 40001, "", 1, 1, now, now,
|
||||
share.ID, 99, "dc-r2", "dc-rk2", "dc-b2", "middle", "fed_chain_dc2", "fed_svc_dc2", "tls", "round", 40002, "", 1, 1, now, now,
|
||||
); err != nil {
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert peer_share_runtime rows: %v", err)
|
||||
}
|
||||
|
||||
var runtimeCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1`, share.ID).Scan(&runtimeCount); err != nil {
|
||||
t.Fatalf("count active runtimes before: %v", err)
|
||||
}
|
||||
runtimeCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1`, share.ID)
|
||||
if runtimeCount != 2 {
|
||||
t.Fatalf("expected 2 active runtimes before delete, got %d", runtimeCount)
|
||||
}
|
||||
@@ -306,37 +289,31 @@ func TestFederationShareDeleteCleansUpRuntimes(t *testing.T) {
|
||||
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
|
||||
}
|
||||
|
||||
var shareCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share WHERE id = ?`, share.ID).Scan(&shareCount); err != nil {
|
||||
t.Fatalf("count peer_share after: %v", err)
|
||||
}
|
||||
shareCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share WHERE id = ?`, share.ID)
|
||||
if shareCount != 0 {
|
||||
t.Fatalf("expected peer_share deleted, got %d rows", shareCount)
|
||||
}
|
||||
|
||||
var runtimeCountAfter int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ?`, share.ID).Scan(&runtimeCountAfter); err != nil {
|
||||
t.Fatalf("count peer_share_runtime after: %v", err)
|
||||
}
|
||||
runtimeCountAfter := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ?`, share.ID)
|
||||
if runtimeCountAfter != 0 {
|
||||
t.Fatalf("expected all peer_share_runtime rows deleted, got %d", runtimeCountAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRemoteUsageListSyncErrorFallback(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "sync-error-node", "sync-error-secret", "10.50.60.70", "10.50.60.70", "", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://unreachable.invalid:9999", "bad-token", `{"shareId":42,"maxBandwidth":5368709120,"currentFlow":999999,"portRangeStart":32000,"portRangeEnd":32010}`); err != nil {
|
||||
`, "sync-error-node", "sync-error-secret", "10.50.60.70", "10.50.60.70", "", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://unreachable.invalid:9999", "bad-token", `{"shareId":42,"maxBandwidth":5368709120,"currentFlow":999999,"portRangeStart":32000,"portRangeEnd":32010}`).Error; err != nil {
|
||||
t.Fatalf("insert remote node: %v", err)
|
||||
}
|
||||
|
||||
@@ -380,15 +357,15 @@ func TestFederationRemoteUsageListSyncErrorFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFederationShareResetFlow(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "reset-flow-share",
|
||||
NodeID: 11,
|
||||
Token: "reset-flow-token",
|
||||
@@ -402,7 +379,7 @@ func TestFederationShareResetFlow(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := repo.GetPeerShareByToken("reset-flow-token")
|
||||
share, err := r.GetPeerShareByToken("reset-flow-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
@@ -428,7 +405,7 @@ func TestFederationShareResetFlow(t *testing.T) {
|
||||
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
|
||||
}
|
||||
|
||||
updated, err := repo.GetPeerShare(share.ID)
|
||||
updated, err := r.GetPeerShare(share.ID)
|
||||
if err != nil || updated == nil {
|
||||
t.Fatalf("reload peer share: %v", err)
|
||||
}
|
||||
@@ -437,48 +414,481 @@ func TestFederationShareResetFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRemoteUsageList(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
func TestFederationTunnelCreateCreatesPeerShareRuntime(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
resNode, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "remote-consumer-node", "remote-consumer-secret", "10.30.40.50", "10.30.40.50", "", "31000-31010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://peer.example", "peer-token", `{"shareId":88,"maxBandwidth":2147483648,"currentFlow":1073741824,"portRangeStart":31000,"portRangeEnd":31010}`)
|
||||
`, "federation-forward-node", "federation-forward-secret", "10.90.80.70", "10.90.80.70", "", "24000-24020", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, r, "federation-forward-node")
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "federation-forward-share",
|
||||
NodeID: nodeID,
|
||||
Token: "federation-forward-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 24000,
|
||||
PortRangeEnd: 24020,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("federation-forward-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(federationTunnelRequest{
|
||||
Protocol: "tcp",
|
||||
RemotePort: 24001,
|
||||
Target: "1.1.1.1:443",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/tunnel/create", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+share.Token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
h.federationTunnelCreate(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Code != 0 {
|
||||
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
|
||||
}
|
||||
|
||||
runtimeCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND port = ? AND status = 1`, share.ID, 24001)
|
||||
if runtimeCount != 1 {
|
||||
t.Fatalf("expected 1 runtime row for new federation forward tunnel, got %d", runtimeCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationTunnelCreateRejectsOccupiedPort(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "federation-port-check-node", "federation-port-check-secret", "10.91.80.70", "10.91.80.70", "", "24100-24120", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, r, "federation-port-check-node")
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "federation-port-check-share",
|
||||
NodeID: nodeID,
|
||||
Token: "federation-port-check-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 24100,
|
||||
PortRangeEnd: 24120,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
|
||||
create := func() response.R {
|
||||
body, err := json.Marshal(federationTunnelRequest{Protocol: "tcp", RemotePort: 24101, Target: "1.1.1.1:443"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/tunnel/create", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer federation-port-check-token")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
h.federationTunnelCreate(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
first := create()
|
||||
if first.Code != 0 {
|
||||
t.Fatalf("expected first create success, got %d (%s)", first.Code, first.Msg)
|
||||
}
|
||||
|
||||
second := create()
|
||||
if second.Code != 403 {
|
||||
t.Fatalf("expected second create to be rejected with 403, got %d (%s)", second.Code, second.Msg)
|
||||
}
|
||||
if second.Msg != "Port already in use" {
|
||||
t.Fatalf("expected occupied port message, got %q", second.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteTunnelReleasesFederationForwardRuntimeByPort(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "delete-forward-share",
|
||||
NodeID: 1,
|
||||
Token: "delete-forward-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 25000,
|
||||
PortRangeEnd: 25020,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("delete-forward-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
tunnelName := fmt.Sprintf("Share-%d-Port-%d", share.ID, 25001)
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 1, tunnelName, 1.0, 1, "tcp", 1, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.ID, share.NodeID, "del-r1", "del-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 25001, "", 1, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert runtime: %v", err)
|
||||
}
|
||||
|
||||
if err := h.deleteTunnelByID(1); err != nil {
|
||||
t.Fatalf("delete tunnel: %v", err)
|
||||
}
|
||||
|
||||
activeCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND port = ? AND status = 1`, share.ID, 25001)
|
||||
if activeCount != 0 {
|
||||
t.Fatalf("expected runtime released after tunnel delete, active rows=%d", activeCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindPeerShareForwardRuntimeServicesOnlyBindsForwardRole(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "bind-forward-role-share",
|
||||
NodeID: 1,
|
||||
Token: "bind-forward-role-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 26000,
|
||||
PortRangeEnd: 26020,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("bind-forward-role-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
1, share.ID, share.NodeID, "bind-r1", "bind-rk1", "", "forward", "", "", "tcp", "fifo", 26001, "", 0, 1, now, now,
|
||||
2, share.ID, share.NodeID, "bind-r2", "bind-rk2", "", "middle", "", "", "tcp", "round", 26002, "", 0, 1, now, now,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert runtimes: %v", err)
|
||||
}
|
||||
|
||||
h.bindPeerShareForwardRuntimeServices(share, map[string]interface{}{
|
||||
"services": []interface{}{
|
||||
map[string]interface{}{"name": "77_2_10_tcp", "addr": "[::]:26001"},
|
||||
map[string]interface{}{"name": "88_2_10_tcp", "addr": "[::]:26002"},
|
||||
},
|
||||
})
|
||||
|
||||
forwardServiceName := ""
|
||||
middleServiceName := ""
|
||||
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 1`).Scan(&forwardServiceName).Error; err != nil {
|
||||
t.Fatalf("load forward runtime service name: %v", err)
|
||||
}
|
||||
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 2`).Scan(&middleServiceName).Error; err != nil {
|
||||
t.Fatalf("load middle runtime service name: %v", err)
|
||||
}
|
||||
|
||||
if forwardServiceName != "77_2_10" {
|
||||
t.Fatalf("expected forward runtime service name bound, got %q", forwardServiceName)
|
||||
}
|
||||
if middleServiceName != "" {
|
||||
t.Fatalf("expected non-forward runtime unchanged, got %q", middleServiceName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindPeerShareForwardRuntimeServicesAcceptsTopLevelServiceArray(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "bind-array-share",
|
||||
NodeID: 1,
|
||||
Token: "bind-array-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 26100,
|
||||
PortRangeEnd: 26120,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("bind-array-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
1, share.ID, share.NodeID, "bind-array-r1", "bind-array-rk1", "", "forward", "", "", "tcp", "fifo", 26101, "", 0, 1, now, now,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert runtime: %v", err)
|
||||
}
|
||||
|
||||
h.bindPeerShareForwardRuntimeServices(share, []interface{}{
|
||||
map[string]interface{}{"name": "99_2_10_tcp", "addr": "[::]:26101"},
|
||||
map[string]interface{}{"name": "99_2_10_udp", "addr": "[::]:26101"},
|
||||
})
|
||||
|
||||
forwardServiceName := ""
|
||||
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 1`).Scan(&forwardServiceName).Error; err != nil {
|
||||
t.Fatalf("load forward runtime service name: %v", err)
|
||||
}
|
||||
if forwardServiceName != "99_2_10" {
|
||||
t.Fatalf("expected forward runtime service name bound from top-level array, got %q", forwardServiceName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindPeerShareForwardRuntimeServicesCreatesRuntimeWhenMissing(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-bind-create-runtime.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "bind-create-runtime-share",
|
||||
NodeID: 1,
|
||||
Token: "bind-create-runtime-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 26300,
|
||||
PortRangeEnd: 26320,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("bind-create-runtime-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
h.bindPeerShareForwardRuntimeServices(share, map[string]interface{}{
|
||||
"services": []interface{}{
|
||||
map[string]interface{}{"name": "55_2_10_tcp", "addr": "[::]:26301"},
|
||||
},
|
||||
})
|
||||
|
||||
var count int64
|
||||
if err := r.DB().Raw(`SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND role = ? AND status = 1`, share.ID, "forward").Scan(&count).Error; err != nil {
|
||||
t.Fatalf("query runtime count: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected 1 active forward runtime row, got %d", count)
|
||||
}
|
||||
|
||||
var serviceName string
|
||||
var port int
|
||||
var applied int
|
||||
if err := r.DB().Raw(`SELECT service_name, port, applied FROM peer_share_runtime WHERE share_id = ? AND role = ? ORDER BY id DESC LIMIT 1`, share.ID, "forward").Row().Scan(&serviceName, &port, &applied); err != nil {
|
||||
t.Fatalf("query created runtime: %v", err)
|
||||
}
|
||||
if serviceName != "55_2_10" {
|
||||
t.Fatalf("expected service_name=55_2_10, got %q", serviceName)
|
||||
}
|
||||
if port != 26301 {
|
||||
t.Fatalf("expected port=26301, got %d", port)
|
||||
}
|
||||
if applied != 1 {
|
||||
t.Fatalf("expected applied=1, got %d", applied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleasePeerShareForwardRuntimeServicesMarksRuntimeReleased(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-release-runtime.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "release-runtime-share",
|
||||
NodeID: 1,
|
||||
Token: "release-runtime-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 26400,
|
||||
PortRangeEnd: 26420,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("release-runtime-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.ID, share.NodeID, "release-r1", "release-rk1", "", "forward", "", "77_2_10", "tcp", "fifo", 26401, "", 1, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert runtime: %v", err)
|
||||
}
|
||||
|
||||
h.releasePeerShareForwardRuntimeServices(share, map[string]interface{}{
|
||||
"services": []interface{}{"77_2_10_tcp"},
|
||||
})
|
||||
|
||||
var status int
|
||||
var applied int
|
||||
var serviceName string
|
||||
if err := r.DB().Raw(`SELECT status, applied, service_name FROM peer_share_runtime WHERE share_id = ? AND role = ? ORDER BY id DESC LIMIT 1`, share.ID, "forward").Row().Scan(&status, &applied, &serviceName); err != nil {
|
||||
t.Fatalf("query released runtime: %v", err)
|
||||
}
|
||||
if status != 0 {
|
||||
t.Fatalf("expected status=0 after release, got %d", status)
|
||||
}
|
||||
if applied != 0 {
|
||||
t.Fatalf("expected applied=0 after release, got %d", applied)
|
||||
}
|
||||
if serviceName != "" {
|
||||
t.Fatalf("expected service_name cleared after release, got %q", serviceName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFederationCommandPortsAcceptsTopLevelServiceArray(t *testing.T) {
|
||||
share := &repo.PeerShare{
|
||||
PortRangeStart: 26200,
|
||||
PortRangeEnd: 26210,
|
||||
}
|
||||
err := validateFederationCommandPorts(share, []interface{}{
|
||||
map[string]interface{}{"name": "11_2_10_tcp", "addr": "[::]:26201"},
|
||||
map[string]interface{}{"name": "11_2_10_udp", "addr": "[::]:26201"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected top-level service array to pass port validation, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRemoteUsageList(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "remote-consumer-node", "remote-consumer-secret", "10.30.40.50", "10.30.40.50", "", "31000-31010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://peer.example", "peer-token", `{"shareId":88,"maxBandwidth":2147483648,"currentFlow":1073741824,"portRangeStart":31000,"portRangeEnd":31010}`).Error; err != nil {
|
||||
t.Fatalf("insert remote node: %v", err)
|
||||
}
|
||||
nodeID, err := resNode.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("remote node id: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, r, "remote-consumer-node")
|
||||
|
||||
resTunnelA, err := repo.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, "consumer-tunnel-a", 2, "tls", 1, now, now, 1, "", 0)
|
||||
if err != nil {
|
||||
if err := r.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, "consumer-tunnel-a", 2, "tls", 1, now, now, 1, "", 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel a: %v", err)
|
||||
}
|
||||
tunnelAID, _ := resTunnelA.LastInsertId()
|
||||
tunnelAID := mustLastInsertID(t, r, "consumer-tunnel-a")
|
||||
|
||||
resTunnelB, err := repo.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, "consumer-tunnel-b", 2, "tls", 1, now, now, 1, "", 0)
|
||||
if err != nil {
|
||||
if err := r.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, "consumer-tunnel-b", 2, "tls", 1, now, now, 1, "", 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel b: %v", err)
|
||||
}
|
||||
tunnelBID, _ := resTunnelB.LastInsertId()
|
||||
tunnelBID := mustLastInsertID(t, r, "consumer-tunnel-b")
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx, remote_url, resource_key, remote_binding_id, allocated_port, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
tunnelAID, nodeID, 2, 1, "http://peer.example", "rk-a", "rb-a", 31001, 1, now, now,
|
||||
tunnelBID, nodeID, 3, 0, "http://peer.example", "rk-b", "rb-b", 31002, 1, now, now,
|
||||
); err != nil {
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert federation bindings: %v", err)
|
||||
}
|
||||
|
||||
@@ -531,14 +941,118 @@ func TestFederationRemoteUsageList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthPeerAllowedIPs(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
func TestFederationRemoteUsageListIncludesForwardPorts(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-usage.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "forward-usage-remote-node", "forward-usage-secret", "10.60.70.80", "10.60.70.80", "", "33000-33010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "", "", `{"shareId":99,"maxBandwidth":0,"currentFlow":0,"portRangeStart":33000,"portRangeEnd":33010}`).Error; err != nil {
|
||||
t.Fatalf("insert remote node: %v", err)
|
||||
}
|
||||
|
||||
var nodeID int64
|
||||
if err := r.DB().Raw(`SELECT id FROM node WHERE name = ? ORDER BY id DESC LIMIT 1`, "forward-usage-remote-node").Row().Scan(&nodeID); err != nil {
|
||||
t.Fatalf("query node id: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "forward-usage-tunnel", 1, "tls", 1, now, now, 1, "", 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
var tunnelID int64
|
||||
if err := r.DB().Raw(`SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, "forward-usage-tunnel").Row().Scan(&tunnelID); err != nil {
|
||||
t.Fatalf("query tunnel id: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 1, "tester", "forward-usage-item", tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
|
||||
var forwardID int64
|
||||
if err := r.DB().Raw(`SELECT id FROM forward WHERE name = ? ORDER BY id DESC LIMIT 1`, "forward-usage-item").Row().Scan(&forwardID); err != nil {
|
||||
t.Fatalf("query forward id: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, 33001).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/remote-usage/list", nil)
|
||||
res := httptest.NewRecorder()
|
||||
h.federationRemoteUsageList(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Code != 0 {
|
||||
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
|
||||
}
|
||||
|
||||
rows, ok := payload.Data.([]interface{})
|
||||
if !ok || len(rows) == 0 {
|
||||
t.Fatalf("expected non-empty usage list, got %T", payload.Data)
|
||||
}
|
||||
|
||||
first, ok := rows[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected usage row map, got %T", rows[0])
|
||||
}
|
||||
|
||||
usedPortsRaw, ok := first["usedPorts"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected usedPorts array, got %T", first["usedPorts"])
|
||||
}
|
||||
if len(usedPortsRaw) != 1 || int(usedPortsRaw[0].(float64)) != 33001 {
|
||||
t.Fatalf("expected usedPorts [33001], got %v", usedPortsRaw)
|
||||
}
|
||||
|
||||
bindingsRaw, ok := first["bindings"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected bindings array, got %T", first["bindings"])
|
||||
}
|
||||
if len(bindingsRaw) != 1 {
|
||||
t.Fatalf("expected 1 binding row from forward usage, got %d", len(bindingsRaw))
|
||||
}
|
||||
|
||||
binding, ok := bindingsRaw[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected binding row object, got %T", bindingsRaw[0])
|
||||
}
|
||||
if int(binding["allocatedPort"].(float64)) != 33001 {
|
||||
t.Fatalf("expected allocatedPort=33001, got %v", binding["allocatedPort"])
|
||||
}
|
||||
if int(binding["chainType"].(float64)) != 1 {
|
||||
t.Fatalf("expected chainType=1 for forward usage row, got %v", binding["chainType"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthPeerAllowedIPs(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
tests := []struct {
|
||||
@@ -567,6 +1081,13 @@ func TestAuthPeerAllowedIPs(t *testing.T) {
|
||||
xff: "198.51.100.20, 172.20.0.3",
|
||||
wantAllowed: true,
|
||||
},
|
||||
{
|
||||
name: "ipv4-mapped proxy xff allowed",
|
||||
allowedIPs: "198.51.100.20",
|
||||
remoteAddr: "[::ffff:172.20.0.3]:34567",
|
||||
xff: "198.51.100.20, 172.20.0.3",
|
||||
wantAllowed: true,
|
||||
},
|
||||
{
|
||||
name: "non whitelisted ip denied",
|
||||
allowedIPs: "203.0.113.10",
|
||||
@@ -578,7 +1099,7 @@ func TestAuthPeerAllowedIPs(t *testing.T) {
|
||||
for idx, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
token := fmt.Sprintf("share-token-%d", idx)
|
||||
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "share-" + tt.name,
|
||||
NodeID: 1,
|
||||
Token: token,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
const bytesPerGB int64 = 1024 * 1024 * 1024
|
||||
@@ -31,7 +34,7 @@ type namedConfigItem struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (h *Handler) processFlowItem(item flowItem) {
|
||||
func (h *Handler) processFlowItem(nodeID int64, item flowItem) {
|
||||
serviceName := strings.TrimSpace(item.N)
|
||||
if serviceName == "" || serviceName == "web_api" {
|
||||
return
|
||||
@@ -41,6 +44,10 @@ func (h *Handler) processFlowItem(item flowItem) {
|
||||
if ok {
|
||||
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
|
||||
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
|
||||
if quota, quotaErr := h.repo.AddUserQuotaUsage(userID, inFlow+outFlow, time.Now()); quotaErr == nil {
|
||||
h.enforceUserQuotaIfNeeded(userID, quota)
|
||||
}
|
||||
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
|
||||
|
||||
if userTunnelID > 0 {
|
||||
h.enforceFlowPolicies(userID, userTunnelID)
|
||||
@@ -88,6 +95,45 @@ func parsePeerShareRuntimeServiceID(serviceName string) (int64, bool) {
|
||||
return runtimeID, true
|
||||
}
|
||||
|
||||
func parsePeerShareInfoFromFederationTunnelName(tunnelName string) (int64, int, bool) {
|
||||
tunnelName = strings.TrimSpace(tunnelName)
|
||||
if !strings.HasPrefix(tunnelName, "Share-") {
|
||||
return 0, 0, false
|
||||
}
|
||||
raw := strings.TrimPrefix(tunnelName, "Share-")
|
||||
idx := strings.Index(raw, "-Port-")
|
||||
if idx <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
shareID, err := strconv.ParseInt(raw[:idx], 10, 64)
|
||||
if err != nil || shareID <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
portValue := strings.TrimSpace(raw[idx+len("-Port-"):])
|
||||
port, err := strconv.Atoi(portValue)
|
||||
if err != nil || port <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return shareID, port, true
|
||||
}
|
||||
|
||||
func parsePeerShareIDFromFederationTunnelName(tunnelName string) (int64, bool) {
|
||||
tunnelName = strings.TrimSpace(tunnelName)
|
||||
if !strings.HasPrefix(tunnelName, "Share-") {
|
||||
return 0, false
|
||||
}
|
||||
raw := strings.TrimPrefix(tunnelName, "Share-")
|
||||
idx := strings.Index(raw, "-Port-")
|
||||
if idx <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
shareID, err := strconv.ParseInt(raw[:idx], 10, 64)
|
||||
if err != nil || shareID <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return shareID, true
|
||||
}
|
||||
|
||||
func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
|
||||
if h == nil || h.repo == nil || runtimeID <= 0 {
|
||||
return
|
||||
@@ -114,6 +160,121 @@ func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
|
||||
h.enforcePeerShareFlowLimit(share.ID)
|
||||
}
|
||||
|
||||
func (h *Handler) processPeerShareFlowFromForward(forwardID int64, nodeID int64, serviceName string, item flowItem) {
|
||||
if h == nil || h.repo == nil || forwardID <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
delta := item.D + item.U
|
||||
if delta <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
forward, err := h.getForwardRecord(forwardID)
|
||||
if err != nil || forward == nil {
|
||||
// Forward not found in local database - might be a federation port-forward
|
||||
// Try to find by service name in peer_share_runtime
|
||||
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
|
||||
return
|
||||
}
|
||||
tunnelName, err := h.repo.GetTunnelName(forward.TunnelID)
|
||||
if err != nil {
|
||||
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
|
||||
return
|
||||
}
|
||||
shareID, ok := parsePeerShareIDFromFederationTunnelName(tunnelName)
|
||||
if !ok {
|
||||
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.AddPeerShareCurrentFlow(shareID, delta); err != nil {
|
||||
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
|
||||
return
|
||||
}
|
||||
|
||||
share, err := h.repo.GetPeerShare(shareID)
|
||||
if err != nil || share == nil {
|
||||
return
|
||||
}
|
||||
if !isPeerShareFlowExceeded(share) {
|
||||
return
|
||||
}
|
||||
h.enforcePeerShareFlowLimit(share.ID)
|
||||
}
|
||||
|
||||
func normalizeForwardRuntimeServiceName(serviceName string) string {
|
||||
name := strings.TrimSpace(serviceName)
|
||||
if strings.HasSuffix(name, "_tcp") {
|
||||
return strings.TrimSuffix(name, "_tcp")
|
||||
}
|
||||
if strings.HasSuffix(name, "_udp") {
|
||||
return strings.TrimSuffix(name, "_udp")
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (h *Handler) processPeerShareFlowByServiceName(nodeID int64, serviceName string, item flowItem) {
|
||||
if h == nil || h.repo == nil || strings.TrimSpace(serviceName) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
delta := item.D + item.U
|
||||
if delta <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
normalized := normalizeForwardRuntimeServiceName(serviceName)
|
||||
var runtimes []model.PeerShareRuntime
|
||||
var err error
|
||||
|
||||
// Try node-scoped query first if nodeID is valid
|
||||
if nodeID > 0 {
|
||||
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID, normalized)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(runtimes) == 0 && normalized != serviceName {
|
||||
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID, serviceName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to global query if node-scoped query returned nothing or nodeID is invalid
|
||||
if len(runtimes) == 0 {
|
||||
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByServiceName(normalized)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(runtimes) == 0 && normalized != serviceName {
|
||||
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByServiceName(serviceName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(runtimes) != 1 {
|
||||
if len(runtimes) > 1 {
|
||||
log.Printf("WARN: ambiguous peer share runtime match for service=%s nodeID=%d count=%d", serviceName, nodeID, len(runtimes))
|
||||
}
|
||||
return
|
||||
}
|
||||
runtime := runtimes[0]
|
||||
|
||||
_ = h.repo.AddPeerShareCurrentFlow(runtime.ShareID, delta)
|
||||
|
||||
matchedShare, err := h.repo.GetPeerShare(runtime.ShareID)
|
||||
if err != nil || matchedShare == nil {
|
||||
return
|
||||
}
|
||||
if isPeerShareFlowExceeded(matchedShare) {
|
||||
h.enforcePeerShareFlowLimit(matchedShare.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) enforcePeerShareFlowLimit(shareID int64) {
|
||||
if h == nil || h.repo == nil || shareID <= 0 {
|
||||
return
|
||||
@@ -170,6 +331,70 @@ func (h *Handler) enforceFlowPolicies(userID int64, userTunnelID int64) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ensureUserTunnelForwardAllowed(userID int64, tunnelID int64, now int64) error {
|
||||
if h == nil || h.repo == nil {
|
||||
return errors.New("invalid flow policy context")
|
||||
}
|
||||
if userID <= 0 || tunnelID <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
user, err := h.repo.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user == nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
|
||||
if user.Status != 1 {
|
||||
return errors.New("账号已禁用")
|
||||
}
|
||||
if user.ExpTime > 0 && user.ExpTime <= now {
|
||||
return errors.New("账号已过期")
|
||||
}
|
||||
|
||||
flowLimit := user.Flow * bytesPerGB
|
||||
current := user.InFlow + user.OutFlow
|
||||
if flowLimit < current {
|
||||
return errors.New("流量已超额,禁止开启转发")
|
||||
}
|
||||
if err := h.ensureUserForwardAllowedByQuota(userID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(userID, tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if userTunnelID <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
policy, err := h.getUserTunnelPolicy(userTunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if policy == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if policy.Status != 1 {
|
||||
return errors.New("该隧道已禁用")
|
||||
}
|
||||
if policy.ExpTime > 0 && policy.ExpTime <= now {
|
||||
return errors.New("该隧道已过期")
|
||||
}
|
||||
|
||||
utFlowLimit := policy.Flow * bytesPerGB
|
||||
utCurrent := policy.InFlow + policy.OutFlow
|
||||
if utCurrent >= utFlowLimit {
|
||||
return errors.New("该隧道流量已超额,禁止开启转发")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) shouldPauseUser(userID int64, now int64) bool {
|
||||
user, err := h.repo.GetUserByID(userID)
|
||||
if err != nil || user == nil {
|
||||
@@ -207,22 +432,18 @@ func (h *Handler) getUserTunnelPolicy(userTunnelID int64) (*userTunnelPolicy, er
|
||||
if userTunnelID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
row := h.repo.DB().QueryRow(`
|
||||
SELECT id, user_id, tunnel_id, flow, in_flow, out_flow, exp_time, status
|
||||
FROM user_tunnel
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
`, userTunnelID)
|
||||
|
||||
var policy userTunnelPolicy
|
||||
if err := row.Scan(&policy.ID, &policy.UserID, &policy.TunnelID, &policy.Flow, &policy.InFlow, &policy.OutFlow, &policy.ExpTime, &policy.Status); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
ut, err := h.repo.GetUserTunnelByID(userTunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &policy, nil
|
||||
if ut == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &userTunnelPolicy{
|
||||
ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID,
|
||||
Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow,
|
||||
ExpTime: ut.ExpTime, Status: ut.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) pauseUserForwards(userID int64, now int64) {
|
||||
@@ -245,60 +466,20 @@ func (h *Handler) pauseForwardRecords(forwards []forwardRecord, now int64) {
|
||||
for i := range forwards {
|
||||
forward := forwards[i]
|
||||
_ = h.controlForwardServices(&forward, "PauseService", false)
|
||||
_, _ = h.repo.DB().Exec(`UPDATE forward SET status = 0, updated_time = ? WHERE id = ?`, now, forward.ID)
|
||||
_ = h.repo.UpdateForwardStatus(forward.ID, 0, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) listActiveForwardsByUser(userID int64) ([]forwardRecord, error) {
|
||||
rows, err := h.repo.DB().Query(`
|
||||
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, strategy, status
|
||||
FROM forward
|
||||
WHERE user_id = ? AND status = 1
|
||||
ORDER BY id ASC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanForwardRecords(rows)
|
||||
return h.repo.ListActiveForwardsByUser(userID)
|
||||
}
|
||||
|
||||
func (h *Handler) listActiveForwardsByUserTunnel(userID int64, tunnelID int64) ([]forwardRecord, error) {
|
||||
rows, err := h.repo.DB().Query(`
|
||||
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, strategy, status
|
||||
FROM forward
|
||||
WHERE user_id = ? AND tunnel_id = ? AND status = 1
|
||||
ORDER BY id ASC
|
||||
`, userID, tunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanForwardRecords(rows)
|
||||
}
|
||||
|
||||
func scanForwardRecords(rows *sql.Rows) ([]forwardRecord, error) {
|
||||
out := make([]forwardRecord, 0)
|
||||
for rows.Next() {
|
||||
var record forwardRecord
|
||||
if err := rows.Scan(&record.ID, &record.UserID, &record.UserName, &record.Name, &record.TunnelID, &record.RemoteAddr, &record.Strategy, &record.Status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(record.Strategy) == "" {
|
||||
record.Strategy = "fifo"
|
||||
}
|
||||
out = append(out, record)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
return h.repo.ListActiveForwardsByUserTunnel(userID, tunnelID)
|
||||
}
|
||||
|
||||
func (h *Handler) cleanNodeConfigs(nodeID int64, rawConfig string) {
|
||||
if h == nil || h.repo == nil || h.repo.DB() == nil || nodeID <= 0 {
|
||||
if h == nil || h.repo == nil || nodeID <= 0 {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(rawConfig) == "" {
|
||||
@@ -316,15 +497,46 @@ func (h *Handler) cleanNodeConfigs(nodeID int64, rawConfig string) {
|
||||
}
|
||||
|
||||
func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem) {
|
||||
runtimeServiceNames, err := h.repo.ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
minUpdatedTime := time.Now().Add(-10 * time.Minute).UnixMilli()
|
||||
hasUnboundForwardPeerRuntime, err := h.repo.HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID, minUpdatedTime)
|
||||
if err != nil {
|
||||
hasUnboundForwardPeerRuntime = false
|
||||
}
|
||||
runtimeServiceSet := make(map[string]struct{}, len(runtimeServiceNames))
|
||||
for _, serviceName := range runtimeServiceNames {
|
||||
serviceName = strings.TrimSpace(serviceName)
|
||||
if serviceName == "" {
|
||||
continue
|
||||
}
|
||||
runtimeServiceSet[serviceName] = struct{}{}
|
||||
}
|
||||
|
||||
for _, item := range services {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if name == "" || name == "web_api" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(name, "fed_svc_") {
|
||||
continue
|
||||
}
|
||||
normalizedName := normalizeForwardRuntimeServiceName(name)
|
||||
if _, ok := runtimeServiceSet[normalizedName]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := runtimeServiceSet[name]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.Split(name, "_")
|
||||
if len(parts) >= 3 {
|
||||
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
|
||||
continue
|
||||
}
|
||||
if err == nil && forwardID > 0 && !h.forwardExists(forwardID) {
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name, parts[0] + "_" + parts[1] + "_" + parts[2], parts[0] + "_" + parts[1] + "_" + parts[2] + "_tcp", parts[0] + "_" + parts[1] + "_" + parts[2] + "_udp"}}, false, true)
|
||||
continue
|
||||
@@ -344,6 +556,9 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
|
||||
continue
|
||||
}
|
||||
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
|
||||
continue
|
||||
}
|
||||
if err != nil || forwardID <= 0 || h.forwardExists(forwardID) {
|
||||
continue
|
||||
}
|
||||
@@ -383,15 +598,13 @@ func (h *Handler) cleanOrphanedLimiters(nodeID int64, limiters []namedConfigItem
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelExists(tunnelID int64) bool {
|
||||
var count int
|
||||
err := h.repo.DB().QueryRow(`SELECT COUNT(1) FROM tunnel WHERE id = ?`, tunnelID).Scan(&count)
|
||||
return err == nil && count > 0
|
||||
ok, _ := h.repo.TunnelExists(tunnelID)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (h *Handler) forwardExists(forwardID int64) bool {
|
||||
var count int
|
||||
err := h.repo.DB().QueryRow(`SELECT COUNT(1) FROM forward WHERE id = ?`, forwardID).Scan(&count)
|
||||
return err == nil && count > 0
|
||||
ok, _ := h.repo.ForwardExists(forwardID)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (h *Handler) speedLimiterExists(name string) bool {
|
||||
@@ -402,8 +615,6 @@ func (h *Handler) speedLimiterExists(name string) bool {
|
||||
if err != nil || id <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
var count int
|
||||
err = h.repo.DB().QueryRow(`SELECT COUNT(1) FROM speed_limit WHERE id = ?`, id).Scan(&count)
|
||||
return err == nil && count > 0
|
||||
ok, _ := h.repo.SpeedLimitExists(id)
|
||||
return ok
|
||||
}
|
||||
|
||||
@@ -2,21 +2,22 @@ package handler
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/sqlite"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer repo.Close()
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "flow-share",
|
||||
NodeID: 1,
|
||||
Token: "flow-share-token",
|
||||
@@ -30,22 +31,22 @@ func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := repo.GetPeerShareByToken("flow-share-token")
|
||||
share, err := r.GetPeerShareByToken("flow-share-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 17, share.ID, share.NodeID, "res-17", "rk-17", "17", "exit", "", "fed_svc_17", "tls", "round", 32001, "", 1, 1, now, now); err != nil {
|
||||
`, 17, share.ID, share.NodeID, "res-17", "rk-17", "17", "exit", "", "fed_svc_17", "tls", "round", 32001, "", 1, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert peer_share_runtime: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: repo}
|
||||
h.processFlowItem(flowItem{N: "fed_svc_17", U: 1200, D: 900})
|
||||
h := &Handler{repo: r}
|
||||
h.processFlowItem(1, flowItem{N: "fed_svc_17", U: 1200, D: 900})
|
||||
|
||||
updatedShare, err := repo.GetPeerShare(share.ID)
|
||||
updatedShare, err := r.GetPeerShare(share.ID)
|
||||
if err != nil || updatedShare == nil {
|
||||
t.Fatalf("reload share: %v", err)
|
||||
}
|
||||
@@ -53,7 +54,7 @@ func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
|
||||
t.Fatalf("expected current_flow=3100, got %d", updatedShare.CurrentFlow)
|
||||
}
|
||||
|
||||
runtime, err := repo.GetPeerShareRuntimeByID(17)
|
||||
runtime, err := r.GetPeerShareRuntimeByID(17)
|
||||
if err != nil || runtime == nil {
|
||||
t.Fatalf("reload runtime: %v", err)
|
||||
}
|
||||
@@ -61,3 +62,345 @@ func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
|
||||
t.Fatalf("expected runtime status=0 after limit enforcement, got %d", runtime.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFlowItemTracksPeerShareFlowForFederationPortForward(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "forward-share",
|
||||
NodeID: 1,
|
||||
Token: "forward-share-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 30000,
|
||||
PortRangeEnd: 30010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("forward-share-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'u2', 'x', 1, ?, 99999, 0, 0, 1, 1, ?, ?, 1)
|
||||
`, now+24*60*60*1000, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
tunnelName := "Share-" + strconv.FormatInt(share.ID, 10) + "-Port-30001"
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(1, ?, 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||
`, tunnelName, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(10, 2, 1, NULL, 1, 99999, 0, 0, 1, ?, 1)
|
||||
`, now+24*60*60*1000).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(20, 2, 'u2', 'f20', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.processFlowItem(1, flowItem{N: "20_2_10", U: 120, D: 80})
|
||||
|
||||
updatedShare, err := r.GetPeerShare(share.ID)
|
||||
if err != nil || updatedShare == nil {
|
||||
t.Fatalf("reload share: %v", err)
|
||||
}
|
||||
if updatedShare.CurrentFlow != 200 {
|
||||
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFlowItemTracksPeerShareFlowByForwardServiceName(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-service.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "forward-service-share",
|
||||
NodeID: 1,
|
||||
Token: "forward-service-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 31000,
|
||||
PortRangeEnd: 31010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("forward-service-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.ID, share.NodeID, "svc-r1", "svc-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31001, "", 1, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert peer_share_runtime: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.processFlowItem(1, flowItem{N: "20_2_10_tcp", U: 120, D: 80})
|
||||
|
||||
updatedShare, err := r.GetPeerShare(share.ID)
|
||||
if err != nil || updatedShare == nil {
|
||||
t.Fatalf("reload share: %v", err)
|
||||
}
|
||||
if updatedShare.CurrentFlow != 200 {
|
||||
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFlowItemFallsBackToServiceNameWhenForwardIDCollidesAcrossPanels(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-collision.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "collision-share",
|
||||
NodeID: 1,
|
||||
Token: "collision-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 31400,
|
||||
PortRangeEnd: 31410,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("collision-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.ID, share.NodeID, "collision-r1", "collision-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31401, "", 1, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert peer_share_runtime: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(2, 'local-tunnel-with-colliding-forward-id', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert local tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(20, 1, 'local-user', 'local-f20', 2, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert local forward: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.processFlowItem(1, flowItem{N: "20_2_10_tcp", U: 120, D: 80})
|
||||
|
||||
updatedShare, err := r.GetPeerShare(share.ID)
|
||||
if err != nil || updatedShare == nil {
|
||||
t.Fatalf("reload share: %v", err)
|
||||
}
|
||||
if updatedShare.CurrentFlow != 200 {
|
||||
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFlowItemSkipsPeerShareFlowWhenServiceNameIsAmbiguous(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-ambiguous.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "ambiguous-share-a",
|
||||
NodeID: 1,
|
||||
Token: "ambiguous-token-a",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 31100,
|
||||
PortRangeEnd: 31110,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share A: %v", err)
|
||||
}
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "ambiguous-share-b",
|
||||
NodeID: 1,
|
||||
Token: "ambiguous-token-b",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 31200,
|
||||
PortRangeEnd: 31210,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share B: %v", err)
|
||||
}
|
||||
shareA, _ := r.GetPeerShareByToken("ambiguous-token-a")
|
||||
shareB, _ := r.GetPeerShareByToken("ambiguous-token-b")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
shareA.ID, 1, "amb-r1", "amb-rk1", "", "forward", "", "99_2_10", "tcp", "fifo", 31101, "", 1, 1, now, now,
|
||||
shareB.ID, 1, "amb-r2", "amb-rk2", "", "forward", "", "99_2_10", "tcp", "fifo", 31201, "", 1, 1, now, now,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert ambiguous runtimes: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.processFlowItem(1, flowItem{N: "99_2_10_tcp", U: 120, D: 80})
|
||||
|
||||
updatedA, _ := r.GetPeerShare(shareA.ID)
|
||||
updatedB, _ := r.GetPeerShare(shareB.ID)
|
||||
if updatedA.CurrentFlow != 0 || updatedB.CurrentFlow != 0 {
|
||||
t.Fatalf("expected ambiguous service flow to be skipped, got shareA=%d shareB=%d", updatedA.CurrentFlow, updatedB.CurrentFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanOrphanedServicesSkipsActiveSharedForwardRuntimeServices(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-runtime.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "cleanup-runtime-share",
|
||||
NodeID: 1,
|
||||
Token: "cleanup-runtime-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 31300,
|
||||
PortRangeEnd: 31310,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("cleanup-runtime-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.ID, share.NodeID, "cleanup-r1", "cleanup-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31301, "", 1, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert peer_share_runtime: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
t.Fatalf("cleanOrphanedServices should skip active shared runtime service; got panic: %v", rec)
|
||||
}
|
||||
}()
|
||||
|
||||
h.cleanOrphanedServices(share.NodeID, []namedConfigItem{{Name: "20_2_10_tcp"}})
|
||||
}
|
||||
|
||||
func TestCleanOrphanedServicesSkipsFederationServicePrefix(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-fed-svc.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
h := &Handler{repo: r}
|
||||
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
t.Fatalf("cleanOrphanedServices should skip fed_svc_ service names; got panic: %v", rec)
|
||||
}
|
||||
}()
|
||||
|
||||
h.cleanOrphanedServices(1, []namedConfigItem{{Name: "fed_svc_999_tcp"}})
|
||||
}
|
||||
|
||||
func TestCleanOrphanedServicesSkipsForwardPatternWhenNodeHasActivePeerShareForwardRuntime(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-forward-runtime-empty-service.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "cleanup-forward-runtime-empty-service",
|
||||
NodeID: 1,
|
||||
Token: "cleanup-forward-runtime-empty-service-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 31420,
|
||||
PortRangeEnd: 31430,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("cleanup-forward-runtime-empty-service-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.ID, share.NodeID, "cleanup-forward-empty-r1", "cleanup-forward-empty-rk1", "", "forward", "", "", "tcp", "fifo", 31421, "", 0, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert peer_share_runtime with empty service name: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
t.Fatalf("cleanOrphanedServices should skip forward-pattern services when active peer-share forward runtime exists; got panic: %v", rec)
|
||||
}
|
||||
}()
|
||||
|
||||
h.cleanOrphanedServices(share.NodeID, []namedConfigItem{{Name: "20_2_10_tcp"}})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -18,12 +19,12 @@ import (
|
||||
"go-backend/internal/http/middleware"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/sqlite"
|
||||
"go-backend/internal/store/repo"
|
||||
"go-backend/internal/ws"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
repo *sqlite.Repository
|
||||
repo *repo.Repository
|
||||
jwtSecret string
|
||||
wsServer *ws.Server
|
||||
|
||||
@@ -34,6 +35,9 @@ type Handler struct {
|
||||
jobsCancel context.CancelFunc
|
||||
jobsStarted bool
|
||||
jobsWG sync.WaitGroup
|
||||
|
||||
upgradeMu sync.Mutex
|
||||
pendingUpgradeRedeploy map[int64]struct{}
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
@@ -69,13 +73,21 @@ type flowItem struct {
|
||||
D int64 `json:"d"`
|
||||
}
|
||||
|
||||
func New(repo *sqlite.Repository, jwtSecret string) *Handler {
|
||||
return &Handler{
|
||||
repo: repo,
|
||||
jwtSecret: jwtSecret,
|
||||
wsServer: ws.NewServer(repo, jwtSecret),
|
||||
captchaTokens: make(map[string]int64),
|
||||
const (
|
||||
pngDataURLPrefix = "data:image/png;base64,"
|
||||
maxBrandAssetDataURLBytes = 1024 * 1024
|
||||
)
|
||||
|
||||
func New(repo *repo.Repository, jwtSecret string) *Handler {
|
||||
h := &Handler{
|
||||
repo: repo,
|
||||
jwtSecret: jwtSecret,
|
||||
wsServer: ws.NewServer(repo, jwtSecret),
|
||||
captchaTokens: make(map[string]int64),
|
||||
pendingUpgradeRedeploy: make(map[int64]struct{}),
|
||||
}
|
||||
h.wsServer.SetNodeOnlineHook(h.onNodeOnline)
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Handler) WebSocketHandler() http.Handler {
|
||||
@@ -89,10 +101,18 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/user/update", h.userUpdate)
|
||||
mux.HandleFunc("/api/v1/user/delete", h.userDelete)
|
||||
mux.HandleFunc("/api/v1/user/reset", h.userResetFlow)
|
||||
mux.HandleFunc("/api/v1/user/quota/reset", h.userQuotaReset)
|
||||
mux.HandleFunc("/api/v1/user/groups", h.userGroups)
|
||||
mux.HandleFunc("/api/v1/config/get", h.getConfigByName)
|
||||
mux.HandleFunc("/api/v1/config/list", h.getConfigs)
|
||||
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
|
||||
mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig)
|
||||
mux.HandleFunc("/api/v1/backup/export", h.backupExport)
|
||||
mux.HandleFunc("/api/v1/backup/import", h.backupImport)
|
||||
mux.HandleFunc("/api/v1/backup/restore", h.backupImport)
|
||||
mux.HandleFunc("/api/v1/api/v1/backup/export", h.backupExport)
|
||||
mux.HandleFunc("/api/v1/api/v1/backup/import", h.backupImport)
|
||||
mux.HandleFunc("/api/v1/api/v1/backup/restore", h.backupImport)
|
||||
mux.HandleFunc("/api/v1/captcha/check", h.checkCaptcha)
|
||||
mux.HandleFunc("/api/v1/captcha/verify", h.captchaVerify)
|
||||
mux.HandleFunc("/api/v1/user/package", h.userPackage)
|
||||
@@ -103,14 +123,20 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/node/delete", h.nodeDelete)
|
||||
mux.HandleFunc("/api/v1/node/install", h.nodeInstall)
|
||||
mux.HandleFunc("/api/v1/node/update-order", h.nodeUpdateOrder)
|
||||
mux.HandleFunc("/api/v1/node/dismiss-expiry-reminder", h.nodeDismissExpiryReminder)
|
||||
mux.HandleFunc("/api/v1/node/batch-delete", h.nodeBatchDelete)
|
||||
mux.HandleFunc("/api/v1/node/check-status", h.nodeCheckStatus)
|
||||
mux.HandleFunc("/api/v1/node/upgrade", h.nodeUpgrade)
|
||||
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/tunnel/list", h.tunnelList)
|
||||
mux.HandleFunc("/api/v1/tunnel/create", h.tunnelCreate)
|
||||
mux.HandleFunc("/api/v1/tunnel/get", h.tunnelGet)
|
||||
mux.HandleFunc("/api/v1/tunnel/update", h.tunnelUpdate)
|
||||
mux.HandleFunc("/api/v1/tunnel/delete", h.tunnelDelete)
|
||||
mux.HandleFunc("/api/v1/tunnel/diagnose", h.tunnelDiagnose)
|
||||
mux.HandleFunc("/api/v1/tunnel/diagnose/stream", h.tunnelDiagnoseStream)
|
||||
mux.HandleFunc("/api/v1/tunnel/update-order", h.tunnelUpdateOrder)
|
||||
mux.HandleFunc("/api/v1/tunnel/batch-delete", h.tunnelBatchDelete)
|
||||
mux.HandleFunc("/api/v1/tunnel/batch-redeploy", h.tunnelBatchRedeploy)
|
||||
@@ -126,6 +152,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/forward/pause", h.forwardPause)
|
||||
mux.HandleFunc("/api/v1/forward/resume", h.forwardResume)
|
||||
mux.HandleFunc("/api/v1/forward/diagnose", h.forwardDiagnose)
|
||||
mux.HandleFunc("/api/v1/forward/diagnose/stream", h.forwardDiagnoseStream)
|
||||
mux.HandleFunc("/api/v1/forward/update-order", h.forwardUpdateOrder)
|
||||
mux.HandleFunc("/api/v1/forward/batch-delete", h.forwardBatchDelete)
|
||||
mux.HandleFunc("/api/v1/forward/batch-pause", h.forwardBatchPause)
|
||||
@@ -136,7 +163,6 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/speed-limit/create", h.speedLimitCreate)
|
||||
mux.HandleFunc("/api/v1/speed-limit/update", h.speedLimitUpdate)
|
||||
mux.HandleFunc("/api/v1/speed-limit/delete", h.speedLimitDelete)
|
||||
mux.HandleFunc("/api/v1/speed-limit/tunnels", h.tunnelList)
|
||||
mux.HandleFunc("/api/v1/tunnel/user/tunnel", h.userTunnelVisibleList)
|
||||
mux.HandleFunc("/api/v1/tunnel/user/list", h.userTunnelList)
|
||||
mux.HandleFunc("/api/v1/group/tunnel/list", h.tunnelGroupList)
|
||||
@@ -165,7 +191,10 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/federation/runtime/apply-role", h.authPeer(h.federationRuntimeApplyRole))
|
||||
mux.HandleFunc("/api/v1/federation/runtime/release-role", h.authPeer(h.federationRuntimeReleaseRole))
|
||||
mux.HandleFunc("/api/v1/federation/runtime/diagnose", h.authPeer(h.federationRuntimeDiagnose))
|
||||
mux.HandleFunc("/api/v1/federation/runtime/command", h.authPeer(h.federationRuntimeCommand))
|
||||
mux.HandleFunc("/api/v1/federation/node/import", h.nodeImport)
|
||||
mux.HandleFunc("/api/v1/announcement/get", h.getAnnouncement)
|
||||
mux.HandleFunc("/api/v1/announcement/update", h.updateAnnouncement)
|
||||
|
||||
mux.HandleFunc("/flow/test", h.flowTest)
|
||||
mux.HandleFunc("/flow/config", h.flowConfig)
|
||||
@@ -199,7 +228,7 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if captchaEnabled {
|
||||
if captchaEnabled && !h.apiClientCaptchaBypassEnabled(r) {
|
||||
captchaID := strings.TrimSpace(req.CaptchaID)
|
||||
if captchaID == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
|
||||
@@ -302,11 +331,35 @@ func (h *Handler) userList(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Current int `json:"current"`
|
||||
Size int `json:"size"`
|
||||
Keyword string `json:"keyword"`
|
||||
}
|
||||
if err := decodeJSON(r.Body, &req); err != nil && err != io.EOF {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
users, err := h.repo.ListUsers()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
keyword := strings.ToLower(strings.TrimSpace(req.Keyword))
|
||||
if keyword != "" {
|
||||
filtered := make([]map[string]interface{}, 0, len(users))
|
||||
for _, item := range users {
|
||||
username := strings.ToLower(strings.TrimSpace(fmt.Sprint(item["user"])))
|
||||
displayName := strings.ToLower(strings.TrimSpace(fmt.Sprint(item["name"])))
|
||||
if strings.Contains(username, keyword) || strings.Contains(displayName, keyword) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
users = filtered
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(users))
|
||||
}
|
||||
|
||||
@@ -389,7 +442,7 @@ func (h *Handler) openAPISubStore(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if h == nil || h.repo == nil || h.repo.DB() == nil {
|
||||
if h == nil || h.repo == nil {
|
||||
response.WriteJSON(w, response.Err(-2, "database unavailable"))
|
||||
return
|
||||
}
|
||||
@@ -432,27 +485,21 @@ func (h *Handler) openAPISubStore(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var userID int64
|
||||
var inFlow int64
|
||||
var outFlow int64
|
||||
var flow int64
|
||||
var expTime int64
|
||||
err = h.repo.DB().QueryRow(`SELECT user_id, in_flow, out_flow, flow, exp_time FROM user_tunnel WHERE id = ? LIMIT 1`, tunnelID).
|
||||
Scan(&userID, &inFlow, &outFlow, &flow, &expTime)
|
||||
ut, err := h.repo.GetUserTunnelByID(tunnelID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if userID != user.ID {
|
||||
if ut == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
|
||||
return
|
||||
}
|
||||
if ut.UserID != user.ID {
|
||||
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
headerValue = buildSubscriptionHeader(outFlow, inFlow, flow*giga, expTime/1000)
|
||||
headerValue = buildSubscriptionHeader(ut.OutFlow, ut.InFlow, ut.Flow*giga, ut.ExpTime/1000)
|
||||
}
|
||||
|
||||
w.Header().Set("subscription-userinfo", headerValue)
|
||||
@@ -526,7 +573,7 @@ func (h *Handler) userTunnelList(w http.ResponseWriter, r *http.Request) {
|
||||
"userId": t.UserID,
|
||||
"tunnelId": t.TunnelID,
|
||||
"tunnelName": t.TunnelName,
|
||||
"status": 1,
|
||||
"status": t.Status,
|
||||
"flow": t.Flow,
|
||||
"num": t.Num,
|
||||
"expTime": t.ExpTime,
|
||||
@@ -666,7 +713,8 @@ func (h *Handler) flowConfig(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
|
||||
secret := r.URL.Query().Get("secret")
|
||||
if ok, _ := h.repo.NodeExistsBySecret(secret); !ok {
|
||||
node, _ := h.repo.GetNodeBySecret(secret)
|
||||
if node == nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
return
|
||||
@@ -677,7 +725,7 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
|
||||
var items []flowItem
|
||||
if json.Unmarshal([]byte(raw), &items) == nil {
|
||||
for _, item := range items {
|
||||
h.processFlowItem(item)
|
||||
h.processFlowItem(node.ID, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -708,7 +756,14 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if err := h.repo.UpsertConfig(key, v, now); err != nil {
|
||||
|
||||
value, err := normalizeAndValidateConfigValue(key, v)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UpsertConfig(key, value, now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -728,16 +783,24 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Value) == "" {
|
||||
|
||||
value, err := normalizeAndValidateConfigValue(name, req.Value)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if value == "" && name != "app_logo" && name != "app_favicon" {
|
||||
response.WriteJSON(w, response.ErrDefault("配置值不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UpsertConfig(strings.TrimSpace(req.Name), req.Value, time.Now().UnixMilli()); err != nil {
|
||||
if err := h.repo.UpsertConfig(name, value, time.Now().UnixMilli()); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -745,6 +808,37 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func normalizeAndValidateConfigValue(key, value string) (string, error) {
|
||||
switch strings.TrimSpace(key) {
|
||||
case "app_logo", "app_favicon":
|
||||
normalized := strings.TrimSpace(value)
|
||||
if normalized == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(normalized, pngDataURLPrefix) {
|
||||
return "", fmt.Errorf("品牌图片必须通过上传生成 PNG 数据")
|
||||
}
|
||||
|
||||
if len(normalized) > maxBrandAssetDataURLBytes {
|
||||
return "", fmt.Errorf("品牌图片过大,请上传更小图片")
|
||||
}
|
||||
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(normalized, pngDataURLPrefix))
|
||||
if payload == "" {
|
||||
return "", fmt.Errorf("品牌图片数据不能为空")
|
||||
}
|
||||
|
||||
if _, err := base64.StdEncoding.DecodeString(payload); err != nil {
|
||||
return "", fmt.Errorf("品牌图片数据格式无效")
|
||||
}
|
||||
|
||||
return pngDataURLPrefix + payload, nil
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
@@ -950,10 +1044,41 @@ func (h *Handler) captchaEnabled() (bool, error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if cfg == nil {
|
||||
if cfg == nil || !strings.EqualFold(strings.TrimSpace(cfg.Value), "true") {
|
||||
return false, nil
|
||||
}
|
||||
return strings.EqualFold(cfg.Value, "true"), nil
|
||||
|
||||
siteCfg, err := h.repo.GetConfigByName("cloudflare_site_key")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if siteCfg == nil || strings.TrimSpace(siteCfg.Value) == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
secretCfg, err := h.repo.GetConfigByName("cloudflare_secret_key")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if secretCfg == nil || strings.TrimSpace(secretCfg.Value) == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (h *Handler) apiClientCaptchaBypassEnabled(r *http.Request) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
client := strings.ToLower(strings.TrimSpace(r.Header.Get("X-FLVX-API-Client")))
|
||||
switch client {
|
||||
case "whmcs", "whmcs-module":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) markCaptchaToken(token string) {
|
||||
@@ -1112,3 +1237,134 @@ func (h *Handler) verifyCloudflareTurnstile(token, secretKey string) bool {
|
||||
}
|
||||
return body.Success
|
||||
}
|
||||
|
||||
type backupExportRequest struct {
|
||||
Types []string `json:"types"`
|
||||
}
|
||||
|
||||
func (h *Handler) backupExport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req backupExportRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.Err(500, "请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
var backup interface{}
|
||||
var err error
|
||||
|
||||
if len(req.Types) == 0 {
|
||||
backup, err = h.repo.ExportAll()
|
||||
} else {
|
||||
backup, err = h.repo.ExportPartial(req.Types)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=backup.json")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(backup); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type backupImportRequest struct {
|
||||
Types []string `json:"types"`
|
||||
repo.BackupData
|
||||
}
|
||||
|
||||
func (h *Handler) backupImport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req backupImportRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.Err(500, "请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Types) == 0 {
|
||||
response.WriteJSON(w, response.Err(500, "请选择要导入的数据类型"))
|
||||
return
|
||||
}
|
||||
|
||||
autoBackup, err := h.repo.ExportAll()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("导入前自动备份失败: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
if req.BackupData.Version == "" {
|
||||
response.WriteJSON(w, response.Err(500, "备份数据格式错误"))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.repo.Import(&req.BackupData, req.Types)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("导入失败: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
result.AutoBackup = autoBackup
|
||||
response.WriteJSON(w, response.OK(result))
|
||||
}
|
||||
|
||||
func (h *Handler) getAnnouncement(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
ann, err := h.repo.GetAnnouncement()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-1, fmt.Sprintf("获取公告失败: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
if ann == nil {
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"content": "",
|
||||
"enabled": 0,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"content": ann.Content,
|
||||
"enabled": ann.Enabled,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handler) updateAnnouncement(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
Enabled int `json:"enabled"`
|
||||
}
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.Err(500, "请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := h.repo.UpsertAnnouncement(req.Content, req.Enabled, now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-1, fmt.Sprintf("更新公告失败: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
@@ -2,12 +2,11 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (h *Handler) StartBackgroundJobs() {
|
||||
if h == nil || h.repo == nil || h.repo.DB() == nil {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -19,11 +18,12 @@ func (h *Handler) StartBackgroundJobs() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
h.jobsCancel = cancel
|
||||
h.jobsStarted = true
|
||||
h.jobsWG.Add(2)
|
||||
h.jobsWG.Add(3)
|
||||
h.jobsMu.Unlock()
|
||||
|
||||
go h.runHourlyStatsLoop(ctx)
|
||||
go h.runDailyMaintenanceLoop(ctx)
|
||||
go h.runNodeRenewalCycleLoop(ctx)
|
||||
}
|
||||
|
||||
func (h *Handler) StopBackgroundJobs() {
|
||||
@@ -97,47 +97,28 @@ func durationUntilNextDailyMaintenance(now time.Time) time.Duration {
|
||||
}
|
||||
|
||||
func (h *Handler) runStatisticsFlowJob(now time.Time) {
|
||||
if h == nil || h.repo == nil || h.repo.DB() == nil {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
db := h.repo.DB()
|
||||
nowMs := now.UnixMilli()
|
||||
cutoffMs := nowMs - int64((48*time.Hour)/time.Millisecond)
|
||||
_, _ = db.Exec(`DELETE FROM statistics_flow WHERE created_time < ?`, cutoffMs)
|
||||
_ = h.repo.PurgeOldStatisticsFlows(cutoffMs)
|
||||
|
||||
hourMark := now.Truncate(time.Hour)
|
||||
hourText := hourMark.Format("15:04")
|
||||
createdTime := hourMark.UnixMilli()
|
||||
|
||||
rows, err := db.Query(`SELECT id, in_flow, out_flow FROM user ORDER BY id ASC`)
|
||||
users, err := h.repo.ListAllUserFlowSnapshots()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
type userFlowSnapshot struct {
|
||||
userID int64
|
||||
inFlow int64
|
||||
outFlow int64
|
||||
}
|
||||
users := make([]userFlowSnapshot, 0)
|
||||
|
||||
for rows.Next() {
|
||||
var userID int64
|
||||
var inFlow int64
|
||||
var outFlow int64
|
||||
if err := rows.Scan(&userID, &inFlow, &outFlow); err != nil {
|
||||
continue
|
||||
}
|
||||
users = append(users, userFlowSnapshot{userID: userID, inFlow: inFlow, outFlow: outFlow})
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for _, user := range users {
|
||||
currentTotal := user.inFlow + user.outFlow
|
||||
currentTotal := user.InFlow + user.OutFlow
|
||||
increment := currentTotal
|
||||
|
||||
var lastTotal sql.NullInt64
|
||||
err := db.QueryRow(`SELECT total_flow FROM statistics_flow WHERE user_id = ? ORDER BY id DESC LIMIT 1`, user.userID).Scan(&lastTotal)
|
||||
lastTotal, err := h.repo.GetLastStatisticsFlowTotal(user.UserID)
|
||||
if err == nil && lastTotal.Valid {
|
||||
increment = currentTotal - lastTotal.Int64
|
||||
if increment < 0 {
|
||||
@@ -145,126 +126,91 @@ func (h *Handler) runStatisticsFlowJob(now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = db.Exec(`
|
||||
INSERT INTO statistics_flow(user_id, flow, total_flow, time, created_time)
|
||||
VALUES(?, ?, ?, ?, ?)
|
||||
`, user.userID, increment, currentTotal, hourText, createdTime)
|
||||
_ = h.repo.CreateStatisticsFlow(user.UserID, increment, currentTotal, hourText, createdTime)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) runResetAndExpiryJob(now time.Time) {
|
||||
if h == nil || h.repo == nil || h.repo.DB() == nil {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.resetMonthlyFlow(now)
|
||||
h.resetUserQuotaWindows(now)
|
||||
h.disableExpiredUsers(now.UnixMilli())
|
||||
h.disableExpiredUserTunnels(now.UnixMilli())
|
||||
}
|
||||
|
||||
func (h *Handler) resetMonthlyFlow(now time.Time) {
|
||||
db := h.repo.DB()
|
||||
currentDay := now.Day()
|
||||
lastDay := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()).Day()
|
||||
|
||||
if currentDay == lastDay {
|
||||
_, _ = db.Exec(`
|
||||
UPDATE user
|
||||
SET in_flow = 0, out_flow = 0
|
||||
WHERE flow_reset_time != 0
|
||||
AND (flow_reset_time = ? OR flow_reset_time > ?)
|
||||
`, currentDay, lastDay)
|
||||
_, _ = db.Exec(`
|
||||
UPDATE user_tunnel
|
||||
SET in_flow = 0, out_flow = 0
|
||||
WHERE flow_reset_time != 0
|
||||
AND (flow_reset_time = ? OR flow_reset_time > ?)
|
||||
`, currentDay, lastDay)
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = db.Exec(`
|
||||
UPDATE user
|
||||
SET in_flow = 0, out_flow = 0
|
||||
WHERE flow_reset_time != 0
|
||||
AND flow_reset_time = ?
|
||||
`, currentDay)
|
||||
_, _ = db.Exec(`
|
||||
UPDATE user_tunnel
|
||||
SET in_flow = 0, out_flow = 0
|
||||
WHERE flow_reset_time != 0
|
||||
AND flow_reset_time = ?
|
||||
`, currentDay)
|
||||
_ = h.repo.ResetUserMonthlyFlow(currentDay, lastDay)
|
||||
_ = h.repo.ResetUserTunnelMonthlyFlow(currentDay, lastDay)
|
||||
}
|
||||
|
||||
func (h *Handler) disableExpiredUsers(nowMs int64) {
|
||||
db := h.repo.DB()
|
||||
rows, err := db.Query(`
|
||||
SELECT id
|
||||
FROM user
|
||||
WHERE role_id != 0
|
||||
AND status = 1
|
||||
AND exp_time IS NOT NULL
|
||||
AND exp_time < ?
|
||||
`, nowMs)
|
||||
userIDs, err := h.repo.ListExpiredActiveUserIDs(nowMs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
userIDs := make([]int64, 0)
|
||||
|
||||
for rows.Next() {
|
||||
var userID int64
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
continue
|
||||
}
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for _, userID := range userIDs {
|
||||
forwards, err := h.listActiveForwardsByUser(userID)
|
||||
if err == nil {
|
||||
h.pauseForwardRecords(forwards, nowMs)
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE user SET status = 0 WHERE id = ?`, userID)
|
||||
_ = h.repo.DisableUser(userID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) disableExpiredUserTunnels(nowMs int64) {
|
||||
db := h.repo.DB()
|
||||
rows, err := db.Query(`
|
||||
SELECT id, user_id, tunnel_id
|
||||
FROM user_tunnel
|
||||
WHERE status = 1
|
||||
AND exp_time IS NOT NULL
|
||||
AND exp_time < ?
|
||||
`, nowMs)
|
||||
items, err := h.repo.ListExpiredActiveUserTunnels(nowMs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
type expiredUserTunnel struct {
|
||||
userTunnelID int64
|
||||
userID int64
|
||||
tunnelID int64
|
||||
}
|
||||
items := make([]expiredUserTunnel, 0)
|
||||
|
||||
for rows.Next() {
|
||||
var userTunnelID int64
|
||||
var userID int64
|
||||
var tunnelID int64
|
||||
if err := rows.Scan(&userTunnelID, &userID, &tunnelID); err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, expiredUserTunnel{userTunnelID: userTunnelID, userID: userID, tunnelID: tunnelID})
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for _, item := range items {
|
||||
forwards, err := h.listActiveForwardsByUserTunnel(item.userID, item.tunnelID)
|
||||
forwards, err := h.listActiveForwardsByUserTunnel(item.UserID, item.TunnelID)
|
||||
if err == nil {
|
||||
h.pauseForwardRecords(forwards, nowMs)
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE user_tunnel SET status = 0 WHERE id = ?`, item.userTunnelID)
|
||||
_ = h.repo.DisableUserTunnel(item.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) runNodeRenewalCycleLoop(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
|
||||
for {
|
||||
wait := durationUntilNextNodeRenewalCycle(time.Now())
|
||||
timer := time.NewTimer(wait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
return
|
||||
case <-timer.C:
|
||||
h.runNodeRenewalCycleJob(time.Now())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func durationUntilNextNodeRenewalCycle(now time.Time) time.Duration {
|
||||
next := now.Truncate(6 * time.Hour).Add(6 * time.Hour)
|
||||
return next.Sub(now)
|
||||
}
|
||||
|
||||
func (h *Handler) runNodeRenewalCycleJob(now time.Time) {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
advanced, err := h.repo.AdvanceNodeRenewalCycles(now.UnixMilli())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = advanced
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestRunNodeRenewalCycleJob_AdvancesOverdueAnchorTimes(t *testing.T) {
|
||||
dbPath := t.TempDir() + "/renewal-test.db"
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = r.Close()
|
||||
})
|
||||
|
||||
now := time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC)
|
||||
nowMs := now.UnixMilli()
|
||||
|
||||
nodeID := int64(101)
|
||||
err = r.DB().Exec(`
|
||||
INSERT INTO node (id, name, secret, server_ip, port, http, tls, socks, created_time, status, renewal_cycle, expiry_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, nodeID, "no-cycle-node", "test-secret", "192.168.1.1", "1000-65535", 1, 1, 1, nowMs, 1, "", nil).Error
|
||||
if err != nil {
|
||||
t.Fatalf("insert test node: %v", err)
|
||||
}
|
||||
|
||||
quarterNodeID := int64(102)
|
||||
err = r.DB().Exec(`
|
||||
INSERT INTO node (id, name, secret, server_ip, port, http, tls, socks, created_time, status, renewal_cycle, expiry_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, quarterNodeID, "quarter-node", "test-secret", "192.168.1.1", "1000-65535", 1, 1, 1, nowMs, 1, "quarter", now.AddDate(0, -4, 0).UnixMilli()).Error
|
||||
if err != nil {
|
||||
t.Fatalf("insert test node: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.runNodeRenewalCycleJob(now)
|
||||
|
||||
var anchor sql.NullInt64
|
||||
err = r.DB().Raw(`SELECT expiry_time FROM node WHERE id = ?`, quarterNodeID).Row().Scan(&anchor)
|
||||
if err != nil {
|
||||
t.Fatalf("query expiry_time: %v", err)
|
||||
}
|
||||
|
||||
expectedAnchor := now.AddDate(0, 2, 0).UnixMilli()
|
||||
if !anchor.Valid || anchor.Int64 != expectedAnchor {
|
||||
t.Fatalf("expected anchor %d (2026-05-08), got %d", expectedAnchor, anchor.Int64)
|
||||
}
|
||||
}
|
||||
@@ -5,48 +5,40 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/sqlite"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestRunStatisticsFlowJobTracksIncrementAndPrunes(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "jobs-stats.db")
|
||||
repo, err := sqlite.Open(dbPath)
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(repo, "secret")
|
||||
h := New(r, "secret")
|
||||
now := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)
|
||||
nowMs := now.UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`UPDATE user SET in_flow = 100, out_flow = 200 WHERE id = 1`); err != nil {
|
||||
if err := r.DB().Exec(`UPDATE user SET in_flow = 100, out_flow = 200 WHERE id = 1`).Error; err != nil {
|
||||
t.Fatalf("seed user flow: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`INSERT INTO statistics_flow(user_id, flow, total_flow, time, created_time) VALUES(1, 250, 250, '11:00', ?)`, now.Add(-time.Hour).UnixMilli()); err != nil {
|
||||
if err := r.DB().Exec(`INSERT INTO statistics_flow(user_id, flow, total_flow, time, created_time) VALUES(1, 250, 250, '11:00', ?)`, now.Add(-time.Hour).UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("seed recent statistics row: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`INSERT INTO statistics_flow(user_id, flow, total_flow, time, created_time) VALUES(1, 10, 10, '00:00', ?)`, now.Add(-49*time.Hour).UnixMilli()); err != nil {
|
||||
if err := r.DB().Exec(`INSERT INTO statistics_flow(user_id, flow, total_flow, time, created_time) VALUES(1, 10, 10, '00:00', ?)`, now.Add(-49*time.Hour).UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("seed stale statistics row: %v", err)
|
||||
}
|
||||
|
||||
h.runStatisticsFlowJob(now)
|
||||
|
||||
var staleCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM statistics_flow WHERE created_time < ?`, nowMs-int64((48*time.Hour)/time.Millisecond)).Scan(&staleCount); err != nil {
|
||||
t.Fatalf("query stale statistics rows: %v", err)
|
||||
}
|
||||
staleCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM statistics_flow WHERE created_time < ?`, nowMs-int64((48*time.Hour)/time.Millisecond))
|
||||
if staleCount != 0 {
|
||||
t.Fatalf("expected stale statistics rows to be pruned, got %d", staleCount)
|
||||
}
|
||||
|
||||
var flow int64
|
||||
var total int64
|
||||
var hour string
|
||||
if err := repo.DB().QueryRow(`SELECT flow, total_flow, time FROM statistics_flow WHERE user_id = 1 ORDER BY id DESC LIMIT 1`).Scan(&flow, &total, &hour); err != nil {
|
||||
t.Fatalf("query latest statistics row: %v", err)
|
||||
}
|
||||
flow, total, hour := mustQueryInt64Int64String(t, r, `SELECT flow, total_flow, time FROM statistics_flow WHERE user_id = 1 ORDER BY id DESC LIMIT 1`)
|
||||
if flow != 50 {
|
||||
t.Fatalf("expected increment flow 50, got %d", flow)
|
||||
}
|
||||
@@ -60,69 +52,131 @@ func TestRunStatisticsFlowJobTracksIncrementAndPrunes(t *testing.T) {
|
||||
|
||||
func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "jobs-reset.db")
|
||||
repo, err := sqlite.Open(dbPath)
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(repo, "secret")
|
||||
h := New(r, "secret")
|
||||
now := time.Date(2026, 3, 15, 0, 0, 5, 0, time.UTC)
|
||||
nowMs := now.UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'expired_user', 'x', 1, ?, 100, 1000, 2000, 15, 1, ?, ?, 1)
|
||||
`, nowMs-1000, nowMs, nowMs); err != nil {
|
||||
`, nowMs-1000, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert expired user: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(3, 'non_expiring_user', 'x', 1, 0, 100, 1000, 2000, 15, 1, ?, ?, 1)
|
||||
`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert non-expiring user: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(1, 't1', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||
`, nowMs, nowMs); err != nil {
|
||||
`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(10, 2, 1, NULL, 1, 1, 300, 400, 15, ?, 1)
|
||||
`, nowMs-1000); err != nil {
|
||||
`, nowMs-1000).Error; err != nil {
|
||||
t.Fatalf("insert expired user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(11, 3, 1, NULL, 1, 1, 300, 400, 15, 0, 1)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert non-expiring user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(20, 2, 'expired_user', 'f1', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, nowMs, nowMs); err != nil {
|
||||
`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(21, 3, 'non_expiring_user', 'f2', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 1)
|
||||
`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert non-expiring forward: %v", err)
|
||||
}
|
||||
|
||||
h.runResetAndExpiryJob(now)
|
||||
|
||||
var userIn, userOut int64
|
||||
var userStatus int
|
||||
if err := repo.DB().QueryRow(`SELECT in_flow, out_flow, status FROM user WHERE id = 2`).Scan(&userIn, &userOut, &userStatus); err != nil {
|
||||
t.Fatalf("query user after maintenance: %v", err)
|
||||
}
|
||||
userIn, userOut, userStatus := mustQueryInt64Int64Int(t, r, `SELECT in_flow, out_flow, status FROM user WHERE id = 2`)
|
||||
if userIn != 0 || userOut != 0 || userStatus != 0 {
|
||||
t.Fatalf("expected user reset+disabled, got in=%d out=%d status=%d", userIn, userOut, userStatus)
|
||||
}
|
||||
|
||||
var utIn, utOut int64
|
||||
var utStatus int
|
||||
if err := repo.DB().QueryRow(`SELECT in_flow, out_flow, status FROM user_tunnel WHERE id = 10`).Scan(&utIn, &utOut, &utStatus); err != nil {
|
||||
t.Fatalf("query user_tunnel after maintenance: %v", err)
|
||||
}
|
||||
utIn, utOut, utStatus := mustQueryInt64Int64Int(t, r, `SELECT in_flow, out_flow, status FROM user_tunnel WHERE id = 10`)
|
||||
if utIn != 0 || utOut != 0 || utStatus != 0 {
|
||||
t.Fatalf("expected user_tunnel reset+disabled, got in=%d out=%d status=%d", utIn, utOut, utStatus)
|
||||
}
|
||||
|
||||
var forwardStatus int
|
||||
if err := repo.DB().QueryRow(`SELECT status FROM forward WHERE id = 20`).Scan(&forwardStatus); err != nil {
|
||||
t.Fatalf("query forward after maintenance: %v", err)
|
||||
}
|
||||
forwardStatus := mustQueryInt(t, r, `SELECT status FROM forward WHERE id = 20`)
|
||||
if forwardStatus != 0 {
|
||||
t.Fatalf("expected forward status=0 after expiry handling, got %d", forwardStatus)
|
||||
}
|
||||
|
||||
nonExpUserStatus := mustQueryInt(t, r, `SELECT status FROM user WHERE id = 3`)
|
||||
if nonExpUserStatus != 1 {
|
||||
t.Fatalf("expected non-expiring user to remain enabled, got status=%d", nonExpUserStatus)
|
||||
}
|
||||
|
||||
nonExpTunnelStatus := mustQueryInt(t, r, `SELECT status FROM user_tunnel WHERE id = 11`)
|
||||
if nonExpTunnelStatus != 1 {
|
||||
t.Fatalf("expected non-expiring user_tunnel to remain enabled, got status=%d", nonExpTunnelStatus)
|
||||
}
|
||||
|
||||
nonExpForwardStatus := mustQueryInt(t, r, `SELECT status FROM forward WHERE id = 21`)
|
||||
if nonExpForwardStatus != 1 {
|
||||
t.Fatalf("expected non-expiring forward to remain enabled, got status=%d", nonExpForwardStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunResetAndExpiryJobResetsUserQuotaAndUnblocksUser(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "jobs-quota-reset.db")
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "secret")
|
||||
now := time.Date(2026, 3, 12, 0, 0, 5, 0, time.UTC)
|
||||
nowMs := now.UnixMilli()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'quota-reset-user', 'x', 1, 0, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user_quota(user_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time)
|
||||
VALUES(2, 10, 0, ?, ?, 20260311, 202603, 1, ?, '', ?, ?)
|
||||
`, 11*int64(1024*1024*1024), 11*int64(1024*1024*1024), nowMs, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user quota: %v", err)
|
||||
}
|
||||
|
||||
h.runResetAndExpiryJob(now)
|
||||
|
||||
dailyUsed := mustQueryInt(t, r, `SELECT daily_used_bytes FROM user_quota WHERE user_id = 2`)
|
||||
if dailyUsed != 0 {
|
||||
t.Fatalf("expected daily quota usage reset, got %d", dailyUsed)
|
||||
}
|
||||
quotaDisabled := mustQueryInt(t, r, `SELECT disabled_by_quota FROM user_quota WHERE user_id = 2`)
|
||||
if quotaDisabled != 0 {
|
||||
t.Fatalf("expected quota disabled flag cleared, got %d", quotaDisabled)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildForwardPortEntriesWithPreservedInIP(t *testing.T) {
|
||||
entryNodeIDs := []int64{10, 20, 30}
|
||||
oldPorts := []forwardPortRecord{
|
||||
{NodeID: 10, Port: 10001, InIP: ""},
|
||||
{NodeID: 10, Port: 10002, InIP: "10.0.0.10"},
|
||||
{NodeID: 20, Port: 10003, InIP: "10.0.0.20"},
|
||||
}
|
||||
|
||||
entries := buildForwardPortEntriesWithPreservedInIP(entryNodeIDs, oldPorts, 18080)
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("expected 3 entries, got %d", len(entries))
|
||||
}
|
||||
|
||||
if entries[0].NodeID != 10 || entries[0].Port != 18080 || entries[0].InIP != "10.0.0.10" {
|
||||
t.Fatalf("unexpected first entry: %+v", entries[0])
|
||||
}
|
||||
if entries[1].NodeID != 20 || entries[1].Port != 18080 || entries[1].InIP != "10.0.0.20" {
|
||||
t.Fatalf("unexpected second entry: %+v", entries[1])
|
||||
}
|
||||
if entries[2].NodeID != 30 || entries[2].Port != 18080 || entries[2].InIP != "" {
|
||||
t.Fatalf("unexpected third entry: %+v", entries[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardPortEntriesWithPreservedInIP_EmptyOldPorts(t *testing.T) {
|
||||
entryNodeIDs := []int64{99}
|
||||
entries := buildForwardPortEntriesWithPreservedInIP(entryNodeIDs, nil, 17000)
|
||||
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||
}
|
||||
if entries[0].NodeID != 99 || entries[0].Port != 17000 || entries[0].InIP != "" {
|
||||
t.Fatalf("unexpected entry: %+v", entries[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestReconstructTunnelState_PreservesConnectIP(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "reconstruct-connect-ip.db")
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(1, 'reconstruct-tunnel', 1.0, 2, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
insertNode := func(id int64, name, ip string) {
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(id, name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, id, name, name+"-secret", ip, ip, "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
insertNode(101, "entry", "10.90.0.10")
|
||||
insertNode(102, "middle", "10.90.0.20")
|
||||
insertNode(103, "exit", "10.90.0.30")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(1, '1', 101, 30001, 'round', 1, 'tls')
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert entry chain: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol, connect_ip)
|
||||
VALUES(1, '2', 102, 30002, 'round', 1, 'tls', '10.99.9.22')
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert middle chain: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol, connect_ip)
|
||||
VALUES(1, '3', 103, 30003, 'round', 1, 'tls', '10.99.9.33')
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert exit chain: %v", err)
|
||||
}
|
||||
|
||||
state, err := h.reconstructTunnelState(1)
|
||||
if err != nil {
|
||||
t.Fatalf("reconstructTunnelState: %v", err)
|
||||
}
|
||||
|
||||
if len(state.ChainHops) != 1 || len(state.ChainHops[0]) != 1 {
|
||||
t.Fatalf("unexpected chain hops: %+v", state.ChainHops)
|
||||
}
|
||||
if got := state.ChainHops[0][0].ConnectIP; got != "10.99.9.22" {
|
||||
t.Fatalf("expected middle connectIp 10.99.9.22, got %q", got)
|
||||
}
|
||||
|
||||
if len(state.OutNodes) != 1 {
|
||||
t.Fatalf("unexpected out nodes: %+v", state.OutNodes)
|
||||
}
|
||||
if got := state.OutNodes[0].ConnectIP; got != "10.99.9.33" {
|
||||
t.Fatalf("expected exit connectIp 10.99.9.33, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
const (
|
||||
githubRepo = "Sagit-chu/flvx"
|
||||
githubProxy = "https://gcode.hostcentral.cc"
|
||||
githubAPIBase = "https://api.github.com"
|
||||
githubHTMLBase = "https://github.com"
|
||||
upgradeTimeout = 5 * time.Minute
|
||||
batchWorkers = 5
|
||||
|
||||
releaseChannelStable = "stable"
|
||||
releaseChannelDev = "dev"
|
||||
)
|
||||
|
||||
var (
|
||||
stableVersionPattern = regexp.MustCompile(`^\d+(?:\.\d+)+$`)
|
||||
testKeywordPattern = regexp.MustCompile(`(?i)(alpha|beta|rc)`)
|
||||
)
|
||||
|
||||
type githubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Draft bool `json:"draft"`
|
||||
}
|
||||
|
||||
func normalizeReleaseChannel(channel string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(channel)) {
|
||||
case releaseChannelDev:
|
||||
return releaseChannelDev
|
||||
default:
|
||||
return releaseChannelStable
|
||||
}
|
||||
}
|
||||
|
||||
func releaseChannelFromTag(tag string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(tag))
|
||||
if normalized == "" {
|
||||
return releaseChannelDev
|
||||
}
|
||||
if testKeywordPattern.MatchString(normalized) {
|
||||
return releaseChannelDev
|
||||
}
|
||||
if stableVersionPattern.MatchString(normalized) {
|
||||
return releaseChannelStable
|
||||
}
|
||||
|
||||
return releaseChannelDev
|
||||
}
|
||||
|
||||
func releaseChannelLabel(channel string) string {
|
||||
if normalizeReleaseChannel(channel) == releaseChannelDev {
|
||||
return "测试版"
|
||||
}
|
||||
|
||||
return "正式版"
|
||||
}
|
||||
|
||||
func fetchGitHubReleases(perPage int) ([]githubRelease, error) {
|
||||
if perPage <= 0 {
|
||||
perPage = 20
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Get(fmt.Sprintf("%s/repos/%s/releases?per_page=%d", githubAPIBase, githubRepo, perPage))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求GitHub API失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var releases []githubRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||
return nil, fmt.Errorf("解析GitHub API响应失败: %v", err)
|
||||
}
|
||||
|
||||
return releases, nil
|
||||
}
|
||||
|
||||
func resolveLatestReleaseByChannel(channel string) (string, error) {
|
||||
normalizedChannel := normalizeReleaseChannel(channel)
|
||||
releases, err := fetchGitHubReleases(50)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, r := range releases {
|
||||
if r.Draft {
|
||||
continue
|
||||
}
|
||||
tag := strings.TrimSpace(r.TagName)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
if releaseChannelFromTag(tag) == normalizedChannel {
|
||||
return tag, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("未找到%s版本号", releaseChannelLabel(normalizedChannel))
|
||||
}
|
||||
|
||||
func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ID int64 `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if req.ID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("节点ID无效"))
|
||||
return
|
||||
}
|
||||
|
||||
channel := normalizeReleaseChannel(req.Channel)
|
||||
version := strings.TrimSpace(req.Version)
|
||||
if version == "" {
|
||||
var err error
|
||||
version, err = resolveLatestReleaseByChannel(channel)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
downloadURL := fmt.Sprintf(
|
||||
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}",
|
||||
githubHTMLBase, githubRepo, version,
|
||||
)
|
||||
checksumURL := fmt.Sprintf(
|
||||
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256",
|
||||
githubHTMLBase, githubRepo, version,
|
||||
)
|
||||
|
||||
result, err := h.wsServer.SendCommand(req.ID, "UpgradeAgent", map[string]interface{}{
|
||||
"downloadUrl": downloadURL,
|
||||
"checksumUrl": checksumURL,
|
||||
}, upgradeTimeout)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("升级失败: %v", err)))
|
||||
return
|
||||
}
|
||||
h.markNodePendingUpgradeRedeploy(req.ID)
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"version": version,
|
||||
"message": result.Message,
|
||||
}))
|
||||
}
|
||||
|
||||
func resolveLatestRelease() (string, error) {
|
||||
return resolveLatestReleaseByChannel(releaseChannelStable)
|
||||
}
|
||||
|
||||
func resolveLatestReleaseAPI() (string, error) {
|
||||
return resolveLatestReleaseByChannel(releaseChannelStable)
|
||||
}
|
||||
|
||||
func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
Version string `json:"version"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if len(req.IDs) == 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("ids不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
channel := normalizeReleaseChannel(req.Channel)
|
||||
version := strings.TrimSpace(req.Version)
|
||||
if version == "" {
|
||||
var err error
|
||||
version, err = resolveLatestReleaseByChannel(channel)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
downloadURL := fmt.Sprintf(
|
||||
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}",
|
||||
githubHTMLBase, githubRepo, version,
|
||||
)
|
||||
checksumURL := fmt.Sprintf(
|
||||
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256",
|
||||
githubHTMLBase, githubRepo, version,
|
||||
)
|
||||
|
||||
type upgradeResult struct {
|
||||
ID int64 `json:"id"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
results := make([]upgradeResult, len(req.IDs))
|
||||
sem := make(chan struct{}, batchWorkers)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, id := range req.IDs {
|
||||
wg.Add(1)
|
||||
go func(index int, nodeID int64) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
result, err := h.wsServer.SendCommand(nodeID, "UpgradeAgent", map[string]interface{}{
|
||||
"downloadUrl": downloadURL,
|
||||
"checksumUrl": checksumURL,
|
||||
}, upgradeTimeout)
|
||||
if err != nil {
|
||||
results[index] = upgradeResult{ID: nodeID, Success: false, Message: err.Error()}
|
||||
return
|
||||
}
|
||||
h.markNodePendingUpgradeRedeploy(nodeID)
|
||||
results[index] = upgradeResult{ID: nodeID, Success: true, Message: result.Message}
|
||||
}(i, id)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"version": version,
|
||||
"results": results,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if err := decodeJSON(r.Body, &req); err != nil && err != io.EOF {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
channel := normalizeReleaseChannel(req.Channel)
|
||||
|
||||
releases, err := fetchGitHubReleases(50)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
type releaseItem struct {
|
||||
Version string `json:"version"`
|
||||
Name string `json:"name"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
|
||||
items := make([]releaseItem, 0, len(releases))
|
||||
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, releaseItem{
|
||||
Version: tag,
|
||||
Name: r.Name,
|
||||
PublishedAt: r.PublishedAt,
|
||||
Prerelease: itemChannel == releaseChannelDev,
|
||||
Channel: itemChannel,
|
||||
})
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
}
|
||||
|
||||
func (h *Handler) nodeRollback(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if req.ID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("节点ID无效"))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.wsServer.SendCommand(req.ID, "RollbackAgent", map[string]interface{}{}, 30*time.Second)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("回退失败: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"message": result.Message,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handler) markNodePendingUpgradeRedeploy(nodeID int64) {
|
||||
if h == nil || nodeID <= 0 {
|
||||
return
|
||||
}
|
||||
h.upgradeMu.Lock()
|
||||
h.pendingUpgradeRedeploy[nodeID] = struct{}{}
|
||||
h.upgradeMu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Handler) consumeNodePendingUpgradeRedeploy(nodeID int64) bool {
|
||||
if h == nil || nodeID <= 0 {
|
||||
return false
|
||||
}
|
||||
h.upgradeMu.Lock()
|
||||
_, ok := h.pendingUpgradeRedeploy[nodeID]
|
||||
if ok {
|
||||
delete(h.pendingUpgradeRedeploy, nodeID)
|
||||
}
|
||||
h.upgradeMu.Unlock()
|
||||
return ok
|
||||
}
|
||||
|
||||
func (h *Handler) onNodeOnline(nodeID int64) {
|
||||
if !h.consumeNodePendingUpgradeRedeploy(nodeID) {
|
||||
return
|
||||
}
|
||||
h.redeployNodeRuntimeAfterUpgrade(nodeID)
|
||||
}
|
||||
|
||||
func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) {
|
||||
tunnelIDs, err := h.repo.ListActiveTunnelIDsByNode(nodeID)
|
||||
if err != nil {
|
||||
fmt.Printf("post-upgrade redeploy: list tunnels for node %d failed: %v\n", nodeID, err)
|
||||
return
|
||||
}
|
||||
forwardIDs, err := h.repo.ListActiveForwardIDsByNode(nodeID)
|
||||
if err != nil {
|
||||
fmt.Printf("post-upgrade redeploy: list forwards for node %d failed: %v\n", nodeID, err)
|
||||
return
|
||||
}
|
||||
|
||||
tunnelFailed := make(map[int64]struct{})
|
||||
for _, tunnelID := range tunnelIDs {
|
||||
if err := h.redeployTunnelAndForwards(tunnelID); err != nil {
|
||||
tunnelFailed[tunnelID] = struct{}{}
|
||||
fmt.Printf("post-upgrade redeploy: tunnel %d failed on node %d: %v\n", tunnelID, nodeID, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, forwardID := range forwardIDs {
|
||||
forward, getErr := h.getForwardRecord(forwardID)
|
||||
if getErr != nil || forward == nil {
|
||||
continue
|
||||
}
|
||||
if _, skipped := tunnelFailed[forward.TunnelID]; skipped {
|
||||
continue
|
||||
}
|
||||
if err := h.syncForwardServices(forward, "UpdateService", true); err != nil {
|
||||
fmt.Printf("post-upgrade redeploy: forward %d failed on node %d: %v\n", forwardID, nodeID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestReleaseChannelFromTag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tag string
|
||||
expects string
|
||||
}{
|
||||
{name: "stable semantic version", tag: "2.1.4", expects: releaseChannelStable},
|
||||
{name: "v prefix should be dev", tag: "v2.1.4", expects: releaseChannelDev},
|
||||
{name: "rc release", tag: "2.1.4-rc2", expects: releaseChannelDev},
|
||||
{name: "beta release", tag: "2.1.4-beta.1", expects: releaseChannelDev},
|
||||
{name: "alpha release", tag: "2.1.4-alpha", expects: releaseChannelDev},
|
||||
{name: "non numeric tag", tag: "nightly", expects: releaseChannelDev},
|
||||
{name: "empty tag", tag: "", expects: releaseChannelDev},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := releaseChannelFromTag(tc.tag); got != tc.expects {
|
||||
t.Fatalf("releaseChannelFromTag(%q) = %q, want %q", tc.tag, got, tc.expects)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeReleaseChannel(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expects string
|
||||
}{
|
||||
{input: "", expects: releaseChannelStable},
|
||||
{input: "stable", expects: releaseChannelStable},
|
||||
{input: "dev", expects: releaseChannelDev},
|
||||
{input: "DEV", expects: releaseChannelDev},
|
||||
{input: "preview", expects: releaseChannelStable},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
if got := normalizeReleaseChannel(tc.input); got != tc.expects {
|
||||
t.Fatalf("normalizeReleaseChannel(%q) = %q, want %q", tc.input, got, tc.expects)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func isUserQuotaExceeded(view *model.UserQuotaView) bool {
|
||||
if view == nil {
|
||||
return false
|
||||
}
|
||||
if view.DailyLimitGB > 0 && view.DailyUsedBytes >= view.DailyLimitGB*bytesPerGB {
|
||||
return true
|
||||
}
|
||||
if view.MonthlyLimitGB > 0 && view.MonthlyUsedBytes >= view.MonthlyLimitGB*bytesPerGB {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Handler) userQuotaBlockReason(userID int64, now int64) (string, error) {
|
||||
if h == nil || h.repo == nil || userID <= 0 {
|
||||
return "", nil
|
||||
}
|
||||
quota, err := h.repo.GetUserQuotaView(userID, time.UnixMilli(now))
|
||||
if err != nil || quota == nil {
|
||||
return "", err
|
||||
}
|
||||
if quota.DisabledByQuota == 1 || isUserQuotaExceeded(quota) {
|
||||
return "该用户流量配额已超额,禁止开启转发", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (h *Handler) enforceUserQuotaIfNeeded(userID int64, quota *model.UserQuotaView) {
|
||||
if h == nil || h.repo == nil || userID <= 0 || quota == nil {
|
||||
return
|
||||
}
|
||||
if quota.DisabledByQuota == 1 || !isUserQuotaExceeded(quota) {
|
||||
return
|
||||
}
|
||||
|
||||
forwards, err := h.listActiveForwardsByUser(userID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
pausedIDs := make([]int64, 0, len(forwards))
|
||||
now := time.Now().UnixMilli()
|
||||
for i := range forwards {
|
||||
forward := &forwards[i]
|
||||
if forward.Status != 1 {
|
||||
continue
|
||||
}
|
||||
if err := h.controlForwardServices(forward, "PauseService", false); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := h.repo.UpdateForwardStatus(forward.ID, 0, now); err != nil {
|
||||
continue
|
||||
}
|
||||
pausedIDs = append(pausedIDs, forward.ID)
|
||||
}
|
||||
_ = h.repo.MarkUserQuotaDisabled(userID, pausedIDs, now)
|
||||
}
|
||||
|
||||
func (h *Handler) applyUserQuotaRelease(release *repo.UserQuotaRelease, now int64) {
|
||||
if h == nil || h.repo == nil || release == nil || release.UserID <= 0 || !release.UnblockUser {
|
||||
return
|
||||
}
|
||||
for _, forwardID := range release.ForwardIDs {
|
||||
forward, err := h.getForwardRecord(forwardID)
|
||||
if err != nil || forward == nil {
|
||||
continue
|
||||
}
|
||||
if err := h.ensureUserTunnelForwardAllowed(forward.UserID, forward.TunnelID, now); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := h.controlForwardServices(forward, "ResumeService", false); err != nil {
|
||||
continue
|
||||
}
|
||||
_ = h.repo.UpdateForwardStatus(forwardID, 1, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) resetUserQuotaWindows(now time.Time) {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
releases, err := h.repo.RollUserQuotaWindows(now)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
nowMs := now.UnixMilli()
|
||||
for i := range releases {
|
||||
h.applyUserQuotaRelease(&releases[i], nowMs)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) userQuotaReset(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
UserID int64 `json:"userId"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if req.UserID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("用户ID不能为空"))
|
||||
return
|
||||
}
|
||||
release, err := h.repo.ResetUserQuotaUsage(req.UserID, req.Scope, time.Now())
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
nowMs := time.Now().UnixMilli()
|
||||
h.applyUserQuotaRelease(release, nowMs)
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func (h *Handler) ensureUserForwardAllowedByQuota(userID int64, now int64) error {
|
||||
reason, err := h.userQuotaBlockReason(userID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if reason != "" {
|
||||
return errors.New(reason)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -93,6 +93,8 @@ func shouldSkip(path string) bool {
|
||||
return true
|
||||
case path == "/api/v1/federation/runtime/diagnose":
|
||||
return true
|
||||
case path == "/api/v1/federation/runtime/command":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -115,6 +117,14 @@ func requiresAdmin(path string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/v1/backup/") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/v1/api/v1/backup/") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/v1/tunnel/") {
|
||||
if strings.HasPrefix(path, "/api/v1/tunnel/user/tunnel") {
|
||||
return false
|
||||
@@ -127,6 +137,8 @@ func requiresAdmin(path string) bool {
|
||||
return true
|
||||
case "/api/v1/config/update", "/api/v1/config/update-single":
|
||||
return true
|
||||
case "/api/v1/announcement/update":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
// Package model defines GORM model structs for all database tables,
|
||||
// providing a single source of truth for the schema that works
|
||||
// transparently with both SQLite and PostgreSQL.
|
||||
package model
|
||||
|
||||
import "database/sql"
|
||||
|
||||
// ─── Core Business Tables ────────────────────────────────────────────
|
||||
|
||||
// User maps to the "user" table. PostgreSQL treats "user" as a reserved
|
||||
// word, so TableName() is required for correct quoting.
|
||||
type User struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
User string `gorm:"column:user;type:varchar(100);not null"`
|
||||
Pwd string `gorm:"type:varchar(100);not null"`
|
||||
RoleID int `gorm:"column:role_id;not null"`
|
||||
ExpTime int64 `gorm:"column:exp_time;not null"`
|
||||
Flow int64 `gorm:"not null"`
|
||||
InFlow int64 `gorm:"column:in_flow;not null;default:0"`
|
||||
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
|
||||
FlowResetTime int64 `gorm:"column:flow_reset_time;not null"`
|
||||
Num int `gorm:"not null"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
||||
Status int `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (User) TableName() string { return "user" }
|
||||
|
||||
// Forward maps to the "forward" table.
|
||||
type Forward struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
UserID int64 `gorm:"column:user_id;not null"`
|
||||
UserName string `gorm:"column:user_name;type:varchar(100);not null"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null"`
|
||||
RemoteAddr string `gorm:"column:remote_addr;type:text;not null"`
|
||||
Strategy string `gorm:"type:varchar(100);not null;default:'fifo'"`
|
||||
InFlow int64 `gorm:"not null;default:0"`
|
||||
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
Status int `gorm:"not null"`
|
||||
Inx int `gorm:"not null;default:0"`
|
||||
SpeedID sql.NullInt64 `gorm:"column:speed_id"`
|
||||
}
|
||||
|
||||
func (Forward) TableName() string { return "forward" }
|
||||
|
||||
type ForwardPort struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
ForwardID int64 `gorm:"column:forward_id;not null"`
|
||||
NodeID int64 `gorm:"column:node_id;not null"`
|
||||
Port int `gorm:"not null"`
|
||||
InIP sql.NullString `gorm:"column:in_ip;type:text"`
|
||||
}
|
||||
|
||||
func (ForwardPort) TableName() string { return "forward_port" }
|
||||
|
||||
type Node struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
Remark sql.NullString `gorm:"column:remark;type:text"`
|
||||
ExpiryTime sql.NullInt64 `gorm:"column:expiry_time"`
|
||||
RenewalCycle sql.NullString `gorm:"column:renewal_cycle;type:varchar(20)"`
|
||||
Secret string `gorm:"type:varchar(100);not null"`
|
||||
ServerIP string `gorm:"column:server_ip;type:varchar(100);not null"`
|
||||
ServerIPV4 sql.NullString `gorm:"column:server_ip_v4;type:varchar(100)"`
|
||||
ServerIPV6 sql.NullString `gorm:"column:server_ip_v6;type:varchar(100)"`
|
||||
ExtraIPs sql.NullString `gorm:"column:extra_ips;type:text"`
|
||||
Port string `gorm:"type:text;not null"`
|
||||
InterfaceName sql.NullString `gorm:"column:interface_name;type:varchar(200)"`
|
||||
Version sql.NullString `gorm:"type:varchar(100)"`
|
||||
HTTP int `gorm:"column:http;not null;default:0"`
|
||||
TLS int `gorm:"column:tls;not null;default:0"`
|
||||
Socks int `gorm:"not null;default:0"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
||||
Status int `gorm:"not null"`
|
||||
TCPListenAddr string `gorm:"column:tcp_listen_addr;type:varchar(100);not null;default:'[::]'"`
|
||||
UDPListenAddr string `gorm:"column:udp_listen_addr;type:varchar(100);not null;default:'[::]'"`
|
||||
Inx int `gorm:"not null;default:0"`
|
||||
IsRemote int `gorm:"column:is_remote;default:0"`
|
||||
RemoteURL sql.NullString `gorm:"column:remote_url;type:text"`
|
||||
RemoteToken sql.NullString `gorm:"column:remote_token;type:text"`
|
||||
RemoteConfig sql.NullString `gorm:"column:remote_config;type:text"`
|
||||
ExpiryReminderDismissed int `gorm:"column:expiry_reminder_dismissed;not null;default:0"`
|
||||
}
|
||||
|
||||
func (Node) TableName() string { return "node" }
|
||||
|
||||
type SpeedLimit struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
Speed int `gorm:"not null"`
|
||||
TunnelID sql.NullInt64 `gorm:"column:tunnel_id"`
|
||||
TunnelName sql.NullString `gorm:"column:tunnel_name;type:varchar(100)"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
|
||||
Status int `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (SpeedLimit) TableName() string { return "speed_limit" }
|
||||
|
||||
type StatisticsFlow struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID int64 `gorm:"column:user_id;not null" json:"userId"`
|
||||
Flow int64 `gorm:"not null" json:"flow"`
|
||||
TotalFlow int64 `gorm:"column:total_flow;not null" json:"totalFlow"`
|
||||
Time string `gorm:"type:varchar(100);not null" json:"time"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null" json:"-"`
|
||||
}
|
||||
|
||||
func (StatisticsFlow) TableName() string { return "statistics_flow" }
|
||||
|
||||
type Tunnel struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
TrafficRatio float64 `gorm:"column:traffic_ratio;not null;default:1.0"`
|
||||
Type int `gorm:"not null"`
|
||||
Protocol string `gorm:"type:varchar(10);not null;default:'tls'"`
|
||||
Flow int64 `gorm:"not null"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
Status int `gorm:"not null"`
|
||||
InIP sql.NullString `gorm:"column:in_ip;type:text"`
|
||||
Inx int `gorm:"not null;default:0"`
|
||||
IPPreference string `gorm:"column:ip_preference;type:varchar(10);not null;default:''"`
|
||||
}
|
||||
|
||||
func (Tunnel) TableName() string { return "tunnel" }
|
||||
|
||||
type UserQuota struct {
|
||||
UserID int64 `gorm:"column:user_id;primaryKey"`
|
||||
DailyLimitGB int64 `gorm:"column:daily_limit_gb;not null;default:0"`
|
||||
MonthlyLimitGB int64 `gorm:"column:monthly_limit_gb;not null;default:0"`
|
||||
DailyUsedBytes int64 `gorm:"column:daily_used_bytes;not null;default:0"`
|
||||
MonthlyUsedBytes int64 `gorm:"column:monthly_used_bytes;not null;default:0"`
|
||||
DayKey int64 `gorm:"column:day_key;not null;default:0"`
|
||||
MonthKey int64 `gorm:"column:month_key;not null;default:0"`
|
||||
DisabledByQuota int `gorm:"column:disabled_by_quota;not null;default:0"`
|
||||
DisabledAt int64 `gorm:"column:disabled_at;not null;default:0"`
|
||||
PausedForwardIDs string `gorm:"column:paused_forward_ids;type:text;not null;default:''"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
}
|
||||
|
||||
func (UserQuota) TableName() string { return "user_quota" }
|
||||
|
||||
type ChainTunnel struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null"`
|
||||
ChainType string `gorm:"column:chain_type;type:varchar(10);not null"`
|
||||
NodeID int64 `gorm:"column:node_id;not null"`
|
||||
Port sql.NullInt64 `gorm:"column:port"`
|
||||
Strategy sql.NullString `gorm:"type:varchar(10)"`
|
||||
Inx sql.NullInt64 `gorm:"column:inx"`
|
||||
Protocol sql.NullString `gorm:"type:varchar(10)"`
|
||||
ConnectIP sql.NullString `gorm:"column:connect_ip;type:varchar(45)"`
|
||||
}
|
||||
|
||||
func (ChainTunnel) TableName() string { return "chain_tunnel" }
|
||||
|
||||
type UserTunnel struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
UserID int64 `gorm:"column:user_id;not null;uniqueIndex:idx_user_tunnel_unique"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null;uniqueIndex:idx_user_tunnel_unique"`
|
||||
SpeedID sql.NullInt64 `gorm:"column:speed_id"`
|
||||
Num int `gorm:"not null"`
|
||||
Flow int64 `gorm:"not null"`
|
||||
InFlow int64 `gorm:"column:in_flow;not null;default:0"`
|
||||
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
|
||||
FlowResetTime int64 `gorm:"column:flow_reset_time;not null"`
|
||||
ExpTime int64 `gorm:"column:exp_time;not null"`
|
||||
Status int `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (UserTunnel) TableName() string { return "user_tunnel" }
|
||||
|
||||
type TunnelGroup struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(100);not null;uniqueIndex:idx_tunnel_group_name"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
Status int `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (TunnelGroup) TableName() string { return "tunnel_group" }
|
||||
|
||||
type UserGroup struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"type:varchar(100);not null;uniqueIndex:idx_user_group_name"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
Status int `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (UserGroup) TableName() string { return "user_group" }
|
||||
|
||||
type TunnelGroupTunnel struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
TunnelGroupID int64 `gorm:"column:tunnel_group_id;not null;uniqueIndex:idx_tunnel_group_tunnel_unique"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null;uniqueIndex:idx_tunnel_group_tunnel_unique"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
}
|
||||
|
||||
func (TunnelGroupTunnel) TableName() string { return "tunnel_group_tunnel" }
|
||||
|
||||
type UserGroupUser struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
UserGroupID int64 `gorm:"column:user_group_id;not null;uniqueIndex:idx_user_group_user_unique"`
|
||||
UserID int64 `gorm:"column:user_id;not null;uniqueIndex:idx_user_group_user_unique"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
}
|
||||
|
||||
func (UserGroupUser) TableName() string { return "user_group_user" }
|
||||
|
||||
type GroupPermission struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
UserGroupID int64 `gorm:"column:user_group_id;not null;uniqueIndex:idx_group_permission_unique"`
|
||||
TunnelGroupID int64 `gorm:"column:tunnel_group_id;not null;uniqueIndex:idx_group_permission_unique"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
}
|
||||
|
||||
func (GroupPermission) TableName() string { return "group_permission" }
|
||||
|
||||
type GroupPermissionGrant struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
UserGroupID int64 `gorm:"column:user_group_id;not null;uniqueIndex:idx_group_permission_grant_unique"`
|
||||
TunnelGroupID int64 `gorm:"column:tunnel_group_id;not null;uniqueIndex:idx_group_permission_grant_unique"`
|
||||
UserTunnelID int64 `gorm:"column:user_tunnel_id;not null;uniqueIndex:idx_group_permission_grant_unique"`
|
||||
CreatedByGroup int `gorm:"column:created_by_group;not null;default:0"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
}
|
||||
|
||||
func (GroupPermissionGrant) TableName() string { return "group_permission_grant" }
|
||||
|
||||
type ViteConfig struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"type:varchar(200);not null;uniqueIndex" json:"name"`
|
||||
Value string `gorm:"type:text;not null" json:"value"`
|
||||
Time int64 `gorm:"not null" json:"time"`
|
||||
}
|
||||
|
||||
func (ViteConfig) TableName() string { return "vite_config" }
|
||||
|
||||
type Announcement struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Enabled int `gorm:"not null;default:1" json:"enabled"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null" json:"created_time"`
|
||||
UpdatedTime sql.NullInt64 `gorm:"column:updated_time" json:"updated_time,omitempty"`
|
||||
}
|
||||
|
||||
func (Announcement) TableName() string { return "announcement" }
|
||||
|
||||
type SchemaVersion struct {
|
||||
Version int `gorm:"not null;default:0"`
|
||||
}
|
||||
|
||||
func (SchemaVersion) TableName() string { return "schema_version" }
|
||||
|
||||
type PeerShare struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"type:text;not null" json:"name"`
|
||||
NodeID int64 `gorm:"column:node_id;not null" json:"nodeId"`
|
||||
Token string `gorm:"type:text;not null;uniqueIndex" json:"token"`
|
||||
MaxBandwidth int64 `gorm:"column:max_bandwidth;default:0" json:"maxBandwidth"`
|
||||
ExpiryTime int64 `gorm:"column:expiry_time;default:0" json:"expiryTime"`
|
||||
PortRangeStart int `gorm:"column:port_range_start;default:0" json:"portRangeStart"`
|
||||
PortRangeEnd int `gorm:"column:port_range_end;default:0" json:"portRangeEnd"`
|
||||
CurrentFlow int64 `gorm:"column:current_flow;default:0" json:"currentFlow"`
|
||||
IsActive int `gorm:"column:is_active;default:1" json:"isActive"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null" json:"createdTime"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null" json:"updatedTime"`
|
||||
AllowedDomains string `gorm:"column:allowed_domains;type:text;default:''" json:"allowedDomains"`
|
||||
AllowedIPs string `gorm:"column:allowed_ips;type:text;default:''" json:"allowedIps"`
|
||||
}
|
||||
|
||||
func (PeerShare) TableName() string { return "peer_share" }
|
||||
|
||||
type PeerShareRuntime struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
ShareID int64 `gorm:"column:share_id;not null;index:idx_peer_share_runtime_share_node_status"`
|
||||
NodeID int64 `gorm:"column:node_id;not null;index:idx_peer_share_runtime_share_node_status"`
|
||||
ReservationID string `gorm:"column:reservation_id;type:text;not null;uniqueIndex"`
|
||||
ResourceKey string `gorm:"column:resource_key;type:text;not null;uniqueIndex"`
|
||||
BindingID string `gorm:"column:binding_id;type:text;not null;default:'';index:idx_peer_share_runtime_binding_id"`
|
||||
Role string `gorm:"type:text;not null;default:''"`
|
||||
ChainName string `gorm:"column:chain_name;type:text;not null;default:''"`
|
||||
ServiceName string `gorm:"column:service_name;type:text;not null;default:''"`
|
||||
Protocol string `gorm:"type:text;not null;default:'tls'"`
|
||||
Strategy string `gorm:"type:text;not null;default:'round'"`
|
||||
Port int `gorm:"not null;default:0"`
|
||||
Target string `gorm:"type:text;not null;default:''"`
|
||||
Applied int `gorm:"not null;default:0"`
|
||||
Status int `gorm:"not null;default:1;index:idx_peer_share_runtime_share_node_status"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
}
|
||||
|
||||
func (PeerShareRuntime) TableName() string { return "peer_share_runtime" }
|
||||
|
||||
type FederationTunnelBinding struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null;uniqueIndex:idx_federation_tunnel_binding_unique;index:idx_federation_tunnel_binding_tunnel"`
|
||||
NodeID int64 `gorm:"column:node_id;not null;uniqueIndex:idx_federation_tunnel_binding_unique"`
|
||||
ChainType int `gorm:"column:chain_type;not null;uniqueIndex:idx_federation_tunnel_binding_unique"`
|
||||
HopInx int `gorm:"column:hop_inx;not null;default:0;uniqueIndex:idx_federation_tunnel_binding_unique"`
|
||||
RemoteURL string `gorm:"column:remote_url;type:text;not null"`
|
||||
ResourceKey string `gorm:"column:resource_key;type:text;not null;uniqueIndex"`
|
||||
RemoteBindingID string `gorm:"column:remote_binding_id;type:text;not null"`
|
||||
AllocatedPort int `gorm:"column:allocated_port;not null"`
|
||||
Status int `gorm:"not null;default:1;index:idx_federation_tunnel_binding_tunnel"`
|
||||
CreatedTime int64 `gorm:"column:created_time;not null"`
|
||||
UpdatedTime int64 `gorm:"column:updated_time;not null"`
|
||||
}
|
||||
|
||||
func (FederationTunnelBinding) TableName() string { return "federation_tunnel_binding" }
|
||||
|
||||
// ─── Backup / Import-Export Structs ──────────────────────────────────
|
||||
// These are not GORM models; they define the JSON wire format for the
|
||||
// backup/restore API and MUST keep their existing json tags unchanged.
|
||||
|
||||
// BackupData represents the full backup structure.
|
||||
type BackupData struct {
|
||||
Version string `json:"version"`
|
||||
ExportedAt int64 `json:"exportedAt"`
|
||||
Users []UserBackup `json:"users,omitempty"`
|
||||
Nodes []NodeBackup `json:"nodes,omitempty"`
|
||||
Tunnels []TunnelBackup `json:"tunnels,omitempty"`
|
||||
Forwards []ForwardBackup `json:"forwards,omitempty"`
|
||||
UserTunnels []UserTunnelBackup `json:"userTunnels,omitempty"`
|
||||
SpeedLimits []SpeedLimitBackup `json:"speedLimits,omitempty"`
|
||||
TunnelGroups []TunnelGroupBackup `json:"tunnelGroups,omitempty"`
|
||||
UserGroups []UserGroupBackup `json:"userGroups,omitempty"`
|
||||
Permissions []PermissionBackup `json:"permissions,omitempty"`
|
||||
Configs map[string]string `json:"configs,omitempty"`
|
||||
}
|
||||
|
||||
type UserBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
User string `json:"user"`
|
||||
Pwd string `json:"pwd"`
|
||||
RoleID int `json:"roleId"`
|
||||
ExpTime int64 `json:"expTime"`
|
||||
Flow int64 `json:"flow"`
|
||||
InFlow int64 `json:"inFlow"`
|
||||
OutFlow int64 `json:"outFlow"`
|
||||
FlowResetTime int64 `json:"flowResetTime"`
|
||||
DailyQuotaGB int64 `json:"dailyQuotaGB,omitempty"`
|
||||
MonthlyQuotaGB int64 `json:"monthlyQuotaGB,omitempty"`
|
||||
DisabledByQuota int `json:"disabledByQuota,omitempty"`
|
||||
QuotaDisabledAt int64 `json:"quotaDisabledAt,omitempty"`
|
||||
Num int `json:"num"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime,omitempty"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type NodeBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
ExpiryTime int64 `json:"expiryTime,omitempty"`
|
||||
RenewalCycle string `json:"renewalCycle,omitempty"`
|
||||
Secret string `json:"secret"`
|
||||
ServerIP string `json:"serverIp"`
|
||||
ServerIPv4 string `json:"serverIpV4,omitempty"`
|
||||
ServerIPv6 string `json:"serverIpV6,omitempty"`
|
||||
ExtraIPs string `json:"extraIPs,omitempty"`
|
||||
Port string `json:"port"`
|
||||
InterfaceName string `json:"interfaceName,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
HTTP int `json:"http"`
|
||||
TLS int `json:"tls"`
|
||||
Socks int `json:"socks"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime,omitempty"`
|
||||
Status int `json:"status"`
|
||||
TCPListenAddr string `json:"tcpListenAddr"`
|
||||
UDPListenAddr string `json:"udpListenAddr"`
|
||||
Inx int `json:"inx"`
|
||||
IsRemote int `json:"isRemote"`
|
||||
RemoteURL string `json:"remoteUrl,omitempty"`
|
||||
RemoteToken string `json:"remoteToken,omitempty"`
|
||||
RemoteConfig string `json:"remoteConfig,omitempty"`
|
||||
}
|
||||
|
||||
type TunnelBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TrafficRatio float64 `json:"trafficRatio"`
|
||||
Type int `json:"type"`
|
||||
Protocol string `json:"protocol"`
|
||||
Flow int64 `json:"flow"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
Status int `json:"status"`
|
||||
InIP string `json:"inIp,omitempty"`
|
||||
Inx int `json:"inx"`
|
||||
IPPreference string `json:"ipPreference,omitempty"`
|
||||
ChainTunnels []ChainTunnelBackup `json:"chainTunnels,omitempty"`
|
||||
}
|
||||
|
||||
type ChainTunnelBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
TunnelID int64 `json:"tunnelId"`
|
||||
ChainType string `json:"chainType"`
|
||||
NodeID int64 `json:"nodeId"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Strategy string `json:"strategy,omitempty"`
|
||||
Inx int `json:"inx,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
}
|
||||
|
||||
type ForwardBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
Name string `json:"name"`
|
||||
TunnelID int64 `json:"tunnelId"`
|
||||
RemoteAddr string `json:"remoteAddr"`
|
||||
Strategy string `json:"strategy"`
|
||||
InFlow int64 `json:"inFlow"`
|
||||
OutFlow int64 `json:"outFlow"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
Status int `json:"status"`
|
||||
Inx int `json:"inx"`
|
||||
SpeedID *int64 `json:"speedId,omitempty"`
|
||||
ForwardPorts *[]ForwardPortBackup `json:"forwardPorts,omitempty"`
|
||||
}
|
||||
|
||||
type ForwardPortBackup struct {
|
||||
NodeID int64 `json:"nodeId"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
type UserTunnelBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"userId"`
|
||||
TunnelID int64 `json:"tunnelId"`
|
||||
SpeedID int64 `json:"speedId,omitempty"`
|
||||
Num int `json:"num"`
|
||||
Flow int64 `json:"flow"`
|
||||
InFlow int64 `json:"inFlow"`
|
||||
OutFlow int64 `json:"outFlow"`
|
||||
FlowResetTime int64 `json:"flowResetTime"`
|
||||
ExpTime int64 `json:"expTime"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type SpeedLimitBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Speed int64 `json:"speed"`
|
||||
TunnelID *int64 `json:"tunnelId,omitempty"`
|
||||
TunnelName string `json:"tunnelName,omitempty"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime,omitempty"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type TunnelGroupBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
Status int `json:"status"`
|
||||
Tunnels []int64 `json:"tunnels,omitempty"`
|
||||
}
|
||||
|
||||
type UserGroupBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
Status int `json:"status"`
|
||||
Users []int64 `json:"users,omitempty"`
|
||||
}
|
||||
|
||||
type PermissionBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
UserGroupID int64 `json:"userGroupId"`
|
||||
TunnelGroupID int64 `json:"tunnelGroupId"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
CreatedByGroup int `json:"createdByGroup"`
|
||||
Grants []PermissionGrantBackup `json:"grants,omitempty"`
|
||||
}
|
||||
|
||||
type PermissionGrantBackup struct {
|
||||
ID int64 `json:"id"`
|
||||
UserGroupID int64 `json:"userGroupId"`
|
||||
TunnelGroupID int64 `json:"tunnelGroupId"`
|
||||
UserTunnelID int64 `json:"userTunnelId"`
|
||||
CreatedTime int64 `json:"createdTime"`
|
||||
CreatedByGroup int `json:"createdByGroup"`
|
||||
}
|
||||
|
||||
// ImportResult contains the result of an import operation.
|
||||
type ImportResult struct {
|
||||
UsersImported int `json:"usersImported"`
|
||||
NodesImported int `json:"nodesImported"`
|
||||
TunnelsImported int `json:"tunnelsImported"`
|
||||
ForwardsImported int `json:"forwardsImported"`
|
||||
UserTunnelsImported int `json:"userTunnelsImported"`
|
||||
SpeedLimitsImported int `json:"speedLimitsImported"`
|
||||
TunnelGroupsImported int `json:"tunnelGroupsImported"`
|
||||
UserGroupsImported int `json:"userGroupsImported"`
|
||||
PermissionsImported int `json:"permissionsImported"`
|
||||
ConfigsImported int `json:"configsImported"`
|
||||
AutoBackup *BackupData `json:"autoBackup,omitempty"`
|
||||
}
|
||||
|
||||
// ─── View Structs (used by Repository, not GORM models) ─────────────
|
||||
// These are used for JOIN query results that don't map 1:1 to a table.
|
||||
|
||||
// ForwardRecord is a minimal forward view used by control plane and flow policy.
|
||||
type ForwardRecord struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
UserName string
|
||||
Name string
|
||||
TunnelID int64
|
||||
RemoteAddr string
|
||||
Strategy string
|
||||
Status int
|
||||
SpeedID sql.NullInt64
|
||||
}
|
||||
|
||||
// TunnelRecord is a minimal tunnel view used by control plane.
|
||||
type TunnelRecord struct {
|
||||
ID int64
|
||||
Type int
|
||||
Status int
|
||||
Flow int64
|
||||
TrafficRatio float64
|
||||
}
|
||||
|
||||
type UserQuotaView struct {
|
||||
UserID int64
|
||||
DailyLimitGB int64
|
||||
MonthlyLimitGB int64
|
||||
DailyUsedBytes int64
|
||||
MonthlyUsedBytes int64
|
||||
DayKey int64
|
||||
MonthKey int64
|
||||
DisabledByQuota int
|
||||
DisabledAt int64
|
||||
PausedForwardIDs string
|
||||
}
|
||||
|
||||
// ForwardPortRecord is a forward port mapping used by control plane.
|
||||
type ForwardPortRecord struct {
|
||||
NodeID int64
|
||||
Port int
|
||||
InIP string
|
||||
}
|
||||
|
||||
// NodeRecord is a node view used by control plane.
|
||||
type NodeRecord struct {
|
||||
ID int64
|
||||
Name string
|
||||
ServerIP string
|
||||
ServerIPv4 string
|
||||
ServerIPv6 string
|
||||
ExtraIPs string
|
||||
Status int
|
||||
PortRange string
|
||||
TCPListenAddr string
|
||||
UDPListenAddr string
|
||||
InterfaceName string
|
||||
IsRemote int
|
||||
RemoteURL string
|
||||
RemoteToken string
|
||||
RemoteConfig string
|
||||
}
|
||||
|
||||
type ChainNodeRecord struct {
|
||||
ChainType int
|
||||
Inx int64
|
||||
NodeID int64
|
||||
Port int
|
||||
NodeName string
|
||||
Protocol string
|
||||
Strategy string
|
||||
ConnectIP string
|
||||
}
|
||||
|
||||
type UserTunnelLimiterInfo struct {
|
||||
UserTunnelID int64
|
||||
LimiterID *int64
|
||||
Speed *int
|
||||
}
|
||||
|
||||
// UserFlowSnapshot holds a user's current flow counters (used by stats job).
|
||||
type UserFlowSnapshot struct {
|
||||
UserID int64
|
||||
InFlow int64
|
||||
OutFlow int64
|
||||
}
|
||||
|
||||
// ExpiredUserTunnel holds minimal info for an expired user_tunnel row.
|
||||
type ExpiredUserTunnel struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
TunnelID int64
|
||||
}
|
||||
|
||||
// UserTunnelDetail is a joined view of user_tunnel + tunnel + speed_limit.
|
||||
type UserTunnelDetail struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
TunnelID int64
|
||||
TunnelName string
|
||||
Status int
|
||||
TunnelFlow int
|
||||
Flow int64
|
||||
InFlow int64
|
||||
OutFlow int64
|
||||
Num int
|
||||
FlowResetTime int64
|
||||
ExpTime int64
|
||||
SpeedID sql.NullInt64
|
||||
SpeedLimit sql.NullString
|
||||
Speed sql.NullInt64
|
||||
}
|
||||
|
||||
// UserForwardDetail is a joined view of forward + tunnel.
|
||||
type UserForwardDetail struct {
|
||||
ID int64
|
||||
Name string
|
||||
TunnelID int64
|
||||
TunnelName string
|
||||
InIP string
|
||||
InPort sql.NullInt64
|
||||
RemoteAddr string
|
||||
InFlow int64
|
||||
OutFlow int64
|
||||
Status int
|
||||
CreatedAt int64
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,373 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
func (r *Repository) UserTunnelExistsByUserAndTunnel(userID, tunnelID int64) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
}
|
||||
var count int64
|
||||
err := r.db.Model(&model.UserTunnel{}).
|
||||
Where("user_id = ? AND tunnel_id = ? AND status = 1", userID, tunnelID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListForwardsByTunnel(tunnelID int64) ([]model.ForwardRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var forwards []model.Forward
|
||||
err := r.db.Where("tunnel_id = ?", tunnelID).Order("id ASC").Find(&forwards).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]model.ForwardRecord, 0, len(forwards))
|
||||
for _, f := range forwards {
|
||||
rows = append(rows, model.ForwardRecord{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
})
|
||||
}
|
||||
for i := range rows {
|
||||
if strings.TrimSpace(rows[i].Strategy) == "" {
|
||||
rows[i].Strategy = "fifo"
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListActiveTunnelIDsByNode(nodeID int64) ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.ChainTunnel{}).
|
||||
Joins("JOIN tunnel ON tunnel.id = chain_tunnel.tunnel_id").
|
||||
Where("chain_tunnel.node_id = ? AND tunnel.status = 1", nodeID).
|
||||
Select("DISTINCT chain_tunnel.tunnel_id").
|
||||
Order("chain_tunnel.tunnel_id ASC").
|
||||
Pluck("chain_tunnel.tunnel_id", &ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListActiveForwardIDsByNode(nodeID int64) ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.ForwardPort{}).
|
||||
Joins("JOIN forward ON forward.id = forward_port.forward_id").
|
||||
Where("forward_port.node_id = ? AND forward.status = 1", nodeID).
|
||||
Select("DISTINCT forward_port.forward_id").
|
||||
Order("forward_port.forward_id ASC").
|
||||
Pluck("forward_port.forward_id", &ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ports []model.ForwardPort
|
||||
err := r.db.Where("forward_id = ?", forwardID).Order("id ASC").Find(&ports).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]model.ForwardPortRecord, 0, len(ports))
|
||||
for _, p := range ports {
|
||||
inIP := ""
|
||||
if p.InIP.Valid {
|
||||
inIP = p.InIP.String
|
||||
}
|
||||
rows = append(rows, model.ForwardPortRecord{NodeID: p.NodeID, Port: p.Port, InIP: inIP})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *Repository) HasOtherForwardOnNodePort(nodeID int64, port int, currentForwardID int64) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
}
|
||||
if nodeID <= 0 || port <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var count int64
|
||||
err := r.db.Model(&model.ForwardPort{}).
|
||||
Where("node_id = ? AND port = ? AND forward_id <> ?", nodeID, port, currentForwardID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetTunnelOutProtocol(tunnelID int64) (string, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return "", errors.New("repository not initialized")
|
||||
}
|
||||
var ct model.ChainTunnel
|
||||
err := r.db.Select("protocol").
|
||||
Where("tunnel_id = ? AND chain_type = ?", tunnelID, "3").
|
||||
Order("id ASC").
|
||||
Take(&ct).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if ct.Protocol.Valid {
|
||||
return ct.Protocol.String, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetNodeRecord(nodeID int64) (*model.NodeRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var n model.Node
|
||||
err := r.db.Where("id = ?", nodeID).First(&n).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nodeRecordFromModel(&n), nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetNodeRecordTx(tx *gorm.DB, nodeID int64) (*model.NodeRecord, error) {
|
||||
if tx == nil {
|
||||
return nil, errors.New("database unavailable")
|
||||
}
|
||||
var n model.Node
|
||||
err := tx.Where("id = ?", nodeID).First(&n).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nodeRecordFromModel(&n), nil
|
||||
}
|
||||
|
||||
func nodeRecordFromModel(n *model.Node) *model.NodeRecord {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
rec := &model.NodeRecord{
|
||||
ID: n.ID,
|
||||
Name: n.Name,
|
||||
ServerIP: n.ServerIP,
|
||||
Status: n.Status,
|
||||
PortRange: n.Port,
|
||||
TCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,
|
||||
IsRemote: n.IsRemote,
|
||||
}
|
||||
if n.ServerIPV4.Valid {
|
||||
rec.ServerIPv4 = strings.TrimSpace(n.ServerIPV4.String)
|
||||
}
|
||||
if n.ServerIPV6.Valid {
|
||||
rec.ServerIPv6 = strings.TrimSpace(n.ServerIPV6.String)
|
||||
}
|
||||
if n.ExtraIPs.Valid {
|
||||
rec.ExtraIPs = strings.TrimSpace(n.ExtraIPs.String)
|
||||
}
|
||||
if n.InterfaceName.Valid {
|
||||
rec.InterfaceName = strings.TrimSpace(n.InterfaceName.String)
|
||||
}
|
||||
if n.RemoteURL.Valid {
|
||||
rec.RemoteURL = strings.TrimSpace(n.RemoteURL.String)
|
||||
}
|
||||
if n.RemoteToken.Valid {
|
||||
rec.RemoteToken = strings.TrimSpace(n.RemoteToken.String)
|
||||
}
|
||||
if n.RemoteConfig.Valid {
|
||||
rec.RemoteConfig = strings.TrimSpace(n.RemoteConfig.String)
|
||||
}
|
||||
if rec.TCPListenAddr == "" {
|
||||
rec.TCPListenAddr = "[::]"
|
||||
}
|
||||
if rec.UDPListenAddr == "" {
|
||||
rec.UDPListenAddr = "[::]"
|
||||
}
|
||||
if strings.TrimSpace(rec.Name) == "" {
|
||||
rec.Name = fmt.Sprintf("node_%d", rec.ID)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
func (r *Repository) ResolveUserTunnelAndLimiter(userID, tunnelID int64) (*model.UserTunnelLimiterInfo, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
type row struct {
|
||||
UserTunnelID int64 `gorm:"column:user_tunnel_id"`
|
||||
LimiterID sql.NullInt64 `gorm:"column:limiter_id"`
|
||||
Speed sql.NullInt64 `gorm:"column:speed"`
|
||||
}
|
||||
var rec row
|
||||
err := r.db.Model(&model.UserTunnel{}).
|
||||
Select("user_tunnel.id AS user_tunnel_id, speed_limit.id AS limiter_id, speed_limit.speed AS speed").
|
||||
Joins("LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id").
|
||||
Where("user_tunnel.user_id = ? AND user_tunnel.tunnel_id = ?", userID, tunnelID).
|
||||
Order("user_tunnel.id ASC").
|
||||
Limit(1).
|
||||
Take(&rec).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &model.UserTunnelLimiterInfo{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
info := &model.UserTunnelLimiterInfo{UserTunnelID: rec.UserTunnelID}
|
||||
if rec.LimiterID.Valid && rec.LimiterID.Int64 > 0 {
|
||||
v := rec.LimiterID.Int64
|
||||
info.LimiterID = &v
|
||||
s := int(rec.Speed.Int64)
|
||||
info.Speed = &s
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListUserTunnelIDs(userID, tunnelID int64) ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.UserTunnel{}).
|
||||
Where("user_id = ? AND tunnel_id = ?", userID, tunnelID).
|
||||
Order("id ASC").Pluck("id", &ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListUserTunnelIDsByUser(userID int64) ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.UserTunnel{}).
|
||||
Where("user_id = ?", userID).
|
||||
Order("id ASC").Pluck("id", &ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetTunnelName(tunnelID int64) (string, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return "", errors.New("repository not initialized")
|
||||
}
|
||||
var name string
|
||||
err := r.db.Model(&model.Tunnel{}).Where("id = ?", tunnelID).Pluck("name", &name).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListChainNodesForTunnel(tunnelID int64) ([]model.ChainNodeRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
type row struct {
|
||||
ChainType string
|
||||
Inx sql.NullInt64
|
||||
NodeID int64
|
||||
Port sql.NullInt64
|
||||
Name sql.NullString
|
||||
Protocol sql.NullString
|
||||
Strategy sql.NullString
|
||||
ConnectIP sql.NullString
|
||||
}
|
||||
var rows []row
|
||||
err := r.db.Model(&model.ChainTunnel{}).
|
||||
Select("chain_tunnel.chain_type, chain_tunnel.inx, chain_tunnel.node_id, chain_tunnel.port, node.name, chain_tunnel.protocol, chain_tunnel.strategy, chain_tunnel.connect_ip").
|
||||
Joins("LEFT JOIN node ON node.id = chain_tunnel.node_id").
|
||||
Where("chain_tunnel.tunnel_id = ?", tunnelID).
|
||||
Order("chain_tunnel.chain_type ASC, chain_tunnel.inx ASC, chain_tunnel.id ASC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]model.ChainNodeRecord, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
chainType := 0
|
||||
if v := strings.TrimSpace(row.ChainType); v != "" {
|
||||
if parsed, parseErr := strconv.Atoi(v); parseErr == nil {
|
||||
chainType = parsed
|
||||
}
|
||||
}
|
||||
inx := int64(0)
|
||||
if row.Inx.Valid {
|
||||
inx = row.Inx.Int64
|
||||
}
|
||||
port := 0
|
||||
if row.Port.Valid {
|
||||
port = int(row.Port.Int64)
|
||||
}
|
||||
item := model.ChainNodeRecord{
|
||||
ChainType: chainType,
|
||||
Inx: inx,
|
||||
NodeID: row.NodeID,
|
||||
Port: port,
|
||||
}
|
||||
if strings.TrimSpace(row.Name.String) == "" {
|
||||
item.NodeName = fmt.Sprintf("node_%d", row.NodeID)
|
||||
} else {
|
||||
item.NodeName = row.Name.String
|
||||
}
|
||||
if strings.TrimSpace(row.Protocol.String) == "" {
|
||||
item.Protocol = "tls"
|
||||
} else {
|
||||
item.Protocol = row.Protocol.String
|
||||
}
|
||||
if strings.TrimSpace(row.Strategy.String) == "" {
|
||||
item.Strategy = "round"
|
||||
} else {
|
||||
item.Strategy = row.Strategy.String
|
||||
}
|
||||
if row.ConnectIP.Valid {
|
||||
item.ConnectIP = row.ConnectIP.String
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RemoteNodeRow holds the columns fetched for a remote node listing.
|
||||
type RemoteNodeRow struct {
|
||||
ID int64
|
||||
Name string
|
||||
RemoteURL sql.NullString
|
||||
RemoteToken sql.NullString
|
||||
RemoteConfig sql.NullString
|
||||
}
|
||||
|
||||
// NodeBasicInfo holds name, server_ip, and status for a node.
|
||||
type NodeBasicInfo struct {
|
||||
Name string
|
||||
ServerIP string
|
||||
Status int
|
||||
}
|
||||
|
||||
// FederationBindingRow holds the columns for an active federation tunnel binding.
|
||||
type FederationBindingRow struct {
|
||||
ID int64
|
||||
TunnelID int64
|
||||
TunnelName string
|
||||
ChainType int
|
||||
HopInx int
|
||||
AllocatedPort int
|
||||
ResourceKey string
|
||||
RemoteBindingID string
|
||||
UpdatedTime int64
|
||||
}
|
||||
|
||||
type ActiveForwardPortRow struct {
|
||||
ForwardID int64
|
||||
TunnelID int64
|
||||
TunnelName string
|
||||
Port int
|
||||
UpdatedTime int64
|
||||
}
|
||||
|
||||
// ListRemoteNodes returns all nodes with is_remote=1, ordered by id desc.
|
||||
func (r *Repository) ListRemoteNodes() ([]RemoteNodeRow, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var result []RemoteNodeRow
|
||||
err := r.db.Model(&model.Node{}).
|
||||
Select("id, name, remote_url, remote_token, remote_config").
|
||||
Where("is_remote = 1").
|
||||
Order("id DESC").
|
||||
Find(&result).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil {
|
||||
result = make([]RemoteNodeRow, 0)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UpdateNodeRemoteConfig sets the remote_config JSON for a given node.
|
||||
func (r *Repository) UpdateNodeRemoteConfig(nodeID int64, configJSON string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
return r.db.Model(&model.Node{}).Where("id = ?", nodeID).Update("remote_config", configJSON).Error
|
||||
}
|
||||
|
||||
// ListActiveBindingsForNode returns active federation tunnel bindings for a node.
|
||||
func (r *Repository) ListActiveBindingsForNode(nodeID int64) ([]FederationBindingRow, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var result []FederationBindingRow
|
||||
err := r.db.Model(&model.FederationTunnelBinding{}).
|
||||
Select("federation_tunnel_binding.id, federation_tunnel_binding.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, federation_tunnel_binding.chain_type, federation_tunnel_binding.hop_inx, federation_tunnel_binding.allocated_port, federation_tunnel_binding.resource_key, federation_tunnel_binding.remote_binding_id, federation_tunnel_binding.updated_time").
|
||||
Joins("LEFT JOIN tunnel ON tunnel.id = federation_tunnel_binding.tunnel_id").
|
||||
Where("federation_tunnel_binding.node_id = ? AND federation_tunnel_binding.status = 1", nodeID).
|
||||
Order("federation_tunnel_binding.allocated_port ASC, federation_tunnel_binding.id ASC").
|
||||
Find(&result).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil {
|
||||
result = make([]FederationBindingRow, 0)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListActiveForwardPortsForNode(nodeID int64) ([]ActiveForwardPortRow, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var result []ActiveForwardPortRow
|
||||
err := r.db.Model(&model.ForwardPort{}).
|
||||
Select("forward_port.forward_id, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward_port.port, forward.updated_time").
|
||||
Joins("JOIN forward ON forward.id = forward_port.forward_id").
|
||||
Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
|
||||
Where("forward_port.node_id = ? AND forward_port.port > 0", nodeID).
|
||||
Order("forward_port.port ASC, forward_port.id ASC").
|
||||
Find(&result).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil {
|
||||
result = make([]ActiveForwardPortRow, 0)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetNodeBasicInfo returns the name, server_ip, and status for a given node.
|
||||
func (r *Repository) GetNodeBasicInfo(nodeID int64) (*NodeBasicInfo, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var n model.Node
|
||||
err := r.db.Select("name", "server_ip", "status").Where("id = ?", nodeID).First(&n).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &NodeBasicInfo{Name: n.Name, ServerIP: n.ServerIP, Status: n.Status}, nil
|
||||
}
|
||||
|
||||
// CreateFederationTunnel creates a tunnel and chain_tunnel entry in a transaction,
|
||||
// returning the new tunnel ID.
|
||||
func (r *Repository) CreateFederationTunnel(name string, tunnelType int, protocol string, now int64, nodeID int64, remotePort int) (int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return 0, errors.New("repository not initialized")
|
||||
}
|
||||
tunnel := model.Tunnel{
|
||||
Name: name,
|
||||
Type: tunnelType,
|
||||
Protocol: protocol,
|
||||
Flow: 0,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
Status: 1,
|
||||
InIP: sql.NullString{String: "", Valid: false},
|
||||
}
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&tunnel).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
ct := model.ChainTunnel{
|
||||
TunnelID: tunnel.ID,
|
||||
ChainType: "1",
|
||||
NodeID: nodeID,
|
||||
Port: sql.NullInt64{Int64: int64(remotePort), Valid: true},
|
||||
Strategy: sql.NullString{String: "fifo", Valid: true},
|
||||
Inx: sql.NullInt64{Int64: 0, Valid: true},
|
||||
Protocol: sql.NullString{String: protocol, Valid: true},
|
||||
}
|
||||
if err := tx.Create(&ct).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tunnel.ID, nil
|
||||
}
|
||||
|
||||
// ListUsedPortsOnNode returns all ports in use on a given node from chain_tunnel and forward_port tables.
|
||||
func (r *Repository) ListUsedPortsOnNode(nodeID int64) ([]int, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
used := make(map[int]struct{})
|
||||
|
||||
var chainPorts []int
|
||||
err := r.db.Model(&model.ChainTunnel{}).
|
||||
Where("node_id = ? AND port > 0", nodeID).
|
||||
Pluck("port", &chainPorts).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range chainPorts {
|
||||
if p > 0 {
|
||||
used[p] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
var forwardPorts []int
|
||||
err = r.db.Model(&model.ForwardPort{}).
|
||||
Where("node_id = ? AND port > 0", nodeID).
|
||||
Pluck("port", &forwardPorts).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range forwardPorts {
|
||||
if p > 0 {
|
||||
used[p] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]int, 0, len(used))
|
||||
for p := range used {
|
||||
result = append(result, p)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListTunnelIDsByNamePrefix returns all tunnel IDs whose name starts with the given prefix.
|
||||
func (r *Repository) ListTunnelIDsByNamePrefix(prefix string) ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.Tunnel{}).
|
||||
Where("name LIKE ?", prefix+"%").
|
||||
Order("id ASC").
|
||||
Pluck("id", &ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ids == nil {
|
||||
ids = make([]int64, 0)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *Repository) NextIndex(table string) int {
|
||||
if r == nil || r.db == nil {
|
||||
return 0
|
||||
}
|
||||
var modelRef interface{}
|
||||
switch table {
|
||||
case "node":
|
||||
modelRef = &model.Node{}
|
||||
case "tunnel":
|
||||
modelRef = &model.Tunnel{}
|
||||
case "forward":
|
||||
modelRef = &model.Forward{}
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
|
||||
type inxRow struct {
|
||||
Inx int
|
||||
}
|
||||
var row inxRow
|
||||
err := r.db.Model(modelRef).
|
||||
Select("inx").
|
||||
Order("inx ASC, id ASC").
|
||||
Limit(1).
|
||||
Take(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0
|
||||
}
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return row.Inx - 1
|
||||
}
|
||||
|
||||
// CreateRemoteNode inserts a new remote node.
|
||||
func (r *Repository) CreateRemoteNode(name, secret, serverIP, portRange string, now int64, status int, inx int, remoteURL, remoteToken, remoteConfigJSON string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
node := model.Node{
|
||||
Name: name,
|
||||
Secret: secret,
|
||||
ServerIP: serverIP,
|
||||
ServerIPV4: sql.NullString{},
|
||||
ServerIPV6: sql.NullString{},
|
||||
Port: portRange,
|
||||
InterfaceName: sql.NullString{},
|
||||
Version: sql.NullString{},
|
||||
HTTP: 0,
|
||||
TLS: 0,
|
||||
Socks: 0,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||
Status: status,
|
||||
TCPListenAddr: "[::]",
|
||||
UDPListenAddr: "[::]",
|
||||
Inx: inx,
|
||||
IsRemote: 1,
|
||||
RemoteURL: sql.NullString{String: remoteURL, Valid: remoteURL != ""},
|
||||
RemoteToken: sql.NullString{String: remoteToken, Valid: remoteToken != ""},
|
||||
RemoteConfig: sql.NullString{String: remoteConfigJSON, Valid: remoteConfigJSON != ""},
|
||||
}
|
||||
return r.db.Create(&node).Error
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
func (r *Repository) UpdateForwardStatus(forwardID int64, status int, now int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
return r.db.Model(&model.Forward{}).Where("id = ?", forwardID).Updates(map[string]interface{}{
|
||||
"status": status, "updated_time": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) ListActiveForwardsByUser(userID int64) ([]model.ForwardRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var forwards []model.Forward
|
||||
err := r.db.Where("user_id = ? AND status = 1", userID).Order("id ASC").Find(&forwards).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]model.ForwardRecord, 0, len(forwards))
|
||||
for _, f := range forwards {
|
||||
rows = append(rows, model.ForwardRecord{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
})
|
||||
}
|
||||
for i := range rows {
|
||||
if strings.TrimSpace(rows[i].Strategy) == "" {
|
||||
rows[i].Strategy = "fifo"
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListActiveForwardsByUserTunnel(userID, tunnelID int64) ([]model.ForwardRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var forwards []model.Forward
|
||||
err := r.db.Where("user_id = ? AND tunnel_id = ? AND status = 1", userID, tunnelID).Order("id ASC").Find(&forwards).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]model.ForwardRecord, 0, len(forwards))
|
||||
for _, f := range forwards {
|
||||
rows = append(rows, model.ForwardRecord{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
})
|
||||
}
|
||||
for i := range rows {
|
||||
if strings.TrimSpace(rows[i].Strategy) == "" {
|
||||
rows[i].Strategy = "fifo"
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetForwardRecord(forwardID int64) (*model.ForwardRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var f model.Forward
|
||||
err := r.db.Where("id = ?", forwardID).First(&f).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
fr := model.ForwardRecord{
|
||||
ID: f.ID,
|
||||
UserID: f.UserID,
|
||||
UserName: f.UserName,
|
||||
Name: f.Name,
|
||||
TunnelID: f.TunnelID,
|
||||
RemoteAddr: f.RemoteAddr,
|
||||
Strategy: f.Strategy,
|
||||
Status: f.Status,
|
||||
SpeedID: f.SpeedID,
|
||||
}
|
||||
if strings.TrimSpace(fr.Strategy) == "" {
|
||||
fr.Strategy = "fifo"
|
||||
}
|
||||
return &fr, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetTunnelRecord(tunnelID int64) (*model.TunnelRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var t model.Tunnel
|
||||
err := r.db.Where("id = ?", tunnelID).First(&t).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
tr := model.TunnelRecord{
|
||||
ID: t.ID,
|
||||
Type: t.Type,
|
||||
Status: t.Status,
|
||||
Flow: t.Flow,
|
||||
TrafficRatio: t.TrafficRatio,
|
||||
}
|
||||
if tr.Flow <= 0 {
|
||||
tr.Flow = 1
|
||||
}
|
||||
if tr.TrafficRatio <= 0 {
|
||||
tr.TrafficRatio = 1
|
||||
}
|
||||
return &tr, nil
|
||||
}
|
||||
|
||||
func (r *Repository) TunnelExists(tunnelID int64) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
}
|
||||
var count int64
|
||||
err := r.db.Model(&model.Tunnel{}).Where("id = ?", tunnelID).Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ForwardExists(forwardID int64) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
}
|
||||
var count int64
|
||||
err := r.db.Model(&model.Forward{}).Where("id = ?", forwardID).Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) SpeedLimitExists(id int64) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
}
|
||||
var count int64
|
||||
err := r.db.Model(&model.SpeedLimit{}).Where("id = ?", id).Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetSpeedLimitSpeed(id int64) (int, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return 0, errors.New("repository not initialized")
|
||||
}
|
||||
var sl model.SpeedLimit
|
||||
err := r.db.Select("speed").Where("id = ?", id).First(&sl).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return sl.Speed, nil
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
// ─── Semantic Group Queries (replacing QueryInt64List/QueryPairs passthrough) ─
|
||||
|
||||
// ListUserIDsByUserGroup returns all user IDs belonging to a user group.
|
||||
func (r *Repository) ListUserIDsByUserGroup(userGroupID int64) ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.UserGroupUser{}).
|
||||
Where("user_group_id = ?", userGroupID).
|
||||
Pluck("user_id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// ListTunnelIDsByTunnelGroup returns all tunnel IDs belonging to a tunnel group.
|
||||
func (r *Repository) ListTunnelIDsByTunnelGroup(tunnelGroupID int64) ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.TunnelGroupTunnel{}).
|
||||
Where("tunnel_group_id = ?", tunnelGroupID).
|
||||
Pluck("tunnel_id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// ListGroupPermissionPairsByUserGroup returns [userGroupID, tunnelGroupID] pairs
|
||||
// for all group permissions associated with a user group.
|
||||
func (r *Repository) ListGroupPermissionPairsByUserGroup(userGroupID int64) ([][2]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var perms []model.GroupPermission
|
||||
err := r.db.Where("user_group_id = ?", userGroupID).Find(&perms).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([][2]int64, len(perms))
|
||||
for i, p := range perms {
|
||||
result[i] = [2]int64{p.UserGroupID, p.TunnelGroupID}
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (r *Repository) GetUserGroupIDsByUserID(userID int64) ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.UserGroupUser{}).
|
||||
Where("user_id = ?", userID).
|
||||
Pluck("user_group_id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
|
||||
func (r *Repository) ListGroupPermissionPairsByTunnelGroup(tunnelGroupID int64) ([][2]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var perms []model.GroupPermission
|
||||
err := r.db.Where("tunnel_group_id = ?", tunnelGroupID).Find(&perms).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([][2]int64, len(perms))
|
||||
for i, p := range perms {
|
||||
result[i] = [2]int64{p.UserGroupID, p.TunnelGroupID}
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
gsqlite "github.com/glebarez/sqlite"
|
||||
"go-backend/internal/store/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestPrepareSQLiteLegacyColumnsAddsNodeMetadataColumns(t *testing.T) {
|
||||
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE node (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
secret VARCHAR(100) NOT NULL,
|
||||
server_ip VARCHAR(100) NOT NULL,
|
||||
port TEXT NOT NULL,
|
||||
interface_name VARCHAR(200),
|
||||
version VARCHAR(100),
|
||||
http INTEGER NOT NULL DEFAULT 0,
|
||||
tls INTEGER NOT NULL DEFAULT 0,
|
||||
socks INTEGER NOT NULL DEFAULT 0,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER,
|
||||
status INTEGER NOT NULL
|
||||
)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create legacy node table: %v", err)
|
||||
}
|
||||
|
||||
if err := prepareSQLiteLegacyColumns(db); err != nil {
|
||||
t.Fatalf("prepareSQLiteLegacyColumns: %v", err)
|
||||
}
|
||||
|
||||
m := db.Migrator()
|
||||
for _, field := range []string{"Remark", "ExpiryTime", "RenewalCycle"} {
|
||||
if !m.HasColumn(&model.Node{}, field) {
|
||||
t.Fatalf("expected node.%s column to exist", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaRunsPostgresIDRepairEvenAtCurrentVersion(t *testing.T) {
|
||||
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
|
||||
t.Fatalf("create schema_version: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, currentSchemaVersion).Error; err != nil {
|
||||
t.Fatalf("seed schema_version: %v", err)
|
||||
}
|
||||
|
||||
called := 0
|
||||
original := ensurePostgresIDDefaultsFn
|
||||
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
|
||||
called++
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ensurePostgresIDDefaultsFn = original
|
||||
})
|
||||
|
||||
if err := migrateSchema(db); err != nil {
|
||||
t.Fatalf("migrateSchema: %v", err)
|
||||
}
|
||||
if called != 1 {
|
||||
t.Fatalf("expected postgres id repair to run once, got %d", called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaReturnsPostgresIDRepairError(t *testing.T) {
|
||||
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
|
||||
t.Fatalf("create schema_version: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, currentSchemaVersion).Error; err != nil {
|
||||
t.Fatalf("seed schema_version: %v", err)
|
||||
}
|
||||
|
||||
wantErr := errors.New("repair failed")
|
||||
original := ensurePostgresIDDefaultsFn
|
||||
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
|
||||
return wantErr
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ensurePostgresIDDefaultsFn = original
|
||||
})
|
||||
|
||||
err = migrateSchema(db)
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("expected error %v, got %v", wantErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaRunsViteConfigValueMigrationForLegacySchema(t *testing.T) {
|
||||
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
|
||||
t.Fatalf("create schema_version: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 2).Error; err != nil {
|
||||
t.Fatalf("seed schema_version: %v", err)
|
||||
}
|
||||
|
||||
originalIDRepair := ensurePostgresIDDefaultsFn
|
||||
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ensurePostgresIDDefaultsFn = originalIDRepair
|
||||
})
|
||||
|
||||
called := 0
|
||||
originalMigrate := migrateViteConfigValueColumnTypeFn
|
||||
migrateViteConfigValueColumnTypeFn = func(db *gorm.DB) error {
|
||||
called++
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
migrateViteConfigValueColumnTypeFn = originalMigrate
|
||||
})
|
||||
|
||||
if err := migrateSchema(db); err != nil {
|
||||
t.Fatalf("migrateSchema: %v", err)
|
||||
}
|
||||
|
||||
if called != 1 {
|
||||
t.Fatalf("expected vite_config migration to run once, got %d", called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaReturnsViteConfigMigrationError(t *testing.T) {
|
||||
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
|
||||
t.Fatalf("create schema_version: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 2).Error; err != nil {
|
||||
t.Fatalf("seed schema_version: %v", err)
|
||||
}
|
||||
|
||||
originalIDRepair := ensurePostgresIDDefaultsFn
|
||||
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ensurePostgresIDDefaultsFn = originalIDRepair
|
||||
})
|
||||
|
||||
wantErr := errors.New("vite config migration failed")
|
||||
originalMigrate := migrateViteConfigValueColumnTypeFn
|
||||
migrateViteConfigValueColumnTypeFn = func(db *gorm.DB) error {
|
||||
return wantErr
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
migrateViteConfigValueColumnTypeFn = originalMigrate
|
||||
})
|
||||
|
||||
err = migrateSchema(db)
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("expected error %v, got %v", wantErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaClearsSpeedLimitTunnelBinding(t *testing.T) {
|
||||
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
|
||||
t.Fatalf("create schema_version: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 3).Error; err != nil {
|
||||
t.Fatalf("seed schema_version: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE speed_limit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
speed INTEGER NOT NULL,
|
||||
tunnel_id INTEGER,
|
||||
tunnel_name VARCHAR(100),
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER,
|
||||
status INTEGER NOT NULL
|
||||
)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create speed_limit: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||
`, "legacy-speed-limit", 100, 101, "legacy-tunnel", 1, 1, 1).Error; err != nil {
|
||||
t.Fatalf("seed speed_limit: %v", err)
|
||||
}
|
||||
|
||||
originalIDRepair := ensurePostgresIDDefaultsFn
|
||||
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ensurePostgresIDDefaultsFn = originalIDRepair
|
||||
})
|
||||
|
||||
if err := migrateSchema(db); err != nil {
|
||||
t.Fatalf("migrateSchema: %v", err)
|
||||
}
|
||||
|
||||
var tunnelID sql.NullInt64
|
||||
var tunnelName sql.NullString
|
||||
if err := db.Raw(`SELECT tunnel_id, tunnel_name FROM speed_limit WHERE name = ?`, "legacy-speed-limit").Row().Scan(&tunnelID, &tunnelName); err != nil {
|
||||
t.Fatalf("query speed_limit: %v", err)
|
||||
}
|
||||
if tunnelID.Valid {
|
||||
t.Fatalf("expected tunnel_id cleared to NULL, got %d", tunnelID.Int64)
|
||||
}
|
||||
if tunnelName.Valid {
|
||||
t.Fatalf("expected tunnel_name cleared to NULL, got %q", tunnelName.String)
|
||||
}
|
||||
|
||||
var schemaVersion int
|
||||
if err := db.Raw(`SELECT version FROM schema_version LIMIT 1`).Row().Scan(&schemaVersion); err != nil {
|
||||
t.Fatalf("query schema_version: %v", err)
|
||||
}
|
||||
if schemaVersion != currentSchemaVersion {
|
||||
t.Fatalf("expected schema version %d, got %d", currentSchemaVersion, schemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaRunsTrafficInt64MigrationForLegacySchema(t *testing.T) {
|
||||
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
|
||||
t.Fatalf("create schema_version: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 4).Error; err != nil {
|
||||
t.Fatalf("seed schema_version: %v", err)
|
||||
}
|
||||
|
||||
originalIDRepair := ensurePostgresIDDefaultsFn
|
||||
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ensurePostgresIDDefaultsFn = originalIDRepair
|
||||
})
|
||||
|
||||
called := 0
|
||||
originalMigrate := migratePostgresTrafficInt64ColumnsFn
|
||||
migratePostgresTrafficInt64ColumnsFn = func(db *gorm.DB) error {
|
||||
called++
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
migratePostgresTrafficInt64ColumnsFn = originalMigrate
|
||||
})
|
||||
|
||||
if err := migrateSchema(db); err != nil {
|
||||
t.Fatalf("migrateSchema: %v", err)
|
||||
}
|
||||
|
||||
if called != 1 {
|
||||
t.Fatalf("expected traffic bigint migration to run once, got %d", called)
|
||||
}
|
||||
|
||||
var schemaVersion int
|
||||
if err := db.Raw(`SELECT version FROM schema_version LIMIT 1`).Row().Scan(&schemaVersion); err != nil {
|
||||
t.Fatalf("query schema_version: %v", err)
|
||||
}
|
||||
if schemaVersion != currentSchemaVersion {
|
||||
t.Fatalf("expected schema version %d, got %d", currentSchemaVersion, schemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaReturnsTrafficInt64MigrationError(t *testing.T) {
|
||||
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
|
||||
t.Fatalf("create schema_version: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 4).Error; err != nil {
|
||||
t.Fatalf("seed schema_version: %v", err)
|
||||
}
|
||||
|
||||
originalIDRepair := ensurePostgresIDDefaultsFn
|
||||
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ensurePostgresIDDefaultsFn = originalIDRepair
|
||||
})
|
||||
|
||||
wantErr := errors.New("traffic bigint migration failed")
|
||||
originalMigrate := migratePostgresTrafficInt64ColumnsFn
|
||||
migratePostgresTrafficInt64ColumnsFn = func(db *gorm.DB) error {
|
||||
return wantErr
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
migratePostgresTrafficInt64ColumnsFn = originalMigrate
|
||||
})
|
||||
|
||||
err = migrateSchema(db)
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("expected error %v, got %v", wantErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlterPostgresColumnToBigIntIfNeededValidatesNames(t *testing.T) {
|
||||
if err := alterPostgresColumnToBigIntIfNeeded(nil, "peer_share", "max_bandwidth"); err == nil || !strings.Contains(err.Error(), "nil db") {
|
||||
t.Fatalf("expected nil db error, got %v", err)
|
||||
}
|
||||
if err := alterPostgresColumnToBigIntIfNeeded(&gorm.DB{}, "", "max_bandwidth"); err == nil || !strings.Contains(err.Error(), "empty table or column name") {
|
||||
t.Fatalf("expected empty name error, got %v", err)
|
||||
}
|
||||
if err := alterPostgresColumnToBigIntIfNeeded(&gorm.DB{}, "peer_share", ""); err == nil || !strings.Contains(err.Error(), "empty table or column name") {
|
||||
t.Fatalf("expected empty name error, got %v", err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,378 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const userQuotaBytesPerGB int64 = 1024 * 1024 * 1024
|
||||
|
||||
type UserQuotaRelease struct {
|
||||
UserID int64
|
||||
ForwardIDs []int64
|
||||
UnblockUser bool
|
||||
}
|
||||
|
||||
func userQuotaWindowKeys(now time.Time) (int64, int64) {
|
||||
return int64(now.Year()*10000 + int(now.Month())*100 + now.Day()), int64(now.Year()*100 + int(now.Month()))
|
||||
}
|
||||
|
||||
func cloneUserQuotaView(q model.UserQuota) *model.UserQuotaView {
|
||||
return &model.UserQuotaView{
|
||||
UserID: q.UserID,
|
||||
DailyLimitGB: q.DailyLimitGB,
|
||||
MonthlyLimitGB: q.MonthlyLimitGB,
|
||||
DailyUsedBytes: q.DailyUsedBytes,
|
||||
MonthlyUsedBytes: q.MonthlyUsedBytes,
|
||||
DayKey: q.DayKey,
|
||||
MonthKey: q.MonthKey,
|
||||
DisabledByQuota: q.DisabledByQuota,
|
||||
DisabledAt: q.DisabledAt,
|
||||
PausedForwardIDs: q.PausedForwardIDs,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeUserQuotaView(view *model.UserQuotaView, now time.Time) *model.UserQuotaView {
|
||||
if view == nil {
|
||||
return nil
|
||||
}
|
||||
dayKey, monthKey := userQuotaWindowKeys(now)
|
||||
out := *view
|
||||
if out.DayKey != dayKey {
|
||||
out.DayKey = dayKey
|
||||
out.DailyUsedBytes = 0
|
||||
}
|
||||
if out.MonthKey != monthKey {
|
||||
out.MonthKey = monthKey
|
||||
out.MonthlyUsedBytes = 0
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
func userQuotaExceeded(view *model.UserQuotaView) bool {
|
||||
if view == nil {
|
||||
return false
|
||||
}
|
||||
if view.DailyLimitGB > 0 && view.DailyUsedBytes >= view.DailyLimitGB*userQuotaBytesPerGB {
|
||||
return true
|
||||
}
|
||||
if view.MonthlyLimitGB > 0 && view.MonthlyUsedBytes >= view.MonthlyLimitGB*userQuotaBytesPerGB {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parsePausedForwardIDs(raw string) []int64 {
|
||||
parts := strings.Split(strings.TrimSpace(raw), ",")
|
||||
out := make([]int64, 0, len(parts))
|
||||
seen := make(map[int64]struct{}, len(parts))
|
||||
for _, part := range parts {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func joinPausedForwardIDs(ids []int64) string {
|
||||
if len(ids) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(ids))
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
parts = append(parts, strconv.FormatInt(id, 10))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func (r *Repository) loadOrCreateUserQuotaTx(tx *gorm.DB, userID int64, now time.Time) (*model.UserQuota, error) {
|
||||
if tx == nil {
|
||||
return nil, errors.New("database unavailable")
|
||||
}
|
||||
dayKey, monthKey := userQuotaWindowKeys(now)
|
||||
q := &model.UserQuota{}
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", userID).First(q).Error
|
||||
if err == nil {
|
||||
return q, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
nowMs := now.UnixMilli()
|
||||
q = &model.UserQuota{
|
||||
UserID: userID,
|
||||
DayKey: dayKey,
|
||||
MonthKey: monthKey,
|
||||
CreatedTime: nowMs,
|
||||
UpdatedTime: nowMs,
|
||||
PausedForwardIDs: "",
|
||||
}
|
||||
if err := tx.Create(q).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func applyUserQuotaWindowRoll(q *model.UserQuota, now time.Time) bool {
|
||||
if q == nil {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
dayKey, monthKey := userQuotaWindowKeys(now)
|
||||
if q.DayKey != dayKey {
|
||||
q.DayKey = dayKey
|
||||
q.DailyUsedBytes = 0
|
||||
changed = true
|
||||
}
|
||||
if q.MonthKey != monthKey {
|
||||
q.MonthKey = monthKey
|
||||
q.MonthlyUsedBytes = 0
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func (r *Repository) SaveUserQuotaConfigTx(tx *gorm.DB, userID, dailyLimitGB, monthlyLimitGB int64, now int64) error {
|
||||
if tx == nil {
|
||||
return errors.New("database unavailable")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return errors.New("user id is required")
|
||||
}
|
||||
if dailyLimitGB < 0 || monthlyLimitGB < 0 {
|
||||
return errors.New("quota limit cannot be negative")
|
||||
}
|
||||
current := time.UnixMilli(now)
|
||||
q, err := r.loadOrCreateUserQuotaTx(tx, userID, current)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"daily_limit_gb": dailyLimitGB,
|
||||
"monthly_limit_gb": monthlyLimitGB,
|
||||
"updated_time": now,
|
||||
}
|
||||
if q.DayKey == 0 || q.MonthKey == 0 {
|
||||
dayKey, monthKey := userQuotaWindowKeys(current)
|
||||
updates["day_key"] = dayKey
|
||||
updates["month_key"] = monthKey
|
||||
}
|
||||
return tx.Model(&model.UserQuota{}).Where("user_id = ?", userID).Updates(updates).Error
|
||||
}
|
||||
|
||||
func (r *Repository) ListUserQuotaViewsByUserIDs(userIDs []int64, now time.Time) (map[int64]*model.UserQuotaView, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
out := make(map[int64]*model.UserQuotaView)
|
||||
if len(userIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
var rows []model.UserQuota
|
||||
if err := r.db.Where("user_id IN ?", userIDs).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
out[row.UserID] = normalizeUserQuotaView(cloneUserQuotaView(row), now)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetUserQuotaView(userID int64, now time.Time) (*model.UserQuotaView, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var row model.UserQuota
|
||||
err := r.db.Where("user_id = ?", userID).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalizeUserQuotaView(cloneUserQuotaView(row), now), nil
|
||||
}
|
||||
|
||||
func (r *Repository) AddUserQuotaUsage(userID int64, usedBytes int64, now time.Time) (*model.UserQuotaView, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
result := &model.UserQuotaView{}
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
q, err := r.loadOrCreateUserQuotaTx(tx, userID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyUserQuotaWindowRoll(q, now)
|
||||
if usedBytes > 0 {
|
||||
q.DailyUsedBytes += usedBytes
|
||||
q.MonthlyUsedBytes += usedBytes
|
||||
}
|
||||
q.UpdatedTime = now.UnixMilli()
|
||||
if err := tx.Model(&model.UserQuota{}).Where("user_id = ?", userID).Updates(map[string]interface{}{
|
||||
"daily_used_bytes": q.DailyUsedBytes,
|
||||
"monthly_used_bytes": q.MonthlyUsedBytes,
|
||||
"day_key": q.DayKey,
|
||||
"month_key": q.MonthKey,
|
||||
"updated_time": q.UpdatedTime,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
*result = *cloneUserQuotaView(*q)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalizeUserQuotaView(result, now), nil
|
||||
}
|
||||
|
||||
func (r *Repository) MarkUserQuotaDisabled(userID int64, pausedForwardIDs []int64, now int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return errors.New("user id is required")
|
||||
}
|
||||
return r.db.Model(&model.UserQuota{}).Where("user_id = ?", userID).Updates(map[string]interface{}{
|
||||
"disabled_by_quota": 1,
|
||||
"disabled_at": now,
|
||||
"paused_forward_ids": joinPausedForwardIDs(pausedForwardIDs),
|
||||
"updated_time": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) ResetUserQuotaUsage(userID int64, scope string, now time.Time) (*UserQuotaRelease, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return nil, errors.New("user id is required")
|
||||
}
|
||||
scope = strings.TrimSpace(strings.ToLower(scope))
|
||||
if scope == "" {
|
||||
scope = "all"
|
||||
}
|
||||
if scope != "daily" && scope != "monthly" && scope != "all" {
|
||||
return nil, fmt.Errorf("unsupported quota reset scope: %s", scope)
|
||||
}
|
||||
var release *UserQuotaRelease
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
q, err := r.loadOrCreateUserQuotaTx(tx, userID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyUserQuotaWindowRoll(q, now)
|
||||
switch scope {
|
||||
case "daily":
|
||||
q.DailyUsedBytes = 0
|
||||
case "monthly":
|
||||
q.MonthlyUsedBytes = 0
|
||||
case "all":
|
||||
q.DailyUsedBytes = 0
|
||||
q.MonthlyUsedBytes = 0
|
||||
}
|
||||
q.UpdatedTime = now.UnixMilli()
|
||||
release = &UserQuotaRelease{UserID: userID}
|
||||
if q.DisabledByQuota == 1 && !userQuotaExceeded(cloneUserQuotaView(*q)) {
|
||||
release.UnblockUser = true
|
||||
release.ForwardIDs = parsePausedForwardIDs(q.PausedForwardIDs)
|
||||
q.DisabledByQuota = 0
|
||||
q.DisabledAt = 0
|
||||
q.PausedForwardIDs = ""
|
||||
}
|
||||
return tx.Model(&model.UserQuota{}).Where("user_id = ?", userID).Updates(map[string]interface{}{
|
||||
"daily_used_bytes": q.DailyUsedBytes,
|
||||
"monthly_used_bytes": q.MonthlyUsedBytes,
|
||||
"day_key": q.DayKey,
|
||||
"month_key": q.MonthKey,
|
||||
"disabled_by_quota": q.DisabledByQuota,
|
||||
"disabled_at": q.DisabledAt,
|
||||
"paused_forward_ids": q.PausedForwardIDs,
|
||||
"updated_time": q.UpdatedTime,
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return release, nil
|
||||
}
|
||||
|
||||
func (r *Repository) RollUserQuotaWindows(now time.Time) ([]UserQuotaRelease, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var releases []UserQuotaRelease
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
var rows []model.UserQuota
|
||||
if err := tx.Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
nowMs := now.UnixMilli()
|
||||
for _, row := range rows {
|
||||
q := row
|
||||
changed := applyUserQuotaWindowRoll(&q, now)
|
||||
release := UserQuotaRelease{UserID: q.UserID}
|
||||
if q.DisabledByQuota == 1 && !userQuotaExceeded(cloneUserQuotaView(q)) {
|
||||
release.UnblockUser = true
|
||||
release.ForwardIDs = parsePausedForwardIDs(q.PausedForwardIDs)
|
||||
q.DisabledByQuota = 0
|
||||
q.DisabledAt = 0
|
||||
q.PausedForwardIDs = ""
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
q.UpdatedTime = nowMs
|
||||
if err := tx.Model(&model.UserQuota{}).Where("user_id = ?", q.UserID).Updates(map[string]interface{}{
|
||||
"daily_used_bytes": q.DailyUsedBytes,
|
||||
"monthly_used_bytes": q.MonthlyUsedBytes,
|
||||
"day_key": q.DayKey,
|
||||
"month_key": q.MonthKey,
|
||||
"disabled_by_quota": q.DisabledByQuota,
|
||||
"disabled_at": q.DisabledAt,
|
||||
"paused_forward_ids": q.PausedForwardIDs,
|
||||
"updated_time": q.UpdatedTime,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if release.UnblockUser {
|
||||
releases = append(releases, release)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return releases, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
||||
INSERT OR IGNORE INTO user (id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES (1, 'admin_user', '3c85cdebade1c51cf64ca9f3c09d182d', 0, 2727251700000, 99999, 0, 0, 1, 99999, 1748914865000, 1754011744252, 1);
|
||||
|
||||
INSERT OR IGNORE INTO vite_config (id, name, value, time)
|
||||
VALUES (1, 'app_name', 'flux', 1755147963000);
|
||||
@@ -1,245 +0,0 @@
|
||||
-- SQLite Auto-generated schema
|
||||
-- This will be executed automatically on startup if tables don't exist
|
||||
|
||||
CREATE TABLE IF NOT EXISTS forward (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
user_name VARCHAR(100) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
remote_addr TEXT NOT NULL,
|
||||
strategy VARCHAR(100) NOT NULL DEFAULT 'fifo',
|
||||
in_flow INTEGER NOT NULL DEFAULT 0,
|
||||
out_flow INTEGER NOT NULL DEFAULT 0,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
inx INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS forward_port (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
forward_id INTEGER NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
port INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS node (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
secret VARCHAR(100) NOT NULL,
|
||||
server_ip VARCHAR(100) NOT NULL,
|
||||
server_ip_v4 VARCHAR(100),
|
||||
server_ip_v6 VARCHAR(100),
|
||||
port TEXT NOT NULL,
|
||||
interface_name VARCHAR(200),
|
||||
version VARCHAR(100),
|
||||
http INTEGER NOT NULL DEFAULT 0,
|
||||
tls INTEGER NOT NULL DEFAULT 0,
|
||||
socks INTEGER NOT NULL DEFAULT 0,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER,
|
||||
status INTEGER NOT NULL,
|
||||
tcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
|
||||
udp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
|
||||
inx INTEGER NOT NULL DEFAULT 0,
|
||||
is_remote INTEGER DEFAULT 0,
|
||||
remote_url TEXT,
|
||||
remote_token TEXT,
|
||||
remote_config TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS speed_limit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
speed INTEGER NOT NULL,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
tunnel_name VARCHAR(100) NOT NULL,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS statistics_flow (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
flow INTEGER NOT NULL,
|
||||
total_flow INTEGER NOT NULL,
|
||||
time VARCHAR(100) NOT NULL,
|
||||
created_time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tunnel (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
traffic_ratio REAL NOT NULL DEFAULT 1.0,
|
||||
type INTEGER NOT NULL,
|
||||
protocol VARCHAR(10) NOT NULL DEFAULT 'tls',
|
||||
flow INTEGER NOT NULL,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
in_ip TEXT,
|
||||
inx INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chain_tunnel (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tunnel_id INTEGER NOT NULL ,
|
||||
chain_type VARCHAR(10) NOT NULL,
|
||||
node_id INTEGER NOT NULL ,
|
||||
port INTEGER,
|
||||
strategy VARCHAR(10),
|
||||
inx INTEGER,
|
||||
protocol VARCHAR(10)
|
||||
);
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user VARCHAR(100) NOT NULL,
|
||||
pwd VARCHAR(100) NOT NULL,
|
||||
role_id INTEGER NOT NULL,
|
||||
exp_time INTEGER NOT NULL,
|
||||
flow INTEGER NOT NULL,
|
||||
in_flow INTEGER NOT NULL DEFAULT 0,
|
||||
out_flow INTEGER NOT NULL DEFAULT 0,
|
||||
flow_reset_time INTEGER NOT NULL,
|
||||
num INTEGER NOT NULL,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_tunnel (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
speed_id INTEGER,
|
||||
num INTEGER NOT NULL,
|
||||
flow INTEGER NOT NULL,
|
||||
in_flow INTEGER NOT NULL DEFAULT 0,
|
||||
out_flow INTEGER NOT NULL DEFAULT 0,
|
||||
flow_reset_time INTEGER NOT NULL,
|
||||
exp_time INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tunnel_group (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_group (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tunnel_group_tunnel (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tunnel_group_id INTEGER NOT NULL,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
created_time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_group_user (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_group_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_permission (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_group_id INTEGER NOT NULL,
|
||||
tunnel_group_id INTEGER NOT NULL,
|
||||
created_time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_permission_grant (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_group_id INTEGER NOT NULL,
|
||||
tunnel_group_id INTEGER NOT NULL,
|
||||
user_tunnel_id INTEGER NOT NULL,
|
||||
created_by_group INTEGER NOT NULL DEFAULT 0,
|
||||
created_time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_name ON tunnel_group(name);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_name ON user_group(name);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_tunnel_unique ON tunnel_group_tunnel(tunnel_group_id, tunnel_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_user_unique ON user_group_user(user_group_id, user_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_unique ON group_permission(user_group_id, tunnel_group_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_grant_unique ON group_permission_grant(user_group_id, tunnel_group_id, user_tunnel_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_tunnel_unique ON user_tunnel(user_id, tunnel_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vite_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(200) NOT NULL UNIQUE,
|
||||
value VARCHAR(200) NOT NULL,
|
||||
time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS peer_share (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
max_bandwidth INTEGER DEFAULT 0,
|
||||
expiry_time INTEGER DEFAULT 0,
|
||||
port_range_start INTEGER DEFAULT 0,
|
||||
port_range_end INTEGER DEFAULT 0,
|
||||
current_flow INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL,
|
||||
allowed_domains TEXT DEFAULT '',
|
||||
allowed_ips TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS peer_share_runtime (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
share_id INTEGER NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
reservation_id TEXT NOT NULL UNIQUE,
|
||||
resource_key TEXT NOT NULL UNIQUE,
|
||||
binding_id TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT '',
|
||||
chain_name TEXT NOT NULL DEFAULT '',
|
||||
service_name TEXT NOT NULL DEFAULT '',
|
||||
protocol TEXT NOT NULL DEFAULT 'tls',
|
||||
strategy TEXT NOT NULL DEFAULT 'round',
|
||||
port INTEGER NOT NULL DEFAULT 0,
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
applied INTEGER NOT NULL DEFAULT 0,
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_peer_share_runtime_share_node_status ON peer_share_runtime(share_id, node_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_peer_share_runtime_binding_id ON peer_share_runtime(binding_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS federation_tunnel_binding (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
chain_type INTEGER NOT NULL,
|
||||
hop_inx INTEGER NOT NULL DEFAULT 0,
|
||||
remote_url TEXT NOT NULL,
|
||||
resource_key TEXT NOT NULL UNIQUE,
|
||||
remote_binding_id TEXT NOT NULL,
|
||||
allocated_port INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_federation_tunnel_binding_unique ON federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx);
|
||||
CREATE INDEX IF NOT EXISTS idx_federation_tunnel_binding_tunnel ON federation_tunnel_binding(tunnel_id, status);
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/sqlite"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
type encryptedMessage struct {
|
||||
@@ -54,6 +54,12 @@ type pendingRequest struct {
|
||||
ch chan CommandResult
|
||||
}
|
||||
|
||||
const (
|
||||
wsPingPeriod = 15 * time.Second
|
||||
wsPongWait = 45 * time.Second
|
||||
wsWriteWait = 5 * time.Second
|
||||
)
|
||||
|
||||
type CommandResult struct {
|
||||
Type string `json:"type"`
|
||||
Success bool `json:"success"`
|
||||
@@ -62,9 +68,10 @@ type CommandResult struct {
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
repo *sqlite.Repository
|
||||
jwtSecret string
|
||||
upgrader websocket.Upgrader
|
||||
repo *repo.Repository
|
||||
jwtSecret string
|
||||
upgrader websocket.Upgrader
|
||||
onNodeOnline func(nodeID int64)
|
||||
|
||||
mu sync.RWMutex
|
||||
admins map[*connWrap]struct{}
|
||||
@@ -73,7 +80,16 @@ type Server struct {
|
||||
pending map[string]pendingRequest
|
||||
}
|
||||
|
||||
func NewServer(repo *sqlite.Repository, jwtSecret string) *Server {
|
||||
func (s *Server) SetNodeOnlineHook(fn func(nodeID int64)) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.onNodeOnline = fn
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func NewServer(repo *repo.Repository, jwtSecret string) *Server {
|
||||
return &Server{
|
||||
repo: repo,
|
||||
jwtSecret: jwtSecret,
|
||||
@@ -120,12 +136,19 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
cw := &connWrap{conn: conn}
|
||||
_ = conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
return conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||
})
|
||||
done := make(chan struct{})
|
||||
go startKeepalive(cw, done)
|
||||
|
||||
s.mu.Lock()
|
||||
s.admins[cw] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
close(done)
|
||||
s.mu.Lock()
|
||||
delete(s.admins, cw)
|
||||
s.mu.Unlock()
|
||||
@@ -145,6 +168,12 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
||||
return
|
||||
}
|
||||
cw := &connWrap{conn: conn}
|
||||
_ = conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
return conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||
})
|
||||
done := make(chan struct{})
|
||||
go startKeepalive(cw, done)
|
||||
|
||||
version := r.URL.Query().Get("version")
|
||||
httpVal := parseIntDefault(r.URL.Query().Get("http"), 0)
|
||||
@@ -164,7 +193,15 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
||||
_ = s.repo.UpdateNodeOnline(nodeID, 1, version, httpVal, tlsVal, socksVal)
|
||||
s.broadcastStatus(nodeID, 1)
|
||||
|
||||
s.mu.RLock()
|
||||
onlineHook := s.onNodeOnline
|
||||
s.mu.RUnlock()
|
||||
if onlineHook != nil {
|
||||
go onlineHook(nodeID)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
close(done)
|
||||
needOfflineBroadcast := false
|
||||
s.mu.Lock()
|
||||
current, ok := s.nodes[nodeID]
|
||||
@@ -190,7 +227,15 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
||||
|
||||
msg := decryptIfNeeded(payload, secret)
|
||||
s.tryResolvePending(nodeID, msg)
|
||||
s.broadcastInfo(nodeID, msg)
|
||||
|
||||
var parsed struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if json.Unmarshal([]byte(msg), &parsed) == nil && parsed.Type == "UpgradeProgress" {
|
||||
s.broadcastTyped(nodeID, "upgrade_progress", msg)
|
||||
} else {
|
||||
s.broadcastInfo(nodeID, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +309,9 @@ func (s *Server) SendCommand(nodeID int64, cmdType string, data interface{}, tim
|
||||
}
|
||||
|
||||
ns.conn.mu.Lock()
|
||||
_ = ns.conn.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||
err = ns.conn.conn.WriteMessage(websocket.TextMessage, messageData)
|
||||
_ = ns.conn.conn.SetWriteDeadline(time.Time{})
|
||||
ns.conn.mu.Unlock()
|
||||
if err != nil {
|
||||
cleanup()
|
||||
@@ -385,6 +432,12 @@ func (s *Server) broadcastInfo(nodeID int64, data string) {
|
||||
s.broadcastToAdmins(string(raw))
|
||||
}
|
||||
|
||||
func (s *Server) broadcastTyped(nodeID int64, msgType string, data string) {
|
||||
payload := broadcastMessage{ID: nodeID, Type: msgType, Data: data}
|
||||
raw, _ := json.Marshal(payload)
|
||||
s.broadcastToAdmins(string(raw))
|
||||
}
|
||||
|
||||
func (s *Server) broadcastToAdmins(message string) {
|
||||
s.mu.RLock()
|
||||
admins := make([]*connWrap, 0, len(s.admins))
|
||||
@@ -395,7 +448,9 @@ func (s *Server) broadcastToAdmins(message string) {
|
||||
|
||||
for _, c := range admins {
|
||||
c.mu.Lock()
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||
err := c.conn.WriteMessage(websocket.TextMessage, []byte(message))
|
||||
_ = c.conn.SetWriteDeadline(time.Time{})
|
||||
c.mu.Unlock()
|
||||
if err != nil {
|
||||
log.Printf("websocket broadcast failed: %v", err)
|
||||
@@ -428,3 +483,28 @@ func parseIntDefault(v string, fallback int) int {
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func startKeepalive(cw *connWrap, done <-chan struct{}) {
|
||||
if cw == nil || cw.conn == nil {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(wsPingPeriod)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
cw.mu.Lock()
|
||||
_ = cw.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||
err := cw.conn.WriteMessage(websocket.PingMessage, nil)
|
||||
_ = cw.conn.SetWriteDeadline(time.Time{})
|
||||
cw.mu.Unlock()
|
||||
if err != nil {
|
||||
_ = cw.conn.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestForwardBatchDeleteReturnsFailureReasonsContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, _ := setupContractRouter(t, secret)
|
||||
adminToken := mustAdminToken(t, secret)
|
||||
|
||||
out := postBatchRequest(t, router, adminToken, "/api/v1/forward/batch-delete", `{"ids":[999]}`)
|
||||
result := mustBatchResult(t, out)
|
||||
assertBatchFailureReasonContains(t, result, "转发不存在")
|
||||
}
|
||||
|
||||
func TestForwardBatchPauseReturnsFailureReasonsContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, _ := setupContractRouter(t, secret)
|
||||
adminToken := mustAdminToken(t, secret)
|
||||
|
||||
out := postBatchRequest(t, router, adminToken, "/api/v1/forward/batch-pause", `{"ids":[999]}`)
|
||||
result := mustBatchResult(t, out)
|
||||
assertBatchFailureReasonContains(t, result, "转发不存在")
|
||||
}
|
||||
|
||||
func TestForwardBatchResumeReturnsFailureReasonsContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
adminToken := mustAdminToken(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
forwardID := seedForwardForBatchAction(t, repo, batchForwardSeedOptions{
|
||||
Now: now,
|
||||
TunnelName: "resume-detail-tunnel",
|
||||
ForwardName: "resume-detail-forward",
|
||||
CreateUserTunnel: true,
|
||||
UserTunnelStatus: 0,
|
||||
})
|
||||
|
||||
out := postBatchRequest(t, router, adminToken, "/api/v1/forward/batch-resume", `{"ids":[`+jsonNumber(forwardID)+`]}`)
|
||||
result := mustBatchResult(t, out)
|
||||
assertBatchFailureNameAndReason(t, result, "resume-detail-forward", "该隧道已禁用")
|
||||
}
|
||||
|
||||
func TestForwardBatchChangeTunnelReturnsFailureReasonsContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
adminToken := mustAdminToken(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
forwardID := seedForwardForBatchAction(t, repo, batchForwardSeedOptions{
|
||||
Now: now,
|
||||
TunnelName: "change-detail-tunnel",
|
||||
ForwardName: "change-detail-forward",
|
||||
})
|
||||
tunnelID := mustQueryInt64(t, repo, `SELECT tunnel_id FROM forward WHERE id = ?`, forwardID)
|
||||
|
||||
payload := `{"forwardIds":[` + jsonNumber(forwardID) + `],"targetTunnelId":` + jsonNumber(tunnelID) + `}`
|
||||
out := postBatchRequest(t, router, adminToken, "/api/v1/forward/batch-change-tunnel", payload)
|
||||
result := mustBatchResult(t, out)
|
||||
assertBatchFailureNameAndReason(t, result, "change-detail-forward", "规则已在目标隧道中")
|
||||
}
|
||||
|
||||
func TestTunnelBatchDeleteReturnsFailureReasonsContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, _ := setupContractRouter(t, secret)
|
||||
adminToken := mustAdminToken(t, secret)
|
||||
|
||||
out := postBatchRequest(t, router, adminToken, "/api/v1/tunnel/batch-delete", `{"ids":[999]}`)
|
||||
result := mustBatchResult(t, out)
|
||||
assertBatchFailureReasonContains(t, result, "隧道不存在")
|
||||
}
|
||||
|
||||
type batchForwardSeedOptions struct {
|
||||
Now int64
|
||||
TunnelName string
|
||||
ForwardName string
|
||||
CreateUserTunnel bool
|
||||
UserTunnelStatus int
|
||||
}
|
||||
|
||||
func mustAdminToken(t *testing.T, secret string) string {
|
||||
t.Helper()
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func postBatchRequest(t *testing.T, router http.Handler, token, path, payload string) response.R {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(payload))
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected API success envelope, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mustBatchResult(t *testing.T, out response.R) map[string]interface{} {
|
||||
t.Helper()
|
||||
result, ok := out.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected map result, got %T", out.Data)
|
||||
}
|
||||
if int(result["failCount"].(float64)) != 1 {
|
||||
t.Fatalf("expected failCount=1, got %v", result["failCount"])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func assertBatchFailureReasonContains(t *testing.T, result map[string]interface{}, snippet string) {
|
||||
t.Helper()
|
||||
failures, ok := result["failures"].([]interface{})
|
||||
if !ok || len(failures) != 1 {
|
||||
t.Fatalf("expected exactly one failure detail, got %#v", result["failures"])
|
||||
}
|
||||
first, ok := failures[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected failure detail object, got %T", failures[0])
|
||||
}
|
||||
reason, _ := first["reason"].(string)
|
||||
if !strings.Contains(reason, snippet) {
|
||||
t.Fatalf("expected failure reason to contain %q, got %q", snippet, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func assertBatchFailureNameAndReason(t *testing.T, result map[string]interface{}, expectedName, reasonSnippet string) {
|
||||
t.Helper()
|
||||
failures, ok := result["failures"].([]interface{})
|
||||
if !ok || len(failures) != 1 {
|
||||
t.Fatalf("expected exactly one failure detail, got %#v", result["failures"])
|
||||
}
|
||||
first, ok := failures[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected failure detail object, got %T", failures[0])
|
||||
}
|
||||
gotName, _ := first["name"].(string)
|
||||
if strings.TrimSpace(gotName) != expectedName {
|
||||
t.Fatalf("expected failure name %q, got %q", expectedName, gotName)
|
||||
}
|
||||
reason, _ := first["reason"].(string)
|
||||
if !strings.Contains(reason, reasonSnippet) {
|
||||
t.Fatalf("expected failure reason to contain %q, got %q", reasonSnippet, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func seedForwardForBatchAction(t *testing.T, repo *repo.Repository, opts batchForwardSeedOptions) int64 {
|
||||
t.Helper()
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'batch_action_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, opts.Now, opts.Now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, opts.TunnelName, 1.0, 1, "tls", 99999, opts.Now, opts.Now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, opts.TunnelName)
|
||||
|
||||
if opts.CreateUserTunnel {
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(20, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, ?)
|
||||
`, tunnelID, opts.UserTunnelStatus).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(2, 'batch_action_user', ?, ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, opts.ForwardName, tunnelID, opts.Now, opts.Now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
|
||||
return mustLastInsertID(t, repo, opts.ForwardName)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func TestForwardBatchRedeployReturnsFailureReasonsContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'batch_redeploy_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "batch-redeploy-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "batch-redeploy-tunnel")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, 0)
|
||||
`, 2, "batch_redeploy_user", "redeploy-forward", tunnelID, "1.1.1.1:443", "fifo", now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, repo, "redeploy-forward")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/batch-redeploy", bytes.NewBufferString(`{"ids":[`+jsonNumber(forwardID)+`]}`))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected API success envelope, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
result, ok := out.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected map result, got %T", out.Data)
|
||||
}
|
||||
if int(result["failCount"].(float64)) != 1 {
|
||||
t.Fatalf("expected failCount=1, got %v", result["failCount"])
|
||||
}
|
||||
if int(result["successCount"].(float64)) != 0 {
|
||||
t.Fatalf("expected successCount=0, got %v", result["successCount"])
|
||||
}
|
||||
|
||||
failures, ok := result["failures"].([]interface{})
|
||||
if !ok || len(failures) != 1 {
|
||||
t.Fatalf("expected exactly one failure detail, got %#v", result["failures"])
|
||||
}
|
||||
first, ok := failures[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected failure detail object, got %T", failures[0])
|
||||
}
|
||||
if gotName := strings.TrimSpace(first["name"].(string)); gotName != "redeploy-forward" {
|
||||
t.Fatalf("expected failure name redeploy-forward, got %q", gotName)
|
||||
}
|
||||
reason, _ := first["reason"].(string)
|
||||
if !strings.Contains(reason, "转发入口端口不存在") {
|
||||
t.Fatalf("expected forward failure reason to mention missing entry port, got %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelBatchRedeployReturnsFailureReasonsContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "broken-redeploy-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "broken-redeploy-tunnel")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "entry-only-node", "entry-only-secret", "10.0.0.20", "10.0.0.20", "", "20000-20010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
entryNodeID := mustLastInsertID(t, repo, "entry-only-node")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 20001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryNodeID).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy", bytes.NewBufferString(`{"ids":[`+jsonNumber(tunnelID)+`]}`))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected API success envelope, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
result, ok := out.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected map result, got %T", out.Data)
|
||||
}
|
||||
if int(result["failCount"].(float64)) != 1 {
|
||||
t.Fatalf("expected failCount=1, got %v", result["failCount"])
|
||||
}
|
||||
|
||||
failures, ok := result["failures"].([]interface{})
|
||||
if !ok || len(failures) != 1 {
|
||||
t.Fatalf("expected exactly one failure detail, got %#v", result["failures"])
|
||||
}
|
||||
first, ok := failures[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected failure detail object, got %T", failures[0])
|
||||
}
|
||||
if gotName := strings.TrimSpace(first["name"].(string)); gotName != "broken-redeploy-tunnel" {
|
||||
t.Fatalf("expected failure name broken-redeploy-tunnel, got %q", gotName)
|
||||
}
|
||||
reason, _ := first["reason"].(string)
|
||||
if !strings.Contains(reason, "转发链目标不能为空") {
|
||||
t.Fatalf("expected tunnel failure reason to mention missing target, got %q", reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func mustLastInsertID(t *testing.T, r *repo.Repository, label string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
if err := r.DB().Raw("SELECT last_insert_rowid()").Row().Scan(&id); err != nil {
|
||||
t.Fatalf("read last_insert_rowid for %s: %v", label, err)
|
||||
}
|
||||
if id <= 0 {
|
||||
t.Fatalf("invalid last_insert_rowid for %s: %d", label, id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func mustQueryInt(t *testing.T, r *repo.Repository, query string, args ...interface{}) int {
|
||||
t.Helper()
|
||||
var v int
|
||||
if err := r.DB().Raw(query, args...).Row().Scan(&v); err != nil {
|
||||
t.Fatalf("query int failed: %v (query=%q)", err, query)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func mustQueryInt64(t *testing.T, r *repo.Repository, query string, args ...interface{}) int64 {
|
||||
t.Helper()
|
||||
var v int64
|
||||
if err := r.DB().Raw(query, args...).Row().Scan(&v); err != nil {
|
||||
t.Fatalf("query int64 failed: %v (query=%q)", err, query)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func mustQueryString(t *testing.T, r *repo.Repository, query string, args ...interface{}) string {
|
||||
t.Helper()
|
||||
var v string
|
||||
if err := r.DB().Raw(query, args...).Row().Scan(&v); err != nil {
|
||||
t.Fatalf("query string failed: %v (query=%q)", err, query)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func mustQueryInt64Int(t *testing.T, r *repo.Repository, query string, args ...interface{}) (int64, int) {
|
||||
t.Helper()
|
||||
var a int64
|
||||
var b int
|
||||
if err := r.DB().Raw(query, args...).Row().Scan(&a, &b); err != nil {
|
||||
t.Fatalf("query int64+int failed: %v (query=%q)", err, query)
|
||||
}
|
||||
return a, b
|
||||
}
|
||||
|
||||
func tryQueryString(t *testing.T, r *repo.Repository, query string, args ...interface{}) (string, error) {
|
||||
t.Helper()
|
||||
var v string
|
||||
err := r.DB().Raw(query, args...).Row().Scan(&v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func mustQueryNullString(t *testing.T, r *repo.Repository, query string, args ...interface{}) sql.NullString {
|
||||
t.Helper()
|
||||
var v sql.NullString
|
||||
if err := r.DB().Raw(query, args...).Row().Scan(&v); err != nil {
|
||||
t.Fatalf("query null string failed: %v (query=%q)", err, query)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func mustQueryTwoNullStrings(t *testing.T, r *repo.Repository, query string, args ...interface{}) (sql.NullString, sql.NullString) {
|
||||
t.Helper()
|
||||
var a sql.NullString
|
||||
var b sql.NullString
|
||||
if err := r.DB().Raw(query, args...).Row().Scan(&a, &b); err != nil {
|
||||
t.Fatalf("query two null strings failed: %v (query=%q)", err, query)
|
||||
}
|
||||
return a, b
|
||||
}
|
||||
|
||||
func mustQueryNodePorts(t *testing.T, r *repo.Repository, query string, args ...interface{}) map[int64]int {
|
||||
t.Helper()
|
||||
rows, err := r.DB().Raw(query, args...).Rows()
|
||||
if err != nil {
|
||||
t.Fatalf("query node ports failed: %v (query=%q)", err, query)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[int64]int)
|
||||
for rows.Next() {
|
||||
var nodeID int64
|
||||
var port int
|
||||
if err := rows.Scan(&nodeID, &port); err != nil {
|
||||
t.Fatalf("scan node ports row failed: %v (query=%q)", err, query)
|
||||
}
|
||||
out[nodeID] = port
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("iterate node ports rows failed: %v (query=%q)", err, query)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tryQueryInt(t *testing.T, r *repo.Repository, query string, args ...interface{}) (int, error) {
|
||||
t.Helper()
|
||||
var v int
|
||||
err := r.DB().Raw(query, args...).Row().Scan(&v)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func valueAsInt(v interface{}) int {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func valueAsString(v interface{}) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func valueAsBool(v interface{}) bool {
|
||||
switch b := v.(type) {
|
||||
case bool:
|
||||
return b
|
||||
case float64:
|
||||
return b != 0
|
||||
case int:
|
||||
return b != 0
|
||||
case int64:
|
||||
return b != 0
|
||||
case string:
|
||||
s := strings.TrimSpace(strings.ToLower(b))
|
||||
return s == "1" || s == "t" || s == "true" || s == "yes" || s == "y"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func jsonInt64(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
package contract
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
@@ -13,85 +13,69 @@ import (
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
httpserver "go-backend/internal/http"
|
||||
"go-backend/internal/http/handler"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/sqlite"
|
||||
)
|
||||
|
||||
func TestDiagnosisChainCoverageContracts(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupDiagnosisContractRouter(t, secret)
|
||||
router, r := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'normal_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now); err != nil {
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
tunnelRes, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "diagnose-chain-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0)
|
||||
if err != nil {
|
||||
`, "diagnose-chain-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID, err := tunnelRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get tunnel id: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "diagnose-chain-tunnel")
|
||||
|
||||
insertNode := func(name, ip string) int64 {
|
||||
res, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", ip, ip, "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
|
||||
if err != nil {
|
||||
`, name, name+"-secret", ip, ip, "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get node id %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
entryNodeID := insertNode("entry-node", "10.0.1.10")
|
||||
chainNodeID := insertNode("chain-node", "10.0.1.20")
|
||||
exitNodeID := insertNode("exit-node", "10.0.1.30")
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 30001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryNodeID); err != nil {
|
||||
`, tunnelID, entryNodeID).Error; err != nil {
|
||||
t.Fatalf("insert entry chain: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 2, ?, 30002, 'round', 1, 'tls')
|
||||
`, tunnelID, chainNodeID); err != nil {
|
||||
`, tunnelID, chainNodeID).Error; err != nil {
|
||||
t.Fatalf("insert middle chain: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 3, ?, 30003, 'round', 1, 'tls')
|
||||
`, tunnelID, exitNodeID); err != nil {
|
||||
`, tunnelID, exitNodeID).Error; err != nil {
|
||||
t.Fatalf("insert exit chain: %v", err)
|
||||
}
|
||||
|
||||
forwardRes, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
|
||||
`, 2, "normal_user", "chain-forward", tunnelID, "8.8.8.8:53", "fifo", now, now, 0)
|
||||
if err != nil {
|
||||
`, 2, "normal_user", "chain-forward", tunnelID, "8.8.8.8:53", "fifo", now, now, 0).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID, err := forwardRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get forward id: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, "chain-forward")
|
||||
|
||||
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
|
||||
if err != nil {
|
||||
@@ -206,9 +190,129 @@ func TestDiagnosisChainCoverageContracts(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestForwardDiagnosisRespectsTunnelIPPreferenceContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'normal_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "diagnose-ip-pref-forward", 1.0, 2, "tls", 99999, now, now, 1, nil, 0, "v6").Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "diagnose-ip-pref-forward")
|
||||
|
||||
insertNode := func(name, v4, v6 string) int64 {
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", v4, v4, v6, "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
entryNodeID := insertNode("entry-node-v6", "10.10.1.10", "2001:db8:10::10")
|
||||
chainNodeID := insertNode("chain-node-v6", "10.10.1.20", "2001:db8:10::20")
|
||||
exitNodeID := insertNode("exit-node-v6", "10.10.1.30", "2001:db8:10::30")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 30001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryNodeID).Error; err != nil {
|
||||
t.Fatalf("insert entry chain: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 2, ?, 30002, 'round', 1, 'tls')
|
||||
`, tunnelID, chainNodeID).Error; err != nil {
|
||||
t.Fatalf("insert middle chain: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 3, ?, 30003, 'round', 1, 'tls')
|
||||
`, tunnelID, exitNodeID).Error; err != nil {
|
||||
t.Fatalf("insert exit chain: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
|
||||
`, 2, "normal_user", "ip-pref-forward", tunnelID, "8.8.8.8:53", "fifo", now, now, 0).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, "ip-pref-forward")
|
||||
|
||||
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate user token: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/diagnose", bytes.NewBufferString(`{"forwardId":`+strconv.FormatInt(forwardID, 10)+`}`))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
payload, ok := out.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected object payload, got %T", out.Data)
|
||||
}
|
||||
results, ok := payload["results"].([]interface{})
|
||||
if !ok || len(results) == 0 {
|
||||
t.Fatalf("expected non-empty results, got %v", payload["results"])
|
||||
}
|
||||
|
||||
hasEntryToChain := false
|
||||
hasChainToExit := false
|
||||
for _, raw := range results {
|
||||
item, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
from := valueAsInt(item["fromChainType"])
|
||||
to := valueAsInt(item["toChainType"])
|
||||
targetIP := strings.TrimSpace(valueAsString(item["targetIp"]))
|
||||
|
||||
if from == 1 && to == 2 {
|
||||
hasEntryToChain = true
|
||||
if targetIP != "2001:db8:10::20" {
|
||||
t.Fatalf("expected entry->chain diagnosis target to use IPv6, got %q", targetIP)
|
||||
}
|
||||
}
|
||||
|
||||
if from == 2 && to == 3 {
|
||||
hasChainToExit = true
|
||||
if targetIP != "2001:db8:10::30" {
|
||||
t.Fatalf("expected chain->exit diagnosis target to use IPv6, got %q", targetIP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasEntryToChain || !hasChainToExit {
|
||||
t.Fatalf("expected entry->chain and chain->exit steps, got entry=%v chain=%v", hasEntryToChain, hasChainToExit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosisUsesFederationRuntimeForRemoteNodes(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupDiagnosisContractRouter(t, secret)
|
||||
router, r := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
remoteToken := "remote-diagnose-token"
|
||||
@@ -256,67 +360,53 @@ func TestDiagnosisUsesFederationRuntimeForRemoteNodes(t *testing.T) {
|
||||
defer remoteServer.Close()
|
||||
|
||||
insertLocalNode := func(name, ip string) int64 {
|
||||
res, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", ip, ip, "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
|
||||
if err != nil {
|
||||
`, name, name+"-secret", ip, ip, "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert local node %s: %v", name, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get local node id %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
insertRemoteNode := func(name, ip string) int64 {
|
||||
res, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 1, ?, ?, ?, 1, ?, ?, ?)
|
||||
`, name, name+"-secret", ip, "", "", "31000-31010", "", "", now, now, "[::]", "[::]", 1, remoteServer.URL, remoteToken, `{"shareId": 123}`)
|
||||
if err != nil {
|
||||
`, name, name+"-secret", ip, "", "", "31000-31010", "", "", now, now, "[::]", "[::]", 1, remoteServer.URL, remoteToken, `{"shareId": 123}`).Error; err != nil {
|
||||
t.Fatalf("insert remote node %s: %v", name, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get remote node id %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
entryNodeID := insertLocalNode("entry-local", "10.50.0.10")
|
||||
remoteChainNodeID := insertRemoteNode("middle-remote", "10.50.0.20")
|
||||
exitNodeID := insertLocalNode("exit-local", "10.50.0.30")
|
||||
|
||||
tunnelRes, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "diagnose-remote-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0)
|
||||
if err != nil {
|
||||
`, "diagnose-remote-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID, err := tunnelRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get tunnel id: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "diagnose-remote-tunnel")
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 30001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryNodeID); err != nil {
|
||||
`, tunnelID, entryNodeID).Error; err != nil {
|
||||
t.Fatalf("insert entry chain: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 2, ?, 30002, 'round', 1, 'tls')
|
||||
`, tunnelID, remoteChainNodeID); err != nil {
|
||||
`, tunnelID, remoteChainNodeID).Error; err != nil {
|
||||
t.Fatalf("insert middle chain: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 3, ?, 30003, 'round', 1, 'tls')
|
||||
`, tunnelID, exitNodeID); err != nil {
|
||||
`, tunnelID, exitNodeID).Error; err != nil {
|
||||
t.Fatalf("insert exit chain: %v", err)
|
||||
}
|
||||
|
||||
@@ -373,53 +463,166 @@ func TestDiagnosisUsesFederationRuntimeForRemoteNodes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func valueAsInt(v interface{}) int {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
default:
|
||||
return 0
|
||||
func TestTunnelDiagnosisUsesConfiguredConnectIPContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
insertNode := func(name, ip string) int64 {
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", ip, ip, "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
}
|
||||
|
||||
func valueAsString(v interface{}) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
entryNodeID := insertNode("entry-connectip", "10.80.0.10")
|
||||
middleNodeID := insertNode("middle-connectip", "10.80.0.20")
|
||||
exitNodeID := insertNode("exit-connectip", "10.80.0.30")
|
||||
|
||||
func valueAsBool(v interface{}) bool {
|
||||
switch b := v.(type) {
|
||||
case bool:
|
||||
return b
|
||||
case float64:
|
||||
return b != 0
|
||||
case int:
|
||||
return b != 0
|
||||
case int64:
|
||||
return b != 0
|
||||
case string:
|
||||
s := strings.TrimSpace(strings.ToLower(b))
|
||||
return s == "1" || s == "t" || s == "true" || s == "yes" || s == "y"
|
||||
default:
|
||||
return false
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "diagnose-connectip-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "diagnose-connectip-tunnel")
|
||||
|
||||
func setupDiagnosisContractRouter(t *testing.T, jwtSecret string) (http.Handler, *sqlite.Repository) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "diagnosis-contract.db")
|
||||
repo, err := sqlite.Open(dbPath)
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 30001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryNodeID).Error; err != nil {
|
||||
t.Fatalf("insert entry chain: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol, connect_ip)
|
||||
VALUES(?, 2, ?, 30002, 'round', 1, 'tls', ?)
|
||||
`, tunnelID, middleNodeID, "10.99.0.22").Error; err != nil {
|
||||
t.Fatalf("insert middle chain: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol, connect_ip)
|
||||
VALUES(?, 3, ?, 30003, 'round', 1, 'tls', ?)
|
||||
`, tunnelID, exitNodeID, "10.99.0.33").Error; err != nil {
|
||||
t.Fatalf("insert exit chain: %v", err)
|
||||
}
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = repo.Close()
|
||||
|
||||
t.Run("normal diagnose should use configured connectIp", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/diagnose", bytes.NewBufferString(`{"tunnelId":`+strconv.FormatInt(tunnelID, 10)+`}`))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
payload, ok := out.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected object payload, got %T", out.Data)
|
||||
}
|
||||
results, ok := payload["results"].([]interface{})
|
||||
if !ok || len(results) == 0 {
|
||||
t.Fatalf("expected non-empty results, got %v", payload["results"])
|
||||
}
|
||||
|
||||
entryToMiddleOK := false
|
||||
middleToExitOK := false
|
||||
for _, raw := range results {
|
||||
item, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
from := valueAsInt(item["fromChainType"])
|
||||
to := valueAsInt(item["toChainType"])
|
||||
targetIP := strings.TrimSpace(valueAsString(item["targetIp"]))
|
||||
|
||||
if from == 1 && to == 2 && targetIP == "10.99.0.22" {
|
||||
entryToMiddleOK = true
|
||||
}
|
||||
if from == 2 && to == 3 && targetIP == "10.99.0.33" {
|
||||
middleToExitOK = true
|
||||
}
|
||||
}
|
||||
|
||||
if !entryToMiddleOK || !middleToExitOK {
|
||||
t.Fatalf("expected connectIp targets 10.99.0.22/10.99.0.33, got entry=%v middle=%v", entryToMiddleOK, middleToExitOK)
|
||||
}
|
||||
})
|
||||
|
||||
h := handler.New(repo, jwtSecret)
|
||||
return httpserver.NewRouter(h, jwtSecret), repo
|
||||
t.Run("stream diagnose start items should use configured connectIp", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/diagnose/stream", bytes.NewBufferString(`{"tunnelId":`+strconv.FormatInt(tunnelID, 10)+`}`))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", res.Code)
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(bytes.NewReader(res.Body.Bytes()))
|
||||
startFound := false
|
||||
entryToMiddleOK := false
|
||||
middleToExitOK := false
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var event map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(valueAsString(event["type"])) != "start" {
|
||||
continue
|
||||
}
|
||||
startFound = true
|
||||
data, ok := event["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
for _, raw := range items {
|
||||
item, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
from := valueAsInt(item["fromChainType"])
|
||||
to := valueAsInt(item["toChainType"])
|
||||
targetIP := strings.TrimSpace(valueAsString(item["targetIp"]))
|
||||
if from == 1 && to == 2 && targetIP == "10.99.0.22" {
|
||||
entryToMiddleOK = true
|
||||
}
|
||||
if from == 2 && to == 3 && targetIP == "10.99.0.33" {
|
||||
middleToExitOK = true
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
t.Fatalf("scan stream body: %v", err)
|
||||
}
|
||||
if !startFound {
|
||||
t.Fatalf("expected start event in stream response")
|
||||
}
|
||||
if !entryToMiddleOK || !middleToExitOK {
|
||||
t.Fatalf("expected start items with connectIp targets 10.99.0.22/10.99.0.33, got entry=%v middle=%v", entryToMiddleOK, middleToExitOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/sqlite"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
|
||||
@@ -39,7 +39,7 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
|
||||
providerMiddleNodeID := insertContractNode(t, providerRepo, "provider-middle", "198.51.100.12", "44000-44010", "provider-middle-secret", 1)
|
||||
providerExitNodeID := insertContractNode(t, providerRepo, "provider-exit", "198.51.100.13", "45000-45010", "provider-exit-secret", 1)
|
||||
|
||||
entryShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
entryShareID := insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "entry-share",
|
||||
NodeID: providerEntryNodeID,
|
||||
Token: "share-entry-token",
|
||||
@@ -49,7 +49,7 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
middleShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
middleShareID := insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "middle-share",
|
||||
NodeID: providerMiddleNodeID,
|
||||
Token: "share-middle-token",
|
||||
@@ -59,7 +59,7 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
exitShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
exitShareID := insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "exit-share",
|
||||
NodeID: providerExitNodeID,
|
||||
Token: "share-exit-token",
|
||||
@@ -78,6 +78,8 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
|
||||
middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-token")
|
||||
exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-token")
|
||||
|
||||
stopEntry := startMockNodeSession(t, providerServer.URL, "provider-entry-secret")
|
||||
defer stopEntry()
|
||||
stopMiddle := startMockNodeSession(t, providerServer.URL, "provider-middle-secret")
|
||||
defer stopMiddle()
|
||||
stopExit := startMockNodeSession(t, providerServer.URL, "provider-exit-secret")
|
||||
@@ -110,10 +112,7 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
|
||||
consumerRouter.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
|
||||
var tunnelID int64
|
||||
if err := consumerRepo.DB().QueryRow(`SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, name).Scan(&tunnelID); err != nil {
|
||||
t.Fatalf("query tunnel id (%s): %v", name, err)
|
||||
}
|
||||
tunnelID := mustQueryInt64(t, consumerRepo, `SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, name)
|
||||
if tunnelID <= 0 {
|
||||
t.Fatalf("invalid tunnel id for %s", name)
|
||||
}
|
||||
@@ -148,9 +147,26 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
|
||||
assertTunnelPortInRange(t, consumerRepo, secondTunnelID, 2, middleRemoteNodeID, 44000, 44010)
|
||||
assertTunnelPortInRange(t, consumerRepo, secondTunnelID, 3, exitRemoteNodeID, 45000, 45010)
|
||||
|
||||
forwardPayload := map[string]interface{}{
|
||||
"name": "dual-panel-remote-entry-forward",
|
||||
"tunnelId": secondTunnelID,
|
||||
"remoteAddr": "1.1.1.1:443",
|
||||
"strategy": "fifo",
|
||||
}
|
||||
forwardBody, err := json.Marshal(forwardPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal forward payload: %v", err)
|
||||
}
|
||||
forwardReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(forwardBody))
|
||||
forwardReq.Header.Set("Authorization", consumerAdminToken)
|
||||
forwardReq.Header.Set("Content-Type", "application/json")
|
||||
forwardRes := httptest.NewRecorder()
|
||||
consumerRouter.ServeHTTP(forwardRes, forwardReq)
|
||||
assertCode(t, forwardRes, 0)
|
||||
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, middleShareID, 1)
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, exitShareID, 1)
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ?`, entryShareID, 0)
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, entryShareID, 1)
|
||||
}
|
||||
|
||||
func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
|
||||
@@ -172,7 +188,7 @@ func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
|
||||
providerMiddleNodeID := insertContractNode(t, providerRepo, "provider-middle-dx", "203.0.113.12", "54000-54010", "provider-middle-dx-secret", 1)
|
||||
providerExitNodeID := insertContractNode(t, providerRepo, "provider-exit-dx", "203.0.113.13", "55000-55010", "provider-exit-dx-secret", 1)
|
||||
|
||||
entryShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
entryShareID := insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "entry-share-dx",
|
||||
NodeID: providerEntryNodeID,
|
||||
Token: "share-entry-dx-token",
|
||||
@@ -182,7 +198,7 @@ func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
middleShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
middleShareID := insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "middle-share-dx",
|
||||
NodeID: providerMiddleNodeID,
|
||||
Token: "share-middle-dx-token",
|
||||
@@ -192,7 +208,7 @@ func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
exitShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
exitShareID := insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "exit-share-dx",
|
||||
NodeID: providerExitNodeID,
|
||||
Token: "share-exit-dx-token",
|
||||
@@ -242,10 +258,7 @@ func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
|
||||
consumerRouter.ServeHTTP(createRes, createReq)
|
||||
assertCode(t, createRes, 0)
|
||||
|
||||
var tunnelID int64
|
||||
if err := consumerRepo.DB().QueryRow(`SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, "dual-panel-diagnose-remote").Scan(&tunnelID); err != nil {
|
||||
t.Fatalf("query tunnel id: %v", err)
|
||||
}
|
||||
tunnelID := mustQueryInt64(t, consumerRepo, `SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, "dual-panel-diagnose-remote")
|
||||
if tunnelID <= 0 {
|
||||
t.Fatalf("invalid tunnel id")
|
||||
}
|
||||
@@ -297,32 +310,154 @@ func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func insertContractNode(t *testing.T, repo *sqlite.Repository, name, ip, portRange, secret string, status int) int64 {
|
||||
t.Helper()
|
||||
func TestFederationDualPanelRemoteEntryRuntimeContract(t *testing.T) {
|
||||
providerSecret := "provider-contract-jwt"
|
||||
providerRouter, providerRepo := setupContractRouter(t, providerSecret)
|
||||
providerServer := httptest.NewServer(providerRouter)
|
||||
defer providerServer.Close()
|
||||
|
||||
consumerSecret := "consumer-contract-jwt"
|
||||
consumerRouter, consumerRepo := setupContractRouter(t, consumerSecret)
|
||||
|
||||
consumerAdminToken, err := auth.GenerateToken(1, "consumer-admin", 0, consumerSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate consumer admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
res, err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, secret, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
providerEntryNodeID := insertContractNode(t, providerRepo, "provider-entry-rt", "198.51.100.21", "43020-43030", "provider-entry-rt-secret", 1)
|
||||
providerMiddleNodeID := insertContractNode(t, providerRepo, "provider-middle-rt", "198.51.100.22", "44020-44030", "provider-middle-rt-secret", 1)
|
||||
providerExitNodeID := insertContractNode(t, providerRepo, "provider-exit-rt", "198.51.100.23", "45020-45030", "provider-exit-rt-secret", 1)
|
||||
|
||||
insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "entry-share-rt",
|
||||
NodeID: providerEntryNodeID,
|
||||
Token: "share-entry-rt-token",
|
||||
PortRangeStart: 43020,
|
||||
PortRangeEnd: 43030,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "middle-share-rt",
|
||||
NodeID: providerMiddleNodeID,
|
||||
Token: "share-middle-rt-token",
|
||||
PortRangeStart: 44020,
|
||||
PortRangeEnd: 44030,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "exit-share-rt",
|
||||
NodeID: providerExitNodeID,
|
||||
Token: "share-exit-rt-token",
|
||||
PortRangeStart: 45020,
|
||||
PortRangeEnd: 45030,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
|
||||
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-entry-rt-token")
|
||||
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-middle-rt-token")
|
||||
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-exit-rt-token")
|
||||
|
||||
entryRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-entry-rt-token")
|
||||
middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-rt-token")
|
||||
exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-rt-token")
|
||||
|
||||
var commandMu sync.Mutex
|
||||
entryCommands := make([]string, 0, 8)
|
||||
stopEntry := startMockNodeSessionWithHook(t, providerServer.URL, "provider-entry-rt-secret", func(cmdType string) {
|
||||
commandMu.Lock()
|
||||
entryCommands = append(entryCommands, cmdType)
|
||||
commandMu.Unlock()
|
||||
})
|
||||
defer stopEntry()
|
||||
stopMiddle := startMockNodeSession(t, providerServer.URL, "provider-middle-rt-secret")
|
||||
defer stopMiddle()
|
||||
stopExit := startMockNodeSession(t, providerServer.URL, "provider-exit-rt-secret")
|
||||
defer stopExit()
|
||||
|
||||
createTunnel := func(name string) int64 {
|
||||
payload := map[string]interface{}{
|
||||
"name": name,
|
||||
"type": 2,
|
||||
"flow": 99999,
|
||||
"status": 1,
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": entryRemoteNodeID, "protocol": "tls", "strategy": "round"},
|
||||
},
|
||||
"chainNodes": [][]map[string]interface{}{
|
||||
{{"nodeId": middleRemoteNodeID, "protocol": "tls", "strategy": "round"}},
|
||||
},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": exitRemoteNodeID, "protocol": "tls", "strategy": "round"},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", consumerAdminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
consumerRouter.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
|
||||
tunnelID := mustQueryInt64(t, consumerRepo, `SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, name)
|
||||
if tunnelID <= 0 {
|
||||
t.Fatalf("invalid tunnel id for %s", name)
|
||||
}
|
||||
return tunnelID
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("node id %s: %v", name, err)
|
||||
|
||||
createTunnel("dual-panel-remote-entry-online")
|
||||
|
||||
commandMu.Lock()
|
||||
seenAddChains := false
|
||||
seenCommands := append([]string(nil), entryCommands...)
|
||||
for _, cmdType := range entryCommands {
|
||||
if strings.EqualFold(strings.TrimSpace(cmdType), "AddChains") {
|
||||
seenAddChains = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return id
|
||||
commandMu.Unlock()
|
||||
if !seenAddChains {
|
||||
t.Fatalf("expected entry remote node to receive AddChains, commands=%v", seenCommands)
|
||||
}
|
||||
|
||||
stopEntry()
|
||||
waitNodeStatus(t, providerRepo, providerEntryNodeID, 0)
|
||||
|
||||
createTunnel("dual-panel-remote-entry-offline")
|
||||
}
|
||||
|
||||
func insertPeerShare(t *testing.T, repo *sqlite.Repository, share *sqlite.PeerShare) int64 {
|
||||
func insertContractNode(t *testing.T, r *repo.Repository, name, ip, portRange, secret string, status int) int64 {
|
||||
t.Helper()
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, secret, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
func insertPeerShare(t *testing.T, r *repo.Repository, share *repo.PeerShare) int64 {
|
||||
t.Helper()
|
||||
if share == nil {
|
||||
t.Fatalf("share is nil")
|
||||
}
|
||||
if err := repo.CreatePeerShare(share); err != nil {
|
||||
if err := r.CreatePeerShare(share); err != nil {
|
||||
t.Fatalf("create peer share %s: %v", share.Name, err)
|
||||
}
|
||||
saved, err := repo.GetPeerShareByToken(share.Token)
|
||||
saved, err := r.GetPeerShareByToken(share.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("query peer share %s: %v", share.Name, err)
|
||||
}
|
||||
@@ -349,47 +484,36 @@ func importRemoteNodeForContract(t *testing.T, router http.Handler, adminToken,
|
||||
assertCode(t, res, 0)
|
||||
}
|
||||
|
||||
func queryRemoteNodeIDByToken(t *testing.T, repo *sqlite.Repository, token string) int64 {
|
||||
func queryRemoteNodeIDByToken(t *testing.T, r *repo.Repository, token string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
if err := repo.DB().QueryRow(`SELECT id FROM node WHERE is_remote = 1 AND remote_token = ? ORDER BY id DESC LIMIT 1`, token).Scan(&id); err != nil {
|
||||
t.Fatalf("query remote node by token %s: %v", token, err)
|
||||
}
|
||||
id := mustQueryInt64(t, r, `SELECT id FROM node WHERE is_remote = 1 AND remote_token = ? ORDER BY id DESC LIMIT 1`, token)
|
||||
if id <= 0 {
|
||||
t.Fatalf("invalid remote node id for token %s", token)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func assertTunnelPortInRange(t *testing.T, repo *sqlite.Repository, tunnelID int64, chainType int, nodeID int64, minPort int, maxPort int) {
|
||||
func assertTunnelPortInRange(t *testing.T, r *repo.Repository, tunnelID int64, chainType int, nodeID int64, minPort int, maxPort int) {
|
||||
t.Helper()
|
||||
var port int
|
||||
err := repo.DB().QueryRow(`
|
||||
SELECT port
|
||||
FROM chain_tunnel
|
||||
WHERE tunnel_id = ? AND chain_type = ? AND node_id = ?
|
||||
LIMIT 1
|
||||
`, tunnelID, chainType, nodeID).Scan(&port)
|
||||
if err != nil {
|
||||
t.Fatalf("query tunnel=%d chainType=%d node=%d port: %v", tunnelID, chainType, nodeID, err)
|
||||
}
|
||||
port := mustQueryInt(t, r, `SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = ? AND node_id = ? LIMIT 1`, tunnelID, chainType, nodeID)
|
||||
if port < minPort || port > maxPort {
|
||||
t.Fatalf("expected port in range [%d,%d], got %d", minPort, maxPort, port)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCount(t *testing.T, repo *sqlite.Repository, query string, arg interface{}, expected int) {
|
||||
func assertCount(t *testing.T, r *repo.Repository, query string, arg interface{}, expected int) {
|
||||
t.Helper()
|
||||
var got int
|
||||
if err := repo.DB().QueryRow(query, arg).Scan(&got); err != nil {
|
||||
t.Fatalf("count query failed: %v", err)
|
||||
}
|
||||
got := mustQueryInt(t, r, query, arg)
|
||||
if got != expected {
|
||||
t.Fatalf("expected count %d, got %d (query: %s, arg: %v)", expected, got, query, arg)
|
||||
}
|
||||
}
|
||||
|
||||
func startMockNodeSession(t *testing.T, baseURL string, nodeSecret string) func() {
|
||||
return startMockNodeSessionWithHook(t, baseURL, nodeSecret, nil)
|
||||
}
|
||||
|
||||
func startMockNodeSessionWithHook(t *testing.T, baseURL string, nodeSecret string, onCommand func(cmdType string)) func() {
|
||||
t.Helper()
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
@@ -449,6 +573,9 @@ func startMockNodeSession(t *testing.T, baseURL string, nodeSecret string) func(
|
||||
if strings.TrimSpace(cmd.RequestID) == "" {
|
||||
continue
|
||||
}
|
||||
if onCommand != nil {
|
||||
onCommand(strings.TrimSpace(cmd.Type))
|
||||
}
|
||||
|
||||
respType := fmt.Sprintf("%sResponse", cmd.Type)
|
||||
respPayload := map[string]interface{}{
|
||||
@@ -473,44 +600,150 @@ func startMockNodeSession(t *testing.T, baseURL string, nodeSecret string) func(
|
||||
}
|
||||
}()
|
||||
|
||||
var stopOnce sync.Once
|
||||
return func() {
|
||||
_ = conn.Close()
|
||||
wg.Wait()
|
||||
stopOnce.Do(func() {
|
||||
_ = conn.Close()
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func valueAsInt(v interface{}) int {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
default:
|
||||
return 0
|
||||
func waitNodeStatus(t *testing.T, r *repo.Repository, nodeID int64, expectedStatus int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
status, err := tryQueryInt(t, r, `SELECT status FROM node WHERE id = ?`, nodeID)
|
||||
if err == nil && status == expectedStatus {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("node %d status did not reach %d before timeout", nodeID, expectedStatus)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func valueAsString(v interface{}) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
func TestFederationRuntimeCommandPortRangeEnforcement(t *testing.T) {
|
||||
providerSecret := "provider-portrange-jwt"
|
||||
providerRouter, providerRepo := setupContractRouter(t, providerSecret)
|
||||
providerServer := httptest.NewServer(providerRouter)
|
||||
defer providerServer.Close()
|
||||
|
||||
func valueAsBool(v interface{}) bool {
|
||||
switch b := v.(type) {
|
||||
case bool:
|
||||
return b
|
||||
case float64:
|
||||
return b != 0
|
||||
case int:
|
||||
return b != 0
|
||||
case int64:
|
||||
return b != 0
|
||||
case string:
|
||||
s := strings.TrimSpace(strings.ToLower(b))
|
||||
return s == "1" || s == "t" || s == "true" || s == "yes" || s == "y"
|
||||
default:
|
||||
return false
|
||||
now := time.Now().UnixMilli()
|
||||
providerNodeID := insertContractNode(t, providerRepo, "provider-portrange-node", "198.51.100.50", "44000-44010", "provider-portrange-secret", 1)
|
||||
|
||||
insertPeerShare(t, providerRepo, &repo.PeerShare{
|
||||
Name: "portrange-share",
|
||||
NodeID: providerNodeID,
|
||||
Token: "share-portrange-token",
|
||||
PortRangeStart: 44000,
|
||||
PortRangeEnd: 44010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
|
||||
stopNode := startMockNodeSession(t, providerServer.URL, "provider-portrange-secret")
|
||||
defer stopNode()
|
||||
|
||||
sendCommand := func(token string, cmdType string, data interface{}) *httptest.ResponseRecorder {
|
||||
payload := map[string]interface{}{
|
||||
"commandType": cmdType,
|
||||
"data": data,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal command payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/runtime/command", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
providerRouter.ServeHTTP(res, req)
|
||||
return res
|
||||
}
|
||||
|
||||
// Test: AddService with port OUTSIDE allowed range should be rejected
|
||||
outOfRangeData := map[string]interface{}{
|
||||
"services": []map[string]interface{}{
|
||||
{
|
||||
"name": "test_service_tcp",
|
||||
"addr": "[::]:55555",
|
||||
"handler": map[string]interface{}{
|
||||
"type": "tcp",
|
||||
},
|
||||
"listener": map[string]interface{}{
|
||||
"type": "tcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
res := sendCommand("share-portrange-token", "AddService", outOfRangeData)
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 403 {
|
||||
t.Fatalf("expected code 403 for out-of-range port, got %d (msg: %s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
// Test: UpdateService with port OUTSIDE allowed range should be rejected
|
||||
res = sendCommand("share-portrange-token", "UpdateService", outOfRangeData)
|
||||
out = response.R{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 403 {
|
||||
t.Fatalf("expected code 403 for out-of-range UpdateService, got %d (msg: %s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
// Test: AddService with port INSIDE allowed range should succeed
|
||||
inRangeData := map[string]interface{}{
|
||||
"services": []map[string]interface{}{
|
||||
{
|
||||
"name": "test_service_ok_tcp",
|
||||
"addr": "[::]:44005",
|
||||
"handler": map[string]interface{}{
|
||||
"type": "tcp",
|
||||
},
|
||||
"listener": map[string]interface{}{
|
||||
"type": "tcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
res = sendCommand("share-portrange-token", "AddService", inRangeData)
|
||||
out = response.R{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0 for in-range port, got %d (msg: %s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
// Test: Non-service commands should pass through without port validation
|
||||
res = sendCommand("share-portrange-token", "UpdateLimiters", map[string]interface{}{
|
||||
"limiter": "federation-limit-test",
|
||||
"data": map[string]interface{}{
|
||||
"name": "federation-limit-test",
|
||||
"limits": []string{"$ 1MB 1MB"},
|
||||
},
|
||||
})
|
||||
out = response.R{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0 for UpdateLimiters command, got %d (msg: %s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
res = sendCommand("share-portrange-token", "reload", nil)
|
||||
out = response.R{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0 for reload command, got %d (msg: %s)", out.Code, out.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,693 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestFederationForwardCardFlowLinkageContract(t *testing.T) {
|
||||
secret := "federation-forward-flow-contract-jwt"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "flow-local-node", "flow-local-secret", "10.20.30.40", "10.20.30.40", "", "32000-32020", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
|
||||
t.Fatalf("insert local node: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, r, "flow-local-node")
|
||||
|
||||
shareToken := "flow-linkage-share-token"
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "flow-linkage-share",
|
||||
NodeID: nodeID,
|
||||
Token: shareToken,
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 1536,
|
||||
ExpiryTime: 0,
|
||||
PortRangeStart: 32000,
|
||||
PortRangeEnd: 32020,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken(shareToken)
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
tunnelName := fmt.Sprintf("Share-%d-Port-%d", share.ID, 32001)
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, tunnelName, 1, "tcp", 1, now, now, 1, "", 0).Error; err != nil {
|
||||
t.Fatalf("insert share tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "flow-share-tunnel")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 1, "admin_user", "flow-linkage-forward", tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, "flow-linkage-forward")
|
||||
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, 32001).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
forwardOut := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
|
||||
if forwardOut.Code != 0 {
|
||||
t.Fatalf("forward list failed: code=%d msg=%q", forwardOut.Code, forwardOut.Msg)
|
||||
}
|
||||
forwardRows := mustContractSlice(t, forwardOut.Data, "forward list data")
|
||||
|
||||
var targetForward map[string]interface{}
|
||||
for _, row := range forwardRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if contractValueAsInt64(m["id"]) == forwardID {
|
||||
targetForward = m
|
||||
break
|
||||
}
|
||||
}
|
||||
if targetForward == nil {
|
||||
t.Fatalf("target forward %d not found in /forward/list response", forwardID)
|
||||
}
|
||||
|
||||
shareOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/list", nil)
|
||||
if shareOut.Code != 0 {
|
||||
t.Fatalf("share list failed: code=%d msg=%q", shareOut.Code, shareOut.Msg)
|
||||
}
|
||||
localShareRows := mustContractSlice(t, shareOut.Data, "share list data")
|
||||
|
||||
remoteUsageOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/remote-usage/list", nil)
|
||||
if remoteUsageOut.Code != 0 {
|
||||
t.Fatalf("remote usage list failed: code=%d msg=%q", remoteUsageOut.Code, remoteUsageOut.Msg)
|
||||
}
|
||||
remoteUsageRows := mustContractSlice(t, remoteUsageOut.Data, "remote usage data")
|
||||
if len(remoteUsageRows) != 0 {
|
||||
t.Fatalf("expected no remote usage rows in local-only fixture, got %d", len(remoteUsageRows))
|
||||
}
|
||||
|
||||
flowByShare := make(map[int64]int64)
|
||||
for _, row := range remoteUsageRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
shareID := contractValueAsInt64(m["shareId"])
|
||||
currentFlow := contractValueAsInt64(m["currentFlow"])
|
||||
if shareID > 0 && currentFlow > 0 {
|
||||
if currentFlow > flowByShare[shareID] {
|
||||
flowByShare[shareID] = currentFlow
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, row := range localShareRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
shareID := contractValueAsInt64(m["id"])
|
||||
currentFlow := contractValueAsInt64(m["currentFlow"])
|
||||
if shareID > 0 && currentFlow > 0 {
|
||||
if currentFlow > flowByShare[shareID] {
|
||||
flowByShare[shareID] = currentFlow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parsedShareID := contractParseShareIDFromTunnelName(contractValueAsString(targetForward["tunnelName"]))
|
||||
if parsedShareID != share.ID {
|
||||
t.Fatalf("expected parsed shareID=%d, got %d (tunnelName=%q)", share.ID, parsedShareID, contractValueAsString(targetForward["tunnelName"]))
|
||||
}
|
||||
|
||||
forwardCountByShare := make(map[int64]int)
|
||||
for _, row := range forwardRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sid := contractParseShareIDFromTunnelName(contractValueAsString(m["tunnelName"]))
|
||||
if sid > 0 && flowByShare[sid] > 0 {
|
||||
forwardCountByShare[sid] = forwardCountByShare[sid] + 1
|
||||
}
|
||||
}
|
||||
|
||||
directFlow := contractValueAsInt64(targetForward["inFlow"]) + contractValueAsInt64(targetForward["outFlow"])
|
||||
if directFlow != 0 {
|
||||
t.Fatalf("fixture expectation failed: directFlow should be 0, got %d", directFlow)
|
||||
}
|
||||
|
||||
shareFlow := flowByShare[parsedShareID]
|
||||
if shareFlow <= 0 {
|
||||
t.Fatalf("expected merged share flow > 0 for share %d", parsedShareID)
|
||||
}
|
||||
|
||||
count := forwardCountByShare[parsedShareID]
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
estimated := shareFlow / int64(count)
|
||||
if estimated < 1 {
|
||||
estimated = 1
|
||||
}
|
||||
displayFlow := estimated
|
||||
|
||||
if displayFlow <= 0 {
|
||||
t.Fatalf("expected displayFlow > 0 after frontend-style merge, got %d", displayFlow)
|
||||
}
|
||||
if displayFlow != share.CurrentFlow {
|
||||
t.Fatalf("expected displayFlow=%d, got %d", share.CurrentFlow, displayFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationForwardCardFlowLinkageContractSplitShareFlowAcrossMultipleForwards(t *testing.T) {
|
||||
secret := "federation-forward-split-flow-contract-jwt"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "flow-split-local-node", "flow-split-local-secret", "10.21.31.41", "10.21.31.41", "", "32100-32120", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
|
||||
t.Fatalf("insert local node: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, r, "flow-split-local-node")
|
||||
|
||||
shareToken := "flow-split-share-token"
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "flow-split-share",
|
||||
NodeID: nodeID,
|
||||
Token: shareToken,
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 4097,
|
||||
ExpiryTime: 0,
|
||||
PortRangeStart: 32100,
|
||||
PortRangeEnd: 32120,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken(shareToken)
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
createShareForward := func(name string, port int) int64 {
|
||||
t.Helper()
|
||||
|
||||
tunnelName := fmt.Sprintf("Share-%d-Port-%d", share.ID, port)
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, tunnelName, 1, "tcp", 1, now, now, 1, "", 0).Error; err != nil {
|
||||
t.Fatalf("insert share tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "flow-split-tunnel")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 1, "admin_user", name, tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, name)
|
||||
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
return forwardID
|
||||
}
|
||||
|
||||
forwardIDA := createShareForward("flow-split-forward-a", 32101)
|
||||
forwardIDB := createShareForward("flow-split-forward-b", 32102)
|
||||
|
||||
forwardOut := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
|
||||
if forwardOut.Code != 0 {
|
||||
t.Fatalf("forward list failed: code=%d msg=%q", forwardOut.Code, forwardOut.Msg)
|
||||
}
|
||||
forwardRows := mustContractSlice(t, forwardOut.Data, "forward list data")
|
||||
|
||||
shareOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/list", nil)
|
||||
if shareOut.Code != 0 {
|
||||
t.Fatalf("share list failed: code=%d msg=%q", shareOut.Code, shareOut.Msg)
|
||||
}
|
||||
localShareRows := mustContractSlice(t, shareOut.Data, "share list data")
|
||||
|
||||
remoteUsageOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/remote-usage/list", nil)
|
||||
if remoteUsageOut.Code != 0 {
|
||||
t.Fatalf("remote usage list failed: code=%d msg=%q", remoteUsageOut.Code, remoteUsageOut.Msg)
|
||||
}
|
||||
remoteUsageRows := mustContractSlice(t, remoteUsageOut.Data, "remote usage data")
|
||||
if len(remoteUsageRows) != 0 {
|
||||
t.Fatalf("expected no remote usage rows in local-only fixture, got %d", len(remoteUsageRows))
|
||||
}
|
||||
|
||||
flowByShare := make(map[int64]int64)
|
||||
for _, row := range remoteUsageRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
shareID := contractValueAsInt64(m["shareId"])
|
||||
currentFlow := contractValueAsInt64(m["currentFlow"])
|
||||
if shareID > 0 && currentFlow > 0 {
|
||||
if currentFlow > flowByShare[shareID] {
|
||||
flowByShare[shareID] = currentFlow
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, row := range localShareRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
shareID := contractValueAsInt64(m["id"])
|
||||
currentFlow := contractValueAsInt64(m["currentFlow"])
|
||||
if shareID > 0 && currentFlow > 0 {
|
||||
if currentFlow > flowByShare[shareID] {
|
||||
flowByShare[shareID] = currentFlow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shareFlow := flowByShare[share.ID]
|
||||
if shareFlow <= 0 {
|
||||
t.Fatalf("expected merged share flow > 0 for share %d", share.ID)
|
||||
}
|
||||
|
||||
forwardCountByShare := make(map[int64]int)
|
||||
for _, row := range forwardRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sid := contractParseShareIDFromTunnelName(contractValueAsString(m["tunnelName"]))
|
||||
if sid > 0 && flowByShare[sid] > 0 {
|
||||
forwardCountByShare[sid] = forwardCountByShare[sid] + 1
|
||||
}
|
||||
}
|
||||
|
||||
count := forwardCountByShare[share.ID]
|
||||
if count != 2 {
|
||||
t.Fatalf("expected 2 forwards sharing share %d, got %d", share.ID, count)
|
||||
}
|
||||
|
||||
expectedEach := shareFlow / int64(count)
|
||||
if expectedEach < 1 {
|
||||
expectedEach = 1
|
||||
}
|
||||
|
||||
findForward := func(forwardID int64) map[string]interface{} {
|
||||
t.Helper()
|
||||
for _, row := range forwardRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if contractValueAsInt64(m["id"]) == forwardID {
|
||||
return m
|
||||
}
|
||||
}
|
||||
t.Fatalf("forward %d not found in /forward/list response", forwardID)
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, forwardID := range []int64{forwardIDA, forwardIDB} {
|
||||
forward := findForward(forwardID)
|
||||
sid := contractParseShareIDFromTunnelName(contractValueAsString(forward["tunnelName"]))
|
||||
if sid != share.ID {
|
||||
t.Fatalf("expected parsed shareID=%d, got %d for forward %d", share.ID, sid, forwardID)
|
||||
}
|
||||
|
||||
directFlow := contractValueAsInt64(forward["inFlow"]) + contractValueAsInt64(forward["outFlow"])
|
||||
if directFlow != 0 {
|
||||
t.Fatalf("fixture expectation failed: directFlow should be 0 for forward %d, got %d", forwardID, directFlow)
|
||||
}
|
||||
|
||||
displayFlow := int64(0)
|
||||
if directFlow > 0 {
|
||||
displayFlow = directFlow
|
||||
} else {
|
||||
shareFlowForForward := flowByShare[sid]
|
||||
if shareFlowForForward > 0 {
|
||||
cnt := forwardCountByShare[sid]
|
||||
if cnt <= 0 {
|
||||
cnt = 1
|
||||
}
|
||||
estimated := shareFlowForForward / int64(cnt)
|
||||
if estimated < 1 {
|
||||
estimated = 1
|
||||
}
|
||||
displayFlow = estimated
|
||||
}
|
||||
}
|
||||
|
||||
if displayFlow <= 0 {
|
||||
t.Fatalf("expected displayFlow > 0 for forward %d, got %d", forwardID, displayFlow)
|
||||
}
|
||||
if displayFlow != expectedEach {
|
||||
t.Fatalf("expected displayFlow=%d for forward %d, got %d", expectedEach, forwardID, displayFlow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationForwardCardFlowLinkageContractResolvesShareByTunnelBindingWhenTunnelNameIsCustom(t *testing.T) {
|
||||
secret := "federation-forward-binding-flow-contract-jwt"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
remoteShareID := int64(901)
|
||||
remoteShareFlow := int64(5000)
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
"flow-binding-remote-node", "flow-binding-remote-secret", "10.31.41.51", "10.31.41.51", "", "33000-33020", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "", "", fmt.Sprintf(`{"shareId":%d,"maxBandwidth":0,"currentFlow":%d,"portRangeStart":33000,"portRangeEnd":33020}`, remoteShareID, remoteShareFlow),
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert remote node: %v", err)
|
||||
}
|
||||
remoteNodeID := mustLastInsertID(t, r, "flow-binding-remote-node")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "federation-port-forward-custom-name", 1, "tcp", 1, now, now, 1, "", 0).Error; err != nil {
|
||||
t.Fatalf("insert custom tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "flow-binding-custom-tunnel")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 1, "admin_user", "flow-binding-forward", tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, "flow-binding-forward")
|
||||
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, remoteNodeID, 33001).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
forwardOut := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
|
||||
if forwardOut.Code != 0 {
|
||||
t.Fatalf("forward list failed: code=%d msg=%q", forwardOut.Code, forwardOut.Msg)
|
||||
}
|
||||
forwardRows := mustContractSlice(t, forwardOut.Data, "forward list data")
|
||||
|
||||
shareOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/list", nil)
|
||||
if shareOut.Code != 0 {
|
||||
t.Fatalf("share list failed: code=%d msg=%q", shareOut.Code, shareOut.Msg)
|
||||
}
|
||||
localShareRows := mustContractSlice(t, shareOut.Data, "share list data")
|
||||
|
||||
remoteUsageOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/remote-usage/list", nil)
|
||||
if remoteUsageOut.Code != 0 {
|
||||
t.Fatalf("remote usage list failed: code=%d msg=%q", remoteUsageOut.Code, remoteUsageOut.Msg)
|
||||
}
|
||||
remoteUsageRows := mustContractSlice(t, remoteUsageOut.Data, "remote usage data")
|
||||
if len(remoteUsageRows) == 0 {
|
||||
t.Fatalf("expected non-empty remote usage rows")
|
||||
}
|
||||
|
||||
findForward := func(id int64) map[string]interface{} {
|
||||
t.Helper()
|
||||
for _, row := range forwardRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if contractValueAsInt64(m["id"]) == id {
|
||||
return m
|
||||
}
|
||||
}
|
||||
t.Fatalf("forward %d not found in /forward/list response", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
flowByShare := make(map[int64]int64)
|
||||
shareIDsByTunnel := make(map[int64]map[int64]struct{})
|
||||
|
||||
for _, row := range remoteUsageRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
shareID := contractValueAsInt64(m["shareId"])
|
||||
currentFlow := contractValueAsInt64(m["currentFlow"])
|
||||
if shareID > 0 && currentFlow > 0 {
|
||||
if currentFlow > flowByShare[shareID] {
|
||||
flowByShare[shareID] = currentFlow
|
||||
}
|
||||
}
|
||||
|
||||
bindings, _ := m["bindings"].([]interface{})
|
||||
for _, bindingRaw := range bindings {
|
||||
binding, ok := bindingRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tunnelIDVal := contractValueAsInt64(binding["tunnelId"])
|
||||
chainType := contractValueAsInt64(binding["chainType"])
|
||||
if shareID <= 0 || tunnelIDVal <= 0 {
|
||||
continue
|
||||
}
|
||||
if chainType != 1 {
|
||||
continue
|
||||
}
|
||||
setByTunnel, ok := shareIDsByTunnel[tunnelIDVal]
|
||||
if !ok {
|
||||
setByTunnel = make(map[int64]struct{})
|
||||
shareIDsByTunnel[tunnelIDVal] = setByTunnel
|
||||
}
|
||||
setByTunnel[shareID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, row := range localShareRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
shareID := contractValueAsInt64(m["id"])
|
||||
currentFlow := contractValueAsInt64(m["currentFlow"])
|
||||
if shareID > 0 && currentFlow > 0 {
|
||||
if currentFlow > flowByShare[shareID] {
|
||||
flowByShare[shareID] = currentFlow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
targetForward := findForward(forwardID)
|
||||
parsedByName := contractParseShareIDFromTunnelName(contractValueAsString(targetForward["tunnelName"]))
|
||||
if parsedByName != 0 {
|
||||
t.Fatalf("expected custom tunnel name cannot be parsed as Share-*-Port-*, got %d", parsedByName)
|
||||
}
|
||||
|
||||
resolveShareIDForForward := func(forward map[string]interface{}) int64 {
|
||||
candidates := make(map[int64]struct{})
|
||||
|
||||
shareIDFromName := contractParseShareIDFromTunnelName(contractValueAsString(forward["tunnelName"]))
|
||||
if shareIDFromName > 0 {
|
||||
candidates[shareIDFromName] = struct{}{}
|
||||
}
|
||||
|
||||
tunnelIDVal := contractValueAsInt64(forward["tunnelId"])
|
||||
if setByTunnel, ok := shareIDsByTunnel[tunnelIDVal]; ok {
|
||||
for sid := range setByTunnel {
|
||||
candidates[sid] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
var bestShareID int64
|
||||
bestFlow := int64(0)
|
||||
for sid := range candidates {
|
||||
flow := flowByShare[sid]
|
||||
if flow > bestFlow {
|
||||
bestFlow = flow
|
||||
bestShareID = sid
|
||||
}
|
||||
}
|
||||
return bestShareID
|
||||
}
|
||||
|
||||
resolvedShareID := resolveShareIDForForward(targetForward)
|
||||
if resolvedShareID != remoteShareID {
|
||||
t.Fatalf("expected resolved shareID=%d via tunnel binding, got %d", remoteShareID, resolvedShareID)
|
||||
}
|
||||
|
||||
forwardCountByShare := make(map[int64]int)
|
||||
resolvedByForwardID := make(map[int64]int64)
|
||||
for _, row := range forwardRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fid := contractValueAsInt64(m["id"])
|
||||
sid := resolveShareIDForForward(m)
|
||||
if sid > 0 {
|
||||
resolvedByForwardID[fid] = sid
|
||||
}
|
||||
if sid > 0 && flowByShare[sid] > 0 {
|
||||
forwardCountByShare[sid] = forwardCountByShare[sid] + 1
|
||||
}
|
||||
}
|
||||
|
||||
directFlow := contractValueAsInt64(targetForward["inFlow"]) + contractValueAsInt64(targetForward["outFlow"])
|
||||
if directFlow != 0 {
|
||||
t.Fatalf("fixture expectation failed: directFlow should be 0, got %d", directFlow)
|
||||
}
|
||||
|
||||
shareFlow := flowByShare[resolvedByForwardID[forwardID]]
|
||||
if shareFlow <= 0 {
|
||||
t.Fatalf("expected merged share flow > 0 for resolved share %d", resolvedByForwardID[forwardID])
|
||||
}
|
||||
|
||||
count := forwardCountByShare[resolvedByForwardID[forwardID]]
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
estimated := shareFlow / int64(count)
|
||||
if estimated < 1 {
|
||||
estimated = 1
|
||||
}
|
||||
|
||||
displayFlow := estimated
|
||||
if displayFlow <= 0 {
|
||||
t.Fatalf("expected displayFlow > 0 after tunnel-binding-based merge, got %d", displayFlow)
|
||||
}
|
||||
if displayFlow != remoteShareFlow {
|
||||
t.Fatalf("expected displayFlow=%d, got %d", remoteShareFlow, displayFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func requestContractEnvelope(t *testing.T, router http.Handler, token string, path string, body interface{}) response.R {
|
||||
t.Helper()
|
||||
|
||||
payload := []byte("{}")
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request body for %s: %v", path, err)
|
||||
}
|
||||
payload = raw
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload))
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected http 200 for %s, got %d", path, res.Code)
|
||||
}
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response for %s: %v", path, err)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func mustContractSlice(t *testing.T, data interface{}, label string) []interface{} {
|
||||
t.Helper()
|
||||
|
||||
rows, ok := data.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected %s to be []interface{}, got %T", label, data)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func contractParseShareIDFromTunnelName(tunnelName string) int64 {
|
||||
normalized := strings.TrimSpace(tunnelName)
|
||||
if !strings.HasPrefix(normalized, "Share-") {
|
||||
return 0
|
||||
}
|
||||
raw := strings.TrimPrefix(normalized, "Share-")
|
||||
idx := strings.Index(raw, "-Port-")
|
||||
if idx <= 0 {
|
||||
return 0
|
||||
}
|
||||
shareID, err := strconv.ParseInt(strings.TrimSpace(raw[:idx]), 10, 64)
|
||||
if err != nil || shareID <= 0 {
|
||||
return 0
|
||||
}
|
||||
return shareID
|
||||
}
|
||||
|
||||
func contractValueAsInt64(v interface{}) int64 {
|
||||
switch n := v.(type) {
|
||||
case int64:
|
||||
return n
|
||||
case int:
|
||||
return int64(n)
|
||||
case float64:
|
||||
return int64(n)
|
||||
case json.Number:
|
||||
i, err := n.Int64()
|
||||
if err == nil {
|
||||
return i
|
||||
}
|
||||
f, err := n.Float64()
|
||||
if err == nil {
|
||||
return int64(f)
|
||||
}
|
||||
return 0
|
||||
case string:
|
||||
i, err := strconv.ParseInt(strings.TrimSpace(n), 10, 64)
|
||||
if err == nil {
|
||||
return i
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func contractValueAsString(v interface{}) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
const contractBytesPerGB int64 = 1024 * 1024 * 1024
|
||||
|
||||
func TestForwardResumeBlockedWhenUserFlowExceeded(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
userID := int64(2)
|
||||
tunnelID := int64(1)
|
||||
forwardID := int64(1)
|
||||
|
||||
flowGB := int64(120)
|
||||
used := flowGB*contractBytesPerGB + 1
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(?, 'flow_user', 'pwd', 1, 2727251700000, ?, ?, 0, 1, 99999, ?, ?, 1)
|
||||
`, userID, flowGB, used, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, 'flow_tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(10, ?, ?, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, userID, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, 'flow_user', 'flow_forward', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 0, 0)
|
||||
`, forwardID, userID, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(userID, "flow_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/resume", bytes.NewBufferString(`{"id":1}`))
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code == 0 {
|
||||
t.Fatalf("expected non-zero code when flow exceeded")
|
||||
}
|
||||
if !strings.Contains(out.Msg, "流量") {
|
||||
t.Fatalf("expected flow exceeded message, got %q", out.Msg)
|
||||
}
|
||||
|
||||
status := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = ?`, forwardID)
|
||||
if status != 0 {
|
||||
t.Fatalf("expected forward status to remain 0, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardResumeBlockedWhenUserTunnelFlowExceeded(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
userID := int64(2)
|
||||
tunnelID := int64(1)
|
||||
forwardID := int64(1)
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(?, 'ut_flow_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, userID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, 'ut_flow_tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
utFlowGB := int64(120)
|
||||
utUsed := utFlowGB * contractBytesPerGB
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(10, ?, ?, NULL, 99999, ?, ?, 0, 1, 2727251700000, 1)
|
||||
`, userID, tunnelID, utFlowGB, utUsed).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, 'ut_flow_user', 'ut_flow_forward', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 0, 0)
|
||||
`, forwardID, userID, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(userID, "ut_flow_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/resume", bytes.NewBufferString(`{"id":1}`))
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code == 0 {
|
||||
t.Fatalf("expected non-zero code when tunnel flow exceeded")
|
||||
}
|
||||
if !strings.Contains(out.Msg, "隧道") || !strings.Contains(out.Msg, "流量") {
|
||||
t.Fatalf("expected tunnel flow exceeded message, got %q", out.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardCreateBlockedWhenFlowExceeded(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
userID := int64(2)
|
||||
tunnelID := int64(1)
|
||||
|
||||
flowGB := int64(120)
|
||||
used := flowGB*contractBytesPerGB + 1
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(?, 'create_flow_user', 'pwd', 1, 2727251700000, ?, ?, 0, 1, 99999, ?, ?, 1)
|
||||
`, userID, flowGB, used, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, 'create_flow_tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(10, ?, ?, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, userID, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(userID, "create_flow_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
payload := `{"tunnelId":1,"name":"n","remoteAddr":"1.1.1.1:53"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewBufferString(payload))
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code == 0 {
|
||||
t.Fatalf("expected non-zero code when flow exceeded")
|
||||
}
|
||||
if !strings.Contains(out.Msg, "流量") {
|
||||
t.Fatalf("expected flow exceeded message, got %q", out.Msg)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
)
|
||||
|
||||
func TestGroupUserUnbindRevokesInheritedTunnelPermission(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(200, 'group_user_contract', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert test user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES('group-contract-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "group-contract-tunnel")
|
||||
|
||||
if err := repo.DB().Exec(`INSERT INTO user_group(name, created_time, updated_time, status) VALUES('ug-contract', ?, ?, 1)`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user_group: %v", err)
|
||||
}
|
||||
userGroupID := mustLastInsertID(t, repo, "ug-contract")
|
||||
|
||||
if err := repo.DB().Exec(`INSERT INTO tunnel_group(name, created_time, updated_time, status) VALUES('tg-contract', ?, ?, 1)`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel_group: %v", err)
|
||||
}
|
||||
tunnelGroupID := mustLastInsertID(t, repo, "tg-contract")
|
||||
|
||||
if err := repo.DB().Exec(`INSERT INTO tunnel_group_tunnel(tunnel_group_id, tunnel_id, created_time) VALUES(?, ?, ?)`, tunnelGroupID, tunnelID, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel_group_tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`INSERT INTO group_permission(user_group_id, tunnel_group_id, created_time) VALUES(?, ?, ?)`, userGroupID, tunnelGroupID, now).Error; err != nil {
|
||||
t.Fatalf("insert group_permission: %v", err)
|
||||
}
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
bindReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/user/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(userGroupID)+`,"userIds":[200]}`))
|
||||
bindReq.Header.Set("Authorization", adminToken)
|
||||
bindRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(bindRes, bindReq)
|
||||
assertCode(t, bindRes, 0)
|
||||
|
||||
userTunnelID := mustQueryInt64(t, repo, `SELECT id FROM user_tunnel WHERE user_id = 200 AND tunnel_id = ?`, tunnelID)
|
||||
|
||||
grantCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID)
|
||||
if grantCount == 0 {
|
||||
t.Fatalf("expected non-zero grants after bind")
|
||||
}
|
||||
|
||||
unbindReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/user/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(userGroupID)+`,"userIds":[]}`))
|
||||
unbindReq.Header.Set("Authorization", adminToken)
|
||||
unbindRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(unbindRes, unbindReq)
|
||||
assertCode(t, unbindRes, 0)
|
||||
|
||||
grantCount = mustQueryInt(t, repo, `SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID)
|
||||
if grantCount != 0 {
|
||||
t.Fatalf("expected grants revoked after unbind, got %d", grantCount)
|
||||
}
|
||||
|
||||
userTunnelCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM user_tunnel WHERE id = ?`, userTunnelID)
|
||||
if userTunnelCount != 0 {
|
||||
t.Fatalf("expected user_tunnel revoked after unbind, got %d", userTunnelCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupPermissionRemoveRevokesInheritedTunnelPermission(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(201, 'group_user_permission_remove', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert test user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES('group-remove-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "group-remove-tunnel")
|
||||
|
||||
if err := repo.DB().Exec(`INSERT INTO user_group(name, created_time, updated_time, status) VALUES('ug-remove-contract', ?, ?, 1)`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user_group: %v", err)
|
||||
}
|
||||
userGroupID := mustLastInsertID(t, repo, "ug-remove-contract")
|
||||
|
||||
if err := repo.DB().Exec(`INSERT INTO tunnel_group(name, created_time, updated_time, status) VALUES('tg-remove-contract', ?, ?, 1)`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel_group: %v", err)
|
||||
}
|
||||
tunnelGroupID := mustLastInsertID(t, repo, "tg-remove-contract")
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
assignTunnelReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/tunnel/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(tunnelGroupID)+`,"tunnelIds":[`+jsonNumber(tunnelID)+`]}`))
|
||||
assignTunnelReq.Header.Set("Authorization", adminToken)
|
||||
assignTunnelRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(assignTunnelRes, assignTunnelReq)
|
||||
assertCode(t, assignTunnelRes, 0)
|
||||
|
||||
assignUserReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/user/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(userGroupID)+`,"userIds":[201]}`))
|
||||
assignUserReq.Header.Set("Authorization", adminToken)
|
||||
assignUserRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(assignUserRes, assignUserReq)
|
||||
assertCode(t, assignUserRes, 0)
|
||||
|
||||
assignPermissionReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/permission/assign", bytes.NewBufferString(`{"userGroupId":`+jsonNumber(userGroupID)+`,"tunnelGroupId":`+jsonNumber(tunnelGroupID)+`}`))
|
||||
assignPermissionReq.Header.Set("Authorization", adminToken)
|
||||
assignPermissionRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(assignPermissionRes, assignPermissionReq)
|
||||
assertCode(t, assignPermissionRes, 0)
|
||||
|
||||
permissionID := mustQueryInt64(t, repo, `SELECT id FROM group_permission WHERE user_group_id = ? AND tunnel_group_id = ?`, userGroupID, tunnelGroupID)
|
||||
|
||||
userTunnelID := mustQueryInt64(t, repo, `SELECT id FROM user_tunnel WHERE user_id = 201 AND tunnel_id = ?`, tunnelID)
|
||||
|
||||
grantCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID)
|
||||
if grantCount == 0 {
|
||||
t.Fatalf("expected non-zero grants after permission assign")
|
||||
}
|
||||
|
||||
removeReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/permission/remove", bytes.NewBufferString(`{"id":`+jsonNumber(permissionID)+`}`))
|
||||
removeReq.Header.Set("Authorization", adminToken)
|
||||
removeRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(removeRes, removeReq)
|
||||
assertCode(t, removeRes, 0)
|
||||
|
||||
permissionCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM group_permission WHERE id = ?`, permissionID)
|
||||
if permissionCount != 0 {
|
||||
t.Fatalf("expected group_permission removed, got %d", permissionCount)
|
||||
}
|
||||
|
||||
grantCount = mustQueryInt(t, repo, `SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID)
|
||||
if grantCount != 0 {
|
||||
t.Fatalf("expected grants removed after permission remove, got %d", grantCount)
|
||||
}
|
||||
|
||||
userTunnelCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM user_tunnel WHERE id = ?`, userTunnelID)
|
||||
if userTunnelCount != 0 {
|
||||
t.Fatalf("expected user_tunnel revoked after permission remove, got %d", userTunnelCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func TestIssue313_EntryPortCrossTunnelConflictContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
insertNode := func(name, ip, portRange string) int64 {
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
return mustLastInsertID(t, repo, name)
|
||||
}
|
||||
|
||||
entryA := insertNode("issue313-entry-a", "10.100.0.1", "2000-2010")
|
||||
entryB1 := insertNode("issue313-entry-b1", "10.100.0.2", "2000-2010")
|
||||
entryB2 := insertNode("issue313-entry-b2", "10.100.0.3", "2000-2010")
|
||||
chainA := insertNode("issue313-chain-a", "10.100.0.4", "3000-3010")
|
||||
chainB := insertNode("issue313-chain-b", "10.100.0.5", "3000-3010")
|
||||
exitA := insertNode("issue313-exit-a", "10.100.0.6", "4000-4010")
|
||||
exitB := insertNode("issue313-exit-b", "10.100.0.7", "4000-4010")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "issue313-tunnel-a", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel a: %v", err)
|
||||
}
|
||||
tunnelAID := mustLastInsertID(t, repo, "issue313-tunnel-a")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 2000, 'round', 1, 'tls')
|
||||
`, tunnelAID, entryA).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel entry a: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 2, ?, 3000, 'round', 1, 'tls')
|
||||
`, tunnelAID, chainA).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel chain a: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 3, ?, 4000, 'round', 1, 'tls')
|
||||
`, tunnelAID, exitA).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel exit a: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "issue313-tunnel-b", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel b: %v", err)
|
||||
}
|
||||
tunnelBID := mustLastInsertID(t, repo, "issue313-tunnel-b")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 2000, 'round', 1, 'tls')
|
||||
`, tunnelBID, entryB1).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel entry b1: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 2, ?, 3000, 'round', 1, 'tls')
|
||||
`, tunnelBID, chainB).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel chain b: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 3, ?, 4000, 'round', 1, 'tls')
|
||||
`, tunnelBID, exitB).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel exit b: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(3131, 1, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, tunnelAID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel for tunnel a: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(1, 'admin_user', 'issue313-forward-a', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, tunnelAID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward a: %v", err)
|
||||
}
|
||||
forwardAID := mustLastInsertID(t, repo, "issue313-forward-a")
|
||||
|
||||
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardAID, entryA, 2000).Error; err != nil {
|
||||
t.Fatalf("insert forward_port a: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(3132, 1, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, tunnelBID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel for tunnel b: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(1, 'admin_user', 'issue313-forward-b', ?, '2.2.2.2:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, tunnelBID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward b: %v", err)
|
||||
}
|
||||
forwardBID := mustLastInsertID(t, repo, "issue313-forward-b")
|
||||
|
||||
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardBID, entryB1, 2000).Error; err != nil {
|
||||
t.Fatalf("insert forward_port b: %v", err)
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"id": tunnelBID,
|
||||
"name": "issue313-tunnel-b",
|
||||
"type": 2,
|
||||
"flow": 99999,
|
||||
"trafficRatio": 1.0,
|
||||
"status": 1,
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": entryB1, "protocol": "tls", "strategy": "round"},
|
||||
{"nodeId": entryB2, "protocol": "tls", "strategy": "round"},
|
||||
},
|
||||
"chainNodes": []interface{}{
|
||||
[]map[string]interface{}{{"nodeId": chainB, "protocol": "tls", "strategy": "round"}},
|
||||
},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": exitB, "protocol": "tls", "strategy": "round"},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
if out.Code == 0 {
|
||||
t.Fatalf("expected update failure due to cross-tunnel port conflict, got success with code 0")
|
||||
}
|
||||
|
||||
if !bytes.Contains(res.Body.Bytes(), []byte("端口")) && !bytes.Contains(res.Body.Bytes(), []byte("占用")) {
|
||||
t.Fatalf("expected port conflict error message, got %q", out.Msg)
|
||||
}
|
||||
|
||||
countB2 := mustQueryInt(t, repo, `SELECT COUNT(1) FROM forward_port WHERE forward_id = ? AND node_id = ?`, forwardBID, entryB2)
|
||||
if countB2 > 0 {
|
||||
t.Fatalf("expected no forward_port record for entryB2, but found %d", countB2)
|
||||
}
|
||||
|
||||
chainCountB2 := mustQueryInt(t, repo, `SELECT COUNT(1) FROM chain_tunnel WHERE tunnel_id = ? AND node_id = ?`, tunnelBID, entryB2)
|
||||
if chainCountB2 > 0 {
|
||||
t.Fatalf("expected no chain_tunnel record for entryB2, but found %d", chainCountB2)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,25 +17,25 @@ import (
|
||||
httpserver "go-backend/internal/http"
|
||||
"go-backend/internal/http/handler"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/sqlite"
|
||||
"go-backend/internal/store/repo"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestCaptchaVerifyLoginContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
router, r := setupContractRouter(t, secret)
|
||||
verifiedToken := ""
|
||||
|
||||
_, err := repo.DB().Exec(`
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO vite_config(name, value, time)
|
||||
VALUES(?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
|
||||
`, "captcha_enabled", "true", time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
`, "captcha_enabled", "true", time.Now().UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("enable captcha: %v", err)
|
||||
}
|
||||
|
||||
t.Run("login denied without verified captcha token", func(t *testing.T) {
|
||||
t.Run("login allowed when cloudflare keys are missing", func(t *testing.T) {
|
||||
body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -43,10 +43,10 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertCodeMsg(t, resp, -1, "验证码校验失败")
|
||||
assertCode(t, resp, 0)
|
||||
})
|
||||
|
||||
t.Run("captcha token is one-time and consumed by login", func(t *testing.T) {
|
||||
t.Run("captcha verify remains compatible without cloudflare secret", func(t *testing.T) {
|
||||
verifyReq := httptest.NewRequest(http.MethodPost, "/api/v1/captcha/verify", bytes.NewBufferString(`{"id":"captcha-token-1","data":"ok"}`))
|
||||
verifyReq.Header.Set("Content-Type", "application/json")
|
||||
verifyResp := httptest.NewRecorder()
|
||||
@@ -66,14 +66,60 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
|
||||
t.Fatalf("unexpected captcha verify payload: success=%v token=%q", verifyOut.Success, verifyOut.Data.ValidToken)
|
||||
}
|
||||
|
||||
loginBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"captcha-token-1"}`)
|
||||
verifiedToken = verifyOut.Data.ValidToken
|
||||
})
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO vite_config(name, value, time)
|
||||
VALUES(?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
|
||||
`, "cloudflare_site_key", "test-site-key", time.Now().UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("set cloudflare site key: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO vite_config(name, value, time)
|
||||
VALUES(?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
|
||||
`, "cloudflare_secret_key", "test-secret-key", time.Now().UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("set cloudflare secret key: %v", err)
|
||||
}
|
||||
|
||||
t.Run("login denied without verified captcha token", func(t *testing.T) {
|
||||
body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertCodeMsg(t, resp, -1, "验证码校验失败")
|
||||
})
|
||||
|
||||
t.Run("whmcs api client bypasses captcha", func(t *testing.T) {
|
||||
body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-FLVX-API-Client", "whmcs")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertCode(t, resp, 0)
|
||||
})
|
||||
|
||||
t.Run("captcha token is one-time and consumed by login", func(t *testing.T) {
|
||||
if strings.TrimSpace(verifiedToken) == "" {
|
||||
t.Fatalf("expected verified token from compatibility captcha verify")
|
||||
}
|
||||
|
||||
loginBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"` + verifiedToken + `"}`)
|
||||
loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", loginBody)
|
||||
loginReq.Header.Set("Content-Type", "application/json")
|
||||
loginResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(loginResp, loginReq)
|
||||
assertCode(t, loginResp, 0)
|
||||
|
||||
replayBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"captcha-token-1"}`)
|
||||
replayBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"` + verifiedToken + `"}`)
|
||||
replayReq := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", replayBody)
|
||||
replayReq.Header.Set("Content-Type", "application/json")
|
||||
replayResp := httptest.NewRecorder()
|
||||
@@ -83,7 +129,7 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestOpenAPISubStoreContracts(t *testing.T) {
|
||||
router, repo := setupContractRouter(t, "contract-jwt-secret")
|
||||
router, r := setupContractRouter(t, "contract-jwt-secret")
|
||||
|
||||
const tunnelFlowGB = int64(500)
|
||||
const tunnelInFlow = int64(123)
|
||||
@@ -91,17 +137,13 @@ func TestOpenAPISubStoreContracts(t *testing.T) {
|
||||
const tunnelExpTimeMs = int64(2727251700000)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
res, err := repo.DB().Exec(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
"contract-tunnel", 1.0, 1, "tls", 1, now, now, 1, nil, 0)
|
||||
if err != nil {
|
||||
if err := r.DB().Exec(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
"contract-tunnel", 1.0, 1, "tls", 1, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("last insert id: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
1, tunnelID, 99999, tunnelFlowGB, tunnelInFlow, tunnelOutFlow, 1, tunnelExpTimeMs, 1); err != nil {
|
||||
tunnelID := mustLastInsertID(t, r, "contract-tunnel")
|
||||
if err := r.DB().Exec(`INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
1, tunnelID, 99999, tunnelFlowGB, tunnelInFlow, tunnelOutFlow, 1, tunnelExpTimeMs, 1).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
@@ -167,54 +209,403 @@ func TestOpenAPISubStoreContracts(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestSpeedLimitTunnelsRouteAlias(t *testing.T) {
|
||||
func TestSpeedLimitTunnelsRouteRemoved(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, _ := setupContractRouter(t, secret)
|
||||
|
||||
t.Run("missing token blocked", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/tunnels", nil)
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/tunnels", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected status 404 after route removal, got %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupExportImportRestoreContracts(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate user token: %v", err)
|
||||
}
|
||||
|
||||
key := "backup_contract_key"
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO vite_config(name, value, time)
|
||||
VALUES(?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
|
||||
`, key, "v1", time.Now().UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("seed config for backup contract: %v", err)
|
||||
}
|
||||
|
||||
t.Run("non-admin is blocked on backup export", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/export", nil)
|
||||
req.Header.Set("Authorization", userToken)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertCodeMsg(t, resp, 401, "未登录或token已过期")
|
||||
assertCodeMsg(t, resp, 403, "权限不足,仅管理员可操作")
|
||||
})
|
||||
|
||||
t.Run("admin token receives success envelope", func(t *testing.T) {
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
t.Run("standard and duplicate export routes both work", func(t *testing.T) {
|
||||
payloadA := exportBackupPayload(t, router, "/api/v1/backup/export", adminToken)
|
||||
if len(payloadA.Configs) == 0 {
|
||||
t.Fatalf("expected exported configs, got none")
|
||||
}
|
||||
if _, ok := payloadA.Configs[key]; !ok {
|
||||
t.Fatalf("expected %q in exported configs", key)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/tunnels", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
payloadB := exportBackupPayload(t, router, "/api/v1/api/v1/backup/export", adminToken)
|
||||
if len(payloadB.Configs) == 0 {
|
||||
t.Fatalf("expected exported configs from duplicate-prefix route, got none")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("backup import applies exported data", func(t *testing.T) {
|
||||
payload := exportBackupPayload(t, router, "/api/v1/backup/export", adminToken)
|
||||
payload.Configs[key] = "v2"
|
||||
raw, err := json.Marshal(backupImportPayload{Types: []string{"configs"}, backupExportPayload: payload})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal import payload: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
t.Fatalf("decode import response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
|
||||
t.Fatalf("expected import code 0, got %d (%s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
cfg, err := r.GetConfigByName(key)
|
||||
if err != nil {
|
||||
t.Fatalf("query imported config: %v", err)
|
||||
}
|
||||
if cfg == nil || cfg.Value != "v2" {
|
||||
t.Fatalf("expected imported config value v2, got %+v", cfg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("backup restore alias applies exported data", func(t *testing.T) {
|
||||
payload := exportBackupPayload(t, router, "/api/v1/backup/export", adminToken)
|
||||
payload.Configs[key] = "v3"
|
||||
raw, err := json.Marshal(backupImportPayload{Types: []string{"configs"}, backupExportPayload: payload})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal restore payload: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/restore", bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
var out response.R
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode restore response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected restore code 0, got %d (%s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
cfg, err := r.GetConfigByName(key)
|
||||
if err != nil {
|
||||
t.Fatalf("query restored config: %v", err)
|
||||
}
|
||||
if cfg == nil || cfg.Value != "v3" {
|
||||
t.Fatalf("expected restored config value v3, got %+v", cfg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("backup export and import preserve forward ports", func(t *testing.T) {
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "backup-forward-tunnel", 1.0, 1, "tls", 0, now, now, 1, "", 88).Error; err != nil {
|
||||
t.Fatalf("seed tunnel for forward backup: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "backup-forward-tunnel")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 1, "admin_user", "backup-forward", tunnelID, "127.0.0.1:9000", "fifo", 0, 0, now, now, 1, 88).Error; err != nil {
|
||||
t.Fatalf("seed forward for backup: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, "backup-forward")
|
||||
|
||||
expected := map[int64]int{
|
||||
2001: 21001,
|
||||
2002: 21002,
|
||||
}
|
||||
for nodeID, port := range expected {
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port).Error; err != nil {
|
||||
t.Fatalf("seed forward_port %d:%d: %v", nodeID, port, err)
|
||||
}
|
||||
}
|
||||
|
||||
exportReq := httptest.NewRequest(http.MethodPost, "/api/v1/backup/export", bytes.NewBufferString(`{"types":["forwards"]}`))
|
||||
exportReq.Header.Set("Authorization", adminToken)
|
||||
exportReq.Header.Set("Content-Type", "application/json")
|
||||
exportResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(exportResp, exportReq)
|
||||
|
||||
if exportResp.Code != http.StatusOK {
|
||||
t.Fatalf("expected export status 200, got %d", exportResp.Code)
|
||||
}
|
||||
|
||||
exportBody, err := io.ReadAll(exportResp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read forwards backup body: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(exportBody, &payload); err != nil {
|
||||
t.Fatalf("decode forwards backup payload: %v", err)
|
||||
}
|
||||
version, _ := payload["version"].(string)
|
||||
if strings.TrimSpace(version) == "" {
|
||||
t.Fatalf("expected backup payload version, body=%s", string(exportBody))
|
||||
}
|
||||
|
||||
forwardsRaw, ok := payload["forwards"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected forwards array in payload, body=%s", string(exportBody))
|
||||
}
|
||||
|
||||
foundForward := false
|
||||
foundPorts := map[int64]int{}
|
||||
for _, item := range forwardsRaw {
|
||||
forwardMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
idValue, ok := forwardMap["id"].(float64)
|
||||
if !ok || int64(idValue) != forwardID {
|
||||
continue
|
||||
}
|
||||
foundForward = true
|
||||
|
||||
portsRaw, ok := forwardMap["forwardPorts"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected forwardPorts for forward %d in payload", forwardID)
|
||||
}
|
||||
for _, p := range portsRaw {
|
||||
portMap, ok := p.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
nodeID, nodeOK := portMap["nodeId"].(float64)
|
||||
port, portOK := portMap["port"].(float64)
|
||||
if nodeOK && portOK {
|
||||
foundPorts[int64(nodeID)] = int(port)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if !foundForward {
|
||||
t.Fatalf("expected forward %d in exported forwards payload", forwardID)
|
||||
}
|
||||
if len(foundPorts) != len(expected) {
|
||||
t.Fatalf("expected %d exported forward ports, got %d", len(expected), len(foundPorts))
|
||||
}
|
||||
for nodeID, port := range expected {
|
||||
if got, ok := foundPorts[nodeID]; !ok || got != port {
|
||||
t.Fatalf("expected exported forward port node=%d port=%d, got %v", nodeID, port, foundPorts)
|
||||
}
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`DELETE FROM forward_port WHERE forward_id = ?`, forwardID).Error; err != nil {
|
||||
t.Fatalf("clear forward_port before import: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, 9999, 39999).Error; err != nil {
|
||||
t.Fatalf("seed wrong forward_port before import: %v", err)
|
||||
}
|
||||
|
||||
payload["types"] = []string{"forwards"}
|
||||
importBody, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal forwards import payload: %v", err)
|
||||
}
|
||||
|
||||
importReq := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", bytes.NewReader(importBody))
|
||||
importReq.Header.Set("Authorization", adminToken)
|
||||
importReq.Header.Set("Content-Type", "application/json")
|
||||
importResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(importResp, importReq)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(importResp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode forwards import response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected forwards import code 0, got %d (%s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
after := mustQueryNodePorts(t, r, `SELECT node_id, port FROM forward_port WHERE forward_id = ? ORDER BY id ASC`, forwardID)
|
||||
|
||||
if len(after) != len(expected) {
|
||||
t.Fatalf("expected %d forward ports after import, got %d (%v)", len(expected), len(after), after)
|
||||
}
|
||||
for nodeID, port := range expected {
|
||||
if got, ok := after[nodeID]; !ok || got != port {
|
||||
t.Fatalf("expected forward_port node=%d port=%d after import, got %v", nodeID, port, after)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("backup export tolerates nullable legacy tunnel chain fields", func(t *testing.T) {
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "legacy-null-chain", 1.0, 1, "tls", 1000, now, now, 1, nil, 1).Error; err != nil {
|
||||
t.Fatalf("seed tunnel for nullable chain export: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "legacy-null-chain")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||
`, tunnelID, "1", 1, nil, nil, nil, nil).Error; err != nil {
|
||||
t.Fatalf("seed nullable chain_tunnel row: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/export", bytes.NewBufferString(`{"types":["tunnels"]}`))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", resp.Code)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Version string `json:"version"`
|
||||
Tunnels []struct {
|
||||
ID int64 `json:"id"`
|
||||
ChainTunnels []struct {
|
||||
Inx int `json:"inx"`
|
||||
Strategy string `json:"strategy"`
|
||||
Protocol string `json:"protocol"`
|
||||
} `json:"chainTunnels"`
|
||||
} `json:"tunnels"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode tunnels backup payload: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(payload.Version) == "" {
|
||||
t.Fatalf("expected backup payload version, got empty")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, tunnel := range payload.Tunnels {
|
||||
if tunnel.ID != tunnelID {
|
||||
continue
|
||||
}
|
||||
if len(tunnel.ChainTunnels) != 1 {
|
||||
t.Fatalf("expected one chain tunnel for seeded tunnel %d, got %d", tunnelID, len(tunnel.ChainTunnels))
|
||||
}
|
||||
if tunnel.ChainTunnels[0].Inx != 0 {
|
||||
t.Fatalf("expected nullable chain inx to export as 0, got %d", tunnel.ChainTunnels[0].Inx)
|
||||
}
|
||||
if tunnel.ChainTunnels[0].Strategy != "" {
|
||||
t.Fatalf("expected nullable chain strategy to export as empty string, got %q", tunnel.ChainTunnels[0].Strategy)
|
||||
}
|
||||
if tunnel.ChainTunnels[0].Protocol != "" {
|
||||
t.Fatalf("expected nullable chain protocol to export as empty string, got %q", tunnel.ChainTunnels[0].Protocol)
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected seeded tunnel %d in backup export", tunnelID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *sqlite.Repository) {
|
||||
type backupExportPayload struct {
|
||||
Version string `json:"version"`
|
||||
ExportedAt int64 `json:"exportedAt"`
|
||||
Configs map[string]string `json:"configs"`
|
||||
}
|
||||
|
||||
type backupImportPayload struct {
|
||||
Types []string `json:"types"`
|
||||
backupExportPayload
|
||||
}
|
||||
|
||||
func exportBackupPayload(t *testing.T, router http.Handler, path, token string) backupExportPayload {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(`{"types":["configs"]}`))
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200 on %s, got %d", path, resp.Code)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read backup payload from %s: %v", path, err)
|
||||
}
|
||||
|
||||
var payload backupExportPayload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("decode backup payload from %s: %v", path, err)
|
||||
}
|
||||
if strings.TrimSpace(payload.Version) == "" {
|
||||
var out response.R
|
||||
if err := json.Unmarshal(body, &out); err == nil {
|
||||
t.Fatalf("expected backup payload on %s, got envelope code=%d msg=%q", path, out.Code, out.Msg)
|
||||
}
|
||||
t.Fatalf("expected non-empty backup payload version on %s, body=%s", path, string(body))
|
||||
}
|
||||
if payload.Configs == nil {
|
||||
t.Fatalf("expected configs map in backup payload on %s", path)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *repo.Repository) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "contract.db")
|
||||
repo, err := sqlite.Open(dbPath)
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = repo.Close()
|
||||
_ = r.Close()
|
||||
})
|
||||
|
||||
h := handler.New(repo, jwtSecret)
|
||||
return httpserver.NewRouter(h, jwtSecret), repo
|
||||
h := handler.New(r, jwtSecret)
|
||||
return httpserver.NewRouter(h, jwtSecret), r
|
||||
}
|
||||
|
||||
func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {
|
||||
@@ -275,15 +666,15 @@ func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {
|
||||
t.Fatalf("seed legacy node row: %v", err)
|
||||
}
|
||||
|
||||
repo, err := sqlite.Open(dbPath)
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open migrated sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = repo.Close()
|
||||
_ = r.Close()
|
||||
})
|
||||
|
||||
nodes, err := repo.ListNodes()
|
||||
nodes, err := r.ListNodes()
|
||||
if err != nil {
|
||||
t.Fatalf("list nodes after migration: %v", err)
|
||||
}
|
||||
@@ -291,42 +682,128 @@ func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {
|
||||
t.Fatalf("expected 1 node after migration, got %d", len(nodes))
|
||||
}
|
||||
|
||||
columns := readTableColumns(t, repo.DB(), "node")
|
||||
columns := readTableColumns(t, r.DB(), "node")
|
||||
|
||||
for _, required := range []string{"server_ip_v4", "server_ip_v6", "inx"} {
|
||||
for _, required := range []string{"server_ip_v4", "server_ip_v6", "inx", "extra_ips"} {
|
||||
if !columns[required] {
|
||||
t.Fatalf("expected node column %q to exist after migration", required)
|
||||
}
|
||||
}
|
||||
|
||||
tunnelColumns := readTableColumns(t, repo.DB(), "tunnel")
|
||||
if !tunnelColumns["inx"] {
|
||||
t.Fatalf("expected tunnel column %q to exist after migration", "inx")
|
||||
tunnelColumns := readTableColumns(t, r.DB(), "tunnel")
|
||||
for _, required := range []string{"inx", "ip_preference"} {
|
||||
if !tunnelColumns[required] {
|
||||
t.Fatalf("expected tunnel column %q to exist after migration", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readTableColumns(t *testing.T, db *sql.DB, table string) map[string]bool {
|
||||
func TestOpenMigratesVeryLegacyNodeAndTunnelColumns(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "legacy-1.x.db")
|
||||
legacyDB, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy sqlite: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = legacyDB.Close()
|
||||
})
|
||||
|
||||
if _, err := legacyDB.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS node (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
secret VARCHAR(100) NOT NULL,
|
||||
server_ip VARCHAR(100) NOT NULL,
|
||||
port TEXT NOT NULL,
|
||||
interface_name VARCHAR(200),
|
||||
version VARCHAR(100),
|
||||
http INTEGER NOT NULL DEFAULT 0,
|
||||
tls INTEGER NOT NULL DEFAULT 0,
|
||||
socks INTEGER NOT NULL DEFAULT 0,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER,
|
||||
status INTEGER NOT NULL
|
||||
)
|
||||
`); err != nil {
|
||||
t.Fatalf("create very legacy node table: %v", err)
|
||||
}
|
||||
|
||||
if _, err := legacyDB.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS tunnel (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
traffic_ratio REAL NOT NULL DEFAULT 1.0,
|
||||
type INTEGER NOT NULL,
|
||||
protocol VARCHAR(10) NOT NULL DEFAULT 'tls',
|
||||
flow INTEGER NOT NULL,
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
in_ip TEXT
|
||||
)
|
||||
`); err != nil {
|
||||
t.Fatalf("create very legacy tunnel table: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if _, err := legacyDB.Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "legacy-node", "legacy-secret", "10.10.0.1", "10000-10010", "eth0", "v-old", 1, 1, 1, now, now, 1); err != nil {
|
||||
t.Fatalf("seed legacy node row: %v", err)
|
||||
}
|
||||
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open migrated sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = r.Close()
|
||||
})
|
||||
|
||||
columns := readTableColumns(t, r.DB(), "node")
|
||||
for _, required := range []string{
|
||||
"server_ip_v4",
|
||||
"server_ip_v6",
|
||||
"extra_ips",
|
||||
"tcp_listen_addr",
|
||||
"udp_listen_addr",
|
||||
"inx",
|
||||
"is_remote",
|
||||
"remote_url",
|
||||
"remote_token",
|
||||
"remote_config",
|
||||
} {
|
||||
if !columns[required] {
|
||||
t.Fatalf("expected node column %q to exist after migration", required)
|
||||
}
|
||||
}
|
||||
|
||||
tunnelColumns := readTableColumns(t, r.DB(), "tunnel")
|
||||
for _, required := range []string{"inx", "ip_preference"} {
|
||||
if !tunnelColumns[required] {
|
||||
t.Fatalf("expected tunnel column %q to exist after migration", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readTableColumns(t *testing.T, db *gorm.DB, table string) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
rows, err := db.Query("PRAGMA table_info(" + table + ")")
|
||||
columnTypes, err := db.Migrator().ColumnTypes(table)
|
||||
if err != nil {
|
||||
t.Fatalf("inspect %s columns: %v", table, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var cid, notNull, pk int
|
||||
var name, typ string
|
||||
var defaultValue sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil {
|
||||
t.Fatalf("scan %s pragma row: %v", table, err)
|
||||
for _, col := range columnTypes {
|
||||
name := strings.TrimSpace(col.Name())
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
columns[name] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("iterate %s pragma rows: %v", table, err)
|
||||
}
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
httpserver "go-backend/internal/http"
|
||||
"go-backend/internal/http/handler"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestPostgresNodeCreateRepairsMissingIDDefaultContract(t *testing.T) {
|
||||
baseDSN := strings.TrimSpace(os.Getenv("FLVX_POSTGRES_TEST_DSN"))
|
||||
if baseDSN == "" {
|
||||
t.Skip("set FLVX_POSTGRES_TEST_DSN to run postgres contract tests")
|
||||
}
|
||||
|
||||
schemaName := "contract_node_id_" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
adminDB, err := sql.Open("pgx", baseDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("open postgres admin connection: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = adminDB.Exec(`DROP SCHEMA IF EXISTS "` + schemaName + `" CASCADE`)
|
||||
_ = adminDB.Close()
|
||||
})
|
||||
|
||||
if _, err := adminDB.Exec(`CREATE SCHEMA "` + schemaName + `"`); err != nil {
|
||||
t.Fatalf("create schema %s: %v", schemaName, err)
|
||||
}
|
||||
|
||||
testDSN, err := withSearchPath(baseDSN, schemaName)
|
||||
if err != nil {
|
||||
t.Fatalf("build schema dsn: %v", err)
|
||||
}
|
||||
|
||||
r, err := repo.OpenPostgres(testDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("open postgres repository: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`ALTER TABLE node ALTER COLUMN id DROP DEFAULT`).Error; err != nil {
|
||||
_ = r.Close()
|
||||
t.Fatalf("drop node.id default to simulate drift: %v", err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatalf("close repository before reopen: %v", err)
|
||||
}
|
||||
|
||||
r, err = repo.OpenPostgres(testDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen postgres repository: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = r.Close()
|
||||
})
|
||||
|
||||
columnDefault := mustQueryNullString(t, r, `
|
||||
SELECT column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'node'
|
||||
AND column_name = 'id'
|
||||
LIMIT 1
|
||||
`)
|
||||
if !columnDefault.Valid || !strings.Contains(strings.ToLower(columnDefault.String), "nextval(") {
|
||||
t.Fatalf("expected node.id default to be nextval(...), got %q", columnDefault.String)
|
||||
}
|
||||
|
||||
jwtSecret := "postgres-contract-secret"
|
||||
router := httpserver.NewRouter(handler.New(r, jwtSecret), jwtSecret)
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, jwtSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
body := strings.NewReader(`{"name":"pg-repair-node","serverIp":"10.77.0.10"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/node/create", body)
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
assertCode(t, resp, 0)
|
||||
|
||||
nodeID := mustQueryInt64(t, r, `SELECT id FROM node WHERE name = ? ORDER BY id DESC LIMIT 1`, "pg-repair-node")
|
||||
if nodeID <= 0 {
|
||||
t.Fatalf("expected positive node id, got %d", nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
func withSearchPath(dsn, schema string) (string, error) {
|
||||
u, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("search_path", schema)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestSpeedLimitWithoutTunnelContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, _ := setupContractRouter(t, secret)
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
t.Run("create speed limit", func(t *testing.T) {
|
||||
body := `{"name":"test-limit-no-tunnel","speed":100,"status":1}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/create", bytes.NewBufferString(body))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
assertCode(t, res, 0)
|
||||
})
|
||||
|
||||
t.Run("list does not expose tunnel binding fields", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/list", nil)
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0, got %d", out.Code)
|
||||
}
|
||||
|
||||
data, ok := out.Data.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected data to be array, got %T", out.Data)
|
||||
}
|
||||
|
||||
for _, item := range data {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if m["name"] != "test-limit-no-tunnel" {
|
||||
continue
|
||||
}
|
||||
if tunnelID, exists := m["tunnelId"]; exists && tunnelID != nil {
|
||||
t.Fatalf("expected tunnelId to be absent or nil, got %v", tunnelID)
|
||||
}
|
||||
if tunnelName, exists := m["tunnelName"]; exists && tunnelName != nil && tunnelName != "" {
|
||||
t.Fatalf("expected tunnelName to be absent or empty, got %v", tunnelName)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
t.Fatal("speed limit 'test-limit-no-tunnel' not found in list")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSpeedLimitCreateIgnoresTunnelBindingContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
tunnelID := mustCreateSpeedLimitTunnel(t, r, "test-speed-limit-create-ignore-tunnel")
|
||||
|
||||
body := `{"name":"test-limit-ignore-tunnel","speed":200,"tunnelId":` + jsonInt(tunnelID) + `,"status":1}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/create", bytes.NewBufferString(body))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
assertCode(t, res, 0)
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/list", nil)
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
res = httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0, got %d", out.Code)
|
||||
}
|
||||
|
||||
data, ok := out.Data.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected data to be array, got %T", out.Data)
|
||||
}
|
||||
|
||||
for _, item := range data {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if m["name"] != "test-limit-ignore-tunnel" {
|
||||
continue
|
||||
}
|
||||
if tunnelIDVal, exists := m["tunnelId"]; exists && tunnelIDVal != nil {
|
||||
t.Fatalf("expected tunnelId ignored and nil, got %v", tunnelIDVal)
|
||||
}
|
||||
if tunnelNameVal, exists := m["tunnelName"]; exists && tunnelNameVal != nil && tunnelNameVal != "" {
|
||||
t.Fatalf("expected tunnelName ignored and empty, got %v", tunnelNameVal)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
t.Fatal("speed limit 'test-limit-ignore-tunnel' not found in list")
|
||||
}
|
||||
|
||||
func TestSpeedLimitUpdateIgnoresTunnelBindingContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
tunnelID := mustCreateSpeedLimitTunnel(t, r, "test-speed-limit-update-ignore-tunnel")
|
||||
speedLimitID := mustCreateSpeedLimitRepo(t, r, "test-limit-update-ignore-tunnel")
|
||||
|
||||
body := `{"id":` + jsonInt(speedLimitID) + `,"name":"test-limit-update-ignore-tunnel","speed":256,"tunnelId":` + jsonInt(tunnelID) + `,"status":1}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/update", bytes.NewBufferString(body))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
assertCode(t, res, 0)
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/list", nil)
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
res = httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0, got %d", out.Code)
|
||||
}
|
||||
|
||||
data, ok := out.Data.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected data to be array, got %T", out.Data)
|
||||
}
|
||||
|
||||
for _, item := range data {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if m["name"] != "test-limit-update-ignore-tunnel" {
|
||||
continue
|
||||
}
|
||||
if tunnelIDVal, exists := m["tunnelId"]; exists && tunnelIDVal != nil {
|
||||
t.Fatalf("expected tunnelId ignored and nil after update, got %v", tunnelIDVal)
|
||||
}
|
||||
if speedVal, ok := m["speed"].(float64); !ok || int(speedVal) != 256 {
|
||||
t.Fatalf("expected speed 256 after update, got %v", m["speed"])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
t.Fatal("speed limit 'test-limit-update-ignore-tunnel' not found in list")
|
||||
}
|
||||
|
||||
func TestSpeedLimitDatabaseNullableFields(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "speed-limit-null.db")
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
id, err := r.CreateSpeedLimit("db-test-limit", 100, 1, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSpeedLimit failed: %v", err)
|
||||
}
|
||||
if id <= 0 {
|
||||
t.Fatalf("expected valid id, got %d", id)
|
||||
}
|
||||
|
||||
var tunnelID sql.NullInt64
|
||||
var tunnelName sql.NullString
|
||||
err = r.DB().Raw("SELECT tunnel_id, tunnel_name FROM speed_limit WHERE id = ?", id).Row().Scan(&tunnelID, &tunnelName)
|
||||
if err != nil {
|
||||
t.Fatalf("query failed: %v", err)
|
||||
}
|
||||
if tunnelID.Valid {
|
||||
t.Fatalf("expected TunnelID to be NULL, got %d", tunnelID.Int64)
|
||||
}
|
||||
if tunnelName.Valid && tunnelName.String != "" {
|
||||
t.Fatalf("expected TunnelName to be NULL or empty, got %s", tunnelName.String)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeedLimitUpdateClearsHistoricalBinding(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "speed-limit-update-clear.db")
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
tunnelID := mustCreateSpeedLimitTunnel(t, r, "speed-limit-update-clear-tunnel")
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||
`, "speed-limit-update-clear", 300, tunnelID, "speed-limit-update-clear-tunnel", now, now, 1).Error; err != nil {
|
||||
t.Fatalf("insert speed limit with tunnel binding: %v", err)
|
||||
}
|
||||
speedLimitID := mustLastInsertID(t, r, "speed-limit-update-clear")
|
||||
|
||||
err = r.UpdateSpeedLimit(speedLimitID, "speed-limit-update-clear", 512, 1, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateSpeedLimit failed: %v", err)
|
||||
}
|
||||
|
||||
var dbTunnelID sql.NullInt64
|
||||
var dbTunnelName sql.NullString
|
||||
err = r.DB().Raw("SELECT tunnel_id, tunnel_name FROM speed_limit WHERE id = ?", speedLimitID).Row().Scan(&dbTunnelID, &dbTunnelName)
|
||||
if err != nil {
|
||||
t.Fatalf("query updated speed limit failed: %v", err)
|
||||
}
|
||||
if dbTunnelID.Valid {
|
||||
t.Fatalf("expected tunnel_id cleared after update, got %d", dbTunnelID.Int64)
|
||||
}
|
||||
if dbTunnelName.Valid && dbTunnelName.String != "" {
|
||||
t.Fatalf("expected tunnel_name cleared after update, got %q", dbTunnelName.String)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeedLimitGetSpeed(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "speed-limit-getspeed.db")
|
||||
r, err := repo.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
speedLimitID, err := r.CreateSpeedLimit("get-speed-test", 500, 1, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("create speed limit: %v", err)
|
||||
}
|
||||
|
||||
t.Run("GetSpeedLimitSpeed returns correct speed", func(t *testing.T) {
|
||||
speed, err := r.GetSpeedLimitSpeed(speedLimitID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSpeedLimitSpeed failed: %v", err)
|
||||
}
|
||||
if speed != 500 {
|
||||
t.Fatalf("expected speed 500, got %d", speed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetSpeedLimitSpeed returns error for non-existent id", func(t *testing.T) {
|
||||
_, err := r.GetSpeedLimitSpeed(99999)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent speed limit ID")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func mustCreateSpeedLimitTunnel(t *testing.T, r *repo.Repository, name string) int64 {
|
||||
t.Helper()
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, name, now, now).Error; err != nil {
|
||||
t.Fatalf("create tunnel failed: %v", err)
|
||||
}
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
func mustCreateSpeedLimitRepo(t *testing.T, r *repo.Repository, name string) int64 {
|
||||
t.Helper()
|
||||
now := time.Now().UnixMilli()
|
||||
id, err := r.CreateSpeedLimit(name, 100, now, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("create speed limit failed: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -25,18 +25,13 @@ func TestTunnelCreateRuntimeRollbackContract(t *testing.T) {
|
||||
}
|
||||
|
||||
insertNode := func(name, ip, portRange string) int64 {
|
||||
res, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
|
||||
if err != nil {
|
||||
`, name, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get node id %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
return mustLastInsertID(t, repo, name)
|
||||
}
|
||||
|
||||
entryID := insertNode("create-entry", "10.20.0.1", "30000-30010")
|
||||
@@ -62,18 +57,12 @@ func TestTunnelCreateRuntimeRollbackContract(t *testing.T) {
|
||||
t.Fatalf("expected node-related error, got %q", out.Msg)
|
||||
}
|
||||
|
||||
var tunnelCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM tunnel WHERE name = ?`, "runtime-rollback-tunnel").Scan(&tunnelCount); err != nil {
|
||||
t.Fatalf("count tunnel: %v", err)
|
||||
}
|
||||
tunnelCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE name = ?`, "runtime-rollback-tunnel")
|
||||
if tunnelCount != 0 {
|
||||
t.Fatalf("expected tunnel rollback, found %d records", tunnelCount)
|
||||
}
|
||||
|
||||
var chainCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM chain_tunnel`).Scan(&chainCount); err != nil {
|
||||
t.Fatalf("count chain_tunnel: %v", err)
|
||||
}
|
||||
chainCount := mustQueryInt(t, repo, `SELECT COUNT(1) FROM chain_tunnel`)
|
||||
if chainCount != 0 {
|
||||
t.Fatalf("expected chain_tunnel rollback, found %d records", chainCount)
|
||||
}
|
||||
@@ -90,35 +79,26 @@ func TestTunnelUpdateAssignsChainPortsContract(t *testing.T) {
|
||||
}
|
||||
|
||||
insertNode := func(name, ip, portRange string) int64 {
|
||||
res, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
|
||||
if err != nil {
|
||||
`, name, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get node id %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
return mustLastInsertID(t, repo, name)
|
||||
}
|
||||
|
||||
entryID := insertNode("update-entry", "10.30.0.1", "40000-40010")
|
||||
chainID := insertNode("update-chain", "10.30.0.2", "41000-41010")
|
||||
exitID := insertNode("update-exit", "10.30.0.3", "42000-42010")
|
||||
|
||||
tunnelRes, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "update-port-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0)
|
||||
if err != nil {
|
||||
`, "update-port-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID, err := tunnelRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get tunnel id: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "update-port-tunnel")
|
||||
|
||||
payload := `{"id":` + jsonInt(tunnelID) + `,"name":"update-port-tunnel","type":2,"flow":99999,"trafficRatio":1.0,"status":1,"inNodeId":[{"nodeId":` + jsonInt(entryID) + `,"protocol":"tls"}],"chainNodes":[[{"nodeId":` + jsonInt(chainID) + `,"protocol":"tls","strategy":"round"}]],"outNodeId":[{"nodeId":` + jsonInt(exitID) + `,"protocol":"tls"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewBufferString(payload))
|
||||
@@ -129,21 +109,23 @@ func TestTunnelUpdateAssignsChainPortsContract(t *testing.T) {
|
||||
router.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
|
||||
var chainPort int
|
||||
if err := repo.DB().QueryRow(`SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 2 LIMIT 1`, tunnelID).Scan(&chainPort); err != nil {
|
||||
t.Fatalf("query chain port: %v", err)
|
||||
}
|
||||
chainPort := mustQueryInt(t, repo, `SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 2 LIMIT 1`, tunnelID)
|
||||
if chainPort <= 0 {
|
||||
t.Fatalf("expected chain node port to be assigned, got %d", chainPort)
|
||||
}
|
||||
|
||||
var outPort int
|
||||
if err := repo.DB().QueryRow(`SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 3 LIMIT 1`, tunnelID).Scan(&outPort); err != nil {
|
||||
t.Fatalf("query out port: %v", err)
|
||||
}
|
||||
outPort := mustQueryInt(t, repo, `SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 3 LIMIT 1`, tunnelID)
|
||||
if outPort <= 0 {
|
||||
t.Fatalf("expected out node port to be assigned, got %d", outPort)
|
||||
}
|
||||
|
||||
entryStrategy := mustQueryNullString(t, repo, `SELECT strategy FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 1 LIMIT 1`, tunnelID)
|
||||
if !entryStrategy.Valid || strings.TrimSpace(entryStrategy.String) == "" {
|
||||
t.Fatalf("expected entry strategy to be non-null and non-empty")
|
||||
}
|
||||
if entryStrategy.String != "round" {
|
||||
t.Fatalf("expected entry strategy round, got %q", entryStrategy.String)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonInt(v int64) string {
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func TestTunnelCreateWithIPPreferenceContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
insertDualStackNode := func(name, v4, v6, portRange string) int64 {
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", v4, v4, v6, portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
return mustLastInsertID(t, repo, name)
|
||||
}
|
||||
|
||||
entryID := insertDualStackNode("ip-pref-entry", "10.50.0.1", "2001:db8::1", "50000-50010")
|
||||
exitID := insertDualStackNode("ip-pref-exit", "10.50.0.2", "2001:db8::2", "51000-51010")
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
preference string
|
||||
}{
|
||||
{"v4-preference", "v4"},
|
||||
{"v6-preference", "v6"},
|
||||
{"empty-preference", ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload := `{"name":"tunnel-` + tc.name + `","type":2,"flow":99999,"status":1,"ipPreference":"` + tc.preference + `","inNodeId":[{"nodeId":` + jsonInt(entryID) + `,"protocol":"tls"}],"chainNodes":[],"outNodeId":[{"nodeId":` + jsonInt(exitID) + `,"protocol":"tls"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewBufferString(payload))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
stored, err := tryQueryString(t, repo, `SELECT COALESCE(ip_preference, '') FROM tunnel WHERE name = ?`, "tunnel-"+tc.name)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
t.Skipf("tunnel not created (nodes offline), skipping DB verification")
|
||||
}
|
||||
t.Fatalf("query ip_preference: %v", err)
|
||||
}
|
||||
if stored != tc.preference {
|
||||
t.Fatalf("expected ip_preference=%q in DB, got %q", tc.preference, stored)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelUpdateIPPreferenceContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
insertDualStackNode := func(name, v4, v6, portRange string) int64 {
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", v4, v4, v6, portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
return mustLastInsertID(t, repo, name)
|
||||
}
|
||||
|
||||
entryID := insertDualStackNode("upd-entry", "10.60.0.1", "2001:db8:1::1", "60000-60010")
|
||||
exitID := insertDualStackNode("upd-exit", "10.60.0.2", "2001:db8:1::2", "61000-61010")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "update-ip-pref-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0, "").Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "update-ip-pref-tunnel")
|
||||
|
||||
payload := `{"id":` + jsonInt(tunnelID) + `,"name":"update-ip-pref-tunnel","type":2,"flow":99999,"trafficRatio":1.0,"status":1,"ipPreference":"v6","inNodeId":[{"nodeId":` + jsonInt(entryID) + `,"protocol":"tls"}],"chainNodes":[],"outNodeId":[{"nodeId":` + jsonInt(exitID) + `,"protocol":"tls"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewBufferString(payload))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
stored := mustQueryString(t, repo, `SELECT COALESCE(ip_preference, '') FROM tunnel WHERE id = ?`, tunnelID)
|
||||
if stored != "v6" {
|
||||
t.Fatalf("expected ip_preference='v6' after update, got %q", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelListReturnsIPPreferenceContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
err = repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "list-ip-pref-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0, "v6").Error
|
||||
if err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0, got %d (msg=%s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
tunnels, ok := out.Data.([]interface{})
|
||||
if !ok || len(tunnels) == 0 {
|
||||
t.Fatalf("expected non-empty tunnel list, got %v", out.Data)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, raw := range tunnels {
|
||||
tm, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if tm["name"] == "list-ip-pref-tunnel" {
|
||||
found = true
|
||||
pref, _ := tm["ipPreference"].(string)
|
||||
if pref != "v6" {
|
||||
t.Fatalf("expected ipPreference='v6' in list response, got %q", pref)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("tunnel 'list-ip-pref-tunnel' not found in list response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPPreferenceColumnDefaultContract(t *testing.T) {
|
||||
_, repo := setupContractRouter(t, "contract-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "no-pref-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel without ip_preference: %v", err)
|
||||
}
|
||||
|
||||
stored := mustQueryString(t, repo, `SELECT COALESCE(ip_preference, '') FROM tunnel WHERE name = ?`, "no-pref-tunnel")
|
||||
if stored != "" {
|
||||
t.Fatalf("expected default ip_preference='', got %q", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPPreferenceColumnMigrationContract(t *testing.T) {
|
||||
_, repo := setupContractRouter(t, "contract-jwt-secret")
|
||||
|
||||
colCount := mustQueryInt(t, repo, `SELECT COUNT(*) FROM pragma_table_info('tunnel') WHERE name = 'ip_preference'`)
|
||||
if colCount != 1 {
|
||||
t.Fatalf("expected ip_preference column to exist in tunnel table, found %d", colCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPPreferenceCoalesceNullSafety(t *testing.T) {
|
||||
_, repo := setupContractRouter(t, "contract-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
||||
`, "null-pref-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Skipf("DB does not allow NULL ip_preference (NOT NULL constraint): %v", err)
|
||||
}
|
||||
|
||||
stored := mustQueryString(t, repo, `SELECT COALESCE(ip_preference, '') FROM tunnel WHERE name = ?`, "null-pref-tunnel")
|
||||
if stored != "" {
|
||||
t.Fatalf("COALESCE should convert NULL to empty string, got %q", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDualStackNodeIPFieldsStoredContract(t *testing.T) {
|
||||
_, repo := setupContractRouter(t, "contract-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "ds-verify-node", "ds-secret", "10.70.0.1", "10.70.0.1", "2001:db8:2::1", "70000-70010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert dual-stack node: %v", err)
|
||||
}
|
||||
|
||||
v4, v6 := mustQueryTwoNullStrings(t, repo, `SELECT server_ip_v4, server_ip_v6 FROM node WHERE name = ?`, "ds-verify-node")
|
||||
if !v4.Valid || v4.String != "10.70.0.1" {
|
||||
t.Fatalf("expected server_ip_v4='10.70.0.1', got %v", v4)
|
||||
}
|
||||
if !v6.Valid || v6.String != "2001:db8:2::1" {
|
||||
t.Fatalf("expected server_ip_v6='2001:db8:2::1', got %v", v6)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPPreferenceValidValuesContract(t *testing.T) {
|
||||
_, repo := setupContractRouter(t, "contract-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
for _, pref := range []string{"", "v4", "v6"} {
|
||||
name := "valid-pref-" + pref
|
||||
if pref == "" {
|
||||
name = "valid-pref-empty"
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, 1.0, 2, "tls", 99999, now, now, 1, nil, 0, pref).Error; err != nil {
|
||||
t.Fatalf("insert tunnel with ip_preference=%q: %v", pref, err)
|
||||
}
|
||||
|
||||
stored := mustQueryString(t, repo, `SELECT COALESCE(ip_preference, '') FROM tunnel WHERE name = ?`, name)
|
||||
if stored != pref {
|
||||
t.Fatalf("expected ip_preference=%q, got %q for %s", pref, stored, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package contract
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -13,51 +13,46 @@ import (
|
||||
|
||||
func TestUserTunnelVisibleListContracts(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupDiagnosisContractRouter(t, secret)
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'normal_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now); err != nil {
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
insertTunnel := func(name string, status int, inx int64) int64 {
|
||||
res, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, 1.0, 1, "tls", 99999, now, now, status, nil, inx)
|
||||
if err != nil {
|
||||
`, name, 1.0, 1, "tls", 99999, now, now, status, nil, inx).Error; err != nil {
|
||||
t.Fatalf("insert tunnel %s: %v", name, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get tunnel id %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
return mustLastInsertID(t, repo, name)
|
||||
}
|
||||
|
||||
enabledA := insertTunnel("enabled-A", 1, 1)
|
||||
enabledB := insertTunnel("enabled-B", 1, 2)
|
||||
disabledC := insertTunnel("disabled-C", 0, 3)
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(?, ?, NULL, ?, ?, 0, 0, ?, ?, ?)
|
||||
`, 2, enabledA, 100, 1000, 1, 2727251700000, 0); err != nil {
|
||||
`, 2, enabledA, 100, 1000, 1, 2727251700000, 0).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel enabledA: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(?, ?, NULL, ?, ?, 0, 0, ?, ?, ?)
|
||||
`, 2, enabledB, 100, 1000, 1, 2727251700000, 1); err != nil {
|
||||
`, 2, enabledB, 100, 1000, 1, 2727251700000, 1).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel enabledB: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(?, ?, NULL, ?, ?, 0, 0, ?, ?, ?)
|
||||
`, 2, disabledC, 100, 1000, 1, 2727251700000, 1); err != nil {
|
||||
`, 2, disabledC, 100, 1000, 1, 2727251700000, 1).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel disabledC: %v", err)
|
||||
}
|
||||
|
||||
@@ -131,8 +126,11 @@ func collectTunnelIDs(t *testing.T, data interface{}) map[int64]bool {
|
||||
if !ok {
|
||||
t.Fatalf("expected object item, got %T", item)
|
||||
}
|
||||
id := int64(obj["id"].(float64))
|
||||
ids[id] = true
|
||||
idFloat, ok := obj["id"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected id to be float64, got %T", obj["id"])
|
||||
}
|
||||
ids[int64(idFloat)] = true
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func TestForwardCreateBlockedWhenUserQuotaExceeded(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now()
|
||||
nowMs := now.UnixMilli()
|
||||
dayKey := int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
|
||||
monthKey := int64(now.Year()*100 + int(now.Month()))
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'quota_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(1, 'quota_tunnel', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||
`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(10, 2, 1, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_quota(user_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time)
|
||||
VALUES(2, 10, 0, ?, ?, ?, ?, 1, ?, '', ?, ?)
|
||||
`, 11*contractBytesPerGB, 11*contractBytesPerGB, dayKey, monthKey, nowMs, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user_quota: %v", err)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(2, "quota_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewBufferString(`{"tunnelId":1,"name":"quota-forward","remoteAddr":"1.1.1.1:53"}`))
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code == 0 {
|
||||
t.Fatalf("expected non-zero code when user quota exceeded")
|
||||
}
|
||||
if !strings.Contains(out.Msg, "配额") {
|
||||
t.Fatalf("expected quota error, got %q", out.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardResumeBlockedWhenUserQuotaExceeded(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now()
|
||||
nowMs := now.UnixMilli()
|
||||
dayKey := int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
|
||||
monthKey := int64(now.Year()*100 + int(now.Month()))
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'quota_resume_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(1, 'quota_resume_tunnel', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||
`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(10, 2, 1, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(1, 2, 'quota_resume_user', 'quota_resume_forward', 1, '1.1.1.1:53', 'fifo', 0, 0, ?, ?, 0, 0)
|
||||
`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_quota(user_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time)
|
||||
VALUES(2, 10, 0, ?, ?, ?, ?, 1, ?, '1', ?, ?)
|
||||
`, 11*contractBytesPerGB, 11*contractBytesPerGB, dayKey, monthKey, nowMs, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user_quota: %v", err)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(2, "quota_resume_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/resume", bytes.NewBufferString(`{"id":1}`))
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code == 0 {
|
||||
t.Fatalf("expected non-zero code when user quota exceeded")
|
||||
}
|
||||
if !strings.Contains(out.Msg, "配额") {
|
||||
t.Fatalf("expected quota error, got %q", out.Msg)
|
||||
}
|
||||
status := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = 1`)
|
||||
if status != 0 {
|
||||
t.Fatalf("expected forward to remain paused, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserQuotaResetClearsDisableFlag(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now()
|
||||
nowMs := now.UnixMilli()
|
||||
dayKey := int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
|
||||
monthKey := int64(now.Year()*100 + int(now.Month()))
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'quota_reset_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_quota(user_id, daily_limit_gb, monthly_limit_gb, daily_used_bytes, monthly_used_bytes, day_key, month_key, disabled_by_quota, disabled_at, paused_forward_ids, created_time, updated_time)
|
||||
VALUES(2, 10, 0, ?, ?, ?, ?, 1, ?, '', ?, ?)
|
||||
`, 11*contractBytesPerGB, 11*contractBytesPerGB, dayKey, monthKey, nowMs, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert user_quota: %v", err)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(1, "admin", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/quota/reset", bytes.NewBufferString(`{"userId":2,"scope":"all"}`))
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected reset success, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
quotaDisabled := mustQueryInt(t, repo, `SELECT disabled_by_quota FROM user_quota WHERE user_id = 2`)
|
||||
if quotaDisabled != 0 {
|
||||
t.Fatalf("expected quota disable flag cleared, got %d", quotaDisabled)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func TestUserTunnelListReturnsStoredStatusContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(201, 'user_tunnel_status_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(301, 'user-tunnel-status-enabled', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel enabled: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(302, 'user-tunnel-status-disabled', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel disabled: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(401, 201, 301, NULL, 10, 500, 0, 0, 1, 2727251700000, 1)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert enabled user_tunnel: %v", err)
|
||||
}
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(402, 201, 302, NULL, 10, 500, 0, 0, 1, 2727251700000, 0)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert disabled user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
body := bytes.NewBufferString(`{"userId":201}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/list", body)
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
items, ok := out.Data.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected array data, got %T", out.Data)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("expected 2 items, got %d", len(items))
|
||||
}
|
||||
|
||||
statusByTunnelID := make(map[int64]int, len(items))
|
||||
for _, item := range items {
|
||||
obj, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected object item, got %T", item)
|
||||
}
|
||||
tunnelID, ok := obj["tunnelId"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected tunnelId to be float64, got %T", obj["tunnelId"])
|
||||
}
|
||||
status, ok := obj["status"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected status to be float64, got %T", obj["status"])
|
||||
}
|
||||
statusByTunnelID[int64(tunnelID)] = int(status)
|
||||
}
|
||||
|
||||
if statusByTunnelID[301] != 1 {
|
||||
t.Fatalf("expected enabled tunnel status 1, got %d", statusByTunnelID[301])
|
||||
}
|
||||
if statusByTunnelID[302] != 0 {
|
||||
t.Fatalf("expected disabled tunnel status 0, got %d", statusByTunnelID[302])
|
||||
}
|
||||
}
|
||||
+12
-5
@@ -1,6 +1,6 @@
|
||||
# GO-GOST SERVICE KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Mon Feb 02 2026
|
||||
**Generated:** Thu Feb 26 2026
|
||||
|
||||
## OVERVIEW
|
||||
Forwarding agent built on GOST v3 with a local fork of `github.com/go-gost/x` under `x/`.
|
||||
@@ -19,14 +19,21 @@ go-gost/
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Panel integration config | `go-gost/config.go` | Expects `config.json` in cwd by default |
|
||||
| Service lifecycle/reload | `go-gost/program.go` | Parses config; handles SIGHUP reload |
|
||||
| WebSocket reporting | `go-gost/main.go` | Starts reporter + sets HTTP report URL |
|
||||
| Protocol behaviors | `go-gost/x/` | Handlers/listeners/dialers live here |
|
||||
| **Panel integration config** | `go-gost/config.go` | Expects `config.json` in cwd by default |
|
||||
| **Service lifecycle/reload** | `go-gost/program.go` | Parses config; handles SIGHUP reload |
|
||||
| **WebSocket reporting** | `go-gost/main.go` | Starts reporter + sets HTTP report URL |
|
||||
| **Protocol behaviors** | `go-gost/x/` | Handlers/listeners/dialers live here |
|
||||
| **Build** | `go-gost/Makefile` | Cross-compile targets for amd64/arm64 |
|
||||
|
||||
## CONVENTIONS
|
||||
- Two configs exist: panel integration uses `config.json`; forwarding services use GOST config (defaults to `gost.{json,yaml}` via viper search paths).
|
||||
- `go-gost/x/` is the primary extension surface; avoid editing vendored deps.
|
||||
- Agent communicates with panel via WebSocket (real-time commands) + HTTP (batch traffic reports).
|
||||
- All panel communication uses AES encryption with node `secret` as PSK.
|
||||
- CI builds with `CGO_ENABLED=0` for static binaries, then compresses with UPX.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- **DO NOT EDIT** generated protobuf in `x/internal/util/grpc/proto/`.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
|
||||
+2
-2
@@ -109,12 +109,12 @@ func main() {
|
||||
// 加载配置文件
|
||||
config, err := LoadConfig("config.json")
|
||||
if err != nil {
|
||||
fmt.Println("❌ 配置加载失败: %v\n", err)
|
||||
fmt.Printf("❌ 配置加载失败: %v\n", err)
|
||||
fmt.Println("请确保当前目录存在 config.json 文件")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("✅ 配置加载成功 - addr: %s", config.Addr)
|
||||
fmt.Printf("✅ 配置加载成功 - addr: %s\n", config.Addr)
|
||||
|
||||
log := xlogger.NewLogger()
|
||||
logger.SetDefault(log)
|
||||
|
||||
+14
-11
@@ -1,36 +1,39 @@
|
||||
# GO-GOST/X KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Local fork of `github.com/go-gost/x` used by `go-gost/` via `replace github.com/go-gost/x => ./x`. Most protocol/runtime behavior changes happen here.
|
||||
Local fork of `github.com/go-gost/x` used by `go-gost/` via `replace github.com/go-gost/x => ./x`. Most protocol/runtime behavior changes happen here. 30+ top-level packages - framework-style layout.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
go-gost/x/
|
||||
├── api/ # Gin management API + embedded swagger docs
|
||||
├── api/ # Gin management API + embedded swagger docs (22 files)
|
||||
├── config/ # Config model + parsing/load/reload
|
||||
├── connector/ # Outbound connect implementations
|
||||
├── dialer/ # Outbound dialers (tcp/tls/ws/quic/...)
|
||||
├── dialer/ # Outbound dialers (tcp/tls/ws/quic/...)
|
||||
├── handler/ # Protocol handlers (socks/http/tunnel/relay/...)
|
||||
├── listener/ # Inbound listeners (tcp/udp/tun/tap/redirect/...)
|
||||
├── limiter/ # Traffic/rate/conn limiters
|
||||
├── registry/ # Registries for services/handlers/listeners/etc
|
||||
├── registry/ # Registries for services/handlers/listeners/etc (20 files)
|
||||
├── service/ # Service wrappers + reporting hooks
|
||||
├── socket/ # WebSocket reporter / panel integration
|
||||
├── socket/ # WebSocket reporter / panel integration (6 files)
|
||||
└── internal/ # Shared internals (grpc proto, net utils, sniffing, tls, ...)
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Management API routes/auth | `go-gost/x/api/api.go` | `/docs`, `/config/*`; BasicAuth + interceptor |
|
||||
| Service config parsing | `go-gost/x/config/parsing/` | Converts config to running services |
|
||||
| Add a handler | `go-gost/x/handler/` | Per-protocol subdirs |
|
||||
| Add a listener/dialer | `go-gost/x/listener/`, `go-gost/x/dialer/` | Transport variants |
|
||||
| Panel reporting | `go-gost/x/socket/` | WebSocket + HTTP report URL hooks |
|
||||
| **Management API routes/auth** | `go-gost/x/api/api.go` | `/docs`, `/config/*`; BasicAuth + interceptor |
|
||||
| **Service config parsing** | `go-gost/x/config/parsing/` | Converts config to running services |
|
||||
| **Add a handler** | `go-gost/x/handler/` | Per-protocol subdirs |
|
||||
| **Add a listener/dialer** | `go-gost/x/listener/`, `go-gost/x/dialer/` | Transport variants |
|
||||
| **Panel reporting** | `go-gost/x/socket/` | WebSocket + HTTP report URL hooks |
|
||||
| **Register new component** | `go-gost/x/registry/` | `Register{Type}(name, creator)` |
|
||||
|
||||
## CONVENTIONS
|
||||
- `go-gost/x/` is a standalone Go module (`go-gost/x/go.mod`); run go tooling from this dir when debugging module resolution.
|
||||
- Generated gRPC/proto code lives under `go-gost/x/internal/util/grpc/proto/`.
|
||||
- Handlers/listeners/dialers follow consistent pattern: `{type}.go` + `metadata.go` per protocol.
|
||||
- OS-specific code uses `name_[os].go` suffix (e.g., `tun_linux.go`, `tun_darwin.go`).
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- Do not edit generated files in `go-gost/x/internal/util/grpc/proto/` (`*.pb.go`, `*_grpc.pb.go`).
|
||||
@@ -39,4 +42,4 @@ go-gost/x/
|
||||
```bash
|
||||
cd go-gost/x
|
||||
go test ./...
|
||||
```
|
||||
```
|
||||
@@ -0,0 +1,39 @@
|
||||
# GOST CONNECTOR KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Feb 13 2026
|
||||
|
||||
## OVERVIEW
|
||||
Connection initiators (clients) for various protocols in GOST forwarding.
|
||||
**Stack:** Go, GOST core.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
connector/
|
||||
├── direct/ # Direct connection
|
||||
├── forward/ # Forward proxy
|
||||
├── http/ # HTTP connector
|
||||
├── http2/ # HTTP/2 connector
|
||||
├── relay/ # Relay protocol
|
||||
├── router/ # Router connector
|
||||
├── serial/ # Serial port
|
||||
├── sni/ # SNI routing
|
||||
├── socks/ # SOCKS4/5
|
||||
├── ss/ # Shadowsocks
|
||||
├── sshd/ # SSH daemon
|
||||
├── tcp/ # TCP connector
|
||||
├── tunnel/ # Tunnel mode
|
||||
└── unix/ # Unix socket
|
||||
```
|
||||
|
||||
## CONVENTIONS
|
||||
- Inherits from parent `go-gost/x/` conventions.
|
||||
- Each subdir implements `Connector` interface from GOST core.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- DO NOT EDIT generated protobuf in `go-gost/x/internal/util/grpc/proto/`.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost
|
||||
go test ./x/connector/...
|
||||
```
|
||||
@@ -54,10 +54,10 @@ var needWrap = false
|
||||
|
||||
// SetProtocolBlock sets protocol blocking switches and recomputes wrapper need
|
||||
func SetProtocolBlock(httpOn int, tlsOn int, socksOn int) {
|
||||
isHttp = httpOn
|
||||
isTls = tlsOn
|
||||
isSocks = socksOn
|
||||
needWrap = isTls+isSocks+isHttp > 0
|
||||
isHttp = httpOn
|
||||
isTls = tlsOn
|
||||
isSocks = socksOn
|
||||
needWrap = isTls+isSocks+isHttp > 0
|
||||
}
|
||||
|
||||
type Option func(opts *options)
|
||||
@@ -292,7 +292,9 @@ func (s *defaultService) Serve() error {
|
||||
}
|
||||
|
||||
if err := s.handler.Handle(ctx, conn); err != nil {
|
||||
log.Error(err)
|
||||
if !errors.Is(err, net.ErrClosed) {
|
||||
log.Error(err)
|
||||
}
|
||||
if v := xmetrics.GetCounter(xmetrics.MetricServiceHandlerErrorsCounter,
|
||||
metrics.Labels{"service": s.name, "client": clientIP}); v != nil {
|
||||
v.Inc()
|
||||
@@ -403,12 +405,12 @@ func (s *defaultService) observeStats(ctx context.Context) {
|
||||
TotalErrs: st.Get(stats.KindTotalErrs),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
// 将流量累积到全局管理器,而不是立即上报
|
||||
if outputBytes > 0 || inputBytes > 0 {
|
||||
globalManager := GetGlobalTrafficManager()
|
||||
globalManager.AddTraffic(s.name, int64(outputBytes), int64(inputBytes))
|
||||
|
||||
|
||||
// 立即重置流量计数(因为已经记录到全局管理器中)
|
||||
if xstats, ok := st.(*xstats.Stats); ok {
|
||||
xstats.ResetTraffic(st.Get(stats.KindInputBytes)-inputBytes, st.Get(stats.KindOutputBytes)-outputBytes)
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/observer/stats"
|
||||
@@ -18,6 +20,15 @@ import (
|
||||
var httpReportURL string
|
||||
var configReportURL string
|
||||
var httpAESCrypto *crypto.AESCrypto // 新增:HTTP上报加密器
|
||||
var reportURLPreferenceMutex sync.RWMutex
|
||||
var preferredUploadURL string
|
||||
var preferredConfigURL string
|
||||
var reportDo = func(ctx context.Context, req *http.Request, timeout time.Duration) (*http.Response, error) {
|
||||
client := &http.Client{
|
||||
Timeout: timeout,
|
||||
}
|
||||
return client.Do(req.WithContext(ctx))
|
||||
}
|
||||
|
||||
// TrafficReportItem 流量报告项(压缩格式)
|
||||
type TrafficReportItem struct {
|
||||
@@ -27,8 +38,17 @@ type TrafficReportItem struct {
|
||||
}
|
||||
|
||||
func SetHTTPReportURL(addr string, secret string) {
|
||||
httpReportURL = "http://" + addr + "/flow/upload?secret=" + secret
|
||||
configReportURL = "http://" + addr + "/flow/config?secret=" + secret
|
||||
uploadURLs, configURLs := buildReportURLCandidates(addr, secret)
|
||||
if len(uploadURLs) > 0 {
|
||||
httpReportURL = strings.Join(uploadURLs, ",")
|
||||
}
|
||||
if len(configURLs) > 0 {
|
||||
configReportURL = strings.Join(configURLs, ",")
|
||||
}
|
||||
reportURLPreferenceMutex.Lock()
|
||||
preferredUploadURL = ""
|
||||
preferredConfigURL = ""
|
||||
reportURLPreferenceMutex.Unlock()
|
||||
|
||||
// 创建 AES 加密器
|
||||
var err error
|
||||
@@ -41,8 +61,173 @@ func SetHTTPReportURL(addr string, secret string) {
|
||||
}
|
||||
}
|
||||
|
||||
func buildReportURLCandidates(addr string, secret string) (upload []string, config []string) {
|
||||
normalizedAddr, explicitScheme := normalizeReportAddress(addr)
|
||||
if normalizedAddr == "" {
|
||||
normalizedAddr = strings.TrimSpace(addr)
|
||||
}
|
||||
|
||||
schemes := []string{"https", "http"}
|
||||
if mappedScheme := mapToHTTPScheme(explicitScheme); mappedScheme == "http" {
|
||||
schemes = []string{"http", "https"}
|
||||
}
|
||||
|
||||
upload = []string{
|
||||
schemes[0] + "://" + normalizedAddr + "/flow/upload?secret=" + secret,
|
||||
schemes[1] + "://" + normalizedAddr + "/flow/upload?secret=" + secret,
|
||||
}
|
||||
config = []string{
|
||||
schemes[0] + "://" + normalizedAddr + "/flow/config?secret=" + secret,
|
||||
schemes[1] + "://" + normalizedAddr + "/flow/config?secret=" + secret,
|
||||
}
|
||||
return upload, config
|
||||
}
|
||||
|
||||
func normalizeReportAddress(addr string) (string, string) {
|
||||
raw := strings.TrimSpace(addr)
|
||||
if raw == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
scheme := ""
|
||||
if idx := strings.Index(raw, "://"); idx > 0 {
|
||||
scheme = strings.ToLower(strings.TrimSpace(raw[:idx]))
|
||||
if parsed, err := url.Parse(raw); err == nil {
|
||||
if host := strings.TrimSpace(parsed.Host); host != "" {
|
||||
return host, scheme
|
||||
}
|
||||
}
|
||||
raw = raw[idx+3:]
|
||||
}
|
||||
|
||||
if idx := strings.IndexAny(raw, "/?#"); idx >= 0 {
|
||||
raw = raw[:idx]
|
||||
}
|
||||
return strings.TrimSpace(raw), scheme
|
||||
}
|
||||
|
||||
func mapToHTTPScheme(scheme string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(scheme)) {
|
||||
case "https", "wss":
|
||||
return "https"
|
||||
case "http", "ws":
|
||||
return "http"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func loadPreferredURL(preferred *string) string {
|
||||
if preferred == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
reportURLPreferenceMutex.RLock()
|
||||
defer reportURLPreferenceMutex.RUnlock()
|
||||
return *preferred
|
||||
}
|
||||
|
||||
func storePreferredURL(preferred *string, value string) {
|
||||
if preferred == nil {
|
||||
return
|
||||
}
|
||||
|
||||
reportURLPreferenceMutex.Lock()
|
||||
defer reportURLPreferenceMutex.Unlock()
|
||||
*preferred = value
|
||||
}
|
||||
|
||||
func prioritizeURLs(urls []string, preferred string) []string {
|
||||
ordered := append([]string(nil), urls...)
|
||||
if preferred == "" || len(ordered) < 2 {
|
||||
return ordered
|
||||
}
|
||||
|
||||
for i, targetURL := range ordered {
|
||||
if targetURL == preferred {
|
||||
if i > 0 {
|
||||
ordered[0], ordered[i] = ordered[i], ordered[0]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return ordered
|
||||
}
|
||||
|
||||
func postJSONWithFallback(ctx context.Context, urls []string, requestBody []byte, userAgent string, timeout time.Duration, preferred *string) (bool, error) {
|
||||
if len(urls) == 0 {
|
||||
return false, fmt.Errorf("上报URL未设置")
|
||||
}
|
||||
|
||||
orderedURLs := prioritizeURLs(urls, loadPreferredURL(preferred))
|
||||
|
||||
var errs []string
|
||||
for i, targetURL := range orderedURLs {
|
||||
req, err := http.NewRequest("POST", targetURL, bytes.NewBuffer(requestBody))
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s => 创建请求失败: %v", targetURL, err))
|
||||
if i < len(orderedURLs)-1 {
|
||||
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => 创建请求失败: %v\n", targetURL, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
resp, err := reportDo(ctx, req, timeout)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s => 请求失败: %v", targetURL, err))
|
||||
if i < len(orderedURLs)-1 {
|
||||
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => 请求失败: %v\n", targetURL, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
var responseBytes bytes.Buffer
|
||||
_, readErr := responseBytes.ReadFrom(resp.Body)
|
||||
resp.Body.Close()
|
||||
if readErr != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s => 读取响应失败: %v", targetURL, readErr))
|
||||
if i < len(orderedURLs)-1 {
|
||||
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => 读取响应失败: %v\n", targetURL, readErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
errs = append(errs, fmt.Sprintf("%s => HTTP响应错误: %d %s", targetURL, resp.StatusCode, resp.Status))
|
||||
if i < len(orderedURLs)-1 {
|
||||
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => HTTP响应错误: %d %s\n", targetURL, resp.StatusCode, resp.Status)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
responseText := strings.TrimSpace(responseBytes.String())
|
||||
if responseText == "ok" {
|
||||
if i > 0 {
|
||||
fmt.Printf("↪️ HTTP上报已自动回退到: %s\n", targetURL)
|
||||
}
|
||||
storePreferredURL(preferred, targetURL)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
errs = append(errs, fmt.Sprintf("%s => 服务器响应: %s (期望: ok)", targetURL, responseText))
|
||||
if i < len(orderedURLs)-1 {
|
||||
fmt.Printf("⚠️ HTTP上报尝试失败,准备回退: %s => 服务器响应: %s (期望: ok)\n", targetURL, responseText)
|
||||
}
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("发送HTTP请求失败: %s", strings.Join(errs, " | "))
|
||||
}
|
||||
|
||||
// sendBatchTrafficReport 批量发送多个服务的流量报告到HTTP接口
|
||||
func sendBatchTrafficReport(ctx context.Context, reportItems []TrafficReportItem) (bool, error) {
|
||||
if httpReportURL == "" {
|
||||
return false, fmt.Errorf("流量上报URL未设置")
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reportItems)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("序列化报告数据失败: %v", err)
|
||||
@@ -73,46 +258,16 @@ func sendBatchTrafficReport(ctx context.Context, reportItems []TrafficReportItem
|
||||
requestBody = jsonData
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", httpReportURL, bytes.NewBuffer(requestBody))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("创建HTTP请求失败: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "GOST-Traffic-Reporter/1.0")
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("发送HTTP请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Errorf("HTTP响应错误: %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
// 读取响应内容
|
||||
var responseBytes bytes.Buffer
|
||||
_, err = responseBytes.ReadFrom(resp.Body)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("读取响应内容失败: %v", err)
|
||||
}
|
||||
|
||||
responseText := strings.TrimSpace(responseBytes.String())
|
||||
|
||||
// 检查响应是否为"ok"
|
||||
if responseText == "ok" {
|
||||
return true, nil
|
||||
} else {
|
||||
return false, fmt.Errorf("服务器响应: %s (期望: ok)", responseText)
|
||||
}
|
||||
return postJSONWithFallback(
|
||||
ctx,
|
||||
strings.Split(httpReportURL, ","),
|
||||
requestBody,
|
||||
"GOST-Traffic-Reporter/1.0",
|
||||
5*time.Second,
|
||||
&preferredUploadURL,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// sendConfigReport 发送配置报告到HTTP接口
|
||||
func sendConfigReport(ctx context.Context) (bool, error) {
|
||||
if configReportURL == "" {
|
||||
@@ -150,43 +305,14 @@ func sendConfigReport(ctx context.Context) (bool, error) {
|
||||
requestBody = configData
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", configReportURL, bytes.NewBuffer(requestBody))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("创建HTTP请求失败: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Config-Reporter/1.0")
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second, // 配置上报可以稍长一些
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("发送HTTP请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Errorf("HTTP响应错误: %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
// 读取响应内容
|
||||
var responseBytes bytes.Buffer
|
||||
_, err = responseBytes.ReadFrom(resp.Body)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("读取响应内容失败: %v", err)
|
||||
}
|
||||
|
||||
responseText := strings.TrimSpace(responseBytes.String())
|
||||
|
||||
// 检查响应是否为"ok"
|
||||
if responseText == "ok" {
|
||||
return true, nil
|
||||
} else {
|
||||
return false, fmt.Errorf("服务器响应: %s (期望: ok)", responseText)
|
||||
}
|
||||
return postJSONWithFallback(
|
||||
ctx,
|
||||
strings.Split(configReportURL, ","),
|
||||
requestBody,
|
||||
"Config-Reporter/1.0",
|
||||
10*time.Second,
|
||||
&preferredConfigURL,
|
||||
)
|
||||
}
|
||||
|
||||
// StartConfigReporter 启动配置定时上报器(每10分钟上报一次)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildReportURLCandidatesSecureFirst(t *testing.T) {
|
||||
upload, config := buildReportURLCandidates("panel.example.com:443", "abc")
|
||||
|
||||
if len(upload) != 2 {
|
||||
t.Fatalf("expected 2 upload candidates, got %d", len(upload))
|
||||
}
|
||||
if len(config) != 2 {
|
||||
t.Fatalf("expected 2 config candidates, got %d", len(config))
|
||||
}
|
||||
|
||||
if upload[0] != "https://panel.example.com:443/flow/upload?secret=abc" {
|
||||
t.Fatalf("unexpected upload[0]: %s", upload[0])
|
||||
}
|
||||
if upload[1] != "http://panel.example.com:443/flow/upload?secret=abc" {
|
||||
t.Fatalf("unexpected upload[1]: %s", upload[1])
|
||||
}
|
||||
if config[0] != "https://panel.example.com:443/flow/config?secret=abc" {
|
||||
t.Fatalf("unexpected config[0]: %s", config[0])
|
||||
}
|
||||
if config[1] != "http://panel.example.com:443/flow/config?secret=abc" {
|
||||
t.Fatalf("unexpected config[1]: %s", config[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReportURLCandidatesNormalizeSchemeAddr(t *testing.T) {
|
||||
upload, config := buildReportURLCandidates("https://panel.example.com:8443/path", "abc")
|
||||
|
||||
if upload[0] != "https://panel.example.com:8443/flow/upload?secret=abc" {
|
||||
t.Fatalf("unexpected upload[0]: %s", upload[0])
|
||||
}
|
||||
if upload[1] != "http://panel.example.com:8443/flow/upload?secret=abc" {
|
||||
t.Fatalf("unexpected upload[1]: %s", upload[1])
|
||||
}
|
||||
if config[0] != "https://panel.example.com:8443/flow/config?secret=abc" {
|
||||
t.Fatalf("unexpected config[0]: %s", config[0])
|
||||
}
|
||||
if config[1] != "http://panel.example.com:8443/flow/config?secret=abc" {
|
||||
t.Fatalf("unexpected config[1]: %s", config[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostJSONWithFallbackUsesHTTPAfterHTTPSFailure(t *testing.T) {
|
||||
orig := reportDo
|
||||
defer func() { reportDo = orig }()
|
||||
|
||||
var calls []string
|
||||
reportDo = func(_ context.Context, req *http.Request, _ time.Duration) (*http.Response, error) {
|
||||
calls = append(calls, req.URL.String())
|
||||
if strings.HasPrefix(req.URL.String(), "https://") {
|
||||
return nil, errors.New("tls handshake failed")
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("ok")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
ok, err := postJSONWithFallback(
|
||||
context.Background(),
|
||||
[]string{
|
||||
"https://panel.example.com:443/flow/upload?secret=abc",
|
||||
"http://panel.example.com:443/flow/upload?secret=abc",
|
||||
},
|
||||
[]byte(`[]`),
|
||||
"GOST-Traffic-Reporter/1.0",
|
||||
5*time.Second,
|
||||
nil,
|
||||
)
|
||||
if !ok || err != nil {
|
||||
t.Fatalf("expected fallback success, ok=%v err=%v", ok, err)
|
||||
}
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("expected 2 calls, got %d", len(calls))
|
||||
}
|
||||
if !strings.HasPrefix(calls[0], "https://") || !strings.HasPrefix(calls[1], "http://") {
|
||||
t.Fatalf("unexpected call order: %#v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostJSONWithFallbackRemembersDetectedURL(t *testing.T) {
|
||||
orig := reportDo
|
||||
defer func() { reportDo = orig }()
|
||||
|
||||
targets := []string{
|
||||
"https://panel.example.com:443/flow/upload?secret=abc",
|
||||
"http://panel.example.com:443/flow/upload?secret=abc",
|
||||
}
|
||||
|
||||
var preferred string
|
||||
var calls []string
|
||||
reportDo = func(_ context.Context, req *http.Request, _ time.Duration) (*http.Response, error) {
|
||||
calls = append(calls, req.URL.String())
|
||||
if strings.HasPrefix(req.URL.String(), "https://") {
|
||||
return nil, errors.New("tls handshake failed")
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("ok")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
ok, err := postJSONWithFallback(
|
||||
context.Background(),
|
||||
targets,
|
||||
[]byte(`[]`),
|
||||
"GOST-Traffic-Reporter/1.0",
|
||||
5*time.Second,
|
||||
&preferred,
|
||||
)
|
||||
if !ok || err != nil {
|
||||
t.Fatalf("expected first call success, ok=%v err=%v", ok, err)
|
||||
}
|
||||
if preferred != targets[1] {
|
||||
t.Fatalf("expected preferred url to be remembered as %s, got %s", targets[1], preferred)
|
||||
}
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("expected 2 calls on first attempt, got %d", len(calls))
|
||||
}
|
||||
|
||||
calls = nil
|
||||
ok, err = postJSONWithFallback(
|
||||
context.Background(),
|
||||
targets,
|
||||
[]byte(`[]`),
|
||||
"GOST-Traffic-Reporter/1.0",
|
||||
5*time.Second,
|
||||
&preferred,
|
||||
)
|
||||
if !ok || err != nil {
|
||||
t.Fatalf("expected second call success, ok=%v err=%v", ok, err)
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected second call to use remembered url once, got %d calls", len(calls))
|
||||
}
|
||||
if !strings.HasPrefix(calls[0], "http://") {
|
||||
t.Fatalf("expected remembered http url first, got %s", calls[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# GOST SOCKET KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Sun Feb 15 2026
|
||||
|
||||
## OVERVIEW
|
||||
WebSocket reporter and socket utilities for panel integration.
|
||||
**Stack:** Go, GOST core, gorilla/websocket.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
socket/
|
||||
├── websocket_reporter.go # Agent-to-panel telemetry (1504 LOC)
|
||||
├── service.go # Socket service orchestration (534 LOC)
|
||||
├── socket.go # Core socket interface
|
||||
├── udp.go # UDP socket handling
|
||||
├── packet.go # Packet framing
|
||||
└── packetconn.go # Packet connection wrapper
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| **Panel Reporting** | `websocket_reporter.go` | Real-time system info (CPU, mem, uptime) every 2s |
|
||||
| **Command Handling** | `websocket_reporter.go` | Processes `AddService`, `UpgradeAgent`, etc. |
|
||||
|
||||
## CONVENTIONS
|
||||
- Inherits from parent `go-gost/x/` conventions.
|
||||
- Low-level network primitives.
|
||||
- All panel communication is AES-encrypted using node `secret`.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- DO NOT EDIT generated protobuf.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost
|
||||
go test ./x/socket/...
|
||||
```
|
||||
@@ -4,10 +4,17 @@ import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync" // 新增:用于管理连接状态的互斥锁
|
||||
@@ -21,7 +28,6 @@ import (
|
||||
"github.com/shirou/gopsutil/v3/host"
|
||||
"github.com/shirou/gopsutil/v3/mem"
|
||||
psnet "github.com/shirou/gopsutil/v3/net"
|
||||
"os"
|
||||
)
|
||||
|
||||
// SystemInfo 系统信息结构体
|
||||
@@ -85,21 +91,31 @@ type TcpPingResponse struct {
|
||||
RequestId string `json:"requestId,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
reporterReadWait = 60 * time.Second
|
||||
reporterWriteWait = 5 * time.Second
|
||||
)
|
||||
|
||||
type WebSocketReporter struct {
|
||||
url string
|
||||
addr string // 保存服务器地址
|
||||
secret string // 保存密钥
|
||||
version string // 保存版本号
|
||||
conn *websocket.Conn
|
||||
reconnectTime time.Duration
|
||||
pingInterval time.Duration
|
||||
configInterval time.Duration
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
connected bool
|
||||
connecting bool // 新增:正在连接状态
|
||||
connMutex sync.Mutex // 新增:连接状态锁
|
||||
aesCrypto *crypto.AESCrypto // 新增:AES加密器
|
||||
url string
|
||||
addr string // 保存服务器地址
|
||||
secret string // 保存密钥
|
||||
version string // 保存版本号
|
||||
preferredWSScheme string
|
||||
conn *websocket.Conn
|
||||
reconnectTime time.Duration
|
||||
pingInterval time.Duration
|
||||
configInterval time.Duration
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
connected bool
|
||||
connecting bool // 新增:正在连接状态
|
||||
connMutex sync.Mutex // 新增:连接状态锁
|
||||
aesCrypto *crypto.AESCrypto // 新增:AES加密器
|
||||
}
|
||||
|
||||
var wsDial = func(dialer *websocket.Dialer, rawURL string) (*websocket.Conn, *http.Response, error) {
|
||||
return dialer.Dial(rawURL, nil)
|
||||
}
|
||||
|
||||
// NewWebSocketReporter 创建一个新的WebSocket报告器
|
||||
@@ -212,21 +228,14 @@ func (w *WebSocketReporter) connect() error {
|
||||
json.Unmarshal(b, &cfg)
|
||||
}
|
||||
|
||||
// 使用最新的配置重新构建 URL
|
||||
currentURL := "ws://" + w.addr + "/system-info?type=1&secret=" + w.secret + "&version=" + w.version +
|
||||
"&http=" + strconv.Itoa(cfg.Http) + "&tls=" + strconv.Itoa(cfg.Tls) + "&socks=" + strconv.Itoa(cfg.Socks)
|
||||
|
||||
u, err := url.Parse(currentURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析URL失败: %v", err)
|
||||
}
|
||||
candidates := buildWebSocketCandidates(w.addr, w.secret, w.version, cfg.Http, cfg.Tls, cfg.Socks, w.preferredWSScheme)
|
||||
|
||||
dialer := websocket.DefaultDialer
|
||||
dialer.HandshakeTimeout = 10 * time.Second
|
||||
|
||||
conn, _, err := dialer.Dial(u.String(), nil)
|
||||
conn, usedURL, err := dialWebSocketWithFallback(dialer, candidates)
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接WebSocket失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果在连接过程中已经有连接了,关闭新连接
|
||||
@@ -237,6 +246,17 @@ func (w *WebSocketReporter) connect() error {
|
||||
|
||||
w.conn = conn
|
||||
w.connected = true
|
||||
if scheme := detectWebSocketScheme(usedURL); scheme != "" {
|
||||
w.preferredWSScheme = scheme
|
||||
}
|
||||
_ = conn.SetReadDeadline(time.Now().Add(reporterReadWait))
|
||||
conn.SetPingHandler(func(appData string) error {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(reporterReadWait))
|
||||
return conn.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(reporterWriteWait))
|
||||
})
|
||||
conn.SetPongHandler(func(string) error {
|
||||
return conn.SetReadDeadline(time.Now().Add(reporterReadWait))
|
||||
})
|
||||
|
||||
// 设置关闭处理器来检测连接状态
|
||||
w.conn.SetCloseHandler(func(code int, text string) error {
|
||||
@@ -246,10 +266,145 @@ func (w *WebSocketReporter) connect() error {
|
||||
return nil
|
||||
})
|
||||
|
||||
fmt.Printf("✅ WebSocket连接建立成功 (http=%d, tls=%d, socks=%d)\n", cfg.Http, cfg.Tls, cfg.Socks)
|
||||
fmt.Printf("✅ WebSocket连接建立成功 (%s, http=%d, tls=%d, socks=%d)\n", sanitizeWebSocketURL(usedURL), cfg.Http, cfg.Tls, cfg.Socks)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildWebSocketCandidates(addr string, secret string, version string, http int, tls int, socks int, preferredScheme string) []string {
|
||||
normalizedAddr, explicitScheme := normalizeReporterAddress(addr)
|
||||
if normalizedAddr == "" {
|
||||
normalizedAddr = strings.TrimSpace(addr)
|
||||
}
|
||||
|
||||
query := "/system-info?type=1&secret=" + secret + "&version=" + version +
|
||||
"&http=" + strconv.Itoa(http) + "&tls=" + strconv.Itoa(tls) + "&socks=" + strconv.Itoa(socks)
|
||||
|
||||
schemes := []string{"wss", "ws"}
|
||||
if mappedScheme := mapToWebSocketScheme(explicitScheme); mappedScheme != "" {
|
||||
if mappedScheme == "ws" {
|
||||
schemes = []string{"ws", "wss"}
|
||||
}
|
||||
} else if preferredScheme == "ws" {
|
||||
schemes = []string{"ws", "wss"}
|
||||
}
|
||||
|
||||
return []string{
|
||||
schemes[0] + "://" + normalizedAddr + query,
|
||||
schemes[1] + "://" + normalizedAddr + query,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeReporterAddress(addr string) (string, string) {
|
||||
raw := strings.TrimSpace(addr)
|
||||
if raw == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
scheme := ""
|
||||
if idx := strings.Index(raw, "://"); idx > 0 {
|
||||
scheme = strings.ToLower(strings.TrimSpace(raw[:idx]))
|
||||
if parsed, err := url.Parse(raw); err == nil {
|
||||
if host := strings.TrimSpace(parsed.Host); host != "" {
|
||||
return host, scheme
|
||||
}
|
||||
}
|
||||
raw = raw[idx+3:]
|
||||
}
|
||||
|
||||
if idx := strings.IndexAny(raw, "/?#"); idx >= 0 {
|
||||
raw = raw[:idx]
|
||||
}
|
||||
return strings.TrimSpace(raw), scheme
|
||||
}
|
||||
|
||||
func mapToWebSocketScheme(scheme string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(scheme)) {
|
||||
case "wss", "https":
|
||||
return "wss"
|
||||
case "ws", "http":
|
||||
return "ws"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func detectWebSocketScheme(rawURL string) string {
|
||||
if strings.HasPrefix(rawURL, "wss://") {
|
||||
return "wss"
|
||||
}
|
||||
if strings.HasPrefix(rawURL, "ws://") {
|
||||
return "ws"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func dialWebSocketWithFallback(dialer *websocket.Dialer, candidates []string) (*websocket.Conn, string, error) {
|
||||
if len(candidates) == 0 {
|
||||
return nil, "", fmt.Errorf("WebSocket候选地址为空")
|
||||
}
|
||||
|
||||
var errs []string
|
||||
for i, targetURL := range candidates {
|
||||
conn, resp, err := wsDial(dialer, targetURL)
|
||||
if err == nil {
|
||||
if i > 0 {
|
||||
fmt.Printf("↪️ WebSocket已自动回退成功: %s\n", sanitizeWebSocketURL(targetURL))
|
||||
}
|
||||
return conn, targetURL, nil
|
||||
}
|
||||
errMsg := formatWebSocketDialError(err, resp)
|
||||
errs = append(errs, fmt.Sprintf("%s => %s", sanitizeWebSocketURL(targetURL), errMsg))
|
||||
if i < len(candidates)-1 {
|
||||
fmt.Printf(
|
||||
"⚠️ WebSocket连接失败,准备从 %s 回退到 %s: %s\n",
|
||||
strings.ToUpper(detectWebSocketScheme(targetURL)),
|
||||
strings.ToUpper(detectWebSocketScheme(candidates[i+1])),
|
||||
errMsg,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf("连接WebSocket失败(已尝试%d种协议): %s", len(candidates), strings.Join(errs, " | "))
|
||||
}
|
||||
|
||||
func sanitizeWebSocketURL(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return rawURL
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
if q.Get("secret") != "" {
|
||||
q.Set("secret", "***")
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func formatWebSocketDialError(err error, resp *http.Response) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
if resp == nil {
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("%s (HTTP %s)", err, resp.Status)
|
||||
if resp.Body == nil {
|
||||
return msg
|
||||
}
|
||||
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
if readErr != nil {
|
||||
return msg
|
||||
}
|
||||
bodyText := strings.TrimSpace(string(body))
|
||||
if bodyText == "" {
|
||||
return msg
|
||||
}
|
||||
return fmt.Sprintf("%s, body=%q", msg, bodyText)
|
||||
}
|
||||
|
||||
// handleConnection 处理WebSocket连接
|
||||
func (w *WebSocketReporter) handleConnection() {
|
||||
defer func() {
|
||||
@@ -377,7 +532,7 @@ func (w *WebSocketReporter) receiveMessages() {
|
||||
}
|
||||
|
||||
// 设置读取超时
|
||||
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
|
||||
conn.SetReadDeadline(time.Now().Add(reporterReadWait))
|
||||
|
||||
messageType, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
@@ -466,9 +621,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
|
||||
}
|
||||
|
||||
if cmdMsg.Type != "call" {
|
||||
// TcpPing 诊断命令异步执行,避免阻塞其他命令
|
||||
// 其他状态变更命令保持同步,确保顺序执行
|
||||
if cmdMsg.Type == "TcpPing" {
|
||||
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
|
||||
go w.routeCommand(cmdMsg)
|
||||
} else {
|
||||
w.routeCommand(cmdMsg)
|
||||
@@ -483,9 +637,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
|
||||
return
|
||||
}
|
||||
if cmdMsg.Type != "call" {
|
||||
// TcpPing 诊断命令异步执行,避免阻塞其他命令
|
||||
// 其他状态变更命令保持同步,确保顺序执行
|
||||
if cmdMsg.Type == "TcpPing" {
|
||||
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
|
||||
go w.routeCommand(cmdMsg)
|
||||
} else {
|
||||
w.routeCommand(cmdMsg)
|
||||
@@ -579,6 +732,18 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
|
||||
response.Type = "SetProtocolResponse"
|
||||
needSaveConfig = true
|
||||
|
||||
// 升级 Agent 命令(异步执行,不需要保存配置)
|
||||
case "UpgradeAgent":
|
||||
err = w.handleUpgradeAgent(cmd.Data)
|
||||
response.Type = "UpgradeAgentResponse"
|
||||
// needSaveConfig = false (默认值)
|
||||
|
||||
// 回退 Agent 到旧版本
|
||||
case "RollbackAgent":
|
||||
err = w.handleRollbackAgent(cmd.Data)
|
||||
response.Type = "RollbackAgentResponse"
|
||||
// needSaveConfig = false (默认值)
|
||||
|
||||
default:
|
||||
err = fmt.Errorf("未知命令类型: %s", cmd.Type)
|
||||
response.Type = "UnknownCommandResponse"
|
||||
@@ -881,6 +1046,186 @@ func (w *WebSocketReporter) handleSetProtocol(data interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendUpgradeProgress 通过 WS 发送升级进度消息
|
||||
func (w *WebSocketReporter) sendUpgradeProgress(stage string, percent int, message string) {
|
||||
response := CommandResponse{
|
||||
Type: "UpgradeProgress",
|
||||
Success: true,
|
||||
Message: message,
|
||||
Data: map[string]interface{}{
|
||||
"stage": stage,
|
||||
"percent": percent,
|
||||
},
|
||||
}
|
||||
w.sendResponse(response)
|
||||
}
|
||||
|
||||
func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化数据失败: %v", err)
|
||||
}
|
||||
|
||||
var req struct {
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
ChecksumURL string `json:"checksumUrl"`
|
||||
}
|
||||
if err := json.Unmarshal(jsonData, &req); err != nil {
|
||||
return fmt.Errorf("解析升级参数失败: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(req.DownloadURL) == "" {
|
||||
return fmt.Errorf("下载地址不能为空")
|
||||
}
|
||||
|
||||
// 替换架构占位符
|
||||
downloadURL := strings.ReplaceAll(req.DownloadURL, "{ARCH}", runtime.GOARCH)
|
||||
checksumURL := strings.ReplaceAll(req.ChecksumURL, "{ARCH}", runtime.GOARCH)
|
||||
|
||||
w.sendUpgradeProgress("downloading", 0, "开始下载升级包...")
|
||||
fmt.Printf("📦 开始下载升级包: %s\n", downloadURL)
|
||||
|
||||
// 下载新版本二进制
|
||||
const binaryPath = "/etc/flux_agent/flux_agent"
|
||||
tmpPath := binaryPath + ".new"
|
||||
backupPath := binaryPath + ".old"
|
||||
|
||||
resp, err := http.Get(downloadURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("下载升级包失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("下载升级包失败, HTTP状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
outFile, err := os.Create(tmpPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建临时文件失败: %v", err)
|
||||
}
|
||||
|
||||
// 带进度的下载
|
||||
totalSize := resp.ContentLength
|
||||
var downloaded int64
|
||||
buf := make([]byte, 32*1024)
|
||||
lastPercent := 0
|
||||
hasher := sha256.New()
|
||||
|
||||
for {
|
||||
n, readErr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
if _, wErr := outFile.Write(buf[:n]); wErr != nil {
|
||||
outFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("写入升级包失败: %v", wErr)
|
||||
}
|
||||
hasher.Write(buf[:n])
|
||||
downloaded += int64(n)
|
||||
if totalSize > 0 {
|
||||
percent := int(downloaded * 100 / totalSize)
|
||||
if percent-lastPercent >= 10 {
|
||||
lastPercent = percent
|
||||
w.sendUpgradeProgress("downloading", percent, fmt.Sprintf("下载中... %d%%", percent))
|
||||
}
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
outFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("读取升级包失败: %v", readErr)
|
||||
}
|
||||
}
|
||||
outFile.Close()
|
||||
|
||||
if downloaded == 0 {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("下载的升级包为空")
|
||||
}
|
||||
|
||||
w.sendUpgradeProgress("downloading", 100, fmt.Sprintf("下载完成 (%d bytes)", downloaded))
|
||||
|
||||
// Checksum 校验
|
||||
if checksumURL != "" {
|
||||
w.sendUpgradeProgress("verifying", 0, "校验文件完整性...")
|
||||
checksumResp, err := http.Get(checksumURL)
|
||||
if err == nil {
|
||||
defer checksumResp.Body.Close()
|
||||
if checksumResp.StatusCode == http.StatusOK {
|
||||
checksumBody, err := io.ReadAll(checksumResp.Body)
|
||||
if err == nil {
|
||||
// 格式: "<hash> <filename>" 或 "<hash>"
|
||||
expectedHash := strings.TrimSpace(strings.Split(string(checksumBody), " ")[0])
|
||||
actualHash := hex.EncodeToString(hasher.Sum(nil))
|
||||
if !strings.EqualFold(expectedHash, actualHash) {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("校验失败: 期望 %s, 实际 %s", expectedHash, actualHash)
|
||||
}
|
||||
fmt.Printf("✅ Checksum 校验通过: %s\n", actualHash)
|
||||
}
|
||||
}
|
||||
}
|
||||
w.sendUpgradeProgress("verifying", 100, "校验通过")
|
||||
}
|
||||
|
||||
if err := os.Chmod(tmpPath, 0755); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("设置执行权限失败: %v", err)
|
||||
}
|
||||
|
||||
// 备份旧版本
|
||||
w.sendUpgradeProgress("installing", 50, "备份旧版本...")
|
||||
if _, err := os.Stat(binaryPath); err == nil {
|
||||
// 复制旧文件作为备份(不用 rename,因为可能正在运行)
|
||||
oldData, err := os.ReadFile(binaryPath)
|
||||
if err == nil {
|
||||
_ = os.WriteFile(backupPath, oldData, 0755)
|
||||
fmt.Println("📦 旧版本已备份到", backupPath)
|
||||
}
|
||||
}
|
||||
|
||||
w.sendUpgradeProgress("installing", 80, "准备重启...")
|
||||
fmt.Printf("✅ 升级包下载完成 (%d bytes), 准备重启...\n", downloaded)
|
||||
|
||||
// 执行重启脚本
|
||||
// 使用 systemd-run 在独立的 transient unit 中运行重启脚本,
|
||||
// 避免 systemctl stop 杀死 flux_agent cgroup 内所有进程(包括此脚本自身)导致 mv 未执行。
|
||||
script := fmt.Sprintf("sleep 1 && systemctl stop flux_agent && mv %s %s && systemctl start flux_agent", tmpPath, binaryPath)
|
||||
cmd := exec.Command("systemd-run", "--quiet", "/bin/sh", "-c", script)
|
||||
if err := cmd.Start(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("启动重启脚本失败: %v", err)
|
||||
}
|
||||
|
||||
w.sendUpgradeProgress("installing", 100, "重启中...")
|
||||
fmt.Println("🔄 重启脚本已启动, Agent 将在 1 秒后重启...")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WebSocketReporter) handleRollbackAgent(data interface{}) error {
|
||||
const binaryPath = "/etc/flux_agent/flux_agent"
|
||||
backupPath := binaryPath + ".old"
|
||||
|
||||
// 检查备份文件是否存在
|
||||
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("没有可用的备份文件,无法回退")
|
||||
}
|
||||
|
||||
fmt.Println("🔄 开始回退到旧版本...")
|
||||
|
||||
// 执行回退脚本(同升级逻辑,使用 systemd-run 避免 cgroup 问题)
|
||||
script := fmt.Sprintf("sleep 1 && systemctl stop flux_agent && cp %s %s && systemctl start flux_agent", backupPath, binaryPath)
|
||||
cmd := exec.Command("systemd-run", "--quiet", "/bin/sh", "-c", script)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("启动回退脚本失败: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("🔄 回退脚本已启动, Agent 将在 1 秒后重启...")
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateLocalConfigJSON 将 http/tls/socks 写入工作目录下的 config.json
|
||||
func updateLocalConfigJSON(httpVal int, tlsVal int, socksVal int) error {
|
||||
path := "config.json"
|
||||
@@ -1081,7 +1426,8 @@ func getMemoryInfo() MemoryInfo {
|
||||
func StartWebSocketReporterWithConfig(addr string, secret string, http int, tls int, socks int, version string) *WebSocketReporter {
|
||||
|
||||
// 构建初始 WebSocket URL
|
||||
fullURL := "ws://" + addr + "/system-info?type=1&secret=" + secret + "&version=" + version + "&http=" + strconv.Itoa(http) + "&tls=" + strconv.Itoa(tls) + "&socks=" + strconv.Itoa(socks)
|
||||
candidates := buildWebSocketCandidates(addr, secret, version, http, tls, socks, "")
|
||||
fullURL := candidates[0]
|
||||
|
||||
fmt.Printf("🔗 WebSocket连接URL: %s\n", fullURL)
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package socket
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func TestBuildWebSocketCandidatesSecureFirst(t *testing.T) {
|
||||
candidates := buildWebSocketCandidates("panel.example.com:443", "abc", "2.0.2", 1, 0, 1, "")
|
||||
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("expected 2 candidates, got %d", len(candidates))
|
||||
}
|
||||
if !strings.HasPrefix(candidates[0], "wss://") {
|
||||
t.Fatalf("expected first candidate to start with wss://, got %s", candidates[0])
|
||||
}
|
||||
if !strings.HasPrefix(candidates[1], "ws://") {
|
||||
t.Fatalf("expected second candidate to start with ws://, got %s", candidates[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWebSocketCandidatesUsesPreferredScheme(t *testing.T) {
|
||||
candidates := buildWebSocketCandidates("panel.example.com:443", "abc", "2.0.2", 1, 0, 1, "ws")
|
||||
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("expected 2 candidates, got %d", len(candidates))
|
||||
}
|
||||
if !strings.HasPrefix(candidates[0], "ws://") {
|
||||
t.Fatalf("expected preferred ws:// candidate first, got %s", candidates[0])
|
||||
}
|
||||
if !strings.HasPrefix(candidates[1], "wss://") {
|
||||
t.Fatalf("expected fallback wss:// candidate second, got %s", candidates[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWebSocketCandidatesNormalizesSchemePrefixedAddr(t *testing.T) {
|
||||
candidates := buildWebSocketCandidates("https://panel.example.com:443/path?q=1", "abc", "2.0.2", 0, 0, 0, "")
|
||||
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("expected 2 candidates, got %d", len(candidates))
|
||||
}
|
||||
if !strings.HasPrefix(candidates[0], "wss://panel.example.com:443/") {
|
||||
t.Fatalf("expected normalized wss candidate, got %s", candidates[0])
|
||||
}
|
||||
if !strings.HasPrefix(candidates[1], "ws://panel.example.com:443/") {
|
||||
t.Fatalf("expected normalized ws fallback candidate, got %s", candidates[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialWebSocketWithFallbackTriesWSAfterWSSFailure(t *testing.T) {
|
||||
orig := wsDial
|
||||
defer func() { wsDial = orig }()
|
||||
|
||||
var attempts []string
|
||||
wsDial = func(_ *websocket.Dialer, rawURL string) (*websocket.Conn, *http.Response, error) {
|
||||
attempts = append(attempts, rawURL)
|
||||
if strings.HasPrefix(rawURL, "wss://") {
|
||||
return nil, nil, errors.New("tls failed")
|
||||
}
|
||||
return &websocket.Conn{}, nil, nil
|
||||
}
|
||||
|
||||
_, usedURL, err := dialWebSocketWithFallback(
|
||||
&websocket.Dialer{},
|
||||
[]string{
|
||||
"wss://panel.example.com/system-info?type=1&secret=abc",
|
||||
"ws://panel.example.com/system-info?type=1&secret=abc",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("expected fallback success, got err=%v", err)
|
||||
}
|
||||
if !strings.HasPrefix(usedURL, "ws://") {
|
||||
t.Fatalf("expected fallback ws:// url, got %s", usedURL)
|
||||
}
|
||||
if len(attempts) != 2 {
|
||||
t.Fatalf("expected 2 attempts, got %d", len(attempts))
|
||||
}
|
||||
if !strings.HasPrefix(attempts[0], "wss://") || !strings.HasPrefix(attempts[1], "ws://") {
|
||||
t.Fatalf("unexpected attempt order: %#v", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectWebSocketScheme(t *testing.T) {
|
||||
if detectWebSocketScheme("wss://panel.example.com/system-info") != "wss" {
|
||||
t.Fatalf("expected wss detection")
|
||||
}
|
||||
if detectWebSocketScheme("ws://panel.example.com/system-info") != "ws" {
|
||||
t.Fatalf("expected ws detection")
|
||||
}
|
||||
if detectWebSocketScheme("http://panel.example.com/system-info") != "" {
|
||||
t.Fatalf("expected empty detection for non-websocket scheme")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeWebSocketURL(t *testing.T) {
|
||||
raw := "wss://panel.example.com/system-info?type=1&secret=abc&version=2.0.2"
|
||||
sanitized := sanitizeWebSocketURL(raw)
|
||||
|
||||
if strings.Contains(sanitized, "secret=abc") {
|
||||
t.Fatalf("expected secret to be masked, got %s", sanitized)
|
||||
}
|
||||
if !strings.Contains(sanitized, "secret=%2A%2A%2A") {
|
||||
t.Fatalf("expected masked secret in url, got %s", sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatWebSocketDialErrorIncludesHTTPStatus(t *testing.T) {
|
||||
err := errors.New("websocket: bad handshake")
|
||||
resp := &http.Response{
|
||||
Status: "403 Forbidden",
|
||||
Body: io.NopCloser(strings.NewReader("forbidden")),
|
||||
}
|
||||
|
||||
msg := formatWebSocketDialError(err, resp)
|
||||
if !strings.Contains(msg, "HTTP 403 Forbidden") {
|
||||
t.Fatalf("expected status in message, got %s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "forbidden") {
|
||||
t.Fatalf("expected response body in message, got %s", msg)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ nav:
|
||||
- 首页: index.md
|
||||
- 安装部署: install.md
|
||||
- 使用指南: usage.md
|
||||
- AI Skill 接入: ai-skill.md
|
||||
- PostgreSQL: postgresql.md
|
||||
- 常见问题: faq.md
|
||||
|
||||
markdown_extensions:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-17
|
||||
@@ -0,0 +1,29 @@
|
||||
## Context
|
||||
|
||||
FLVX is a distributed system consisting of a central management panel (Backend + Frontend) and multiple forwarding agents (Nodes). The backend manages configuration, users, and billing, while agents handle the actual traffic forwarding using a modified GOST v3 stack. Communication between the panel and agents is secured and synchronized.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Document the high-level architecture of the system.
|
||||
- Describe the data model for users, tunnels, and nodes.
|
||||
- Explain the communication protocol between Panel and Agent.
|
||||
- Detail the authentication and authorization mechanisms.
|
||||
|
||||
**Non-Goals:**
|
||||
- Refactoring the existing architecture.
|
||||
- Detailed code-level documentation of every function.
|
||||
- Changing the database schema.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Architecture**: The system follows a client-server model where the Panel acts as the server and Agents act as clients that pull configuration and push status.
|
||||
- **Data Model**: Core entities are Users, Nodes (Agents), Tunnels (Groups of rules), and Forwarding Rules.
|
||||
- **Communication**: Agents use a heartbeat mechanism to report status and fetch configuration updates. The protocol uses AES encryption with a pre-shared key (Node Secret).
|
||||
- **Authentication**: JWT for Frontend-Backend communication; API Key (Node Secret) for Agent-Backend communication.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Security**: The security of the agent communication relies heavily on the secrecy of the Node Secret.
|
||||
- **Scalability**: Centralized management might become a bottleneck with a very large number of agents.
|
||||
- **Complexity**: Synchronizing state across distributed agents introduces complexity in handling failures and inconsistencies.
|
||||
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
The current system lacks formal specification documents describing its capabilities. This makes it difficult for new developers to understand the intended behavior and for existing developers to ensure consistency when adding new features. Documenting the existing functionality will serve as a baseline for future changes and help in identifying gaps or inconsistencies.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Create formal specification documents for core system capabilities.
|
||||
- Document user management features (roles, limits).
|
||||
- Document tunnel and forwarding management (protocols, rules).
|
||||
- Document agent interactions and management.
|
||||
- Document system-level configurations.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `user-management`: Authentication, user roles, and resource limits.
|
||||
- `tunnel-management`: Creation and management of traffic tunnels (TCP/UDP).
|
||||
- `forwarding-rules`: Configuration of port forwarding and tunnel forwarding rules, including rate limiting.
|
||||
- `agent-management`: Management of forwarding agents, including installation and configuration synchronization.
|
||||
- `system-config`: Global system settings and configurations.
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- None, as this is a documentation effort for existing features. -->
|
||||
|
||||
## Impact
|
||||
|
||||
- **Documentation**: New spec files in `openspec/specs/`.
|
||||
- **No Code Changes**: This change is purely documentation-focused.
|
||||
@@ -0,0 +1,29 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Agent Registration
|
||||
The system SHALL require new agents (Nodes) to register using a unique node key/secret.
|
||||
|
||||
#### Scenario: Node Connection
|
||||
- **WHEN** a new agent starts up with a valid configuration
|
||||
- **THEN** it connects to the backend and is registered as active.
|
||||
|
||||
### Requirement: Heartbeat Monitoring
|
||||
The system SHALL monitor the status of all registered agents using periodic heartbeats.
|
||||
|
||||
#### Scenario: Agent Status
|
||||
- **WHEN** an agent sends periodic heartbeats
|
||||
- **THEN** the system updates its last-seen timestamp and marks it as online.
|
||||
|
||||
### Requirement: Configuration Sync
|
||||
The system MUST synchronize configuration changes (tunnels, rules) to agents securely and reliably.
|
||||
|
||||
#### Scenario: Push Config
|
||||
- **WHEN** a configuration change is made in the panel
|
||||
- **THEN** the agent receives the updated configuration via the next heartbeat or push mechanism.
|
||||
|
||||
### Requirement: Version Management
|
||||
The system SHOULD track the version of the agent software running on each node.
|
||||
|
||||
#### Scenario: Version Reporting
|
||||
- **WHEN** an agent connects
|
||||
- **THEN** it reports its version number to the backend for tracking.
|
||||
@@ -0,0 +1,22 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Port Forwarding Rules
|
||||
The system SHALL support configuring port forwarding rules, defining the listening port on the node and the destination IP/port.
|
||||
|
||||
#### Scenario: Rule Configuration
|
||||
- **WHEN** an admin creates a port forwarding rule
|
||||
- **THEN** the rule is stored and synchronized to the assigned node.
|
||||
|
||||
### Requirement: Rate Limiting
|
||||
The system SHALL support configuring bandwidth rate limits for tunnels and users.
|
||||
|
||||
#### Scenario: Bandwidth Restriction
|
||||
- **WHEN** a rate limit is applied to a user
|
||||
- **THEN** their total bandwidth usage does not exceed the specified limit across all their tunnels.
|
||||
|
||||
### Requirement: Traffic Accounting
|
||||
The system MUST track incoming and outgoing traffic volume for each tunnel and user for billing and quota enforcement.
|
||||
|
||||
#### Scenario: Traffic Calculation
|
||||
- **WHEN** traffic flows through a tunnel
|
||||
- **THEN** the system increments the user's traffic usage counter accurately.
|
||||
@@ -0,0 +1,22 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Site Settings
|
||||
The system SHALL allow customization of the site title, logo, and other branding elements.
|
||||
|
||||
#### Scenario: Update Branding
|
||||
- **WHEN** an administrator changes the site logo
|
||||
- **THEN** the new logo is displayed across the interface.
|
||||
|
||||
### Requirement: Notification Settings
|
||||
The system SHALL support configuring notifications for user registration, traffic limits, and other events.
|
||||
|
||||
#### Scenario: User Limit Alert
|
||||
- **WHEN** a user approaches their traffic quota
|
||||
- **THEN** a notification is sent to the user/admin.
|
||||
|
||||
### Requirement: Backup & Restore
|
||||
The system SHOULD provide a mechanism to backup and restore database configurations.
|
||||
|
||||
#### Scenario: Restore Database
|
||||
- **WHEN** initiating a restore operation
|
||||
- **THEN** the system accepts a valid backup file and overwrites the current database state.
|
||||
@@ -0,0 +1,22 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Tunnel Creation
|
||||
The system SHALL allow administrators to create tunnels, specifying protocols (TCP, UDP), listening ports, and destination endpoints.
|
||||
|
||||
#### Scenario: Create TCP Tunnel
|
||||
- **WHEN** an admin creates a new TCP tunnel configuration
|
||||
- **THEN** the backend stores the tunnel definition and assigns it to a node.
|
||||
|
||||
### Requirement: Tunnel Forwarding Configuration
|
||||
The system SHALL support both standard port forwarding (listening on a port and forwarding to a destination) and tunnel forwarding modes.
|
||||
|
||||
#### Scenario: Configure Port Forwarding
|
||||
- **WHEN** configuring a tunnel for port forwarding
|
||||
- **THEN** traffic arriving at the specified port is forwarded to the destination IP:port.
|
||||
|
||||
### Requirement: Tunnel Assignment
|
||||
The system SHALL allow tunnels to be assigned to specific users, tracking their usage against the user's quota.
|
||||
|
||||
#### Scenario: User Tunnel Usage
|
||||
- **WHEN** a user is assigned a tunnel
|
||||
- **THEN** traffic passing through that tunnel is accounted for under the user's usage.
|
||||
@@ -0,0 +1,29 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: User Registration
|
||||
The system SHALL allow new users to register an account with a username and password.
|
||||
|
||||
#### Scenario: Successful Registration
|
||||
- **WHEN** a user submits valid registration details
|
||||
- **THEN** a new user account is created and the user can log in.
|
||||
|
||||
### Requirement: User Authentication
|
||||
The system MUST authenticate users using JWT tokens. The `Authorization` header MUST contain the raw token without a `Bearer` prefix.
|
||||
|
||||
#### Scenario: Valid Login
|
||||
- **WHEN** a user provides correct credentials
|
||||
- **THEN** the system returns a valid JWT token.
|
||||
|
||||
### Requirement: Role Management
|
||||
The system SHALL support different user roles, specifically Administrator and Regular User, with distinct permissions.
|
||||
|
||||
#### Scenario: Admin Access
|
||||
- **WHEN** an administrator logs in
|
||||
- **THEN** they have access to system-wide settings and all user management functions.
|
||||
|
||||
### Requirement: Resource Quotas
|
||||
The system SHALL allow administrators to set traffic limits and connection limits for individual users.
|
||||
|
||||
#### Scenario: Traffic Limit Enforcement
|
||||
- **WHEN** a user exceeds their traffic quota
|
||||
- **THEN** the system prevents further traffic forwarding for that user.
|
||||
@@ -0,0 +1,30 @@
|
||||
## 1. User Management Verification
|
||||
|
||||
- [ ] 1.1 Verify User Registration logic in backend
|
||||
- [ ] 1.2 Verify JWT Authentication implementation
|
||||
- [ ] 1.3 Verify Role Management checks
|
||||
- [ ] 1.4 Verify Quota Enforcement logic
|
||||
|
||||
## 2. Tunnel Management Verification
|
||||
|
||||
- [ ] 2.1 Verify Tunnel Creation API
|
||||
- [ ] 2.2 Verify Forwarding Configuration parsing
|
||||
- [ ] 2.3 Verify Tunnel Assignment logic
|
||||
|
||||
## 3. Forwarding Rules Verification
|
||||
|
||||
- [ ] 3.1 Verify Port Forwarding rule processing
|
||||
- [ ] 3.2 Verify Rate Limiting implementation (token bucket/leaky bucket?)
|
||||
- [ ] 3.3 Verify Traffic Accounting mechanisms
|
||||
|
||||
## 4. Agent Management Verification
|
||||
|
||||
- [ ] 4.1 Verify Agent Registration handshake
|
||||
- [ ] 4.2 Verify Heartbeat processing
|
||||
- [ ] 4.3 Verify Config Sync protocol
|
||||
|
||||
## 5. System Config Verification
|
||||
|
||||
- [ ] 5.1 Verify Site Settings API
|
||||
- [ ] 5.2 Verify Notification triggers
|
||||
- [ ] 5.3 Verify Backup/Restore functionality
|
||||
@@ -0,0 +1,20 @@
|
||||
schema: spec-driven
|
||||
|
||||
# Project context (optional)
|
||||
# This is shown to AI when creating artifacts.
|
||||
# Add your tech stack, conventions, style guides, domain knowledge, etc.
|
||||
# Example:
|
||||
# context: |
|
||||
# Tech stack: TypeScript, React, Node.js
|
||||
# We use conventional commits
|
||||
# Domain: e-commerce platform
|
||||
|
||||
# Per-artifact rules (optional)
|
||||
# Add custom rules for specific artifacts.
|
||||
# Example:
|
||||
# rules:
|
||||
# proposal:
|
||||
# - Keep proposals under 500 words
|
||||
# - Always include a "Non-goals" section
|
||||
# tasks:
|
||||
# - Break tasks into chunks of max 2 hours
|
||||
@@ -0,0 +1,52 @@
|
||||
# Project Overview
|
||||
|
||||
**Name**: FLVX (Flux Panel)
|
||||
**Description**: Traffic forwarding management system built on a forked GOST v3 stack. It provides a web-based panel for managing traffic tunnels, users, and forwarding rules.
|
||||
**Repository**: Monorepo containing Admin API, Web UI, and Forwarding Agent.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend (`go-backend/`)
|
||||
- **Language**: Go
|
||||
- **Database**: SQLite (default), PostgreSQL (supported)
|
||||
- **Framework**: Standard library `net/http` (no heavy framework)
|
||||
- **ORM**: None (Raw SQL via `database/sql`)
|
||||
|
||||
### Frontend (`vite-frontend/`)
|
||||
- **Framework**: React
|
||||
- **Build Tool**: Vite (using `rolldown-vite` experimental bundler)
|
||||
- **UI Library**: HeroUI
|
||||
- **Styling**: Tailwind CSS
|
||||
- **Mode**: Hybrid (Desktop + Mobile WebView support)
|
||||
|
||||
### Agent (`go-gost/`)
|
||||
- **Language**: Go
|
||||
- **Base**: Fork of `gost` v3
|
||||
- **Extensions**: Custom extensions in `go-gost/x/`
|
||||
|
||||
### Infrastructure
|
||||
- **Containerization**: Docker, Docker Compose (v4/v6)
|
||||
- **CI/CD**: GitHub Actions
|
||||
- **Installers**: Shell scripts (`panel_install.sh`, `install.sh`)
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Panel**: Central management server (Go Backend + React Frontend).
|
||||
- **Agent**: Forwarding node running on remote servers.
|
||||
- **Communication**:
|
||||
- Frontend -> Backend: REST API (JWT Auth, raw token in header).
|
||||
- Agent -> Backend: AES-encrypted heartbeat/config sync.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Authentication**: `Authorization` header expects raw JWT token (do NOT add `Bearer ` prefix).
|
||||
- **API Response**: Standard envelope `{code, msg, data, ts}` (code 0 = success).
|
||||
- **Database**: Backend uses raw SQL queries. Do not introduce an ORM.
|
||||
- **File Structure**: Flat monorepo with language-prefixed directories (`go-backend`, `go-gost`).
|
||||
- **Protobuf**: Do not edit generated `.pb.go` files manually.
|
||||
|
||||
## Development
|
||||
|
||||
- **Backend Build**: `cd go-backend && make build`
|
||||
- **Frontend Dev**: `cd vite-frontend && npm run dev`
|
||||
- **Agent Run**: `cd go-gost && go run .`
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user