mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
Compare commits
200 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f2150828d | |||
| c76bb77b93 | |||
| 01d5c25df1 | |||
| a19e8d2bcb | |||
| 76f443f900 | |||
| 47b1663938 | |||
| 406f5bb380 | |||
| 85ea6c17a4 | |||
| 3420dc5460 | |||
| 3d7a0b697d | |||
| 065b23d9c3 | |||
| 7919dfde59 | |||
| 565d732967 | |||
| bf88b0dd7e | |||
| 20dc151aec | |||
| 630ed969d3 | |||
| e94aa01213 | |||
| 67d8f7a381 | |||
| 0c7b7deaf5 | |||
| a4def9c5f3 | |||
| 6582348da2 | |||
| 3a14b22ebc | |||
| d7b44916bf | |||
| f8a0bda3fd | |||
| 634562e56d | |||
| d06e02998b | |||
| 169df953d8 | |||
| 7bc33f63ba | |||
| 5925a84a6d | |||
| bbffe5872c | |||
| 98db0e6a5e | |||
| 30591a008a | |||
| 52885d1821 | |||
| 124cf1ced2 | |||
| 36ba2f95ef | |||
| 858f9ef1d5 | |||
| 218084b542 | |||
| a9c304546e | |||
| 85250aa2d3 | |||
| aeeb57b89d | |||
| 4e163163cb | |||
| eb5464511a | |||
| efa6c0e322 | |||
| fef4c28777 | |||
| 9ab5140258 | |||
| 11dc21e46f | |||
| 576654fc5a | |||
| a1454a3549 | |||
| 1c72fb233a | |||
| ac1c2fa6bf | |||
| 4af2186e35 | |||
| 9fe9798677 | |||
| cfee6092c7 | |||
| 7ab6545594 | |||
| 2e2c182a0d | |||
| 96eec61a92 | |||
| 6e4bde4053 | |||
| 33f347d1db | |||
| 17e5f4f95a | |||
| b0304876a8 | |||
| 2710cd1674 | |||
| a74eb9f431 | |||
| d5c0060cd9 | |||
| 7f9c05172b | |||
| d720b9e00c | |||
| 15cf25f74b | |||
| ae6c65e9a9 | |||
| 8a85ab2844 | |||
| b9b4312768 | |||
| a2b819dbcc | |||
| 6f0412de0f | |||
| e2ae241f8c | |||
| 1a8f424d53 | |||
| 1977ba9eab | |||
| b2407c3442 | |||
| f9bc165c16 | |||
| b6333d81a2 | |||
| 507893dc55 | |||
| 652b44e08e | |||
| 95b3803745 | |||
| 0b85cd2af1 | |||
| 0f37017760 | |||
| 4d0dcf5db5 | |||
| 4c756e9156 | |||
| f5a40bf530 | |||
| c7dbbef0d9 | |||
| a6773fe65d | |||
| 27a32b3ff4 | |||
| 06a45a87f6 | |||
| 053aef42c0 | |||
| 9f79efd44b | |||
| 08ba876291 | |||
| 59af67a5b5 | |||
| 65f8f7506e | |||
| efd5a107b9 | |||
| 35c8063ac5 | |||
| e0efadf298 | |||
| a9fadfc08c | |||
| 529257c8d0 | |||
| a667c03b6c | |||
| 5a1fc808b2 | |||
| 02ff215f99 | |||
| 2a4e7777ab | |||
| eac94a5719 | |||
| 96fcd0fc57 | |||
| 06869aedfd | |||
| 6a201131a3 | |||
| 1130a55ef5 | |||
| 265cd0a50e | |||
| e7ffa77b15 | |||
| 51cbd4b9de | |||
| e122e7460d | |||
| 7c898154b3 | |||
| 1d19d68019 | |||
| 09c58e2298 | |||
| 583905b7ed | |||
| 6e3f045b9b | |||
| ec41202b3c | |||
| 1ee7dea8b4 | |||
| 2d69350bab | |||
| c984e5b62a | |||
| 5b79b11101 | |||
| 2c22e600f7 | |||
| aef284c474 | |||
| 0443cd9ceb | |||
| 3337422775 | |||
| 3e046fc80e | |||
| 0273bc6921 | |||
| be095057bd | |||
| a98057d06a | |||
| 7d47903541 | |||
| a7aabdd1dd | |||
| 0357a92960 | |||
| 8ff41c962e | |||
| 6f6fececa8 | |||
| 72c2e28667 | |||
| 74c78851ca | |||
| 42732f844a | |||
| 99b8ac206a | |||
| 0e5cd86ed1 | |||
| 60fc80b6ac | |||
| c8f0c55fda | |||
| bfdaa47ea5 | |||
| d7b76b4590 | |||
| 2c2262b55d | |||
| 7ca01aba5d | |||
| 0f57ec58b3 | |||
| ac30f0172f | |||
| 531ba0bfed | |||
| d787e4b07a | |||
| 68e5d0ac0b | |||
| 38b70821c5 | |||
| 936158dd32 | |||
| e0d0553fd3 | |||
| 78aa86b23c | |||
| 1f850593dd | |||
| c4519c243a | |||
| 31ccc48436 | |||
| 1c4914ec77 | |||
| 71c40127d5 | |||
| aac1d63ac0 | |||
| 8222b31917 | |||
| 81c9c117a2 | |||
| de8e487dff | |||
| f4c59f64b0 | |||
| 6adf90d45c | |||
| 71eab8e07e | |||
| dd58ac67de | |||
| e6226d8069 | |||
| 6e25e1838d | |||
| c0c88a8466 | |||
| 4edbb66224 | |||
| 84e01c7851 | |||
| 761db1844d | |||
| ca76cc29de | |||
| c20a78a196 | |||
| 2053cbec32 | |||
| 687e9c78ad | |||
| 07105309fb | |||
| 31290137fe | |||
| 4a23f3b45a | |||
| 0b2974726c | |||
| 43aebdf8f4 | |||
| 37ea338a35 | |||
| 4063d39d6a | |||
| f6dd3ad657 | |||
| 252c16de29 | |||
| 4fef8eeb32 | |||
| 9146eb0375 | |||
| ad974875e4 | |||
| acd00ae413 | |||
| 65ca18d649 | |||
| a27b4d781e | |||
| ba50076468 | |||
| b83f6c0023 | |||
| d0822fc3ef | |||
| f83d4d76be | |||
| 70ff6969e7 | |||
| 374bafec57 | |||
| d9992d433d |
@@ -0,0 +1,70 @@
|
||||
name: CI Build Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
branches: ['**']
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
name: Build Frontend
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: vite-frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.19.0'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --legacy-peer-deps
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
backend:
|
||||
name: Build Go Backend
|
||||
runs-on: ubuntu-latest
|
||||
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: Build
|
||||
run: go build -v ./...
|
||||
|
||||
agent:
|
||||
name: Build Agent
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: go-gost
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
cache-dependency-path: go-gost/go.sum
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Build
|
||||
run: go build -v .
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Deploy Docs
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- beta
|
||||
paths:
|
||||
- 'doc/**'
|
||||
- 'mkdocs.yml'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Configure Git Credentials
|
||||
run: |
|
||||
git config user.name github-actions[bot]
|
||||
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.x
|
||||
|
||||
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
key: mkdocs-material-${{ env.cache_id }}
|
||||
path: .cache
|
||||
restore-keys: |
|
||||
mkdocs-material-
|
||||
|
||||
- name: Install MkDocs and Material Theme
|
||||
run: pip install mkdocs-material
|
||||
|
||||
- name: Build and Deploy
|
||||
run: mkdocs gh-deploy --force
|
||||
+321
-143
@@ -1,192 +1,370 @@
|
||||
name: Build and Push Vue or Spring Boot Images
|
||||
name: Build and Push Images
|
||||
|
||||
env:
|
||||
VERSION: "2.0.7-beta" # 分支推送时使用的默认版本
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'vue-frontend/**'
|
||||
- 'vite-frontend/**'
|
||||
- 'springboot-backend/**'
|
||||
- 'go-gost/**'
|
||||
- 'android-app/**'
|
||||
- '.github/workflows/docker-build.yml'
|
||||
tags:
|
||||
- '[0-9]*' # 匹配 2.0.8, 2.0.8-beta 等格式
|
||||
|
||||
jobs:
|
||||
build-gost:
|
||||
if: contains(github.event.head_commit.message, '[skip gost]') == false
|
||||
name: Build & Compress GOST Binary
|
||||
check-version:
|
||||
name: Check Version and Decide Build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
should_build: ${{ steps.version.outputs.should_build }}
|
||||
should_build_gost: ${{ steps.version.outputs.should_build_gost }}
|
||||
is_tag: ${{ steps.version.outputs.is_tag }}
|
||||
image_owner: ${{ steps.version.outputs.image_owner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Check for GOST changes
|
||||
uses: dorny/paths-filter@v2
|
||||
id: gost-changes
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
filters: |
|
||||
gost:
|
||||
- 'go-gost/**'
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Determine version and build strategy
|
||||
id: version
|
||||
run: |
|
||||
# 镜像 owner 需要小写
|
||||
IMAGE_OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
|
||||
echo "image_owner=$IMAGE_OWNER" >> $GITHUB_OUTPUT
|
||||
|
||||
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
# Tag 触发:直接使用 tag 名作为版本,全量构建
|
||||
VERSION="${{ github.ref_name }}"
|
||||
echo "🏷️ Tag trigger detected: $VERSION"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "is_tag=true" >> $GITHUB_OUTPUT
|
||||
echo "should_build=true" >> $GITHUB_OUTPUT
|
||||
echo "should_build_gost=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
# 分支触发:使用 env.VERSION,检查是否需要构建
|
||||
VERSION="${{ env.VERSION }}"
|
||||
echo "🌿 Branch trigger detected, using version: $VERSION"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "is_tag=false" >> $GITHUB_OUTPUT
|
||||
|
||||
# 检查 tag 是否已存在
|
||||
if git rev-parse "$VERSION" >/dev/null 2>&1; then
|
||||
echo "Tag $VERSION already exists"
|
||||
echo "should_build=false" >> $GITHUB_OUTPUT
|
||||
|
||||
# 检查 go-gost 目录是否有变化
|
||||
TAG_COMMIT=$(git rev-list -n 1 "$VERSION")
|
||||
if git diff --quiet --ignore-all-space --ignore-blank-lines $TAG_COMMIT HEAD -- go-gost/ 2>/dev/null; then
|
||||
echo "✅ GOST files unchanged since tag"
|
||||
echo "should_build_gost=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "🔄 Detected changes in go-gost directory"
|
||||
git diff --stat $TAG_COMMIT HEAD -- go-gost/ || true
|
||||
echo "should_build_gost=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
else
|
||||
echo "Tag $VERSION does not exist, will build all components"
|
||||
echo "should_build=true" >> $GITHUB_OUTPUT
|
||||
echo "should_build_gost=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
fi
|
||||
|
||||
build-gost:
|
||||
name: Build & Compress GOST Binary
|
||||
needs: check-version
|
||||
if: needs.check-version.outputs.should_build_gost == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
if: steps.gost-changes.outputs.gost == 'true'
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
go-version: '1.23'
|
||||
|
||||
- name: Cache Go modules
|
||||
if: steps.gost-changes.outputs.gost == 'true'
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('go-gost/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Install UPX
|
||||
if: steps.gost-changes.outputs.gost == 'true'
|
||||
run: |
|
||||
wget https://github.com/upx/upx/releases/download/v4.2.1/upx-4.2.1-amd64_linux.tar.xz
|
||||
wget -q https://github.com/upx/upx/releases/download/v4.2.1/upx-4.2.1-amd64_linux.tar.xz
|
||||
tar -xf upx-4.2.1-amd64_linux.tar.xz
|
||||
sudo mv upx-4.2.1-amd64_linux/upx /usr/local/bin/
|
||||
rm -rf upx-4.2.1-amd64_linux*
|
||||
|
||||
- name: Build GOST binary
|
||||
if: steps.gost-changes.outputs.gost == 'true'
|
||||
- name: Build GOST binary (AMD64)
|
||||
working-directory: ./go-gost
|
||||
run: |
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o gost
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=${{ needs.check-version.outputs.version }}" -o gost-amd64
|
||||
|
||||
- name: Build GOST binary (ARM64)
|
||||
working-directory: ./go-gost
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=${{ needs.check-version.outputs.version }}" -o gost-arm64
|
||||
|
||||
- name: Compress with UPX
|
||||
if: steps.gost-changes.outputs.gost == 'true'
|
||||
working-directory: ./go-gost
|
||||
run: |
|
||||
upx --best --lzma gost
|
||||
upx --best --lzma gost-amd64
|
||||
upx --best --lzma gost-arm64
|
||||
|
||||
- name: Create or Update Release
|
||||
if: steps.gost-changes.outputs.gost == 'true'
|
||||
- name: Upload GOST AMD64 artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gost-binary-amd64
|
||||
path: ./go-gost/gost-amd64
|
||||
|
||||
- name: Upload GOST ARM64 artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gost-binary-arm64
|
||||
path: ./go-gost/gost-arm64
|
||||
|
||||
build-vite:
|
||||
name: Build & Push Vite Frontend
|
||||
needs: check-version
|
||||
if: needs.check-version.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Prepare build args
|
||||
run: |
|
||||
VERSION="${{ needs.check-version.outputs.version }}"
|
||||
{
|
||||
echo "VITE_GITHUB_REPO=https://github.com/${{ github.repository }}"
|
||||
echo "VITE_APP_VERSION=$VERSION"
|
||||
} > ./vite-frontend/.env.production
|
||||
|
||||
- name: Build and push Vite Docker images
|
||||
run: |
|
||||
VERSION="${{ needs.check-version.outputs.version }}"
|
||||
OWNER="${{ needs.check-version.outputs.image_owner }}"
|
||||
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
-t ${{ env.REGISTRY }}/${OWNER}/vite-frontend:latest \
|
||||
-t ${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION} \
|
||||
./vite-frontend
|
||||
|
||||
build-go-backend:
|
||||
name: Build & Push Go Backend
|
||||
needs: check-version
|
||||
if: needs.check-version.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
|
||||
- name: Cache Go dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-backend-${{ hashFiles('go-backend/go.sum') }}
|
||||
restore-keys: ${{ runner.os }}-go-backend-
|
||||
|
||||
- name: Download dependencies
|
||||
working-directory: ./go-backend
|
||||
run: go mod download
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Go backend Docker images
|
||||
run: |
|
||||
VERSION="${{ needs.check-version.outputs.version }}"
|
||||
OWNER="${{ needs.check-version.outputs.image_owner }}"
|
||||
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
-t ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:latest \
|
||||
-t ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:${VERSION} \
|
||||
./go-backend
|
||||
|
||||
create-release:
|
||||
name: Create Release (Tag Only)
|
||||
needs: [check-version, build-gost, build-vite, build-go-backend]
|
||||
if: needs.check-version.outputs.is_tag == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download GOST AMD64 binary
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: gost-binary-amd64
|
||||
path: ./artifacts/amd64
|
||||
|
||||
- name: Download GOST ARM64 binary
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: gost-binary-arm64
|
||||
path: ./artifacts/arm64
|
||||
|
||||
- name: Prepare release files
|
||||
run: |
|
||||
VERSION="${{ needs.check-version.outputs.version }}"
|
||||
OWNER="${{ needs.check-version.outputs.image_owner }}"
|
||||
REPO="${{ github.repository }}"
|
||||
|
||||
# 移动二进制文件
|
||||
mv ./artifacts/amd64/gost-amd64 ./artifacts/gost-amd64
|
||||
mv ./artifacts/arm64/gost-arm64 ./artifacts/gost-arm64
|
||||
|
||||
# 复制并修改 docker-compose 文件
|
||||
cp docker-compose-v4.yml ./artifacts/docker-compose-v4.yml
|
||||
cp docker-compose-v6.yml ./artifacts/docker-compose-v6.yml
|
||||
|
||||
# 替换镜像地址为 GHCR
|
||||
sed -i "s|image: .*flux-panel-backend:[^[:space:]]*|image: ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:${VERSION}|g" ./artifacts/docker-compose-v4.yml
|
||||
sed -i "s|image: .*vite-frontend:[^[:space:]]*|image: ${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION}|g" ./artifacts/docker-compose-v4.yml
|
||||
sed -i "s|image: .*flux-panel-backend:[^[:space:]]*|image: ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:${VERSION}|g" ./artifacts/docker-compose-v6.yml
|
||||
sed -i "s|image: .*vite-frontend:[^[:space:]]*|image: ${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION}|g" ./artifacts/docker-compose-v6.yml
|
||||
|
||||
# 复制并修改安装脚本
|
||||
cp install.sh ./artifacts/install.sh
|
||||
cp panel_install.sh ./artifacts/panel_install.sh
|
||||
|
||||
# 替换仓库地址和版本号
|
||||
sed -i "s|bqlpfy/flux-panel|${REPO}|g" ./artifacts/install.sh
|
||||
sed -i "s|bqlpfy/flux-panel|${REPO}|g" ./artifacts/panel_install.sh
|
||||
sed -i "s|2.0.7-beta|${VERSION}|g" ./artifacts/install.sh
|
||||
sed -i "s|2.0.7-beta|${VERSION}|g" ./artifacts/panel_install.sh
|
||||
|
||||
- name: Create Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# 确保 tag 指向当前 commit
|
||||
git tag -f gost-latest ${{ github.sha }}
|
||||
git push origin gost-latest --force
|
||||
VERSION="${{ needs.check-version.outputs.version }}"
|
||||
OWNER="${{ needs.check-version.outputs.image_owner }}"
|
||||
|
||||
# 检查 release 是否存在,不存在则创建
|
||||
if gh release view gost-latest 2>/dev/null; then
|
||||
echo "Release exists, will update assets"
|
||||
else
|
||||
echo "Release not found, creating new release"
|
||||
gh release create gost-latest gost-latest \
|
||||
--title "GOST Binary Latest" \
|
||||
--notes "GOST binary compiled from commit ${{ github.sha }}
|
||||
|
||||
- Platform: Linux AMD64
|
||||
- Compressed with UPX
|
||||
- Build date: ${{ github.event.head_commit.timestamp }}" \
|
||||
--repo ${{ github.repository }}
|
||||
fi
|
||||
# 获取 commit 信息
|
||||
COMMIT_MSG=$(git log -1 --pretty=format:"%s")
|
||||
COMMIT_AUTHOR=$(git log -1 --pretty=format:"%an")
|
||||
COMMIT_DATE=$(git log -1 --pretty=format:"%ai")
|
||||
|
||||
# 上传 asset 并覆盖同名文件
|
||||
gh release upload gost-latest ./go-gost/gost --clobber
|
||||
# 创建 release
|
||||
gh release create "${VERSION}" \
|
||||
--title "Release ${VERSION}" \
|
||||
--notes "## 📝 Release Information
|
||||
|
||||
- **Version**: ${VERSION}
|
||||
- **Commit**: [\`${GITHUB_SHA:0:7}\`](https://github.com/${{ github.repository }}/commit/${{ github.sha }})
|
||||
- **Author**: ${COMMIT_AUTHOR}
|
||||
- **Date**: ${COMMIT_DATE}
|
||||
- **Message**: ${COMMIT_MSG}
|
||||
|
||||
build-vite:
|
||||
if: contains(github.event.head_commit.message, '[skip vite]') == false
|
||||
name: Build & Push Vite Frontend
|
||||
## 📦 Docker Images
|
||||
|
||||
\`\`\`bash
|
||||
# Backend
|
||||
docker pull ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:${VERSION}
|
||||
|
||||
# Frontend
|
||||
docker pull ${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION}
|
||||
\`\`\`
|
||||
|
||||
## 🚀 Quick Install
|
||||
|
||||
**Panel:**
|
||||
\`\`\`bash
|
||||
curl -L https://github.com/${{ github.repository }}/releases/download/${VERSION}/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
|
||||
\`\`\`
|
||||
|
||||
**Node:**
|
||||
\`\`\`bash
|
||||
curl -L https://github.com/${{ github.repository }}/releases/download/${VERSION}/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
||||
\`\`\`" \
|
||||
--repo ${{ github.repository }}
|
||||
|
||||
# 上传所有文件到 release
|
||||
echo "📤 上传 GOST 二进制文件..."
|
||||
gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber
|
||||
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
|
||||
|
||||
echo "📤 上传安装脚本..."
|
||||
gh release upload "${VERSION}" ./artifacts/install.sh --clobber
|
||||
gh release upload "${VERSION}" ./artifacts/panel_install.sh --clobber
|
||||
|
||||
echo "📤 上传 Docker Compose 配置文件..."
|
||||
gh release upload "${VERSION}" ./artifacts/docker-compose-v4.yml --clobber
|
||||
gh release upload "${VERSION}" ./artifacts/docker-compose-v6.yml --clobber
|
||||
|
||||
echo "✅ Release ${VERSION} 创建完成"
|
||||
|
||||
update-release-gost:
|
||||
name: Update GOST Binaries in Release
|
||||
needs: [check-version, build-gost]
|
||||
if: needs.check-version.outputs.is_tag == 'false' && needs.check-version.outputs.should_build == 'false' && needs.check-version.outputs.should_build_gost == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check for Vite changes
|
||||
uses: dorny/paths-filter@v2
|
||||
id: vite-changes
|
||||
- name: Download GOST AMD64 binary
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
filters: |
|
||||
vite:
|
||||
- 'vite-frontend/**'
|
||||
name: gost-binary-amd64
|
||||
path: ./artifacts/amd64
|
||||
|
||||
- name: Set up Node.js
|
||||
if: steps.vite-changes.outputs.vite == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
- name: Download GOST ARM64 binary
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
node-version: 20
|
||||
name: gost-binary-arm64
|
||||
path: ./artifacts/arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.vite-changes.outputs.vite == 'true'
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Log in to DockerHub
|
||||
if: steps.vite-changes.outputs.vite == 'true'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
|
||||
- name: Build and push Vite multi-arch image
|
||||
if: steps.vite-changes.outputs.vite == 'true'
|
||||
- name: Rename binaries
|
||||
run: |
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--push \
|
||||
-t bqlpfy/vite-frontend \
|
||||
./vite-frontend
|
||||
mv ./artifacts/amd64/gost-amd64 ./artifacts/gost-amd64
|
||||
mv ./artifacts/arm64/gost-arm64 ./artifacts/gost-arm64
|
||||
|
||||
build-java:
|
||||
if: contains(github.event.head_commit.message, '[skip java]') == false
|
||||
name: Build & Push Spring Boot Backend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Check for Java changes
|
||||
uses: dorny/paths-filter@v2
|
||||
id: java-changes
|
||||
with:
|
||||
filters: |
|
||||
java:
|
||||
- 'springboot-backend/**'
|
||||
|
||||
- name: Set up JDK and Maven
|
||||
if: steps.java-changes.outputs.java == 'true'
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: 21
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Cache Maven dependencies
|
||||
if: steps.java-changes.outputs.java == 'true'
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.m2
|
||||
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
|
||||
restore-keys: ${{ runner.os }}-m2
|
||||
|
||||
- name: Build Java JAR
|
||||
if: steps.java-changes.outputs.java == 'true'
|
||||
working-directory: ./springboot-backend
|
||||
run: mvn clean package -DskipTests
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.java-changes.outputs.java == 'true'
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Log in to DockerHub
|
||||
if: steps.java-changes.outputs.java == 'true'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
|
||||
- name: Build and push Java multi-arch image
|
||||
if: steps.java-changes.outputs.java == 'true'
|
||||
- name: Update GOST binaries in Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--push \
|
||||
-t bqlpfy/springboot-backend \
|
||||
./springboot-backend
|
||||
VERSION="${{ needs.check-version.outputs.version }}"
|
||||
|
||||
echo "🔄 更新 Release ${VERSION} 中的 GOST 二进制文件..."
|
||||
|
||||
gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber
|
||||
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
|
||||
|
||||
echo "✅ GOST 二进制文件更新完成"
|
||||
|
||||
+6
-4
@@ -254,8 +254,10 @@ gitee/
|
||||
# 证书文件
|
||||
*.crt
|
||||
*.cert
|
||||
android-app/
|
||||
ios-app/
|
||||
doraemon.jks
|
||||
flux.ipa
|
||||
device.id
|
||||
device.id
|
||||
commit.sh
|
||||
sql/
|
||||
!go-backend/internal/store/sqlite/sql/
|
||||
!go-backend/internal/store/sqlite/sql/schema.sql
|
||||
!go-backend/internal/store/sqlite/sql/data.sql
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Plan: 搭建开发环境
|
||||
|
||||
## 目标
|
||||
为 Flux Panel 项目安装所有缺失的开发依赖,使 3 个子项目都能本地开发和构建。
|
||||
|
||||
## 当前状态
|
||||
|
||||
### ✅ 已安装
|
||||
| 工具 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| Node.js | v20.19.2 | vite-frontend |
|
||||
| npm | 9.2.0 | vite-frontend |
|
||||
| Go | 1.24.4 | go-gost |
|
||||
| Docker | 29.1.4 | 容器化部署 |
|
||||
|
||||
### ❌ 缺失
|
||||
| 工具 | 需求版本 | 用途 |
|
||||
|------|----------|------|
|
||||
| Java | 21 | springboot-backend |
|
||||
| Maven | 3.x | 构建后端 |
|
||||
| Docker Compose | v2 | 容器编排 |
|
||||
|
||||
---
|
||||
|
||||
## 执行任务
|
||||
|
||||
### Task 1: 安装 Java 21
|
||||
```bash
|
||||
apt-get update && apt-get install -y openjdk-21-jdk
|
||||
```
|
||||
**验证**: `java -version` 应显示 openjdk 21
|
||||
|
||||
### Task 2: 安装 Maven
|
||||
```bash
|
||||
apt-get install -y maven
|
||||
```
|
||||
**验证**: `mvn -v` 应显示 Maven 3.x
|
||||
|
||||
### Task 3: 安装 Docker Compose Plugin
|
||||
```bash
|
||||
apt-get install -y docker-compose-plugin
|
||||
```
|
||||
**验证**: `docker compose version` 应显示版本号
|
||||
|
||||
### Task 4: 安装前端依赖
|
||||
```bash
|
||||
cd /root/flux-panel/vite-frontend && npm install
|
||||
```
|
||||
**验证**: `node_modules/` 目录存在
|
||||
|
||||
### Task 5: 验证后端可构建
|
||||
```bash
|
||||
cd /root/flux-panel/springboot-backend && mvn clean compile -q
|
||||
```
|
||||
**验证**: 编译成功无错误
|
||||
|
||||
### Task 6: 验证 Go 模块
|
||||
```bash
|
||||
cd /root/flux-panel/go-gost && go mod download
|
||||
```
|
||||
**验证**: 依赖下载成功
|
||||
|
||||
---
|
||||
|
||||
## 完成标准
|
||||
- [ ] `java -version` → openjdk 21
|
||||
- [ ] `mvn -v` → Maven 3.x
|
||||
- [ ] `docker compose version` → v2.x
|
||||
- [ ] 前端: `npm run dev` 可启动
|
||||
- [ ] 后端: `mvn compile` 成功
|
||||
- [ ] Go: `go build .` 成功
|
||||
@@ -0,0 +1,70 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Mon Feb 02 2026
|
||||
**Commit:** 7ca01ab
|
||||
**Branch:** beta
|
||||
|
||||
## 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.
|
||||
|
||||
## 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)
|
||||
├── 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
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| **Deploy (Docker)** | `docker-compose-v4.yml` | Env: `JWT_SECRET`, `BACKEND_PORT`, `FRONTEND_PORT` |
|
||||
| **Deploy (IPv6)** | `docker-compose-v6.yml` | Same as v4 + IPv6-enabled bridge |
|
||||
| **Panel install** | `panel_install.sh` | Picks v4/v6, generates `JWT_SECRET`, downloads compose |
|
||||
| **Node install** | `install.sh` | Installs `/etc/flux_agent/flux_agent` + writes `config.json`/`gost.json` + systemd `flux_agent.service` |
|
||||
| **Admin API** | `go-backend/` | Go Admin API (SQLite) |
|
||||
| **Web UI** | `vite-frontend/` | React/Vite dashboard (HeroUI + Tailwind) |
|
||||
| **Go Agent** | `go-gost/` | Forwarding agent (forked gost + local x/) |
|
||||
| **Go Core** | `go-gost/x/` | Handlers/listeners/dialers + management API |
|
||||
|
||||
## CODE MAP
|
||||
| Symbol | Type | Location | Role |
|
||||
|--------|------|----------|------|
|
||||
| `flvx` | Project | `.` | Root directory |
|
||||
| `main` | Func | `go-backend/cmd/paneld/main.go` | Backend Entry |
|
||||
| `App` | Component | `vite-frontend/src/App.tsx` | Frontend Entry |
|
||||
| `main` | Func | `go-gost/main.go` | Agent Entry |
|
||||
|
||||
|
||||
## CONVENTIONS
|
||||
- `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `springboot-backend/`.
|
||||
- `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
|
||||
|
||||
## 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`.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
# Panel (Docker)
|
||||
docker compose -f docker-compose-v4.yml up -d
|
||||
docker compose -f docker-compose-v6.yml up -d
|
||||
|
||||
# Release-based install scripts
|
||||
./panel_install.sh
|
||||
./install.sh
|
||||
|
||||
# Local dev (per subproject)
|
||||
(cd springboot-backend && mvn clean package)
|
||||
(cd vite-frontend && npm run dev)
|
||||
(cd go-gost && go run .)
|
||||
```
|
||||
|
||||
## NOTES
|
||||
- LSP servers are not installed in this environment (gopls/jdtls/typescript-language-server); rely on grep-based navigation.
|
||||
- `vite-frontend/vite.config.ts` sets `minify: false` and disables treeshake; expect larger bundles.
|
||||
@@ -1,6 +1,6 @@
|
||||
# FLVX
|
||||
|
||||
# flux-panel转发面板 哆啦A梦转发面板
|
||||
|
||||
> 📞 **联系我们**: [Telegram群组](https://t.me/flvxpanel)
|
||||
|
||||
本项目基于 [go-gost/gost](https://github.com/go-gost/gost) 和 [go-gost/x](https://github.com/go-gost/x) 两个开源库,实现了转发面板。
|
||||
---
|
||||
@@ -17,10 +17,15 @@
|
||||
## 部署流程
|
||||
---
|
||||
### Docker Compose部署
|
||||
#### 快速部署
|
||||
面板端:
|
||||
```bash
|
||||
curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
|
||||
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
|
||||
```
|
||||
节点端:
|
||||
```bash
|
||||
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
||||
```
|
||||
|
||||
|
||||
#### 默认管理员账号
|
||||
|
||||
@@ -52,17 +57,14 @@ curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/main/pane
|
||||
作者对因使用本项目所造成的任何直接或间接损失概不负责,亦不提供任何形式的担保、承诺或技术支持。
|
||||
|
||||
|
||||
请务必在合法、合规、安全的前提下使用本项目。
|
||||
请务必在合法、合规、安全的前提下使用本项目。
|
||||
|
||||
---
|
||||
## ⭐ 喝杯咖啡!
|
||||
## ⭐ 喝杯咖啡!(USDT)
|
||||
|
||||
| 网络 | 地址 |
|
||||
|------------|----------------------------------------------------------------------|
|
||||
| BNB(BEP20) | `0x755492c03728851bbf855daa28a1e089f9aca4d1` |
|
||||
| TRC20 | `TYh2L3xxXpuJhAcBWnt3yiiADiCSJLgUm7` |
|
||||
| Aptos | `0xf2f9fb14749457748506a8281628d556e8540d1eb586d202cd8b02b99d369ef8` |
|
||||
|
||||
[](https://www.star-history.com/#bqlpfy/flux-panel&Date)
|
||||
|
||||
|
||||
| BNB(BEP20) | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
|
||||
| TRC20 | `TM8VYdU3s3gSX5PC8swjAJrAzZFCHKqG2k` |
|
||||
| Aptos | `0x49427bfcba1006a346447430689b2307ac156316bb34850d1d3029ff9d118da5` |
|
||||
| polygon | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# 常见问题 (FAQ)
|
||||
|
||||
### Q1: 安装脚本提示 "Docker command not found"?
|
||||
**A**: 请确保您的系统已安装 Docker 和 Docker Compose。
|
||||
- Ubuntu/Debian 安装 Docker: `curl -fsSL https://get.docker.com | bash`
|
||||
|
||||
### Q2: 面板无法访问 (Connection Refused)?
|
||||
**A**:
|
||||
1. 检查防火墙是否放行了前端端口(默认 `6366`)。
|
||||
2. 检查容器是否正常运行: `docker ps`。
|
||||
3. 查看容器日志: `docker logs flux-panel-backend` 或 `docker logs vite-frontend`。
|
||||
|
||||
### Q3: 节点显示离线?
|
||||
**A**:
|
||||
1. 检查节点服务器与面板服务器之间的网络连通性。
|
||||
2. 确认在节点端安装时输入的 **面板地址** 和 **密钥** 是否正确。
|
||||
3. 检查节点端服务状态: `systemctl status flux_agent`。
|
||||
4. 查看节点端日志: `journalctl -u flux_agent -f`。
|
||||
|
||||
### Q4: 只有 TCP 能通,UDP 不通?
|
||||
**A**: 请检查服务器防火墙和安全组(AWS/阿里云/腾讯云等)是否同时放行了对应端口的 **TCP 和 UDP** 协议。
|
||||
|
||||
### Q5: IPv6 无法使用?
|
||||
**A**: 面板安装脚本会自动尝试配置 Docker 的 IPv6。如果失败,请手动检查 `/etc/docker/daemon.json` 配置,确保 `ipv6: true` 且分配了正确的 `fixed-cidr-v6` 子网。
|
||||
@@ -0,0 +1,37 @@
|
||||
# FLVX 官方文档
|
||||
|
||||
**FLVX** 是一个基于 [go-gost/gost](https://github.com/go-gost/gost) 和 [go-gost/x](https://github.com/go-gost/x) 开发的高性能流量转发管理系统。
|
||||
|
||||
> 📞 **联系我们**: [Telegram群组](https://t.me/flvxpanel)
|
||||
|
||||
## 核心特性
|
||||
|
||||
- **多协议支持**: 完美支持 TCP 和 UDP 协议转发。
|
||||
- **灵活转发**: 支持 **端口转发** 与 **隧道转发** 两种模式。
|
||||
- **流量控制**: 支持按 **隧道账号级别** 管理流量转发数量,用于用户/隧道配额控制。
|
||||
- **限速管理**: 可针对 **指定用户的指定隧道进行限速** 设置。
|
||||
- **计费策略**: 支持配置 **单向或双向流量计费方式**,灵活适配不同计费模型。
|
||||
- **策略配置**: 提供灵活的转发策略配置,适用于多种网络场景。
|
||||
|
||||
## 快速开始
|
||||
|
||||
- [安装部署](./install.md)
|
||||
- [使用指南](./usage.md)
|
||||
- [常见问题](./faq.md)
|
||||
|
||||
## 免责声明
|
||||
|
||||
本项目仅供个人学习与研究使用,基于开源项目进行二次开发。
|
||||
使用本项目所带来的任何风险均由使用者自行承担。本项目为开源的流量转发工具,仅限合法、合规用途。
|
||||
**禁止将本项目用于任何违法或未经授权的行为,包括但不限于网络攻击、数据窃取、非法访问等。**
|
||||
|
||||
## 捐赠支持
|
||||
|
||||
如果您觉得本项目对您有帮助,欢迎请作者喝杯咖啡!
|
||||
|
||||
| 网络 | 地址 |
|
||||
|------------|----------------------------------------------------------------------|
|
||||
| BNB(BEP20) | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
|
||||
| TRC20 | `TM8VYdU3s3gSX5PC8swjAJrAzZFCHKqG2k` |
|
||||
| Aptos | `0x49427bfcba1006a346447430689b2307ac156316bb34850d1d3029ff9d118da5` |
|
||||
| Polygon | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
|
||||
@@ -0,0 +1,79 @@
|
||||
# 安装部署指南
|
||||
|
||||
本文档介绍如何部署 FLVX 面板端及节点端。
|
||||
|
||||
## 一、面板端部署
|
||||
|
||||
面板端负责管理用户、节点和转发规则。
|
||||
|
||||
### 1. 环境要求
|
||||
- 操作系统:Linux (推荐 Debian 10+ / Ubuntu 20.04+)
|
||||
- 必须安装 Docker 和 Docker Compose
|
||||
|
||||
### 2. 一键安装脚本
|
||||
|
||||
使用以下命令即可快速安装面板:
|
||||
|
||||
```bash
|
||||
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
|
||||
```
|
||||
|
||||
**安装过程中会提示输入以下信息:**
|
||||
- **前端端口**: 默认为 `6366`
|
||||
- **后端端口**: 默认为 `6365`
|
||||
|
||||
脚本会自动检测系统是否支持 IPv6,并自动配置 Docker 的 IPv6 支持。
|
||||
|
||||
### 3. 访问面板
|
||||
|
||||
安装完成后,访问:
|
||||
`http://<服务器IP>:<前端端口>` (默认: `http://<服务器IP>:6366`)
|
||||
|
||||
**默认管理员账号:**
|
||||
- 用户名: `admin_user`
|
||||
- 密码: `admin_user`
|
||||
|
||||
> ⚠️ **注意**: 首次登录后,请务必在“个人中心”或“设置”中修改默认密码!
|
||||
|
||||
### 4. 维护命令
|
||||
|
||||
再次运行 `./panel_install.sh` 脚本可以看到管理菜单:
|
||||
1. 安装面板
|
||||
2. 更新面板
|
||||
3. 卸载面板
|
||||
|
||||
---
|
||||
|
||||
## 二、节点端部署
|
||||
|
||||
节点端运行在实际进行流量转发的服务器上,需要连接到面板端进行管理。
|
||||
|
||||
### 1. 获取接入密钥
|
||||
1. 登录面板端。
|
||||
2. 进入 **节点管理 (Node)** 页面。
|
||||
3. 点击 **添加节点**。
|
||||
4. 获取该节点的 **接入密钥 (Secret)**。
|
||||
|
||||
### 2. 一键安装脚本
|
||||
|
||||
在节点服务器上运行:
|
||||
|
||||
```bash
|
||||
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
||||
```
|
||||
|
||||
**安装过程中会提示输入:**
|
||||
- **服务器地址**: 面板端的通信地址(通常是 `http://<面板IP>:<后端端口>`,例如 `http://1.2.3.4:6365`)。
|
||||
- **密钥**: 刚才在面板中获取的节点密钥。
|
||||
|
||||
或者直接使用带参数的命令(适用于自动化部署):
|
||||
|
||||
```bash
|
||||
# 替换 <面板地址> 和 <密钥>
|
||||
./install.sh -a "http://1.2.3.4:6365" -s "your_node_secret"
|
||||
```
|
||||
|
||||
### 3. 验证安装
|
||||
安装完成后,服务会自动启动。
|
||||
- 查看状态: `systemctl status flux_agent`
|
||||
- 回到面板 **节点管理** 页面,该节点状态应显示为 **在线**。
|
||||
@@ -0,0 +1,35 @@
|
||||
# 使用指南
|
||||
|
||||
## 1. 仪表盘 (Dashboard)
|
||||
登录系统后首先看到的是仪表盘,这里展示了系统的整体运行状态,包括:
|
||||
- 在线节点数量
|
||||
- 用户总数
|
||||
- 流量统计信息
|
||||
|
||||
## 2. 节点管理 (Node)
|
||||
节点是实际承载流量转发的服务器。
|
||||
- **添加节点**: 点击“添加”,获取密钥用于节点端安装。
|
||||
- **管理**: 可以查看节点在线状态、版本信息,以及对节点进行编辑或删除。
|
||||
|
||||
## 3. 用户管理 (User)
|
||||
管理员可以创建和管理普通用户。
|
||||
- **创建用户**: 设置用户名、密码、流量配额等。
|
||||
- **用户组**: 可以将用户分配到不同的组 (Group),便于统一管理权限或策略。
|
||||
|
||||
## 4. 转发管理 (Forward)
|
||||
这是核心功能区,用于设置端口转发规则。
|
||||
- **端口转发**: 将节点服务器的某个端口流量转发到目标地址。
|
||||
- **协议**: 支持 TCP / UDP。
|
||||
- **入口**: 选择入口节点和监听端口。
|
||||
- **出口**: 设置目标 IP 和端口。
|
||||
- **隧道转发**: 用于更复杂的网络穿透场景(具体配置视业务需求而定)。
|
||||
|
||||
## 5. 限制与策略 (Limit)
|
||||
- **限速**: 可以对指定用户或指定隧道进行带宽限制,防止资源滥用。
|
||||
- **计费模式**: 支持配置流量计算方式(单向或双向),适合运营场景。
|
||||
|
||||
## 6. 系统配置 (Config)
|
||||
在此页面进行系统的全局设置。
|
||||
|
||||
## 7. 个人设置 (Profile)
|
||||
- **修改密码**: 为了安全,建议定期修改管理员密码。
|
||||
+21
-41
@@ -1,62 +1,42 @@
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:5.7
|
||||
container_name: gost-mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
|
||||
MYSQL_DATABASE: ${DB_NAME}
|
||||
MYSQL_USER: ${DB_USER}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||
TZ: Asia/Shanghai
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
- ./gost.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
command: >
|
||||
--default-authentication-plugin=mysql_native_password
|
||||
--character-set-server=utf8mb4
|
||||
--collation-server=utf8mb4_unicode_ci
|
||||
--max_connections=1000
|
||||
--innodb_buffer_pool_size=256M
|
||||
networks:
|
||||
- gost-network
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
|
||||
backend:
|
||||
image: bqlpfy/springboot-backend:latest
|
||||
container_name: springboot-backend
|
||||
image: ghcr.io/sagit-chu/flux-panel-backend:${FLUX_VERSION:-latest}
|
||||
container_name: flux-panel-backend
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
environment:
|
||||
DB_HOST: mysql
|
||||
DB_NAME: ${DB_NAME}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
DB_PATH: /app/data/gost.db
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
LOG_DIR: /app/logs
|
||||
JAVA_OPTS: "-Xms256m -Xmx512m -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"
|
||||
SERVER_ADDR: :6365
|
||||
TZ: Asia/Shanghai
|
||||
ports:
|
||||
- "${BACKEND_PORT}:6365"
|
||||
volumes:
|
||||
- backend_logs:/app/logs
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
- sqlite_data:/app/data
|
||||
networks:
|
||||
- gost-network
|
||||
stop_grace_period: 30s
|
||||
stop_signal: SIGTERM
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-c", "wget --no-verbose --tries=1 --spider http://localhost:6365/flow/test || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 90s
|
||||
start_period: 30s
|
||||
|
||||
frontend:
|
||||
image: bqlpfy/vite-frontend:latest
|
||||
image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}
|
||||
container_name: vite-frontend
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
ports:
|
||||
- "${FRONTEND_PORT}:80"
|
||||
depends_on:
|
||||
@@ -67,8 +47,8 @@ services:
|
||||
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
name: mysql_data
|
||||
sqlite_data:
|
||||
name: sqlite_data
|
||||
driver: local
|
||||
backend_logs:
|
||||
name: backend_logs
|
||||
@@ -81,4 +61,4 @@ networks:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
- subnet: 172.20.0.0/16
|
||||
|
||||
+21
-41
@@ -1,62 +1,42 @@
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:5.7
|
||||
container_name: gost-mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
|
||||
MYSQL_DATABASE: ${DB_NAME}
|
||||
MYSQL_USER: ${DB_USER}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||
TZ: Asia/Shanghai
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
- ./gost.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
command: >
|
||||
--default-authentication-plugin=mysql_native_password
|
||||
--character-set-server=utf8mb4
|
||||
--collation-server=utf8mb4_unicode_ci
|
||||
--max_connections=1000
|
||||
--innodb_buffer_pool_size=256M
|
||||
networks:
|
||||
- gost-network
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
|
||||
backend:
|
||||
image: bqlpfy/springboot-backend:latest
|
||||
container_name: springboot-backend
|
||||
image: ghcr.io/sagit-chu/flux-panel-backend:${FLUX_VERSION:-latest}
|
||||
container_name: flux-panel-backend
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
environment:
|
||||
DB_HOST: mysql
|
||||
DB_NAME: ${DB_NAME}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
DB_PATH: /app/data/gost.db
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
LOG_DIR: /app/logs
|
||||
JAVA_OPTS: "-Xms256m -Xmx512m -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"
|
||||
SERVER_ADDR: :6365
|
||||
TZ: Asia/Shanghai
|
||||
ports:
|
||||
- "${BACKEND_PORT}:6365"
|
||||
volumes:
|
||||
- backend_logs:/app/logs
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
- sqlite_data:/app/data
|
||||
networks:
|
||||
- gost-network
|
||||
stop_grace_period: 30s
|
||||
stop_signal: SIGTERM
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-c", "wget --no-verbose --tries=1 --spider http://localhost:6365/flow/test || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 90s
|
||||
start_period: 30s
|
||||
|
||||
frontend:
|
||||
image: bqlpfy/vite-frontend:latest
|
||||
image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}
|
||||
container_name: vite-frontend
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
ports:
|
||||
- "${FRONTEND_PORT}:80"
|
||||
depends_on:
|
||||
@@ -67,8 +47,8 @@ services:
|
||||
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
name: mysql_data
|
||||
sqlite_data:
|
||||
name: sqlite_data
|
||||
driver: local
|
||||
backend_logs:
|
||||
name: backend_logs
|
||||
@@ -83,4 +63,4 @@ networks:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
- subnet: fd00:dead:beef::/48
|
||||
- subnet: fd00:dead:beef::/48
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# 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).
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
go-backend/
|
||||
├── cmd/paneld/main.go # Entry point; starts HTTP server + WebSocket
|
||||
├── internal/
|
||||
│ ├── http/ # HTTP layer
|
||||
│ │ ├── router.go # Routes (NewServeMux) + Middleware chain
|
||||
│ │ ├── handler/ # API Handlers (User, Tunnel, Node, etc.)
|
||||
│ │ ├── middleware/ # JWT, CORS, Logging, Recover
|
||||
│ │ └── response/ # JSON response helpers
|
||||
│ ├── store/sqlite/ # Data Access Layer (Repository pattern)
|
||||
│ │ ├── repository.go # SQL queries & Struct definitions
|
||||
│ │ └── sql/ # Embedded schema.sql & data.sql
|
||||
│ └── auth/ # Auth logic
|
||||
├── tests/ # Integration/Contract tests
|
||||
├── Dockerfile # Multi-stage build (alpine)
|
||||
└── Makefile # Build commands
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| 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 |
|
||||
| **Auth Middleware** | `go-backend/internal/http/middleware/jwt.go` | Extracts `Authorization` header |
|
||||
| **WebSocket** | `go-backend/internal/ws/` | Real-time updates (traffic, status) |
|
||||
|
||||
## CONVENTIONS
|
||||
- **No ORM**: Uses raw SQL with `database/sql` and `modernc.org/sqlite`.
|
||||
- **Standard Lib**: Uses `net/http` for routing (Go 1.22+ patterns).
|
||||
- **Auth**: Expects raw JWT in `Authorization` header (no `Bearer` prefix).
|
||||
- **Config**: Loaded from environment variables (see `cmd/paneld/main.go`).
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-backend
|
||||
go run ./cmd/paneld
|
||||
go test ./...
|
||||
make build
|
||||
```
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM golang:1.23-bookworm AS builder
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} env ${TARGETARCH:+GOARCH=${TARGETARCH}} go build -o /out/paneld ./cmd/paneld
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /out/paneld /app/paneld
|
||||
|
||||
ENV SERVER_ADDR=:6365
|
||||
EXPOSE 6365
|
||||
ENTRYPOINT ["/app/paneld"]
|
||||
@@ -0,0 +1,12 @@
|
||||
GO ?= go
|
||||
|
||||
.PHONY: test build run
|
||||
|
||||
test:
|
||||
$(GO) test ./...
|
||||
|
||||
build:
|
||||
$(GO) build ./cmd/paneld
|
||||
|
||||
run:
|
||||
SERVER_ADDR=:6365 $(GO) run ./cmd/paneld
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/app"
|
||||
"go-backend/internal/config"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.FromEnv()
|
||||
if cfg.JWTSecret == "" {
|
||||
log.Println("warning: JWT_SECRET is empty")
|
||||
}
|
||||
log.Printf("starting go-backend on %s (db=%s)", cfg.Addr, cfg.DBPath)
|
||||
|
||||
a, err := app.New(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create app: %v", err)
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- a.Run()
|
||||
}()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case sig := <-sigCh:
|
||||
log.Printf("received signal %s, shutting down", sig)
|
||||
case runErr := <-errCh:
|
||||
if runErr != nil && !errors.Is(runErr, http.ErrServerClosed) {
|
||||
log.Fatalf("server stopped unexpectedly: %v", runErr)
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := a.Shutdown(ctx); err != nil {
|
||||
log.Fatalf("shutdown failed: %v", err)
|
||||
}
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,23 @@
|
||||
module go-backend
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.4
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
modernc.org/sqlite v1.37.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // 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/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
modernc.org/libc v1.65.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
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/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/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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
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/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=
|
||||
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=
|
||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
|
||||
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
|
||||
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
|
||||
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,60 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/config"
|
||||
httpserver "go-backend/internal/http"
|
||||
"go-backend/internal/http/handler"
|
||||
"go-backend/internal/store/sqlite"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
cfg config.Config
|
||||
server *http.Server
|
||||
repo *sqlite.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)
|
||||
}
|
||||
|
||||
h := handler.New(repo, cfg.JWTSecret)
|
||||
router := httpserver.NewRouter(h, cfg.JWTSecret)
|
||||
|
||||
s := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: router,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
return &App{cfg: cfg, server: s, repo: repo, h: h}, nil
|
||||
}
|
||||
|
||||
func (a *App) Run() error {
|
||||
if a.h != nil {
|
||||
a.h.StartBackgroundJobs()
|
||||
}
|
||||
return a.server.ListenAndServe()
|
||||
}
|
||||
|
||||
func (a *App) Shutdown(ctx context.Context) error {
|
||||
if a.h != nil {
|
||||
a.h.StopBackgroundJobs()
|
||||
}
|
||||
shutdownErr := a.server.Shutdown(ctx)
|
||||
closeErr := a.repo.Close()
|
||||
if shutdownErr != nil {
|
||||
return shutdownErr
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
algorithm = "HmacSHA256"
|
||||
expireTime = 90 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
Sub string `json:"sub"`
|
||||
Iat int64 `json:"iat"`
|
||||
Exp int64 `json:"exp"`
|
||||
User string `json:"user"`
|
||||
Name string `json:"name"`
|
||||
RoleID int `json:"role_id"`
|
||||
}
|
||||
|
||||
type tokenHeader struct {
|
||||
Alg string `json:"alg"`
|
||||
Typ string `json:"typ"`
|
||||
}
|
||||
|
||||
func GenerateToken(userID int64, username string, roleID int, secret string) (string, error) {
|
||||
now := time.Now()
|
||||
header := tokenHeader{Alg: algorithm, Typ: "JWT"}
|
||||
claims := Claims{
|
||||
Sub: strconv.FormatInt(userID, 10),
|
||||
Iat: now.Unix(),
|
||||
Exp: now.Add(expireTime).Unix(),
|
||||
User: username,
|
||||
Name: username,
|
||||
RoleID: roleID,
|
||||
}
|
||||
|
||||
headerPart, err := encodeJSON(header)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
payloadPart, err := encodeJSON(claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sig := sign(headerPart+"."+payloadPart, secret)
|
||||
|
||||
return headerPart + "." + payloadPart + "." + sig, nil
|
||||
}
|
||||
|
||||
func ValidateToken(token, secret string) (Claims, bool) {
|
||||
claims, err := ParseClaims(token, secret)
|
||||
if err != nil {
|
||||
return Claims{}, false
|
||||
}
|
||||
return claims, true
|
||||
}
|
||||
|
||||
func ParseClaims(token, secret string) (Claims, error) {
|
||||
parts := splitToken(token)
|
||||
if len(parts) != 3 {
|
||||
return Claims{}, errors.New("invalid token")
|
||||
}
|
||||
|
||||
signedContent := parts[0] + "." + parts[1]
|
||||
expected := sign(signedContent, secret)
|
||||
if !hmac.Equal([]byte(expected), []byte(parts[2])) {
|
||||
return Claims{}, errors.New("invalid signature")
|
||||
}
|
||||
|
||||
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return Claims{}, err
|
||||
}
|
||||
|
||||
var claims Claims
|
||||
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
|
||||
return Claims{}, err
|
||||
}
|
||||
|
||||
if claims.Exp <= time.Now().Unix() {
|
||||
return Claims{}, errors.New("token expired")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func splitToken(token string) []string {
|
||||
parts := make([]string, 0, 3)
|
||||
current := ""
|
||||
for i := 0; i < len(token); i++ {
|
||||
if token[i] == '.' {
|
||||
parts = append(parts, current)
|
||||
current = ""
|
||||
continue
|
||||
}
|
||||
current += string(token[i])
|
||||
}
|
||||
parts = append(parts, current)
|
||||
return parts
|
||||
}
|
||||
|
||||
func encodeJSON(v interface{}) (string, error) {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
func sign(content, secret string) string {
|
||||
h := hmac.New(sha256.New, []byte(secret))
|
||||
h.Write([]byte(content))
|
||||
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package config
|
||||
|
||||
import "os"
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
DBPath 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"),
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FederationClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type RemoteNodeInfo struct {
|
||||
ShareID int64 `json:"shareId"`
|
||||
ShareName string `json:"shareName"`
|
||||
NodeID int64 `json:"nodeId"`
|
||||
NodeName string `json:"nodeName"`
|
||||
ServerIP string `json:"serverIp"`
|
||||
Status int `json:"status"`
|
||||
MaxBandwidth int64 `json:"maxBandwidth"`
|
||||
ExpiryTime int64 `json:"expiryTime"`
|
||||
PortRangeStart int `json:"portRangeStart"`
|
||||
PortRangeEnd int `json:"portRangeEnd"`
|
||||
}
|
||||
|
||||
type RemoteTunnelResponse struct {
|
||||
TunnelID int64 `json:"tunnelId"`
|
||||
}
|
||||
|
||||
func NewFederationClient() *FederationClient {
|
||||
return &FederationClient{
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *FederationClient) Connect(url, token, localDomain string) (*RemoteNodeInfo, error) {
|
||||
url = strings.TrimSuffix(url, "/")
|
||||
req, err := http.NewRequest("POST", url+"/api/v1/federation/connect", nil)
|
||||
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 RemoteNodeInfo `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
|
||||
}
|
||||
|
||||
func (c *FederationClient) CreateTunnel(url, token, localDomain, protocol string, remotePort int, target string) (*RemoteTunnelResponse, error) {
|
||||
url = strings.TrimSuffix(url, "/")
|
||||
payload := map[string]interface{}{
|
||||
"protocol": protocol,
|
||||
"remotePort": remotePort,
|
||||
"target": target,
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest("POST", url+"/api/v1/federation/tunnel/create", 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 RemoteTunnelResponse `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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildForwardControlServiceNamesPauseResume(t *testing.T) {
|
||||
base := "12_34_56"
|
||||
want := []string{base + "_tcp", base + "_udp"}
|
||||
|
||||
for _, command := range []string{"PauseService", "ResumeService"} {
|
||||
got := buildForwardControlServiceNames(base, command)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("command %s expected %v, got %v", command, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardControlServiceNamesDelete(t *testing.T) {
|
||||
base := "12_34_56"
|
||||
want := []string{base, base + "_tcp", base + "_udp"}
|
||||
got := buildForwardControlServiceNames(base, " DeleteService ")
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("expected %v, got %v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceBaseCandidates(t *testing.T) {
|
||||
got := buildForwardServiceBaseCandidates(12, 34, 56, []int64{56, 78, 90})
|
||||
want := []string{"12_34_56", "12_34_78", "12_34_90", "12_34_0"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("expected %v, got %v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceBaseCandidatesWithZeroPreferred(t *testing.T) {
|
||||
got := buildForwardServiceBaseCandidates(12, 34, 0, []int64{78, 0, 90})
|
||||
want := []string{"12_34_0", "12_34_78", "12_34_90"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("expected %v, got %v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldTryLegacySingleService(t *testing.T) {
|
||||
if !shouldTryLegacySingleService("PauseService") {
|
||||
t.Fatalf("PauseService should require legacy fallback")
|
||||
}
|
||||
if !shouldTryLegacySingleService("resumeService") {
|
||||
t.Fatalf("ResumeService should require legacy fallback")
|
||||
}
|
||||
if shouldTryLegacySingleService("DeleteService") {
|
||||
t.Fatalf("DeleteService should not require legacy fallback")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/client"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/sqlite"
|
||||
)
|
||||
|
||||
type federationTunnelRequest struct {
|
||||
Protocol string `json:"protocol"`
|
||||
RemotePort int `json:"remotePort"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
|
||||
type createPeerShareRequest struct {
|
||||
Name string `json:"name"`
|
||||
NodeID int64 `json:"nodeId"`
|
||||
MaxBandwidth int64 `json:"maxBandwidth"`
|
||||
ExpiryTime int64 `json:"expiryTime"`
|
||||
PortRangeStart int `json:"portRangeStart"`
|
||||
PortRangeEnd int `json:"portRangeEnd"`
|
||||
AllowedDomains string `json:"allowedDomains"`
|
||||
}
|
||||
|
||||
type deletePeerShareRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type nodeImportRequest struct {
|
||||
RemoteURL string `json:"remoteUrl"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func (h *Handler) federationShareList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||
return
|
||||
}
|
||||
|
||||
shares, err := h.repo.ListPeerShares()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(shares))
|
||||
}
|
||||
|
||||
func (h *Handler) federationShareCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||
return
|
||||
}
|
||||
|
||||
var req createPeerShareRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" || req.NodeID == 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("Name and NodeID are required"))
|
||||
return
|
||||
}
|
||||
|
||||
if req.MaxBandwidth < 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("Max bandwidth cannot be negative"))
|
||||
return
|
||||
}
|
||||
|
||||
if req.ExpiryTime < 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("Expiry time cannot be negative"))
|
||||
return
|
||||
}
|
||||
|
||||
if req.PortRangeStart < 0 || req.PortRangeStart > 65535 || req.PortRangeEnd < 0 || req.PortRangeEnd > 65535 {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid port range"))
|
||||
return
|
||||
}
|
||||
|
||||
if req.PortRangeStart > req.PortRangeEnd {
|
||||
response.WriteJSON(w, response.ErrDefault("Port range start cannot be greater than end"))
|
||||
return
|
||||
}
|
||||
|
||||
node, err := h.repo.GetNodeByID(req.NodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if node == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("Node not found"))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
token := randomToken(32)
|
||||
|
||||
share := &sqlite.PeerShare{
|
||||
Name: req.Name,
|
||||
NodeID: req.NodeID,
|
||||
Token: token,
|
||||
MaxBandwidth: req.MaxBandwidth,
|
||||
ExpiryTime: req.ExpiryTime,
|
||||
PortRangeStart: req.PortRangeStart,
|
||||
PortRangeEnd: req.PortRangeEnd,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
AllowedDomains: req.AllowedDomains,
|
||||
}
|
||||
|
||||
if err := h.repo.CreatePeerShare(share); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func (h *Handler) federationShareDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||
return
|
||||
}
|
||||
|
||||
var req deletePeerShareRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.DeletePeerShare(req.ID); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func (h *Handler) nodeImport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||
return
|
||||
}
|
||||
|
||||
var req nodeImportRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
|
||||
return
|
||||
}
|
||||
|
||||
if req.RemoteURL == "" || req.Token == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("Remote URL and Token are required"))
|
||||
return
|
||||
}
|
||||
|
||||
domainCfg, _ := h.repo.GetConfigByName("panel_domain")
|
||||
localDomain := ""
|
||||
if domainCfg != nil {
|
||||
localDomain = domainCfg.Value
|
||||
}
|
||||
|
||||
fc := client.NewFederationClient()
|
||||
info, err := fc.Connect(req.RemoteURL, req.Token, localDomain)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, "Failed to connect: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare config json for local storage (metadata about limits)
|
||||
configData := map[string]interface{}{
|
||||
"shareId": info.ShareID,
|
||||
"maxBandwidth": info.MaxBandwidth,
|
||||
"expiryTime": info.ExpiryTime,
|
||||
"portRangeStart": info.PortRangeStart,
|
||||
"portRangeEnd": info.PortRangeEnd,
|
||||
}
|
||||
configBytes, _ := json.Marshal(configData)
|
||||
|
||||
db := h.repo.DB()
|
||||
inx := nextIndex(db, "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, ?, ?, ?)
|
||||
`,
|
||||
fmt.Sprintf("%s (Remote)", info.NodeName),
|
||||
randomToken(16), // Dummy secret
|
||||
info.ServerIP,
|
||||
"", "", // v4/v6 unknown, use server_ip
|
||||
"0", // port range not applicable for remote
|
||||
"",
|
||||
"",
|
||||
now, now,
|
||||
info.Status,
|
||||
"[::]", "[::]",
|
||||
inx,
|
||||
req.RemoteURL,
|
||||
req.Token,
|
||||
string(configBytes),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, "Database error: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func (h *Handler) authPeer(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
response.WriteJSON(w, response.Err(401, "Missing Authorization header"))
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
response.WriteJSON(w, response.Err(401, "Invalid Authorization format"))
|
||||
return
|
||||
}
|
||||
|
||||
token := parts[1]
|
||||
share, err := h.repo.GetPeerShareByToken(token)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if share == nil {
|
||||
response.WriteJSON(w, response.Err(401, "Invalid token"))
|
||||
return
|
||||
}
|
||||
|
||||
if share.IsActive == 0 {
|
||||
response.WriteJSON(w, response.Err(403, "Share is disabled"))
|
||||
return
|
||||
}
|
||||
|
||||
if share.ExpiryTime > 0 && share.ExpiryTime < time.Now().UnixMilli() {
|
||||
response.WriteJSON(w, response.Err(403, "Share expired"))
|
||||
return
|
||||
}
|
||||
|
||||
if share.AllowedDomains != "" {
|
||||
clientDomain := r.Header.Get("X-Panel-Domain")
|
||||
if clientDomain == "" {
|
||||
response.WriteJSON(w, response.Err(403, "Domain verification required"))
|
||||
return
|
||||
}
|
||||
allowed := false
|
||||
domains := strings.Split(share.AllowedDomains, ",")
|
||||
for _, d := range domains {
|
||||
if strings.TrimSpace(d) == clientDomain {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
response.WriteJSON(w, response.Err(403, "Domain not allowed"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) federationConnect(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 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)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, "Node not found"))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"shareId": share.ID,
|
||||
"shareName": share.Name,
|
||||
"nodeId": share.NodeID,
|
||||
"nodeName": nodeName,
|
||||
"serverIp": serverIP,
|
||||
"status": status,
|
||||
"maxBandwidth": share.MaxBandwidth,
|
||||
"expiryTime": share.ExpiryTime,
|
||||
"portRangeStart": share.PortRangeStart,
|
||||
"portRangeEnd": share.PortRangeEnd,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handler) federationTunnelCreate(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 federationTunnelRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
|
||||
return
|
||||
}
|
||||
|
||||
if req.RemotePort < share.PortRangeStart || req.RemotePort > share.PortRangeEnd {
|
||||
response.WriteJSON(w, response.Err(403, "Port out of range"))
|
||||
return
|
||||
}
|
||||
|
||||
tunnelType := 1
|
||||
if strings.ToLower(req.Protocol) == "udp" {
|
||||
tunnelType = 2
|
||||
}
|
||||
|
||||
tx, err := h.repo.DB().Begin()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
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, ?)`,
|
||||
fmt.Sprintf("Share-%d-Port-%d", share.ID, req.RemotePort),
|
||||
tunnelType,
|
||||
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 {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
h.wsServer.SendCommand(share.NodeID, "reload", nil, time.Second*5)
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"tunnelId": tunnelID,
|
||||
}))
|
||||
}
|
||||
|
||||
func extractBearerToken(r *http.Request) string {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) == 2 && parts[0] == "Bearer" {
|
||||
return parts[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const bytesPerGB int64 = 1024 * 1024 * 1024
|
||||
|
||||
type userTunnelPolicy struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
TunnelID int64
|
||||
Flow int64
|
||||
InFlow int64
|
||||
OutFlow int64
|
||||
ExpTime int64
|
||||
Status int
|
||||
}
|
||||
|
||||
type gostConfigSnapshot struct {
|
||||
Services []namedConfigItem `json:"services"`
|
||||
Chains []namedConfigItem `json:"chains"`
|
||||
Limiters []namedConfigItem `json:"limiters"`
|
||||
}
|
||||
|
||||
type namedConfigItem struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (h *Handler) processFlowItem(item flowItem) {
|
||||
serviceName := strings.TrimSpace(item.N)
|
||||
if serviceName == "" || serviceName == "web_api" {
|
||||
return
|
||||
}
|
||||
|
||||
forwardID, userID, userTunnelID, ok := parseFlowServiceIDs(serviceName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
|
||||
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
|
||||
|
||||
if userTunnelID > 0 {
|
||||
h.enforceFlowPolicies(userID, userTunnelID)
|
||||
}
|
||||
}
|
||||
|
||||
func parseFlowServiceIDs(serviceName string) (int64, int64, int64, bool) {
|
||||
parts := strings.Split(serviceName, "_")
|
||||
if len(parts) < 3 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
|
||||
forwardID, err1 := strconv.ParseInt(parts[0], 10, 64)
|
||||
userID, err2 := strconv.ParseInt(parts[1], 10, 64)
|
||||
userTunnelID, err3 := strconv.ParseInt(parts[2], 10, 64)
|
||||
if err1 != nil || err2 != nil || err3 != nil || forwardID <= 0 || userID <= 0 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
|
||||
return forwardID, userID, userTunnelID, true
|
||||
}
|
||||
|
||||
func (h *Handler) scaleFlowByTunnel(forwardID int64, inFlow int64, outFlow int64) (int64, int64) {
|
||||
forward, err := h.getForwardRecord(forwardID)
|
||||
if err != nil || forward == nil {
|
||||
return inFlow, outFlow
|
||||
}
|
||||
|
||||
tunnel, err := h.getTunnelRecord(forward.TunnelID)
|
||||
if err != nil || tunnel == nil {
|
||||
return inFlow, outFlow
|
||||
}
|
||||
|
||||
scaledIn := int64(float64(inFlow)*tunnel.TrafficRatio) * tunnel.Flow
|
||||
scaledOut := int64(float64(outFlow)*tunnel.TrafficRatio) * tunnel.Flow
|
||||
return scaledIn, scaledOut
|
||||
}
|
||||
|
||||
func (h *Handler) enforceFlowPolicies(userID int64, userTunnelID int64) {
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if h.shouldPauseUser(userID, now) {
|
||||
h.pauseUserForwards(userID, now)
|
||||
}
|
||||
|
||||
policy, err := h.getUserTunnelPolicy(userTunnelID)
|
||||
if err != nil || policy == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if shouldPauseUserTunnel(policy, now) {
|
||||
h.pauseUserTunnelForwards(policy.UserID, policy.TunnelID, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) shouldPauseUser(userID int64, now int64) bool {
|
||||
user, err := h.repo.GetUserByID(userID)
|
||||
if err != nil || user == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
flowLimit := user.Flow * bytesPerGB
|
||||
current := user.InFlow + user.OutFlow
|
||||
if flowLimit < current {
|
||||
return true
|
||||
}
|
||||
if user.ExpTime > 0 && user.ExpTime <= now {
|
||||
return true
|
||||
}
|
||||
return user.Status != 1
|
||||
}
|
||||
|
||||
func shouldPauseUserTunnel(policy *userTunnelPolicy, now int64) bool {
|
||||
if policy == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
flowLimit := policy.Flow * bytesPerGB
|
||||
current := policy.InFlow + policy.OutFlow
|
||||
if current >= flowLimit {
|
||||
return true
|
||||
}
|
||||
if policy.ExpTime > 0 && policy.ExpTime <= now {
|
||||
return true
|
||||
}
|
||||
return policy.Status != 1
|
||||
}
|
||||
|
||||
func (h *Handler) getUserTunnelPolicy(userTunnelID int64) (*userTunnelPolicy, error) {
|
||||
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
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &policy, nil
|
||||
}
|
||||
|
||||
func (h *Handler) pauseUserForwards(userID int64, now int64) {
|
||||
forwards, err := h.listActiveForwardsByUser(userID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
h.pauseForwardRecords(forwards, now)
|
||||
}
|
||||
|
||||
func (h *Handler) pauseUserTunnelForwards(userID int64, tunnelID int64, now int64) {
|
||||
forwards, err := h.listActiveForwardsByUserTunnel(userID, tunnelID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
h.pauseForwardRecords(forwards, now)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (h *Handler) cleanNodeConfigs(nodeID int64, rawConfig string) {
|
||||
if h == nil || h.repo == nil || h.repo.DB() == nil || nodeID <= 0 {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(rawConfig) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var snapshot gostConfigSnapshot
|
||||
if err := json.Unmarshal([]byte(rawConfig), &snapshot); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.cleanOrphanedServices(nodeID, snapshot.Services)
|
||||
h.cleanOrphanedChains(nodeID, snapshot.Chains)
|
||||
h.cleanOrphanedLimiters(nodeID, snapshot.Limiters)
|
||||
}
|
||||
|
||||
func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem) {
|
||||
for _, item := range services {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if name == "" || name == "web_api" {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.Split(name, "_")
|
||||
if len(parts) >= 3 {
|
||||
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
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
|
||||
}
|
||||
}
|
||||
suffix := parts[len(parts)-1]
|
||||
|
||||
switch suffix {
|
||||
case "tls":
|
||||
tunnelID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || tunnelID <= 0 || h.tunnelExists(tunnelID) {
|
||||
continue
|
||||
}
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name}}, false, true)
|
||||
case "tcp":
|
||||
if len(parts) < 4 {
|
||||
continue
|
||||
}
|
||||
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || forwardID <= 0 || h.forwardExists(forwardID) {
|
||||
continue
|
||||
}
|
||||
base := strings.TrimSuffix(name, "_tcp")
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{base + "_tcp", base + "_udp"}}, false, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) cleanOrphanedChains(nodeID int64, chains []namedConfigItem) {
|
||||
for _, item := range chains {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
idx := strings.LastIndex(name, "_")
|
||||
if idx <= 0 || idx >= len(name)-1 {
|
||||
continue
|
||||
}
|
||||
tunnelID, err := strconv.ParseInt(name[idx+1:], 10, 64)
|
||||
if err != nil || tunnelID <= 0 || h.tunnelExists(tunnelID) {
|
||||
continue
|
||||
}
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteChains", map[string]interface{}{"chain": name}, false, true)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) cleanOrphanedLimiters(nodeID int64, limiters []namedConfigItem) {
|
||||
for _, item := range limiters {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if name == "" || h.speedLimiterExists(name) {
|
||||
continue
|
||||
}
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteLimiters", map[string]interface{}{"limiter": name}, false, true)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (h *Handler) speedLimiterExists(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
id, err := strconv.ParseInt(name, 10, 64)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,994 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/middleware"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/sqlite"
|
||||
"go-backend/internal/ws"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
repo *sqlite.Repository
|
||||
jwtSecret string
|
||||
wsServer *ws.Server
|
||||
|
||||
jobsMu sync.Mutex
|
||||
jobsCancel context.CancelFunc
|
||||
jobsStarted bool
|
||||
jobsWG sync.WaitGroup
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
CaptchaID string `json:"captchaId"`
|
||||
}
|
||||
|
||||
type nameRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type configSingleRequest struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type changePasswordRequest struct {
|
||||
NewUsername string `json:"newUsername"`
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
ConfirmPassword string `json:"confirmPassword"`
|
||||
}
|
||||
|
||||
type flowItem struct {
|
||||
N string `json:"n"`
|
||||
U int64 `json:"u"`
|
||||
D int64 `json:"d"`
|
||||
}
|
||||
|
||||
func New(repo *sqlite.Repository, jwtSecret string) *Handler {
|
||||
return &Handler{
|
||||
repo: repo,
|
||||
jwtSecret: jwtSecret,
|
||||
wsServer: ws.NewServer(repo, jwtSecret),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) WebSocketHandler() http.Handler {
|
||||
return h.wsServer
|
||||
}
|
||||
|
||||
func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/user/login", h.login)
|
||||
mux.HandleFunc("/api/v1/user/list", h.userList)
|
||||
mux.HandleFunc("/api/v1/user/create", h.userCreate)
|
||||
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/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/captcha/check", h.checkCaptcha)
|
||||
mux.HandleFunc("/api/v1/user/package", h.userPackage)
|
||||
mux.HandleFunc("/api/v1/user/updatePassword", h.updatePassword)
|
||||
mux.HandleFunc("/api/v1/node/list", h.nodeList)
|
||||
mux.HandleFunc("/api/v1/node/create", h.nodeCreate)
|
||||
mux.HandleFunc("/api/v1/node/update", h.nodeUpdate)
|
||||
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/batch-delete", h.nodeBatchDelete)
|
||||
mux.HandleFunc("/api/v1/node/check-status", h.nodeCheckStatus)
|
||||
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/update-order", h.tunnelUpdateOrder)
|
||||
mux.HandleFunc("/api/v1/tunnel/batch-delete", h.tunnelBatchDelete)
|
||||
mux.HandleFunc("/api/v1/tunnel/batch-redeploy", h.tunnelBatchRedeploy)
|
||||
mux.HandleFunc("/api/v1/tunnel/user/assign", h.userTunnelAssign)
|
||||
mux.HandleFunc("/api/v1/tunnel/user/batch-assign", h.userTunnelBatchAssign)
|
||||
mux.HandleFunc("/api/v1/tunnel/user/remove", h.userTunnelRemove)
|
||||
mux.HandleFunc("/api/v1/tunnel/user/update", h.userTunnelUpdate)
|
||||
mux.HandleFunc("/api/v1/forward/list", h.forwardList)
|
||||
mux.HandleFunc("/api/v1/forward/create", h.forwardCreate)
|
||||
mux.HandleFunc("/api/v1/forward/update", h.forwardUpdate)
|
||||
mux.HandleFunc("/api/v1/forward/delete", h.forwardDelete)
|
||||
mux.HandleFunc("/api/v1/forward/force-delete", h.forwardForceDelete)
|
||||
mux.HandleFunc("/api/v1/forward/pause", h.forwardPause)
|
||||
mux.HandleFunc("/api/v1/forward/resume", h.forwardResume)
|
||||
mux.HandleFunc("/api/v1/forward/diagnose", h.forwardDiagnose)
|
||||
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)
|
||||
mux.HandleFunc("/api/v1/forward/batch-resume", h.forwardBatchResume)
|
||||
mux.HandleFunc("/api/v1/forward/batch-redeploy", h.forwardBatchRedeploy)
|
||||
mux.HandleFunc("/api/v1/forward/batch-change-tunnel", h.forwardBatchChangeTunnel)
|
||||
mux.HandleFunc("/api/v1/speed-limit/list", h.speedLimitList)
|
||||
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)
|
||||
mux.HandleFunc("/api/v1/group/tunnel/create", h.groupTunnelCreate)
|
||||
mux.HandleFunc("/api/v1/group/tunnel/update", h.groupTunnelUpdate)
|
||||
mux.HandleFunc("/api/v1/group/tunnel/delete", h.groupTunnelDelete)
|
||||
mux.HandleFunc("/api/v1/group/tunnel/assign", h.groupTunnelAssign)
|
||||
mux.HandleFunc("/api/v1/group/user/list", h.userGroupList)
|
||||
mux.HandleFunc("/api/v1/group/user/create", h.groupUserCreate)
|
||||
mux.HandleFunc("/api/v1/group/user/update", h.groupUserUpdate)
|
||||
mux.HandleFunc("/api/v1/group/user/delete", h.groupUserDelete)
|
||||
mux.HandleFunc("/api/v1/group/user/assign", h.groupUserAssign)
|
||||
mux.HandleFunc("/api/v1/group/permission/list", h.groupPermissionList)
|
||||
mux.HandleFunc("/api/v1/group/permission/assign", h.groupPermissionAssign)
|
||||
mux.HandleFunc("/api/v1/group/permission/remove", h.groupPermissionRemove)
|
||||
mux.HandleFunc("/api/v1/open_api/sub_store", h.openAPISubStore)
|
||||
mux.HandleFunc("/api/v1/federation/share/list", h.federationShareList)
|
||||
mux.HandleFunc("/api/v1/federation/share/create", h.federationShareCreate)
|
||||
mux.HandleFunc("/api/v1/federation/share/delete", h.federationShareDelete)
|
||||
mux.HandleFunc("/api/v1/federation/connect", h.authPeer(h.federationConnect))
|
||||
mux.HandleFunc("/api/v1/federation/tunnel/create", h.authPeer(h.federationTunnelCreate))
|
||||
mux.HandleFunc("/api/v1/federation/node/import", h.nodeImport)
|
||||
|
||||
mux.HandleFunc("/flow/test", h.flowTest)
|
||||
mux.HandleFunc("/flow/config", h.flowConfig)
|
||||
mux.HandleFunc("/flow/upload", h.flowUpload)
|
||||
mux.HandleFunc("/error", h.errorPage)
|
||||
}
|
||||
|
||||
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req loginRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.Err(500, "请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Username) == "" {
|
||||
response.WriteJSON(w, response.Err(500, "用户名不能为空"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Password) == "" {
|
||||
response.WriteJSON(w, response.Err(500, "密码不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
captchaEnabled, err := h.captchaEnabled()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if captchaEnabled {
|
||||
if strings.TrimSpace(req.CaptchaID) == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
|
||||
return
|
||||
}
|
||||
|
||||
secretCfg, err := h.repo.GetConfigByName("cloudflare_secret_key")
|
||||
if err != nil || secretCfg == nil || secretCfg.Value == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("验证码配置错误:未配置Secret Key"))
|
||||
return
|
||||
}
|
||||
|
||||
if !h.verifyCloudflareTurnstile(req.CaptchaID, secretCfg.Value) {
|
||||
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
user, err := h.repo.GetUserByUsername(req.Username)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("账号或密码错误"))
|
||||
return
|
||||
}
|
||||
if user.Pwd != security.MD5(req.Password) {
|
||||
response.WriteJSON(w, response.ErrDefault("账号或密码错误"))
|
||||
return
|
||||
}
|
||||
if user.Status == 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("账号被停用"))
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(user.ID, user.User, user.RoleID, h.jwtSecret)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
requirePasswordChange := req.Username == "admin_user" || req.Password == "admin_user"
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"token": token,
|
||||
"name": user.User,
|
||||
"role_id": user.RoleID,
|
||||
"requirePasswordChange": requirePasswordChange,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handler) getConfigByName(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req nameRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.repo.GetConfigByName(req.Name)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if cfg == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("配置不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(cfg))
|
||||
}
|
||||
|
||||
func (h *Handler) getConfigs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
cfgMap, err := h.repo.ListConfigs()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(cfgMap))
|
||||
}
|
||||
|
||||
func (h *Handler) userList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
users, err := h.repo.ListUsers()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(users))
|
||||
}
|
||||
|
||||
func (h *Handler) nodeList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.repo.ListNodes()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.repo.ListTunnels()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
}
|
||||
|
||||
func (h *Handler) forwardList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
userID, roleID, err := userRoleFromRequest(r)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.repo.ListForwards()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if roleID != 0 {
|
||||
filtered := make([]map[string]interface{}, 0, len(items))
|
||||
for _, item := range items {
|
||||
if asInt64(item["userId"], 0) == userID {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
items = filtered
|
||||
}
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
}
|
||||
|
||||
func (h *Handler) speedLimitList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.repo.ListSpeedLimits()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
}
|
||||
|
||||
func (h *Handler) openAPISubStore(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if h == nil || h.repo == nil || h.repo.DB() == nil {
|
||||
response.WriteJSON(w, response.Err(-2, "database unavailable"))
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(r.URL.Query().Get("user"))
|
||||
password := strings.TrimSpace(r.URL.Query().Get("pwd"))
|
||||
tunnel := strings.TrimSpace(r.URL.Query().Get("tunnel"))
|
||||
if tunnel == "" {
|
||||
tunnel = "-1"
|
||||
}
|
||||
|
||||
if username == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("用户不能为空"))
|
||||
return
|
||||
}
|
||||
if password == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("密码不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if user == nil || user.Pwd != security.MD5(password) {
|
||||
response.WriteJSON(w, response.ErrDefault("鉴权失败"))
|
||||
return
|
||||
}
|
||||
|
||||
const giga = int64(1024 * 1024 * 1024)
|
||||
headerValue := ""
|
||||
|
||||
if tunnel == "-1" {
|
||||
headerValue = buildSubscriptionHeader(user.OutFlow, user.InFlow, user.Flow*giga, user.ExpTime/1000)
|
||||
} else {
|
||||
tunnelID, parseErr := strconv.ParseInt(tunnel, 10, 64)
|
||||
if parseErr != nil || tunnelID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
|
||||
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)
|
||||
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 {
|
||||
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
headerValue = buildSubscriptionHeader(outFlow, inFlow, flow*giga, expTime/1000)
|
||||
}
|
||||
|
||||
w.Header().Set("subscription-userinfo", headerValue)
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte(headerValue))
|
||||
}
|
||||
|
||||
func (h *Handler) errorPage(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte("<!DOCTYPE html><html lang='zh-CN'><head><meta charset='UTF-8'><meta name='viewport' content='width=device-width, initial-scale=1.0'><title>错误 404</title></head><body><div style='min-height:100vh;display:flex;align-items:center;justify-content:center;flex-direction:column;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Arial,sans-serif;'><div style='font-size:6rem;color:#333;font-weight:300;'>404</div><div style='font-size:1.2rem;color:#666;'>你推开了后端的大门,却发现里面只有寂寞。</div></div></body></html>"))
|
||||
}
|
||||
|
||||
func buildSubscriptionHeader(upload, download, total, expire int64) string {
|
||||
return fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", download, upload, total, expire)
|
||||
}
|
||||
|
||||
func (h *Handler) userTunnelVisibleList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
userID, roleID, err := userRoleFromRequest(r)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]map[string]interface{}, 0)
|
||||
if roleID == 0 {
|
||||
items, err = h.repo.ListEnabledTunnelSummaries()
|
||||
} else {
|
||||
items, err = h.repo.ListUserAccessibleTunnels(userID)
|
||||
}
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
}
|
||||
|
||||
func (h *Handler) userTunnelList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
UserID int64 `json:"userId"`
|
||||
}
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if req.UserID <= 0 {
|
||||
response.WriteJSON(w, response.OK([]interface{}{}))
|
||||
return
|
||||
}
|
||||
|
||||
tunnels, err := h.repo.GetUserPackageTunnels(req.UserID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]map[string]interface{}, 0, len(tunnels))
|
||||
for _, t := range tunnels {
|
||||
item := map[string]interface{}{
|
||||
"id": t.ID,
|
||||
"userId": t.UserID,
|
||||
"tunnelId": t.TunnelID,
|
||||
"tunnelName": t.TunnelName,
|
||||
"status": 1,
|
||||
"flow": t.Flow,
|
||||
"num": t.Num,
|
||||
"expTime": t.ExpTime,
|
||||
"flowResetTime": t.FlowResetTime,
|
||||
"inFlow": t.InFlow,
|
||||
"outFlow": t.OutFlow,
|
||||
"tunnelFlow": t.TunnelFlow,
|
||||
"speedId": nil,
|
||||
"speedLimitName": nil,
|
||||
}
|
||||
if t.SpeedID.Valid {
|
||||
item["speedId"] = t.SpeedID.Int64
|
||||
}
|
||||
if t.SpeedLimit.Valid {
|
||||
item["speedLimitName"] = t.SpeedLimit.String
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
response.WriteJSON(w, response.OK(out))
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelGroupList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.repo.ListTunnelGroups()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
}
|
||||
|
||||
func (h *Handler) userGroupList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.repo.ListUserGroups()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
}
|
||||
|
||||
func (h *Handler) groupPermissionList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.repo.ListGroupPermissions()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
}
|
||||
|
||||
func (h *Handler) checkCaptcha(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
enabled, err := h.captchaEnabled()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if enabled {
|
||||
response.WriteJSON(w, response.OK(1))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(0))
|
||||
}
|
||||
|
||||
func (h *Handler) flowTest(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("test"))
|
||||
}
|
||||
|
||||
func (h *Handler) flowConfig(w http.ResponseWriter, r *http.Request) {
|
||||
secret := r.URL.Query().Get("secret")
|
||||
node, err := h.repo.GetNodeBySecret(secret)
|
||||
if err != nil || node == nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
return
|
||||
}
|
||||
|
||||
rawData, err := readAndDecryptFlowBody(r.Body, secret)
|
||||
if err == nil && strings.TrimSpace(rawData) != "" {
|
||||
h.cleanNodeConfigs(node.ID, rawData)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
|
||||
secret := r.URL.Query().Get("secret")
|
||||
if ok, _ := h.repo.NodeExistsBySecret(secret); !ok {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
return
|
||||
}
|
||||
|
||||
raw, err := readAndDecryptFlowBody(r.Body, secret)
|
||||
if err == nil && strings.TrimSpace(raw) != "" {
|
||||
var items []flowItem
|
||||
if json.Unmarshal([]byte(raw), &items) == nil {
|
||||
for _, item := range items {
|
||||
h.processFlowItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var payload map[string]string
|
||||
if err := decodeJSON(r.Body, &payload); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("配置数据不能为空"))
|
||||
return
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("配置数据不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
for k, v := range payload {
|
||||
key := strings.TrimSpace(k)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if err := h.repo.UpsertConfig(key, v, now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req configSingleRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Value) == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("配置值不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UpsertConfig(strings.TrimSpace(req.Name), req.Value, time.Now().UnixMilli()); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := r.Context().Value(middleware.ClaimsContextKey).(auth.Claims)
|
||||
if !ok {
|
||||
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := parseUserID(claims.Sub)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.GetUserByID(userID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("用户不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
tunnels, err := h.repo.GetUserPackageTunnels(userID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
forwards, err := h.repo.GetUserPackageForwards(userID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := h.repo.GetStatisticsFlows(userID, 24)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
sort.Slice(stats, func(i, j int) bool { return stats[i].ID < stats[j].ID })
|
||||
|
||||
tunnelOut := make([]map[string]interface{}, 0, len(tunnels))
|
||||
for _, t := range tunnels {
|
||||
item := map[string]interface{}{
|
||||
"id": t.ID,
|
||||
"userId": t.UserID,
|
||||
"tunnelId": t.TunnelID,
|
||||
"tunnelName": t.TunnelName,
|
||||
"tunnelFlow": t.TunnelFlow,
|
||||
"flow": t.Flow,
|
||||
"inFlow": t.InFlow,
|
||||
"outFlow": t.OutFlow,
|
||||
"num": t.Num,
|
||||
"flowResetTime": t.FlowResetTime,
|
||||
"expTime": t.ExpTime,
|
||||
"speedId": nil,
|
||||
"speedLimitName": nil,
|
||||
"speed": nil,
|
||||
}
|
||||
if t.SpeedID.Valid {
|
||||
item["speedId"] = t.SpeedID.Int64
|
||||
}
|
||||
if t.SpeedLimit.Valid {
|
||||
item["speedLimitName"] = t.SpeedLimit.String
|
||||
}
|
||||
if t.Speed.Valid {
|
||||
item["speed"] = t.Speed.Int64
|
||||
}
|
||||
tunnelOut = append(tunnelOut, item)
|
||||
}
|
||||
|
||||
forwardOut := make([]map[string]interface{}, 0, len(forwards))
|
||||
for _, f := range forwards {
|
||||
item := map[string]interface{}{
|
||||
"id": f.ID,
|
||||
"name": f.Name,
|
||||
"tunnelId": f.TunnelID,
|
||||
"tunnelName": f.TunnelName,
|
||||
"inIp": f.InIP,
|
||||
"inPort": nil,
|
||||
"remoteAddr": f.RemoteAddr,
|
||||
"inFlow": f.InFlow,
|
||||
"outFlow": f.OutFlow,
|
||||
"status": f.Status,
|
||||
"createdTime": f.CreatedAt,
|
||||
}
|
||||
if f.InPort.Valid {
|
||||
item["inPort"] = f.InPort.Int64
|
||||
}
|
||||
forwardOut = append(forwardOut, item)
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"userInfo": map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"name": user.User,
|
||||
"user": user.User,
|
||||
"status": user.Status,
|
||||
"flow": user.Flow,
|
||||
"inFlow": user.InFlow,
|
||||
"outFlow": user.OutFlow,
|
||||
"num": user.Num,
|
||||
"expTime": user.ExpTime,
|
||||
"flowResetTime": user.FlowResetTime,
|
||||
"createdTime": user.CreatedTime,
|
||||
"updatedTime": nullableNullInt64(user.UpdatedTime),
|
||||
},
|
||||
"tunnelPermissions": tunnelOut,
|
||||
"forwards": forwardOut,
|
||||
"statisticsFlows": stats,
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(payload))
|
||||
}
|
||||
|
||||
func (h *Handler) updatePassword(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := r.Context().Value(middleware.ClaimsContextKey).(auth.Claims)
|
||||
if !ok {
|
||||
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := parseUserID(claims.Sub)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
|
||||
return
|
||||
}
|
||||
|
||||
var req changePasswordRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("修改账号密码时发生错误"))
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.NewUsername) == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("新用户名不能为空"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.CurrentPassword) == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("当前密码不能为空"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.NewPassword) == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("新密码不能为空"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.ConfirmPassword) == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("确认密码不能为空"))
|
||||
return
|
||||
}
|
||||
if req.NewPassword != req.ConfirmPassword {
|
||||
response.WriteJSON(w, response.ErrDefault("新密码和确认密码不匹配"))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.repo.GetUserByID(userID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("用户不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
if user.Pwd != security.MD5(req.CurrentPassword) {
|
||||
response.WriteJSON(w, response.ErrDefault("当前密码错误"))
|
||||
return
|
||||
}
|
||||
|
||||
exists, err := h.repo.UsernameExistsExceptID(req.NewUsername, userID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
response.WriteJSON(w, response.ErrDefault("用户名已存在"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UpdateUserNameAndPassword(userID, req.NewUsername, security.MD5(req.NewPassword), time.Now().UnixMilli()); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func (h *Handler) captchaEnabled() (bool, error) {
|
||||
cfg, err := h.repo.GetConfigByName("captcha_enabled")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if cfg == nil {
|
||||
return false, nil
|
||||
}
|
||||
return strings.EqualFold(cfg.Value, "true"), nil
|
||||
}
|
||||
|
||||
func decodeJSON(body io.ReadCloser, out interface{}) error {
|
||||
defer body.Close()
|
||||
decoder := json.NewDecoder(body)
|
||||
decoder.DisallowUnknownFields()
|
||||
return decoder.Decode(out)
|
||||
}
|
||||
|
||||
func parseUserID(sub string) (int64, error) {
|
||||
id, err := strconv.ParseInt(sub, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, strconv.ErrSyntax
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func userIDFromRequest(r *http.Request) (int64, error) {
|
||||
claims, ok := r.Context().Value(middleware.ClaimsContextKey).(auth.Claims)
|
||||
if !ok {
|
||||
return 0, strconv.ErrSyntax
|
||||
}
|
||||
return parseUserID(claims.Sub)
|
||||
}
|
||||
|
||||
func userRoleFromRequest(r *http.Request) (int64, int, error) {
|
||||
claims, ok := r.Context().Value(middleware.ClaimsContextKey).(auth.Claims)
|
||||
if !ok {
|
||||
return 0, 0, strconv.ErrSyntax
|
||||
}
|
||||
userID, err := parseUserID(claims.Sub)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return userID, claims.RoleID, nil
|
||||
}
|
||||
|
||||
func nullableNullInt64(v sql.NullInt64) interface{} {
|
||||
if v.Valid {
|
||||
return v.Int64
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readAndDecryptFlowBody(body io.ReadCloser, secret string) (string, error) {
|
||||
defer body.Close()
|
||||
raw, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
text := strings.TrimSpace(string(raw))
|
||||
if text == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var wrap struct {
|
||||
Encrypted bool `json:"encrypted"`
|
||||
Data string `json:"data"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrap); err != nil || !wrap.Encrypted || strings.TrimSpace(wrap.Data) == "" {
|
||||
return text, nil
|
||||
}
|
||||
|
||||
crypto, err := security.NewAESCrypto(secret)
|
||||
if err != nil {
|
||||
return text, nil
|
||||
}
|
||||
plain, err := crypto.Decrypt(wrap.Data)
|
||||
if err != nil {
|
||||
return text, nil
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
func (h *Handler) verifyCloudflareTurnstile(token, secretKey string) bool {
|
||||
if token == "" || secretKey == "" {
|
||||
return false
|
||||
}
|
||||
resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", url.Values{
|
||||
"secret": {secretKey},
|
||||
"response": {token},
|
||||
})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var body struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return false
|
||||
}
|
||||
return body.Success
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (h *Handler) StartBackgroundJobs() {
|
||||
if h == nil || h.repo == nil || h.repo.DB() == nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.jobsMu.Lock()
|
||||
if h.jobsStarted {
|
||||
h.jobsMu.Unlock()
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
h.jobsCancel = cancel
|
||||
h.jobsStarted = true
|
||||
h.jobsWG.Add(2)
|
||||
h.jobsMu.Unlock()
|
||||
|
||||
go h.runHourlyStatsLoop(ctx)
|
||||
go h.runDailyMaintenanceLoop(ctx)
|
||||
}
|
||||
|
||||
func (h *Handler) StopBackgroundJobs() {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.jobsMu.Lock()
|
||||
if !h.jobsStarted {
|
||||
h.jobsMu.Unlock()
|
||||
return
|
||||
}
|
||||
cancel := h.jobsCancel
|
||||
h.jobsCancel = nil
|
||||
h.jobsStarted = false
|
||||
h.jobsMu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
h.jobsWG.Wait()
|
||||
}
|
||||
|
||||
func (h *Handler) runHourlyStatsLoop(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
|
||||
for {
|
||||
wait := durationUntilNextHour(time.Now())
|
||||
timer := time.NewTimer(wait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
return
|
||||
case <-timer.C:
|
||||
h.runStatisticsFlowJob(time.Now())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) runDailyMaintenanceLoop(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
|
||||
for {
|
||||
wait := durationUntilNextDailyMaintenance(time.Now())
|
||||
timer := time.NewTimer(wait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
return
|
||||
case <-timer.C:
|
||||
h.runResetAndExpiryJob(time.Now())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func durationUntilNextHour(now time.Time) time.Duration {
|
||||
next := now.Truncate(time.Hour).Add(time.Hour)
|
||||
return next.Sub(now)
|
||||
}
|
||||
|
||||
func durationUntilNextDailyMaintenance(now time.Time) time.Duration {
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 5, 0, now.Location())
|
||||
if !next.After(now) {
|
||||
next = next.Add(24 * time.Hour)
|
||||
}
|
||||
return next.Sub(now)
|
||||
}
|
||||
|
||||
func (h *Handler) runStatisticsFlowJob(now time.Time) {
|
||||
if h == nil || h.repo == nil || h.repo.DB() == 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)
|
||||
|
||||
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`)
|
||||
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
|
||||
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)
|
||||
if err == nil && lastTotal.Valid {
|
||||
increment = currentTotal - lastTotal.Int64
|
||||
if increment < 0 {
|
||||
increment = currentTotal
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = db.Exec(`
|
||||
INSERT INTO statistics_flow(user_id, flow, total_flow, time, created_time)
|
||||
VALUES(?, ?, ?, ?, ?)
|
||||
`, user.userID, increment, currentTotal, hourText, createdTime)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) runResetAndExpiryJob(now time.Time) {
|
||||
if h == nil || h.repo == nil || h.repo.DB() == nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.resetMonthlyFlow(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)
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
if err == nil {
|
||||
h.pauseForwardRecords(forwards, nowMs)
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE user_tunnel SET status = 0 WHERE id = ?`, item.userTunnelID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/sqlite"
|
||||
)
|
||||
|
||||
func TestRunStatisticsFlowJobTracksIncrementAndPrunes(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "jobs-stats.db")
|
||||
repo, err := sqlite.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
|
||||
h := New(repo, "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 {
|
||||
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 {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if flow != 50 {
|
||||
t.Fatalf("expected increment flow 50, got %d", flow)
|
||||
}
|
||||
if total != 300 {
|
||||
t.Fatalf("expected total flow 300, got %d", total)
|
||||
}
|
||||
if hour != "12:00" {
|
||||
t.Fatalf("expected hour mark 12:00, got %s", hour)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "jobs-reset.db")
|
||||
repo, err := sqlite.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
|
||||
h := New(repo, "secret")
|
||||
now := time.Date(2026, 3, 15, 0, 0, 5, 0, time.UTC)
|
||||
nowMs := now.UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'expired_user', 'x', 1, ?, 100, 1000, 2000, 15, 1, ?, ?, 1)
|
||||
`, nowMs-1000, nowMs, nowMs); err != nil {
|
||||
t.Fatalf("insert expired 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, 't1', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||
`, nowMs, nowMs); 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, 1, 1, 300, 400, 15, ?, 1)
|
||||
`, nowMs-1000); err != nil {
|
||||
t.Fatalf("insert expired 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(20, 2, 'expired_user', 'f1', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, nowMs, nowMs); err != nil {
|
||||
t.Fatalf("insert 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if forwardStatus != 0 {
|
||||
t.Fatalf("expected forward status=0 after expiry handling, got %d", forwardStatus)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const ClaimsContextKey contextKey = "claims"
|
||||
|
||||
type AuthOptions struct {
|
||||
JWTSecret string
|
||||
}
|
||||
|
||||
func JWT(opts AuthOptions) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if shouldSkip(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if token == "" {
|
||||
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := auth.ValidateToken(token, opts.JWTSecret)
|
||||
if !ok {
|
||||
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
|
||||
return
|
||||
}
|
||||
|
||||
if requiresAdmin(r.URL.Path) && claims.RoleID != 0 {
|
||||
response.WriteJSON(w, response.Err(403, "权限不足,仅管理员可操作"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), ClaimsContextKey, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func RequireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw := r.Context().Value(ClaimsContextKey)
|
||||
claims, ok := raw.(auth.Claims)
|
||||
if !ok {
|
||||
response.WriteJSON(w, response.Err(401, "无法获取用户权限信息"))
|
||||
return
|
||||
}
|
||||
if claims.RoleID != 0 {
|
||||
response.WriteJSON(w, response.Err(403, "权限不足,仅管理员可操作"))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func shouldSkip(path string) bool {
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/flow/"):
|
||||
return true
|
||||
case strings.HasPrefix(path, "/api/v1/open_api/"):
|
||||
return true
|
||||
case strings.HasPrefix(path, "/api/v1/captcha/"):
|
||||
return true
|
||||
case path == "/api/v1/config/get":
|
||||
return true
|
||||
case path == "/api/v1/user/login":
|
||||
return true
|
||||
case path == "/api/v1/federation/connect":
|
||||
return true
|
||||
case path == "/api/v1/federation/tunnel/create":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func requiresAdmin(path string) bool {
|
||||
if strings.HasPrefix(path, "/api/v1/group/") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/v1/federation/share/") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/v1/node/") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/v1/speed-limit/") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/v1/tunnel/") {
|
||||
if strings.HasPrefix(path, "/api/v1/tunnel/user/tunnel") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
switch path {
|
||||
case "/api/v1/user/create", "/api/v1/user/list", "/api/v1/user/update", "/api/v1/user/delete", "/api/v1/user/reset":
|
||||
return true
|
||||
case "/api/v1/config/update", "/api/v1/config/update-single":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package middleware
|
||||
|
||||
import "net/http"
|
||||
|
||||
func CORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS")
|
||||
w.Header().Set("Access-Control-Expose-Headers", "Authorization")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func Recover(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprint(rec)))
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
w.status = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
hj, ok := w.ResponseWriter.(http.Hijacker)
|
||||
if !ok {
|
||||
return nil, nil, http.ErrNotSupported
|
||||
}
|
||||
return hj.Hijack()
|
||||
}
|
||||
|
||||
func (w *statusWriter) Flush() {
|
||||
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *statusWriter) ReadFrom(r io.Reader) (int64, error) {
|
||||
if rf, ok := w.ResponseWriter.(io.ReaderFrom); ok {
|
||||
return rf.ReadFrom(r)
|
||||
}
|
||||
return io.Copy(w.ResponseWriter, r)
|
||||
}
|
||||
|
||||
func (w *statusWriter) Push(target string, opts *http.PushOptions) error {
|
||||
if p, ok := w.ResponseWriter.(http.Pusher); ok {
|
||||
return p.Push(target, opts)
|
||||
}
|
||||
return http.ErrNotSupported
|
||||
}
|
||||
|
||||
func RequestLog(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
start := time.Now()
|
||||
next.ServeHTTP(sw, r)
|
||||
log.Printf("%s %s -> %d (%s)", r.Method, r.URL.Path, sw.status, time.Since(start).String())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type R struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TS int64 `json:"ts"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func OK(data interface{}) R {
|
||||
return R{
|
||||
Code: 0,
|
||||
Msg: "操作成功",
|
||||
TS: time.Now().UnixMilli(),
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func OKEmpty() R {
|
||||
return R{
|
||||
Code: 0,
|
||||
Msg: "操作成功",
|
||||
TS: time.Now().UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
func Err(code int, msg string) R {
|
||||
return R{
|
||||
Code: code,
|
||||
Msg: msg,
|
||||
TS: time.Now().UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
func ErrDefault(msg string) R {
|
||||
return Err(-1, msg)
|
||||
}
|
||||
|
||||
func WriteJSON(w http.ResponseWriter, payload R) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"go-backend/internal/http/handler"
|
||||
"go-backend/internal/http/middleware"
|
||||
)
|
||||
|
||||
func NewRouter(h *handler.Handler, jwtSecret string) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
h.Register(mux)
|
||||
mux.Handle("/system-info", h.WebSocketHandler())
|
||||
|
||||
wrapped := middleware.Recover(mux)
|
||||
wrapped = middleware.JWT(middleware.AuthOptions{JWTSecret: jwtSecret})(wrapped)
|
||||
wrapped = middleware.RequestLog(wrapped)
|
||||
wrapped = middleware.CORS(wrapped)
|
||||
return wrapped
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type AESCrypto struct {
|
||||
key []byte
|
||||
}
|
||||
|
||||
func NewAESCrypto(secret string) (*AESCrypto, error) {
|
||||
if secret == "" {
|
||||
return nil, fmt.Errorf("secret is empty")
|
||||
}
|
||||
hash := sha256.Sum256([]byte(secret))
|
||||
return &AESCrypto{key: hash[:]}, nil
|
||||
}
|
||||
|
||||
func (a *AESCrypto) Encrypt(plain []byte) (string, error) {
|
||||
if len(plain) == 0 {
|
||||
return "", fmt.Errorf("empty plaintext")
|
||||
}
|
||||
block, err := aes.NewCipher(a.key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed := gcm.Seal(nil, nonce, plain, nil)
|
||||
data := append(nonce, sealed...)
|
||||
return base64.StdEncoding.EncodeToString(data), nil
|
||||
}
|
||||
|
||||
func (a *AESCrypto) Decrypt(cipherText string) ([]byte, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(cipherText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, err := aes.NewCipher(a.key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(raw) < nonceSize {
|
||||
return nil, fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
nonce := raw[:nonceSize]
|
||||
data := raw[nonceSize:]
|
||||
return gcm.Open(nil, nonce, data, nil)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func MD5(input string) string {
|
||||
hash := md5.Sum([]byte(input))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
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);
|
||||
@@ -0,0 +1,183 @@
|
||||
-- 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
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
@@ -0,0 +1,430 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/sqlite"
|
||||
)
|
||||
|
||||
type encryptedMessage struct {
|
||||
Encrypted bool `json:"encrypted"`
|
||||
Data string `json:"data"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
type broadcastMessage struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type connWrap struct {
|
||||
conn *websocket.Conn
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type nodeSession struct {
|
||||
nodeID int64
|
||||
secret string
|
||||
conn *connWrap
|
||||
}
|
||||
|
||||
type commandResponse struct {
|
||||
Type string `json:"type"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
}
|
||||
|
||||
type pendingRequest struct {
|
||||
nodeID int64
|
||||
ch chan CommandResult
|
||||
}
|
||||
|
||||
type CommandResult struct {
|
||||
Type string `json:"type"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
repo *sqlite.Repository
|
||||
jwtSecret string
|
||||
upgrader websocket.Upgrader
|
||||
|
||||
mu sync.RWMutex
|
||||
admins map[*connWrap]struct{}
|
||||
nodes map[int64]*nodeSession
|
||||
byConn map[*websocket.Conn]*nodeSession
|
||||
pending map[string]pendingRequest
|
||||
}
|
||||
|
||||
func NewServer(repo *sqlite.Repository, jwtSecret string) *Server {
|
||||
return &Server{
|
||||
repo: repo,
|
||||
jwtSecret: jwtSecret,
|
||||
upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
},
|
||||
admins: make(map[*connWrap]struct{}),
|
||||
nodes: make(map[int64]*nodeSession),
|
||||
byConn: make(map[*websocket.Conn]*nodeSession),
|
||||
pending: make(map[string]pendingRequest),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
typeVal := query.Get("type")
|
||||
secret := query.Get("secret")
|
||||
|
||||
if typeVal == "1" {
|
||||
node, err := s.repo.GetNodeBySecret(secret)
|
||||
if err != nil || node == nil {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
s.handleNode(w, r, node.ID, secret)
|
||||
return
|
||||
}
|
||||
|
||||
if typeVal == "0" {
|
||||
if _, ok := auth.ValidateToken(secret, s.jwtSecret); !ok {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
s.handleAdmin(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := s.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cw := &connWrap{conn: conn}
|
||||
|
||||
s.mu.Lock()
|
||||
s.admins[cw] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.admins, cw)
|
||||
s.mu.Unlock()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64, secret string) {
|
||||
conn, err := s.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cw := &connWrap{conn: conn}
|
||||
|
||||
version := r.URL.Query().Get("version")
|
||||
httpVal := parseIntDefault(r.URL.Query().Get("http"), 0)
|
||||
tlsVal := parseIntDefault(r.URL.Query().Get("tls"), 0)
|
||||
socksVal := parseIntDefault(r.URL.Query().Get("socks"), 0)
|
||||
|
||||
s.mu.Lock()
|
||||
if old, ok := s.nodes[nodeID]; ok {
|
||||
_ = old.conn.conn.Close()
|
||||
delete(s.byConn, old.conn.conn)
|
||||
}
|
||||
ns := &nodeSession{nodeID: nodeID, secret: secret, conn: cw}
|
||||
s.nodes[nodeID] = ns
|
||||
s.byConn[conn] = ns
|
||||
s.mu.Unlock()
|
||||
|
||||
_ = s.repo.UpdateNodeOnline(nodeID, 1, version, httpVal, tlsVal, socksVal)
|
||||
s.broadcastStatus(nodeID, 1)
|
||||
|
||||
defer func() {
|
||||
needOfflineBroadcast := false
|
||||
s.mu.Lock()
|
||||
current, ok := s.nodes[nodeID]
|
||||
if ok && current.conn.conn == conn {
|
||||
delete(s.nodes, nodeID)
|
||||
needOfflineBroadcast = true
|
||||
}
|
||||
delete(s.byConn, conn)
|
||||
s.mu.Unlock()
|
||||
if needOfflineBroadcast {
|
||||
s.failPendingForNode(nodeID, "节点连接已断开")
|
||||
_ = s.repo.UpdateNodeStatus(nodeID, 0)
|
||||
s.broadcastStatus(nodeID, 0)
|
||||
}
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
_, payload, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
msg := decryptIfNeeded(payload, secret)
|
||||
s.tryResolvePending(nodeID, msg)
|
||||
s.broadcastInfo(nodeID, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) SendCommand(nodeID int64, cmdType string, data interface{}, timeout time.Duration) (CommandResult, error) {
|
||||
if s == nil {
|
||||
return CommandResult{}, errors.New("server not initialized")
|
||||
}
|
||||
if strings.TrimSpace(cmdType) == "" {
|
||||
return CommandResult{}, errors.New("command type is empty")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
ns, ok := s.nodes[nodeID]
|
||||
s.mu.RUnlock()
|
||||
if !ok || ns == nil || ns.conn == nil || ns.conn.conn == nil {
|
||||
return CommandResult{}, errors.New("节点不在线")
|
||||
}
|
||||
|
||||
requestID := fmt.Sprintf("%d_%d", nodeID, time.Now().UnixNano())
|
||||
ch := make(chan CommandResult, 1)
|
||||
|
||||
s.mu.Lock()
|
||||
s.pending[requestID] = pendingRequest{nodeID: nodeID, ch: ch}
|
||||
s.mu.Unlock()
|
||||
|
||||
cleanup := func() {
|
||||
s.mu.Lock()
|
||||
if p, exists := s.pending[requestID]; exists {
|
||||
delete(s.pending, requestID)
|
||||
close(p.ch)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
cmdPayload := map[string]interface{}{
|
||||
"type": cmdType,
|
||||
"data": data,
|
||||
"requestId": requestID,
|
||||
}
|
||||
rawCmd, err := json.Marshal(cmdPayload)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return CommandResult{}, err
|
||||
}
|
||||
|
||||
messageData := rawCmd
|
||||
if strings.TrimSpace(ns.secret) != "" {
|
||||
crypto, err := security.NewAESCrypto(ns.secret)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return CommandResult{}, err
|
||||
}
|
||||
encrypted, err := crypto.Encrypt(rawCmd)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return CommandResult{}, err
|
||||
}
|
||||
wrapper := map[string]interface{}{
|
||||
"encrypted": true,
|
||||
"data": encrypted,
|
||||
"timestamp": time.Now().UnixMilli(),
|
||||
}
|
||||
messageData, err = json.Marshal(wrapper)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return CommandResult{}, err
|
||||
}
|
||||
}
|
||||
|
||||
ns.conn.mu.Lock()
|
||||
err = ns.conn.conn.WriteMessage(websocket.TextMessage, messageData)
|
||||
ns.conn.mu.Unlock()
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return CommandResult{}, err
|
||||
}
|
||||
|
||||
select {
|
||||
case result, ok := <-ch:
|
||||
if !ok {
|
||||
return CommandResult{}, errors.New("命令通道已关闭")
|
||||
}
|
||||
if !result.Success {
|
||||
if strings.TrimSpace(result.Message) == "" {
|
||||
result.Message = "命令执行失败"
|
||||
}
|
||||
return result, errors.New(result.Message)
|
||||
}
|
||||
return result, nil
|
||||
case <-time.After(timeout):
|
||||
cleanup()
|
||||
return CommandResult{}, errors.New("等待节点响应超时")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) tryResolvePending(nodeID int64, message string) {
|
||||
if s == nil || strings.TrimSpace(message) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var resp commandResponse
|
||||
if err := json.Unmarshal([]byte(message), &resp); err != nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(resp.RequestID) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
p, ok := s.pending[resp.RequestID]
|
||||
if ok {
|
||||
delete(s.pending, resp.RequestID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if p.nodeID != nodeID {
|
||||
select {
|
||||
case p.ch <- CommandResult{Type: resp.Type, Success: false, Message: "节点响应与请求不匹配"}:
|
||||
default:
|
||||
}
|
||||
close(p.ch)
|
||||
return
|
||||
}
|
||||
|
||||
result := CommandResult{
|
||||
Type: resp.Type,
|
||||
Success: resp.Success,
|
||||
Message: resp.Message,
|
||||
}
|
||||
if len(resp.Data) > 0 {
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(resp.Data, &data); err == nil {
|
||||
result.Data = data
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case p.ch <- result:
|
||||
default:
|
||||
}
|
||||
close(p.ch)
|
||||
}
|
||||
|
||||
func (s *Server) failPendingForNode(nodeID int64, message string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
type pair struct {
|
||||
id string
|
||||
pr pendingRequest
|
||||
}
|
||||
items := make([]pair, 0)
|
||||
|
||||
s.mu.Lock()
|
||||
for id, pr := range s.pending {
|
||||
if pr.nodeID != nodeID {
|
||||
continue
|
||||
}
|
||||
items = append(items, pair{id: id, pr: pr})
|
||||
delete(s.pending, id)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
for _, item := range items {
|
||||
select {
|
||||
case item.pr.ch <- CommandResult{Success: false, Message: message}:
|
||||
default:
|
||||
}
|
||||
close(item.pr.ch)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) broadcastStatus(nodeID int64, status int) {
|
||||
payload := map[string]interface{}{
|
||||
"id": strconv.FormatInt(nodeID, 10),
|
||||
"type": "status",
|
||||
"data": status,
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
s.broadcastToAdmins(string(raw))
|
||||
}
|
||||
|
||||
func (s *Server) broadcastInfo(nodeID int64, data string) {
|
||||
payload := broadcastMessage{ID: nodeID, Type: "info", 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))
|
||||
for c := range s.admins {
|
||||
admins = append(admins, c)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
for _, c := range admins {
|
||||
c.mu.Lock()
|
||||
err := c.conn.WriteMessage(websocket.TextMessage, []byte(message))
|
||||
c.mu.Unlock()
|
||||
if err != nil {
|
||||
log.Printf("websocket broadcast failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func decryptIfNeeded(payload []byte, secret string) string {
|
||||
text := string(payload)
|
||||
var wrap encryptedMessage
|
||||
if err := json.Unmarshal(payload, &wrap); err != nil || !wrap.Encrypted || strings.TrimSpace(wrap.Data) == "" {
|
||||
return text
|
||||
}
|
||||
|
||||
crypto, err := security.NewAESCrypto(secret)
|
||||
if err != nil {
|
||||
return text
|
||||
}
|
||||
plain, err := crypto.Decrypt(wrap.Data)
|
||||
if err != nil {
|
||||
return text
|
||||
}
|
||||
return string(plain)
|
||||
}
|
||||
|
||||
func parseIntDefault(v string, fallback int) int {
|
||||
x, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return x
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/middleware"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func TestJWTMiddlewareContracts(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OK("pass"))
|
||||
})
|
||||
|
||||
wrapped := middleware.JWT(middleware.AuthOptions{JWTSecret: secret})(next)
|
||||
|
||||
t.Run("login path is excluded", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", nil)
|
||||
res := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
})
|
||||
|
||||
t.Run("missing token returns 401 contract message", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
|
||||
res := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(res, req)
|
||||
assertCodeMsg(t, res, 401, "未登录或token已过期")
|
||||
})
|
||||
|
||||
t.Run("invalid token returns 401 contract message", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
|
||||
req.Header.Set("Authorization", "invalid.token.value")
|
||||
res := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(res, req)
|
||||
assertCodeMsg(t, res, 401, "无效的token或token已过期")
|
||||
})
|
||||
|
||||
t.Run("valid token reaches next", func(t *testing.T) {
|
||||
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/tunnel/list", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
})
|
||||
|
||||
t.Run("non-admin blocked on admin path", func(t *testing.T) {
|
||||
token, err := auth.GenerateToken(2, "normal_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/update", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(res, req)
|
||||
assertCodeMsg(t, res, 403, "权限不足,仅管理员可操作")
|
||||
})
|
||||
}
|
||||
|
||||
func assertCode(t *testing.T, rec *httptest.ResponseRecorder, expected int) {
|
||||
t.Helper()
|
||||
var out response.R
|
||||
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != expected {
|
||||
t.Fatalf("expected code %d, got %d", expected, out.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCodeMsg(t *testing.T, rec *httptest.ResponseRecorder, expectedCode int, expectedMsg string) {
|
||||
t.Helper()
|
||||
var out response.R
|
||||
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != expectedCode || out.Msg != expectedMsg {
|
||||
t.Fatalf("expected (%d,%q), got (%d,%q)", expectedCode, expectedMsg, out.Code, out.Msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"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)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'normal_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now); err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
tunnelRes, err := repo.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 {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID, err := tunnelRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get tunnel id: %v", err)
|
||||
}
|
||||
|
||||
insertNode := func(name, ip string) int64 {
|
||||
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, name+"-secret", ip, ip, "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
|
||||
if 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
|
||||
}
|
||||
|
||||
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(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 30001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryNodeID); err != nil {
|
||||
t.Fatalf("insert entry chain: %v", err)
|
||||
}
|
||||
if _, err := repo.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 {
|
||||
t.Fatalf("insert middle chain: %v", err)
|
||||
}
|
||||
if _, err := repo.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 {
|
||||
t.Fatalf("insert exit chain: %v", err)
|
||||
}
|
||||
|
||||
forwardRes, 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, ?)
|
||||
`, 2, "normal_user", "chain-forward", tunnelID, "8.8.8.8:53", "fifo", now, now, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID, err := forwardRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get forward id: %v", err)
|
||||
}
|
||||
|
||||
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate user token: %v", err)
|
||||
}
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
t.Run("forward diagnose includes entry chain exit paths", func(t *testing.T) {
|
||||
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
|
||||
hasExitToTarget := false
|
||||
for _, raw := range results {
|
||||
item, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected result object, got %T", raw)
|
||||
}
|
||||
if strings.TrimSpace(valueAsString(item["message"])) == "" {
|
||||
t.Fatalf("expected non-empty message field")
|
||||
}
|
||||
from := valueAsInt(item["fromChainType"])
|
||||
to := valueAsInt(item["toChainType"])
|
||||
if from == 1 && to == 2 {
|
||||
hasEntryToChain = true
|
||||
}
|
||||
if from == 2 && to == 3 {
|
||||
hasChainToExit = true
|
||||
}
|
||||
if from == 3 {
|
||||
hasExitToTarget = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasEntryToChain || !hasChainToExit || !hasExitToTarget {
|
||||
t.Fatalf("expected entry->chain, chain->exit, exit->target coverage; got entry=%v chain=%v exit=%v", hasEntryToChain, hasChainToExit, hasExitToTarget)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tunnel diagnose includes entry chain exit groups", 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"])
|
||||
}
|
||||
|
||||
hasEntry := false
|
||||
hasChain := false
|
||||
hasExit := false
|
||||
for _, raw := range results {
|
||||
item, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected result object, got %T", raw)
|
||||
}
|
||||
if strings.TrimSpace(valueAsString(item["message"])) == "" {
|
||||
t.Fatalf("expected non-empty message field")
|
||||
}
|
||||
switch valueAsInt(item["fromChainType"]) {
|
||||
case 1:
|
||||
hasEntry = true
|
||||
case 2:
|
||||
hasChain = true
|
||||
case 3:
|
||||
hasExit = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasEntry || !hasChain || !hasExit {
|
||||
t.Fatalf("expected entry/chain/exit groups, got entry=%v chain=%v exit=%v", hasEntry, hasChain, hasExit)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 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 != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = repo.Close()
|
||||
})
|
||||
|
||||
h := handler.New(repo, jwtSecret)
|
||||
return httpserver.NewRouter(h, jwtSecret), repo
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/http/handler"
|
||||
)
|
||||
|
||||
func TestFlowEndpointsStringResponses(t *testing.T) {
|
||||
h := handler.New(nil, "secret")
|
||||
mux := http.NewServeMux()
|
||||
h.Register(mux)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{name: "flow test", method: http.MethodGet, path: "/flow/test", expected: "test"},
|
||||
{name: "flow config", method: http.MethodPost, path: "/flow/config?secret=abc", expected: "ok"},
|
||||
{name: "flow upload", method: http.MethodPost, path: "/flow/upload?secret=abc", expected: "ok"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
res := httptest.NewRecorder()
|
||||
mux.ServeHTTP(res, req)
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
|
||||
if string(body) != tc.expected {
|
||||
t.Fatalf("expected %q, got %q", tc.expected, string(body))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func TestForwardOwnershipAndScopeContracts(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'normal_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now); err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
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", 99999, now, now, 1, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get tunnel id: %v", err)
|
||||
}
|
||||
|
||||
nodeRes, 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-node", "entry-secret", "10.0.0.10", "10.0.0.10", "", "20000-20010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
entryNodeID, err := nodeRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get node id: %v", err)
|
||||
}
|
||||
|
||||
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); err != nil {
|
||||
t.Fatalf("insert chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
resAdmin, 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, ?)
|
||||
`, 1, "admin_user", "admin-forward", tunnelID, "1.1.1.1:443", "fifo", now, now, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("insert admin forward: %v", err)
|
||||
}
|
||||
adminForwardID, err := resAdmin.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get admin forward id: %v", err)
|
||||
}
|
||||
|
||||
resUser, 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, ?)
|
||||
`, 2, "normal_user", "user-forward", tunnelID, "8.8.8.8:53", "fifo", now, now, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("insert user forward: %v", err)
|
||||
}
|
||||
userForwardID, err := resUser.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get user forward id: %v", err)
|
||||
}
|
||||
|
||||
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate user token: %v", err)
|
||||
}
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
t.Run("non-owner cannot delete another user's forward", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/delete", bytes.NewBufferString(`{"id":`+jsonNumber(adminForwardID)+`}`))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
assertCodeMsg(t, res, -1, "转发不存在")
|
||||
})
|
||||
|
||||
t.Run("non-admin forward list is scoped to owner", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/list", bytes.NewBufferString(`{}`))
|
||||
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)
|
||||
}
|
||||
arr, ok := out.Data.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected array data, got %T", out.Data)
|
||||
}
|
||||
if len(arr) != 1 {
|
||||
t.Fatalf("expected 1 forward, got %d", len(arr))
|
||||
}
|
||||
item, ok := arr[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected object item, got %T", arr[0])
|
||||
}
|
||||
if got := int64(item["id"].(float64)); got != userForwardID {
|
||||
t.Fatalf("expected forward id %d, got %d", userForwardID, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("forward diagnose returns structured payload", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/diagnose", bytes.NewBufferString(`{"forwardId":`+jsonNumber(userForwardID)+`}`))
|
||||
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"])
|
||||
}
|
||||
first, ok := results[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected result object, got %T", results[0])
|
||||
}
|
||||
if _, ok := first["message"]; !ok {
|
||||
t.Fatalf("expected message field in diagnosis result")
|
||||
}
|
||||
if got := int(first["fromChainType"].(float64)); got != 1 {
|
||||
t.Fatalf("expected fromChainType=1, got %d", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tunnel diagnose returns structured payload", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/diagnose", bytes.NewBufferString(`{"tunnelId":`+jsonNumber(tunnelID)+`}`))
|
||||
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"])
|
||||
}
|
||||
first, ok := results[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected result object, got %T", results[0])
|
||||
}
|
||||
if _, ok := first["message"]; !ok {
|
||||
t.Fatalf("expected message field in tunnel diagnosis result")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestForwardSwitchTunnelRollbackOnSyncFailure(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, 'switch_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now); err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
insertTunnel := func(name string, inx int) int64 {
|
||||
res, 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, 1, nil, inx)
|
||||
if 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
|
||||
}
|
||||
|
||||
insertNode := func(name, ip, portRange string, inx int) int64 {
|
||||
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, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx)
|
||||
if 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
|
||||
}
|
||||
|
||||
tunnelA := insertTunnel("switch-tunnel-a", 0)
|
||||
tunnelB := insertTunnel("switch-tunnel-b", 1)
|
||||
nodeA := insertNode("switch-node-a", "10.10.0.1", "21000-21010", 0)
|
||||
nodeB := insertNode("switch-node-b", "10.10.0.2", "22000-22010", 1)
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 21001, 'round', 1, 'tls')
|
||||
`, tunnelA, nodeA); err != nil {
|
||||
t.Fatalf("insert chain_tunnel tunnelA: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 22001, 'round', 1, 'tls')
|
||||
`, tunnelB, nodeB); err != nil {
|
||||
t.Fatalf("insert chain_tunnel tunnelB: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(10, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, tunnelA); err != nil {
|
||||
t.Fatalf("insert user_tunnel 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(11, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, tunnelB); err != nil {
|
||||
t.Fatalf("insert user_tunnel B: %v", err)
|
||||
}
|
||||
|
||||
forwardRes, 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, 'switch_user', 'switch-forward', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, tunnelA, now, now)
|
||||
if err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID, err := forwardRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get forward id: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeA, 21001); err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
payload := `{"id":` + jsonNumber(forwardID) + `,"tunnelId":` + jsonNumber(tunnelB) + `}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/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)
|
||||
}
|
||||
if out.Code == 0 {
|
||||
t.Fatalf("expected update failure when node is offline")
|
||||
}
|
||||
|
||||
var tunnelAfter int64
|
||||
if err := repo.DB().QueryRow(`SELECT tunnel_id FROM forward WHERE id = ?`, forwardID).Scan(&tunnelAfter); err != nil {
|
||||
t.Fatalf("query forward tunnel_id: %v", err)
|
||||
}
|
||||
if tunnelAfter != tunnelA {
|
||||
t.Fatalf("expected tunnel rollback to %d, got %d", tunnelA, tunnelAfter)
|
||||
}
|
||||
|
||||
var nodeAfter int64
|
||||
var portAfter int
|
||||
if err := repo.DB().QueryRow(`SELECT node_id, port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID).Scan(&nodeAfter, &portAfter); err != nil {
|
||||
t.Fatalf("query forward_port: %v", err)
|
||||
}
|
||||
if nodeAfter != nodeA || portAfter != 21001 {
|
||||
t.Fatalf("expected forward_port rollback to node=%d port=21001, got node=%d port=%d", nodeA, nodeAfter, portAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardBatchChangeTunnelRollbackOnSyncFailure(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_switch_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now); err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
tunnelResA, err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES('batch-switch-tunnel-a', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, now, now)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tunnel A: %v", err)
|
||||
}
|
||||
tunnelA, _ := tunnelResA.LastInsertId()
|
||||
|
||||
tunnelResB, err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES('batch-switch-tunnel-b', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 1)
|
||||
`, now, now)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tunnel B: %v", err)
|
||||
}
|
||||
tunnelB, _ := tunnelResB.LastInsertId()
|
||||
|
||||
nodeResA, 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('batch-switch-node-a', 'batch-switch-node-a-secret', '10.11.0.1', '10.11.0.1', '', '23000-23010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0)
|
||||
`, now, now)
|
||||
if err != nil {
|
||||
t.Fatalf("insert node A: %v", err)
|
||||
}
|
||||
nodeA, _ := nodeResA.LastInsertId()
|
||||
|
||||
nodeResB, 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('batch-switch-node-b', 'batch-switch-node-b-secret', '10.11.0.2', '10.11.0.2', '', '24000-24010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 1)
|
||||
`, now, now)
|
||||
if err != nil {
|
||||
t.Fatalf("insert node B: %v", err)
|
||||
}
|
||||
nodeB, _ := nodeResB.LastInsertId()
|
||||
|
||||
if _, err := repo.DB().Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 1, ?, 23001, 'round', 1, 'tls')`, tunnelA, nodeA); err != nil {
|
||||
t.Fatalf("insert chain_tunnel A: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 1, ?, 24001, 'round', 1, 'tls')`, tunnelB, nodeB); err != nil {
|
||||
t.Fatalf("insert chain_tunnel 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(20, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)`, tunnelA); err != nil {
|
||||
t.Fatalf("insert user_tunnel 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(21, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)`, tunnelB); err != nil {
|
||||
t.Fatalf("insert user_tunnel B: %v", err)
|
||||
}
|
||||
|
||||
forwardRes, 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_switch_user', 'batch-switch-forward', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, tunnelA, now, now)
|
||||
if err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID, _ := forwardRes.LastInsertId()
|
||||
|
||||
if _, err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeA, 23001); err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
payload := `{"forwardIds":[` + jsonNumber(forwardID) + `],"targetTunnelId":` + jsonNumber(tunnelB) + `}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/batch-change-tunnel", 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)
|
||||
}
|
||||
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"])
|
||||
}
|
||||
|
||||
var tunnelAfter int64
|
||||
if err := repo.DB().QueryRow(`SELECT tunnel_id FROM forward WHERE id = ?`, forwardID).Scan(&tunnelAfter); err != nil {
|
||||
t.Fatalf("query forward tunnel_id: %v", err)
|
||||
}
|
||||
if tunnelAfter != tunnelA {
|
||||
t.Fatalf("expected tunnel rollback to %d, got %d", tunnelA, tunnelAfter)
|
||||
}
|
||||
|
||||
var nodeAfter int64
|
||||
var portAfter int
|
||||
if err := repo.DB().QueryRow(`SELECT node_id, port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID).Scan(&nodeAfter, &portAfter); err != nil {
|
||||
t.Fatalf("query forward_port: %v", err)
|
||||
}
|
||||
if nodeAfter != nodeA || portAfter != 23001 {
|
||||
t.Fatalf("expected forward_port rollback to node=%d port=23001, got node=%d port=%d", nodeA, nodeAfter, portAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserTunnelReassignmentKeepsStableID(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(100, 'stable_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now); err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
tunnelRes, err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES('stable-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, now, now)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID, _ := tunnelRes.LastInsertId()
|
||||
|
||||
// 1. Assign permission (creates new user_tunnel)
|
||||
// userTunnelBatchAssign expects structure: {userId: 123, tunnels: [{tunnelId: 456, ...}]}
|
||||
assignPayload := `{"userId":100,"tunnels":[{"tunnelId":` + jsonNumber(tunnelID) + `}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/batch-assign", bytes.NewBufferString(assignPayload))
|
||||
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 msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
var initialID int64
|
||||
if err := repo.DB().QueryRow(`SELECT id FROM user_tunnel WHERE user_id = 100 AND tunnel_id = ?`, tunnelID).Scan(&initialID); err != nil {
|
||||
t.Fatalf("query initial user_tunnel id: %v", err)
|
||||
}
|
||||
|
||||
// 2. Re-assign permission (should UPDATE, not INSERT)
|
||||
reassignPayload := `{"userId":100,"tunnels":[{"tunnelId":` + jsonNumber(tunnelID) + `}]}`
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/batch-assign", bytes.NewBufferString(reassignPayload))
|
||||
req2.Header.Set("Authorization", adminToken)
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
res2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(res2, req2)
|
||||
|
||||
var out2 response.R
|
||||
if err := json.NewDecoder(res2.Body).Decode(&out2); err != nil {
|
||||
t.Fatalf("decode response 2: %v", err)
|
||||
}
|
||||
if out2.Code != 0 {
|
||||
t.Fatalf("expected code 0, got %d msg=%q", out2.Code, out2.Msg)
|
||||
}
|
||||
|
||||
// 3. Verify stable ID and no duplicates
|
||||
var count int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM user_tunnel WHERE user_id = 100 AND tunnel_id = ?`, tunnelID).Scan(&count); err != nil {
|
||||
t.Fatalf("query count: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected exactly 1 user_tunnel record, got %d", count)
|
||||
}
|
||||
|
||||
var currentID int64
|
||||
if err := repo.DB().QueryRow(`SELECT id FROM user_tunnel WHERE user_id = 100 AND tunnel_id = ?`, tunnelID).Scan(¤tID); err != nil {
|
||||
t.Fatalf("query current user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if currentID != initialID {
|
||||
t.Fatalf("user_tunnel ID changed from %d to %d (unstable ID!)", initialID, currentID)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonNumber(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"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 TestCaptchaVerifyLoginContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
|
||||
_, err := repo.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 {
|
||||
t.Fatalf("enable captcha: %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("captcha token is one-time and consumed by login", func(t *testing.T) {
|
||||
verifyReq := httptest.NewRequest(http.MethodPost, "/api/v1/captcha/verify", bytes.NewBufferString(`{"id":"captcha-token-1","data":"ok"}`))
|
||||
verifyReq.Header.Set("Content-Type", "application/json")
|
||||
verifyResp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(verifyResp, verifyReq)
|
||||
|
||||
var verifyOut struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
ValidToken string `json:"validToken"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(verifyResp.Body).Decode(&verifyOut); err != nil {
|
||||
t.Fatalf("decode captcha verify response: %v", err)
|
||||
}
|
||||
if !verifyOut.Success || verifyOut.Data.ValidToken != "captcha-token-1" {
|
||||
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"}`)
|
||||
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"}`)
|
||||
replayReq := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", replayBody)
|
||||
replayReq.Header.Set("Content-Type", "application/json")
|
||||
replayResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(replayResp, replayReq)
|
||||
assertCodeMsg(t, replayResp, -1, "验证码校验失败")
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenAPISubStoreContracts(t *testing.T) {
|
||||
router, repo := setupContractRouter(t, "contract-jwt-secret")
|
||||
|
||||
const tunnelFlowGB = int64(500)
|
||||
const tunnelInFlow = int64(123)
|
||||
const tunnelOutFlow = int64(456)
|
||||
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 {
|
||||
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 {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
t.Run("default user subscription payload", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
|
||||
expected := "upload=0; download=0; total=107373108658176; expire=2727251700"
|
||||
if string(body) != expected {
|
||||
t.Fatalf("expected body %q, got %q", expected, string(body))
|
||||
}
|
||||
if got := resp.Header().Get("subscription-userinfo"); got != expected {
|
||||
t.Fatalf("expected subscription-userinfo %q, got %q", expected, got)
|
||||
}
|
||||
if !strings.Contains(resp.Header().Get("Content-Type"), "text/plain") {
|
||||
t.Fatalf("expected text/plain content type, got %q", resp.Header().Get("Content-Type"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tunnel scoped subscription payload", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user&tunnel="+strconv.FormatInt(tunnelID, 10), nil)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
|
||||
expected := "upload=123; download=456; total=536870912000; expire=2727251700"
|
||||
if string(body) != expected {
|
||||
t.Fatalf("expected body %q, got %q", expected, string(body))
|
||||
}
|
||||
if got := resp.Header().Get("subscription-userinfo"); got != expected {
|
||||
t.Fatalf("expected subscription-userinfo %q, got %q", expected, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid credentials returns contract error", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/open_api/sub_store?user=admin_user&pwd=wrong", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertCodeMsg(t, resp, -1, "鉴权失败")
|
||||
})
|
||||
|
||||
t.Run("missing tunnel returns contract error", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user&tunnel=999999", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertCodeMsg(t, resp, -1, "隧道不存在")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSpeedLimitTunnelsRouteAlias(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)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertCodeMsg(t, resp, 401, "未登录或token已过期")
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/tunnels", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
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)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *sqlite.Repository) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "contract.db")
|
||||
repo, err := sqlite.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = repo.Close()
|
||||
})
|
||||
|
||||
h := handler.New(repo, jwtSecret)
|
||||
return httpserver.NewRouter(h, jwtSecret), repo
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func TestTunnelCreateRuntimeRollbackContract(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 {
|
||||
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, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
|
||||
if 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
|
||||
}
|
||||
|
||||
entryID := insertNode("create-entry", "10.20.0.1", "30000-30010")
|
||||
chainID := insertNode("create-chain", "10.20.0.2", "31000-31010")
|
||||
exitID := insertNode("create-exit", "10.20.0.3", "32000-32010")
|
||||
|
||||
payload := `{"name":"runtime-rollback-tunnel","type":2,"flow":99999,"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/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)
|
||||
}
|
||||
if out.Code == 0 {
|
||||
t.Fatalf("expected create failure when nodes are offline")
|
||||
}
|
||||
if !strings.Contains(out.Msg, "节点") {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if chainCount != 0 {
|
||||
t.Fatalf("expected chain_tunnel rollback, found %d records", chainCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelUpdateAssignsChainPortsContract(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 {
|
||||
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, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
|
||||
if 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
|
||||
}
|
||||
|
||||
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(`
|
||||
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 {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID, err := tunnelRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("get tunnel id: %v", err)
|
||||
}
|
||||
|
||||
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))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if outPort <= 0 {
|
||||
t.Fatalf("expected out node port to be assigned, got %d", outPort)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonInt(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func TestUserTunnelVisibleListContracts(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupDiagnosisContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'normal_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now); err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
insertTunnel := func(name string, status int, inx int64) int64 {
|
||||
res, 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 {
|
||||
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
|
||||
}
|
||||
|
||||
enabledA := insertTunnel("enabled-A", 1, 1)
|
||||
enabledB := insertTunnel("enabled-B", 1, 2)
|
||||
disabledC := insertTunnel("disabled-C", 0, 3)
|
||||
|
||||
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 {
|
||||
t.Fatalf("insert user_tunnel enabledA: %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, ?, ?, 0, 0, ?, ?, ?)
|
||||
`, 2, enabledB, 100, 1000, 1, 2727251700000, 1); err != nil {
|
||||
t.Fatalf("insert user_tunnel enabledB: %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, ?, ?, 0, 0, ?, ?, ?)
|
||||
`, 2, disabledC, 100, 1000, 1, 2727251700000, 1); err != nil {
|
||||
t.Fatalf("insert user_tunnel disabledC: %v", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
t.Run("admin sees all enabled tunnels without user_tunnel rows", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/tunnel", 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 (%s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
ids := collectTunnelIDs(t, out.Data)
|
||||
if !ids[enabledA] || !ids[enabledB] {
|
||||
t.Fatalf("expected enabled tunnels for admin, got %v", ids)
|
||||
}
|
||||
if ids[disabledC] {
|
||||
t.Fatalf("did not expect disabled tunnel for admin")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("normal user sees enabled assigned tunnels regardless of user_tunnel status", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/tunnel", nil)
|
||||
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)
|
||||
}
|
||||
|
||||
ids := collectTunnelIDs(t, out.Data)
|
||||
if !ids[enabledA] || !ids[enabledB] {
|
||||
t.Fatalf("expected enabled assigned tunnels for user, got %v", ids)
|
||||
}
|
||||
if ids[disabledC] {
|
||||
t.Fatalf("did not expect disabled tunnel for user")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func collectTunnelIDs(t *testing.T, data interface{}) map[int64]bool {
|
||||
t.Helper()
|
||||
arr, ok := data.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected array data, got %T", data)
|
||||
}
|
||||
ids := make(map[int64]bool, len(arr))
|
||||
for _, item := range arr {
|
||||
obj, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected object item, got %T", item)
|
||||
}
|
||||
id := int64(obj["id"].(float64))
|
||||
ids[id] = true
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
# GO-GOST SERVICE KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Mon Feb 02 2026
|
||||
|
||||
## OVERVIEW
|
||||
Forwarding agent built on GOST v3 with a local fork of `github.com/go-gost/x` under `x/`.
|
||||
**Stack:** Go 1.23, github.com/go-gost/core v0.3.1, local `go-gost/x` module.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
go-gost/
|
||||
├── main.go # Entry; reads panel config.json; starts svc.Run(program)
|
||||
├── config.go # Panel config.json loader (addr/secret + ports)
|
||||
├── program.go # GOST runtime: parse config, run/reload services
|
||||
├── x/ # Local fork of github.com/go-gost/x (has its own go.mod)
|
||||
└── go.mod # replace github.com/go-gost/x => ./x
|
||||
```
|
||||
|
||||
## 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 |
|
||||
|
||||
## 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.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost
|
||||
go run .
|
||||
go test ./...
|
||||
go build .
|
||||
```
|
||||
+1
-1
@@ -119,7 +119,7 @@ func main() {
|
||||
log := xlogger.NewLogger()
|
||||
logger.SetDefault(log)
|
||||
|
||||
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, "1.2.3")
|
||||
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, version)
|
||||
defer wsReporter.Stop()
|
||||
service.SetHTTPReportURL(config.Addr, config.Secret)
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
package main
|
||||
|
||||
var (
|
||||
version = "3.1.0"
|
||||
version = "dev"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 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.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
go-gost/x/
|
||||
├── api/ # Gin management API + embedded swagger docs
|
||||
├── config/ # Config model + parsing/load/reload
|
||||
├── connector/ # Outbound connect implementations
|
||||
├── dialer/ # Outbound dialers (tcp/tls/ws/quic/...)
|
||||
├── handler/ # Protocol handlers (socks/http/tunnel/relay/...)
|
||||
├── listener/ # Inbound listeners (tcp/udp/tun/tap/redirect/...)
|
||||
├── limiter/ # Traffic/rate/conn limiters
|
||||
├── registry/ # Registries for services/handlers/listeners/etc
|
||||
├── service/ # Service wrappers + reporting hooks
|
||||
├── socket/ # WebSocket reporter / panel integration
|
||||
└── 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 |
|
||||
|
||||
## 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/`.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- Do not edit generated files in `go-gost/x/internal/util/grpc/proto/` (`*.pb.go`, `*_grpc.pb.go`).
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost/x
|
||||
go test ./...
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
# GO-GOST/X API KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Gin-based management API for reading/writing config and controlling services at runtime.
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Route registration | `go-gost/x/api/api.go` | `Register(*gin.Engine, *Options)` |
|
||||
| Auth gating | `go-gost/x/api/middleware.go` | Drops non-BasicAuth requests; optional auther check |
|
||||
| Service CRUD + pause/resume | `go-gost/x/api/config_service.go` | Uses registry + `config.OnUpdate(...)` |
|
||||
| Swagger spec | `go-gost/x/api/swagger.yaml` | Served at `/docs` via embedded FS |
|
||||
|
||||
## CONVENTIONS
|
||||
- CORS is `AllowAllOrigins: true` (see `go-gost/x/api/api.go`).
|
||||
- Requests without a valid Basic `Authorization` header are silently dropped (connection hijack + close) by `GlobalInterceptor()`.
|
||||
- Many operations mutate the in-memory config via `config.OnUpdate(...)` after starting/stopping services.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost/x
|
||||
go test ./...
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
# GO-GOST/X CONFIG KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Config model + parsing/loading pipeline for the `go-gost/x` runtime. This is the bridge between `gost.json`/`gost.yaml` and in-memory registries/services.
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Config structs + global state | `go-gost/x/config/config.go` | `Global()`, `Set()`, `OnUpdate()` |
|
||||
| Default config file search | `go-gost/x/config/config.go` | Viper `SetConfigName("gost")` + paths `/etc/gost/`, `$HOME/.gost/`, `.` |
|
||||
| Registry wiring | `go-gost/x/config/loader/loader.go` | Parses config sections and registers into registries |
|
||||
| Metadata keys | `go-gost/x/config/parsing/parse.go` | `MDKey*` constants used by parsers |
|
||||
| Config parser behavior | `go-gost/x/config/parsing/parser/parser.go` | CLI/env overrides; loads `gost.*` when empty |
|
||||
|
||||
## CONVENTIONS
|
||||
- Default config file is named `gost` (e.g. `gost.json`) and is discovered via viper search paths.
|
||||
- Runtime config mutations should go through `config.OnUpdate(...)` so changes are applied under the global mutex.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost/x
|
||||
go test ./...
|
||||
```
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -30,6 +31,7 @@ import (
|
||||
logger_parser "github.com/go-gost/x/config/parsing/logger"
|
||||
selector_parser "github.com/go-gost/x/config/parsing/selector"
|
||||
tls_util "github.com/go-gost/x/internal/util/tls"
|
||||
xtraffic "github.com/go-gost/x/limiter/traffic"
|
||||
cache_limiter "github.com/go-gost/x/limiter/traffic/cache"
|
||||
"github.com/go-gost/x/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
@@ -181,6 +183,32 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
)
|
||||
}
|
||||
|
||||
var trafficLimiter listener.Option
|
||||
if cfg.Limiter != "" {
|
||||
lim := registry.TrafficLimiterRegistry().Get(cfg.Limiter)
|
||||
if lim == nil {
|
||||
// Try to parse as simple number (bandwidth in bytes/sec)
|
||||
if val, err := strconv.Atoi(cfg.Limiter); err == nil && val > 0 {
|
||||
lim = xtraffic.NewTrafficLimiter(
|
||||
xtraffic.LimitsOption(fmt.Sprintf("%s %dB %dB", xtraffic.ServiceLimitKey, val, val)),
|
||||
)
|
||||
}
|
||||
if lim == nil {
|
||||
lim = xtraffic.NewTrafficLimiter(
|
||||
xtraffic.LimitsOption(fmt.Sprintf("%s %s %s", xtraffic.ServiceLimitKey, cfg.Limiter, cfg.Limiter)),
|
||||
)
|
||||
}
|
||||
}
|
||||
trafficLimiter = listener.TrafficLimiterOption(
|
||||
cache_limiter.NewCachedTrafficLimiter(
|
||||
lim,
|
||||
cache_limiter.RefreshIntervalOption(limiterRefreshInterval),
|
||||
cache_limiter.CleanupIntervalOption(limiterCleanupInterval),
|
||||
cache_limiter.ScopeOption(limiterScope),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
listenOpts := []listener.Option{
|
||||
listener.AddrOption(cfg.Addr),
|
||||
listener.RouterOption(xchain.NewRouter(routerOpts...)),
|
||||
@@ -188,14 +216,6 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
listener.AuthOption(auth_parser.Info(cfg.Listener.Auth)),
|
||||
listener.TLSConfigOption(tlsConfig),
|
||||
listener.AdmissionOption(xadmission.AdmissionGroup(admissions...)),
|
||||
listener.TrafficLimiterOption(
|
||||
cache_limiter.NewCachedTrafficLimiter(
|
||||
registry.TrafficLimiterRegistry().Get(cfg.Limiter),
|
||||
cache_limiter.RefreshIntervalOption(limiterRefreshInterval),
|
||||
cache_limiter.CleanupIntervalOption(limiterCleanupInterval),
|
||||
cache_limiter.ScopeOption(limiterScope),
|
||||
),
|
||||
),
|
||||
listener.ConnLimiterOption(registry.ConnLimiterRegistry().Get(cfg.CLimiter)),
|
||||
listener.ServiceOption(cfg.Name),
|
||||
listener.ProxyProtocolOption(ppv),
|
||||
@@ -203,6 +223,9 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
|
||||
listener.NetnsOption(netnsIn),
|
||||
listener.LoggerOption(listenerLogger),
|
||||
}
|
||||
if trafficLimiter != nil {
|
||||
listenOpts = append(listenOpts, trafficLimiter)
|
||||
}
|
||||
|
||||
if netnsIn != "" {
|
||||
runtime.LockOSThread()
|
||||
|
||||
@@ -109,3 +109,23 @@ func LoggerFromContext(ctx context.Context) logger.Logger {
|
||||
v, _ := ctx.Value(keyLogger).(logger.Logger)
|
||||
return v
|
||||
}
|
||||
|
||||
// excludeNodesKey saves the list of node addresses to exclude during selection.
|
||||
// This is used for failover retry logic - when a node fails, it gets added to
|
||||
// the exclude list so the next Select() call will skip it.
|
||||
type excludeNodesKey struct{}
|
||||
|
||||
var (
|
||||
keyExcludeNodes = &excludeNodesKey{}
|
||||
)
|
||||
|
||||
// ContextWithExcludeNodes returns a context with the list of node addresses to exclude.
|
||||
func ContextWithExcludeNodes(ctx context.Context, nodes []string) context.Context {
|
||||
return context.WithValue(ctx, keyExcludeNodes, nodes)
|
||||
}
|
||||
|
||||
// ExcludeNodesFromContext returns the list of node addresses to exclude from selection.
|
||||
func ExcludeNodesFromContext(ctx context.Context) []string {
|
||||
v, _ := ctx.Value(keyExcludeNodes).([]string)
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# GO-GOST/X DIALERS KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Outbound dialers (client-side connection establishment) used by connectors/handlers.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
go-gost/x/dialer/
|
||||
├── direct/ # Baseline dialer
|
||||
├── tcp/
|
||||
├── udp/
|
||||
├── tls/
|
||||
├── ws/
|
||||
├── quic/
|
||||
├── http2/
|
||||
├── http3/
|
||||
├── ssh/
|
||||
├── wg/ # WireGuard dialer
|
||||
└── ...
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Pick a dialer | `go-gost/x/dialer/` | One subdir per transport |
|
||||
| TCP baseline | `go-gost/x/dialer/tcp/dialer.go` | Reference implementation |
|
||||
|
||||
## CONVENTIONS
|
||||
- Dialer implementations typically live in `dialer.go` with a paired `metadata.go` (e.g. `go-gost/x/dialer/tcp/`).
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost/x
|
||||
go test ./...
|
||||
```
|
||||
+8
-3
@@ -4,9 +4,7 @@ go 1.22.0
|
||||
|
||||
toolchain go1.23.4
|
||||
|
||||
require (
|
||||
github.com/shirou/gopsutil/v3 v3.24.5
|
||||
)
|
||||
require github.com/shirou/gopsutil/v3 v3.24.5
|
||||
|
||||
require (
|
||||
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137
|
||||
@@ -76,6 +74,7 @@ require (
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
@@ -91,6 +90,7 @@ require (
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/klauspost/reedsolomon v1.11.8 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
@@ -103,6 +103,7 @@ require (
|
||||
github.com/pion/udp/v2 v2.0.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/prometheus/client_model v0.6.0 // indirect
|
||||
github.com/prometheus/common v0.48.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
@@ -110,6 +111,7 @@ require (
|
||||
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
@@ -118,8 +120,11 @@ require (
|
||||
github.com/templexxx/cpu v0.1.0 // indirect
|
||||
github.com/templexxx/xorsimd v0.4.2 // indirect
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
|
||||
@@ -65,6 +65,8 @@ github.com/go-gost/tls-dissector v0.1.1 h1:2zUOTPzCQAUQ54Rpy0UEi3JPMQSYsIFSeFeKr
|
||||
github.com/go-gost/tls-dissector v0.1.1/go.mod h1:/9QfdewqmHdaE362Hv5nDaSWLx3pCmtD870d6GaquXs=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -102,6 +104,7 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -133,6 +136,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -175,6 +180,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
|
||||
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
@@ -204,7 +211,12 @@ github.com/shadowsocks/go-shadowsocks2 v0.1.5 h1:PDSQv9y2S85Fl7VBeOMF9StzeXZyK1H
|
||||
github.com/shadowsocks/go-shadowsocks2 v0.1.5/go.mod h1:AGGpIoek4HRno4xzyFiAtLHkOpcoznZEkAccaI/rplM=
|
||||
github.com/shadowsocks/shadowsocks-go v0.0.0-20200409064450-3e585ff90601 h1:XU9hik0exChEmY92ALW4l9WnDodxLVS9yOSNh2SizaQ=
|
||||
github.com/shadowsocks/shadowsocks-go v0.0.0-20200409064450-3e585ff90601/go.mod h1:mttDPaeLm87u74HMrP+n2tugXvIKWcwff/cqSX0lehY=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
|
||||
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
@@ -244,6 +256,10 @@ github.com/templexxx/xorsimd v0.4.2 h1:ocZZ+Nvu65LGHmCLZ7OoCtg8Fx8jnHKK37SjvngUo
|
||||
github.com/templexxx/xorsimd v0.4.2/go.mod h1:HgwaPoDREdi6OnULpSfxhzaiiSUY4Fi3JPn1wpt28NI=
|
||||
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
||||
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
@@ -266,6 +282,8 @@ github.com/xtaci/tcpraw v1.2.25/go.mod h1:dKyZ2V75s0cZ7cbgJYdxPvms7af0joIeOyx1Gg
|
||||
github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU=
|
||||
github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zalando/go-keyring v0.2.4 h1:wi2xxTqdiwMKbM6TWwi+uJCG/Tum2UV0jqaQhCa9/68=
|
||||
github.com/zalando/go-keyring v0.2.4/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
@@ -321,15 +339,19 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# GO-GOST/X HANDLERS KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Protocol handlers (server-side request handling) used by services defined in the GOST config.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
go-gost/x/handler/
|
||||
├── http/ # handler.go + metadata.go (+ udp.go)
|
||||
├── socks/ # SOCKS variants
|
||||
├── tunnel/ # Tunnel forwarding
|
||||
├── relay/ # Relay forwarding
|
||||
├── redirect/ # TCP/UDP redirect handlers
|
||||
├── router/ # Routing/association entrypoints
|
||||
└── ...
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Find a protocol handler | `go-gost/x/handler/` | Subdir per protocol (`http`, `socks`, `tunnel`, ...) |
|
||||
| HTTP specifics | `go-gost/x/handler/http/handler.go` | Implements HTTP proxy behavior |
|
||||
| SOCKS specifics | `go-gost/x/handler/socks/` | v4/v5 implementations |
|
||||
|
||||
## CONVENTIONS
|
||||
- Handler implementations typically live in `handler.go` with a paired `metadata.go` (e.g. `go-gost/x/handler/http/`).
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost/x
|
||||
go test ./...
|
||||
```
|
||||
@@ -176,51 +176,98 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand
|
||||
}
|
||||
}
|
||||
|
||||
target := &chain.Node{}
|
||||
if h.hop != nil {
|
||||
target = h.hop.Select(ctx,
|
||||
hop.ProtocolSelectOption(proto),
|
||||
)
|
||||
}
|
||||
if target == nil {
|
||||
err := errors.New("node not available")
|
||||
return err
|
||||
// Determine max retry attempts
|
||||
maxRetries := h.md.maxRetries
|
||||
if maxRetries <= 0 {
|
||||
// Default: try all available nodes
|
||||
if nl, ok := h.hop.(hop.NodeList); ok {
|
||||
maxRetries = len(nl.Nodes())
|
||||
}
|
||||
if maxRetries <= 0 {
|
||||
maxRetries = 1
|
||||
}
|
||||
}
|
||||
|
||||
addr := target.Addr
|
||||
if opts := target.Options(); opts != nil {
|
||||
switch opts.Network {
|
||||
case "unix":
|
||||
network = opts.Network
|
||||
default:
|
||||
if _, _, err := net.SplitHostPort(addr); err != nil {
|
||||
addr += ":0"
|
||||
var triedNodes []string
|
||||
var lastErr error
|
||||
var cc net.Conn
|
||||
|
||||
h.options.Logger.Debugf("[handler.retry] starting retry loop: maxRetries=%d", maxRetries)
|
||||
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
// Select a target node, excluding previously tried nodes
|
||||
selectCtx := ctxvalue.ContextWithExcludeNodes(ctx, triedNodes)
|
||||
var target *chain.Node
|
||||
if h.hop != nil {
|
||||
target = h.hop.Select(selectCtx,
|
||||
hop.ProtocolSelectOption(proto),
|
||||
)
|
||||
}
|
||||
if target == nil {
|
||||
h.options.Logger.Debugf("[handler.retry] attempt=%d target=nil, triedNodes=%v", attempt, triedNodes)
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return errors.New("node not available")
|
||||
}
|
||||
|
||||
h.options.Logger.Debugf("[handler.retry] attempt=%d selected node=%s addr=%s", attempt, target.Name, target.Addr)
|
||||
|
||||
// Track this node as tried
|
||||
triedNodes = append(triedNodes, target.Addr)
|
||||
|
||||
addr := target.Addr
|
||||
if opts := target.Options(); opts != nil {
|
||||
switch opts.Network {
|
||||
case "unix":
|
||||
network = opts.Network
|
||||
default:
|
||||
if _, _, err := net.SplitHostPort(addr); err != nil {
|
||||
addr += ":0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ro.Network = network
|
||||
ro.Host = addr
|
||||
ro.Network = network
|
||||
ro.Host = addr
|
||||
|
||||
var buf bytes.Buffer
|
||||
cc, err := h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, addr)
|
||||
ro.Route = buf.String()
|
||||
if err != nil {
|
||||
// TODO: the router itself may be failed due to the failed node in the router,
|
||||
// the dead marker may be a wrong operation.
|
||||
if marker := target.Marker(); marker != nil {
|
||||
marker.Mark()
|
||||
var buf bytes.Buffer
|
||||
cc, err = h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, addr)
|
||||
ro.Route = buf.String()
|
||||
if err != nil {
|
||||
// Mark node as failed for future selections
|
||||
if marker := target.Marker(); marker != nil {
|
||||
marker.Mark()
|
||||
h.options.Logger.Debugf("[handler.retry] attempt=%d dial failed, marked node=%s count=%d err=%v",
|
||||
attempt, target.Addr, marker.Count(), err)
|
||||
}
|
||||
lastErr = err
|
||||
// Try next node
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
if marker := target.Marker(); marker != nil {
|
||||
marker.Reset()
|
||||
}
|
||||
defer cc.Close()
|
||||
|
||||
xnet.Transport(conn, cc)
|
||||
// Success - reset marker and proceed
|
||||
if marker := target.Marker(); marker != nil {
|
||||
marker.Reset()
|
||||
}
|
||||
defer cc.Close()
|
||||
|
||||
return nil
|
||||
if err := xnet.Transport(conn, cc); err != nil {
|
||||
if marker := target.Marker(); marker != nil {
|
||||
marker.Mark()
|
||||
h.options.Logger.Debugf("[handler.transport] transport failed, marked node=%s count=%d err=%v",
|
||||
target.Addr, marker.Count(), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return errors.New("all nodes failed")
|
||||
}
|
||||
|
||||
func (h *forwardHandler) checkRateLimit(addr net.Addr) bool {
|
||||
|
||||
@@ -25,6 +25,12 @@ type metadata struct {
|
||||
privateKey crypto.PrivateKey
|
||||
alpn string
|
||||
mitmBypass bypass.Bypass
|
||||
|
||||
// maxRetries specifies the maximum number of failover retry attempts.
|
||||
// When a target node fails, the handler will try the next available node.
|
||||
// 0 means use the total number of available nodes (try all nodes once).
|
||||
// Default: 0 (try all available nodes)
|
||||
maxRetries int
|
||||
}
|
||||
|
||||
func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) {
|
||||
@@ -56,5 +62,8 @@ func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) {
|
||||
h.md.alpn = mdutil.GetString(md, "mitm.alpn")
|
||||
h.md.mitmBypass = registry.BypassRegistry().Get(mdutil.GetString(md, "mitm.bypass"))
|
||||
|
||||
// maxRetries: 0 means try all available nodes (default behavior)
|
||||
h.md.maxRetries = mdutil.GetInt(md, "maxRetries", "retry.max")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -204,68 +204,101 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand
|
||||
}
|
||||
}
|
||||
|
||||
var target *chain.Node
|
||||
if host != "" {
|
||||
target = &chain.Node{
|
||||
Addr: host,
|
||||
// Determine max retry attempts
|
||||
maxRetries := h.md.maxRetries
|
||||
if maxRetries <= 0 {
|
||||
// Default: try all available nodes
|
||||
if nl, ok := h.hop.(hop.NodeList); ok {
|
||||
maxRetries = len(nl.Nodes())
|
||||
}
|
||||
}
|
||||
if h.hop != nil {
|
||||
target = h.hop.Select(ctx,
|
||||
hop.ProtocolSelectOption(proto),
|
||||
)
|
||||
}
|
||||
if target == nil {
|
||||
err := errors.New("node not available")
|
||||
log.Error(err)
|
||||
return err
|
||||
}
|
||||
|
||||
if opts := target.Options(); opts != nil {
|
||||
switch opts.Network {
|
||||
case "unix":
|
||||
network = opts.Network
|
||||
default:
|
||||
if maxRetries <= 0 {
|
||||
maxRetries = 1
|
||||
}
|
||||
}
|
||||
|
||||
ro.Network = network
|
||||
ro.Host = target.Addr
|
||||
var triedNodes []string
|
||||
var lastErr error
|
||||
var cc net.Conn
|
||||
|
||||
log = log.WithFields(map[string]any{
|
||||
"node": target.Name,
|
||||
"dst": fmt.Sprintf("%s/%s", target.Addr, network),
|
||||
})
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
// Select a target node, excluding previously tried nodes
|
||||
selectCtx := ctxvalue.ContextWithExcludeNodes(ctx, triedNodes)
|
||||
var target *chain.Node
|
||||
if host != "" {
|
||||
target = chain.NewNode("", host)
|
||||
}
|
||||
if h.hop != nil {
|
||||
target = h.hop.Select(selectCtx,
|
||||
hop.ProtocolSelectOption(proto),
|
||||
)
|
||||
}
|
||||
if target == nil {
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
err := errors.New("node not available")
|
||||
log.Error(err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("%s >> %s", conn.RemoteAddr(), target.Addr)
|
||||
// Track this node as tried
|
||||
triedNodes = append(triedNodes, target.Addr)
|
||||
|
||||
var buf bytes.Buffer
|
||||
cc, err := h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, target.Addr)
|
||||
ro.Route = buf.String()
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
// TODO: the router itself may be failed due to the failed node in the router,
|
||||
// the dead marker may be a wrong operation.
|
||||
if opts := target.Options(); opts != nil {
|
||||
switch opts.Network {
|
||||
case "unix":
|
||||
network = opts.Network
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
ro.Network = network
|
||||
ro.Host = target.Addr
|
||||
|
||||
targetLog := log.WithFields(map[string]any{
|
||||
"node": target.Name,
|
||||
"dst": fmt.Sprintf("%s/%s", target.Addr, network),
|
||||
})
|
||||
|
||||
targetLog.Debugf("%s >> %s", conn.RemoteAddr(), target.Addr)
|
||||
|
||||
var buf bytes.Buffer
|
||||
cc, err = h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, target.Addr)
|
||||
ro.Route = buf.String()
|
||||
if err != nil {
|
||||
targetLog.Error(err)
|
||||
// Mark node as failed for future selections
|
||||
if marker := target.Marker(); marker != nil {
|
||||
marker.Mark()
|
||||
}
|
||||
lastErr = err
|
||||
// Try next node
|
||||
continue
|
||||
}
|
||||
|
||||
// Success - reset marker and proceed
|
||||
if marker := target.Marker(); marker != nil {
|
||||
marker.Mark()
|
||||
marker.Reset()
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer cc.Close()
|
||||
if marker := target.Marker(); marker != nil {
|
||||
marker.Reset()
|
||||
defer cc.Close()
|
||||
|
||||
cc = proxyproto.WrapClientConn(h.md.proxyProtocol, conn.RemoteAddr(), convertAddr(conn.LocalAddr()), cc)
|
||||
|
||||
t := time.Now()
|
||||
targetLog.Infof("%s <-> %s", conn.RemoteAddr(), target.Addr)
|
||||
xnet.Transport(conn, cc)
|
||||
targetLog.WithFields(map[string]any{
|
||||
"duration": time.Since(t),
|
||||
}).Infof("%s >-< %s", conn.RemoteAddr(), target.Addr)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
cc = proxyproto.WrapClientConn(h.md.proxyProtocol, conn.RemoteAddr(), convertAddr(conn.LocalAddr()), cc)
|
||||
|
||||
t := time.Now()
|
||||
log.Infof("%s <-> %s", conn.RemoteAddr(), target.Addr)
|
||||
xnet.Transport(conn, cc)
|
||||
log.WithFields(map[string]any{
|
||||
"duration": time.Since(t),
|
||||
}).Infof("%s >-< %s", conn.RemoteAddr(), target.Addr)
|
||||
|
||||
return nil
|
||||
// All retries exhausted
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return errors.New("all nodes failed")
|
||||
}
|
||||
|
||||
func (h *forwardHandler) checkRateLimit(addr net.Addr) bool {
|
||||
|
||||
@@ -26,6 +26,12 @@ type metadata struct {
|
||||
privateKey crypto.PrivateKey
|
||||
alpn string
|
||||
mitmBypass bypass.Bypass
|
||||
|
||||
// maxRetries specifies the maximum number of failover retry attempts.
|
||||
// When a target node fails, the handler will try the next available node.
|
||||
// 0 means use the total number of available nodes (try all nodes once).
|
||||
// Default: 0 (try all available nodes)
|
||||
maxRetries int
|
||||
}
|
||||
|
||||
func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) {
|
||||
@@ -57,5 +63,9 @@ func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) {
|
||||
}
|
||||
h.md.alpn = mdutil.GetString(md, "mitm.alpn")
|
||||
h.md.mitmBypass = registry.BypassRegistry().Get(mdutil.GetString(md, "mitm.bypass"))
|
||||
|
||||
// maxRetries: 0 means try all available nodes (default behavior)
|
||||
h.md.maxRetries = mdutil.GetInt(md, "maxRetries", "retry.max")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
+35
-4
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/go-gost/core/selector"
|
||||
"github.com/go-gost/x/config"
|
||||
node_parser "github.com/go-gost/x/config/parsing/node"
|
||||
ctxvalue "github.com/go-gost/x/ctx"
|
||||
"github.com/go-gost/x/internal/loader"
|
||||
)
|
||||
|
||||
@@ -141,11 +142,28 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get list of nodes to exclude (for failover retry)
|
||||
excludeNodes := ctxvalue.ExcludeNodesFromContext(ctx)
|
||||
excludeSet := make(map[string]bool)
|
||||
for _, addr := range excludeNodes {
|
||||
excludeSet[addr] = true
|
||||
}
|
||||
|
||||
// Debug logging for failover analysis
|
||||
log.Debugf("[hop.Select] excludeNodes=%v, totalNodes=%d", excludeNodes, len(p.Nodes()))
|
||||
|
||||
var nodes []*chain.Node
|
||||
for _, node := range p.Nodes() {
|
||||
if node == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip nodes in the exclude list (failover retry)
|
||||
if excludeSet[node.Addr] || excludeSet[node.Name] {
|
||||
log.Debugf("node %s(%s) excluded for failover retry", node.Name, node.Addr)
|
||||
continue
|
||||
}
|
||||
|
||||
// node level bypass
|
||||
if node.Options().Bypass != nil &&
|
||||
node.Options().Bypass.Contains(ctx, options.Network, options.Addr, bypass.WithHostOpton(options.Host)) {
|
||||
@@ -177,9 +195,6 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
|
||||
if len(nodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(nodes) == 1 {
|
||||
return nodes[0]
|
||||
}
|
||||
|
||||
sort.Slice(nodes, func(i, j int) bool {
|
||||
return nodes[i].Options().Priority > nodes[j].Options().Priority
|
||||
@@ -189,9 +204,25 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
|
||||
return nodes[0]
|
||||
}
|
||||
|
||||
// Use selector with FailFilter for proper failover.
|
||||
// FailFilter will exclude recently-failed nodes, allowing traffic to
|
||||
// be routed to healthy alternatives.
|
||||
// Note: FailFilter has a safety guard (len <= 1 returns as-is) to ensure
|
||||
// the last remaining node is never permanently blocked.
|
||||
if s := p.options.selector; s != nil {
|
||||
return s.Select(ctx, nodes...)
|
||||
log.Debugf("[hop.Select] calling selector.Select with %d nodes", len(nodes))
|
||||
if node := s.Select(ctx, nodes...); node != nil {
|
||||
log.Debugf("[hop.Select] selected node=%s addr=%s", node.Name, node.Addr)
|
||||
return node
|
||||
}
|
||||
// All nodes filtered out by FailFilter - all are marked as failed.
|
||||
// Return nil to signal "no healthy nodes available" to the caller.
|
||||
// The handler's retry loop will handle this appropriately.
|
||||
log.Debugf("all %d nodes filtered out by FailFilter, no healthy nodes available", len(nodes))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fallback: return first node if no selector configured
|
||||
return nodes[0]
|
||||
}
|
||||
|
||||
|
||||
@@ -247,64 +247,98 @@ func (h *Sniffer) dial(ctx context.Context, conn net.Conn, req *http.Request, ho
|
||||
}
|
||||
}
|
||||
|
||||
node = &chain.Node{
|
||||
Addr: host,
|
||||
// Determine max retry attempts
|
||||
maxRetries := 1
|
||||
if nl, ok := ho.Hop.(hop.NodeList); ok {
|
||||
maxRetries = len(nl.Nodes())
|
||||
}
|
||||
if ho.Hop != nil {
|
||||
node = ho.Hop.Select(ctx,
|
||||
hop.ClientIPSelectOption(net.ParseIP(ro.ClientIP)),
|
||||
hop.ProtocolSelectOption(sniffing.ProtoHTTP),
|
||||
hop.HostSelectOption(host),
|
||||
hop.MethodSelectOption(req.Method),
|
||||
hop.PathSelectOption(req.URL.Path),
|
||||
hop.QuerySelectOption(req.URL.Query()),
|
||||
hop.HeaderSelectOption(req.Header),
|
||||
)
|
||||
}
|
||||
if node == nil {
|
||||
ho.Log.Warnf("node for %s not found", host)
|
||||
res.StatusCode = http.StatusBadGateway
|
||||
ro.HTTP.StatusCode = res.StatusCode
|
||||
res.Write(conn)
|
||||
return nil, nil, errors.New("node not available")
|
||||
if maxRetries <= 0 {
|
||||
maxRetries = 1
|
||||
}
|
||||
|
||||
ro.Host = node.Addr
|
||||
ho.Log = ho.Log.WithFields(map[string]any{
|
||||
"node": node.Name,
|
||||
"dst": node.Addr,
|
||||
})
|
||||
ho.Log.Debugf("find node for host %s -> %s(%s)", host, node.Name, node.Addr)
|
||||
var triedNodes []string
|
||||
var lastErr error
|
||||
|
||||
cc, err = dial(ctx, "tcp", node.Addr)
|
||||
if err != nil {
|
||||
// TODO: the router itself may be failed due to the failed node in the router,
|
||||
// the dead marker may be a wrong operation.
|
||||
if marker := node.Marker(); marker != nil {
|
||||
marker.Mark()
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
// Select a node, excluding previously tried nodes
|
||||
selectCtx := ctxvalue.ContextWithExcludeNodes(ctx, triedNodes)
|
||||
|
||||
node = chain.NewNode("", host)
|
||||
if ho.Hop != nil {
|
||||
node = ho.Hop.Select(selectCtx,
|
||||
hop.ClientIPSelectOption(net.ParseIP(ro.ClientIP)),
|
||||
hop.ProtocolSelectOption(sniffing.ProtoHTTP),
|
||||
hop.HostSelectOption(host),
|
||||
hop.MethodSelectOption(req.Method),
|
||||
hop.PathSelectOption(req.URL.Path),
|
||||
hop.QuerySelectOption(req.URL.Query()),
|
||||
hop.HeaderSelectOption(req.Header),
|
||||
)
|
||||
}
|
||||
ho.Log.Warnf("connect to node %s(%s) failed: %v", node.Name, node.Addr, err)
|
||||
res.Write(conn)
|
||||
return
|
||||
}
|
||||
if marker := node.Marker(); marker != nil {
|
||||
marker.Reset()
|
||||
}
|
||||
|
||||
if tlsSettings := node.Options().TLS; tlsSettings != nil {
|
||||
cfg := &tls.Config{
|
||||
ServerName: tlsSettings.ServerName,
|
||||
InsecureSkipVerify: !tlsSettings.Secure,
|
||||
if node == nil {
|
||||
if lastErr != nil {
|
||||
ho.Log.Warnf("node for %s not found after retries", host)
|
||||
res.StatusCode = http.StatusBadGateway
|
||||
ro.HTTP.StatusCode = res.StatusCode
|
||||
res.Write(conn)
|
||||
return nil, nil, lastErr
|
||||
}
|
||||
ho.Log.Warnf("node for %s not found", host)
|
||||
res.StatusCode = http.StatusBadGateway
|
||||
ro.HTTP.StatusCode = res.StatusCode
|
||||
res.Write(conn)
|
||||
return nil, nil, errors.New("node not available")
|
||||
}
|
||||
tls_util.SetTLSOptions(cfg, &config.TLSOptions{
|
||||
MinVersion: tlsSettings.Options.MinVersion,
|
||||
MaxVersion: tlsSettings.Options.MaxVersion,
|
||||
CipherSuites: tlsSettings.Options.CipherSuites,
|
||||
ALPN: tlsSettings.Options.ALPN,
|
||||
|
||||
// Track this node as tried
|
||||
triedNodes = append(triedNodes, node.Addr)
|
||||
|
||||
ro.Host = node.Addr
|
||||
ho.Log = ho.Log.WithFields(map[string]any{
|
||||
"node": node.Name,
|
||||
"dst": node.Addr,
|
||||
})
|
||||
cc = tls.Client(cc, cfg)
|
||||
ho.Log.Debugf("find node for host %s -> %s(%s)", host, node.Name, node.Addr)
|
||||
|
||||
cc, err = dial(ctx, "tcp", node.Addr)
|
||||
if err != nil {
|
||||
// Mark node as failed for future selections
|
||||
if marker := node.Marker(); marker != nil {
|
||||
marker.Mark()
|
||||
}
|
||||
ho.Log.Warnf("connect to node %s(%s) failed: %v, trying next node", node.Name, node.Addr, err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
// Success - reset marker
|
||||
if marker := node.Marker(); marker != nil {
|
||||
marker.Reset()
|
||||
}
|
||||
|
||||
if tlsSettings := node.Options().TLS; tlsSettings != nil {
|
||||
cfg := &tls.Config{
|
||||
ServerName: tlsSettings.ServerName,
|
||||
InsecureSkipVerify: !tlsSettings.Secure,
|
||||
}
|
||||
tls_util.SetTLSOptions(cfg, &config.TLSOptions{
|
||||
MinVersion: tlsSettings.Options.MinVersion,
|
||||
MaxVersion: tlsSettings.Options.MaxVersion,
|
||||
CipherSuites: tlsSettings.Options.CipherSuites,
|
||||
ALPN: tlsSettings.Options.ALPN,
|
||||
})
|
||||
cc = tls.Client(cc, cfg)
|
||||
}
|
||||
return node, cc, nil
|
||||
}
|
||||
return
|
||||
|
||||
// All retries exhausted
|
||||
ho.Log.Warnf("all nodes failed for host %s", host)
|
||||
res.Write(conn)
|
||||
if lastErr != nil {
|
||||
return nil, nil, lastErr
|
||||
}
|
||||
return nil, nil, errors.New("all nodes failed")
|
||||
}
|
||||
|
||||
func (h *Sniffer) serveH2(ctx context.Context, conn net.Conn, ho *HandleOptions) error {
|
||||
@@ -847,74 +881,105 @@ func (h *Sniffer) dialTLS(ctx context.Context, host string, ho *HandleOptions) (
|
||||
return
|
||||
}
|
||||
|
||||
if host != "" {
|
||||
node = &chain.Node{
|
||||
Addr: host,
|
||||
}
|
||||
}
|
||||
|
||||
ro := ho.RecorderObject
|
||||
if ho.Hop != nil {
|
||||
node = ho.Hop.Select(ctx,
|
||||
hop.ClientIPSelectOption(net.ParseIP(ro.ClientIP)),
|
||||
hop.HostSelectOption(host),
|
||||
hop.ProtocolSelectOption(sniffing.ProtoTLS),
|
||||
)
|
||||
|
||||
// Determine max retry attempts
|
||||
maxRetries := 1
|
||||
if nl, ok := ho.Hop.(hop.NodeList); ok {
|
||||
maxRetries = len(nl.Nodes())
|
||||
}
|
||||
if node == nil {
|
||||
err = errors.New("node not available")
|
||||
return
|
||||
if maxRetries <= 0 {
|
||||
maxRetries = 1
|
||||
}
|
||||
|
||||
addr := node.Addr
|
||||
if opts := node.Options(); opts != nil {
|
||||
switch opts.Network {
|
||||
case "unix":
|
||||
ro.Network = opts.Network
|
||||
default:
|
||||
if _, _, err := net.SplitHostPort(addr); err != nil {
|
||||
addr += ":443"
|
||||
var triedNodes []string
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
// Select a node, excluding previously tried nodes
|
||||
selectCtx := ctxvalue.ContextWithExcludeNodes(ctx, triedNodes)
|
||||
|
||||
node = nil
|
||||
if host != "" {
|
||||
node = chain.NewNode("", host)
|
||||
}
|
||||
if ho.Hop != nil {
|
||||
node = ho.Hop.Select(selectCtx,
|
||||
hop.ClientIPSelectOption(net.ParseIP(ro.ClientIP)),
|
||||
hop.HostSelectOption(host),
|
||||
hop.ProtocolSelectOption(sniffing.ProtoTLS),
|
||||
)
|
||||
}
|
||||
if node == nil {
|
||||
if lastErr != nil {
|
||||
ho.Log.Warnf("node for %s not found after retries", host)
|
||||
return nil, nil, lastErr
|
||||
}
|
||||
ho.Log.Warnf("node for %s not found", host)
|
||||
return nil, nil, errors.New("node not available")
|
||||
}
|
||||
|
||||
// Track this node as tried
|
||||
triedNodes = append(triedNodes, node.Addr)
|
||||
|
||||
addr := node.Addr
|
||||
if opts := node.Options(); opts != nil {
|
||||
switch opts.Network {
|
||||
case "unix":
|
||||
ro.Network = opts.Network
|
||||
default:
|
||||
if _, _, err := net.SplitHostPort(addr); err != nil {
|
||||
addr += ":443"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ro.Host = addr
|
||||
ro.Host = addr
|
||||
|
||||
ho.Log = ho.Log.WithFields(map[string]any{
|
||||
"host": host,
|
||||
"node": node.Name,
|
||||
"dst": fmt.Sprintf("%s/%s", addr, ro.Network),
|
||||
})
|
||||
ho.Log.Debugf("find node for host %s -> %s(%s)", host, node.Name, addr)
|
||||
|
||||
cc, err = dial(ctx, ro.Network, addr)
|
||||
if err != nil {
|
||||
// TODO: the router itself may be failed due to the failed node in the router,
|
||||
// the dead marker may be a wrong operation.
|
||||
if marker := node.Marker(); marker != nil {
|
||||
marker.Mark()
|
||||
}
|
||||
ho.Log.Warnf("connect to node %s(%s) failed: %v", node.Name, node.Addr, err)
|
||||
return
|
||||
}
|
||||
|
||||
if marker := node.Marker(); marker != nil {
|
||||
marker.Reset()
|
||||
}
|
||||
|
||||
if tlsSettings := node.Options().TLS; tlsSettings != nil {
|
||||
cfg := &tls.Config{
|
||||
ServerName: tlsSettings.ServerName,
|
||||
InsecureSkipVerify: !tlsSettings.Secure,
|
||||
}
|
||||
tls_util.SetTLSOptions(cfg, &config.TLSOptions{
|
||||
MinVersion: tlsSettings.Options.MinVersion,
|
||||
MaxVersion: tlsSettings.Options.MaxVersion,
|
||||
CipherSuites: tlsSettings.Options.CipherSuites,
|
||||
ALPN: tlsSettings.Options.ALPN,
|
||||
ho.Log = ho.Log.WithFields(map[string]any{
|
||||
"host": host,
|
||||
"node": node.Name,
|
||||
"dst": fmt.Sprintf("%s/%s", addr, ro.Network),
|
||||
})
|
||||
cc = tls.Client(cc, cfg)
|
||||
ho.Log.Debugf("find node for host %s -> %s(%s)", host, node.Name, addr)
|
||||
|
||||
cc, err = dial(ctx, ro.Network, addr)
|
||||
if err != nil {
|
||||
// Mark node as failed for future selections
|
||||
if marker := node.Marker(); marker != nil {
|
||||
marker.Mark()
|
||||
}
|
||||
ho.Log.Warnf("connect to node %s(%s) failed: %v, trying next node", node.Name, node.Addr, err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
// Success - reset marker
|
||||
if marker := node.Marker(); marker != nil {
|
||||
marker.Reset()
|
||||
}
|
||||
|
||||
if tlsSettings := node.Options().TLS; tlsSettings != nil {
|
||||
cfg := &tls.Config{
|
||||
ServerName: tlsSettings.ServerName,
|
||||
InsecureSkipVerify: !tlsSettings.Secure,
|
||||
}
|
||||
tls_util.SetTLSOptions(cfg, &config.TLSOptions{
|
||||
MinVersion: tlsSettings.Options.MinVersion,
|
||||
MaxVersion: tlsSettings.Options.MaxVersion,
|
||||
CipherSuites: tlsSettings.Options.CipherSuites,
|
||||
ALPN: tlsSettings.Options.ALPN,
|
||||
})
|
||||
cc = tls.Client(cc, cfg)
|
||||
}
|
||||
return node, cc, nil
|
||||
}
|
||||
return
|
||||
|
||||
// All retries exhausted
|
||||
ho.Log.Warnf("all nodes failed for host %s", host)
|
||||
if lastErr != nil {
|
||||
return nil, nil, lastErr
|
||||
}
|
||||
return nil, nil, errors.New("all nodes failed")
|
||||
}
|
||||
|
||||
func (h *Sniffer) terminateTLS(ctx context.Context, conn, cc net.Conn, clientHello *dissector.ClientHelloInfo, ho *HandleOptions) error {
|
||||
|
||||
@@ -136,6 +136,9 @@ func (l *trafficLimiter) In(ctx context.Context, key string, opts ...limiter.Opt
|
||||
return nil
|
||||
|
||||
case limiter.ScopeClient:
|
||||
if lim, ok := l.inLimits.Get(key); ok && lim != nil {
|
||||
return lim.(traffic.Limiter)
|
||||
}
|
||||
return nil
|
||||
|
||||
case limiter.ScopeConn:
|
||||
@@ -215,6 +218,9 @@ func (l *trafficLimiter) Out(ctx context.Context, key string, opts ...limiter.Op
|
||||
return nil
|
||||
|
||||
case limiter.ScopeClient:
|
||||
if lim, ok := l.outLimits.Get(key); ok && lim != nil {
|
||||
return lim.(traffic.Limiter)
|
||||
}
|
||||
return nil
|
||||
|
||||
case limiter.ScopeConn:
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# GO-GOST/X LISTENERS KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Inbound listeners (transport-level accept loops) used by services defined in the GOST config.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
go-gost/x/listener/
|
||||
├── tcp/ # listener.go + metadata.go
|
||||
├── udp/
|
||||
├── tls/
|
||||
├── ws/
|
||||
├── quic/
|
||||
├── redirect/ # tcp/ + udp/
|
||||
├── tun/ # TUN device listener
|
||||
├── tap/ # TAP device listener
|
||||
└── ...
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Listener registry | `go-gost/x/listener/` | One subdir per transport |
|
||||
| TCP baseline | `go-gost/x/listener/tcp/listener.go` | Reference for other transports |
|
||||
| Redirect listeners | `go-gost/x/listener/redirect/` | Per-protocol accept + redirect |
|
||||
| TUN/TAP | `go-gost/x/listener/tun/`, `go-gost/x/listener/tap/` | Virtual interface listeners |
|
||||
|
||||
## CONVENTIONS
|
||||
- Listener implementations typically live in `listener.go` with a paired `metadata.go` (e.g. `go-gost/x/listener/tcp/`).
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost/x
|
||||
go test ./...
|
||||
```
|
||||
@@ -0,0 +1,29 @@
|
||||
# GO-GOST REGISTRY KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Wed Feb 04 2026
|
||||
|
||||
## OVERVIEW
|
||||
Central registration point for all pluggable GOST components (handlers, listeners, dialers, etc.).
|
||||
Allows the configuration system to resolve string types (e.g., "socks5") to actual Go implementations.
|
||||
|
||||
## STRUCTURE
|
||||
One file per component type, exporting a standard Registry interface.
|
||||
```
|
||||
go-gost/x/registry/
|
||||
├── handler.go # RegisterHandler(name, newFunc)
|
||||
├── listener.go # RegisterListener(name, newFunc)
|
||||
├── dialer.go # RegisterDialer(name, newFunc)
|
||||
└── ... # Same pattern for auth, bypass, admission
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Register a new component | `go-gost/x/registry/{type}.go` | Use `Register{Type}(name, creator)` |
|
||||
| Component lookup | `go-gost/x/registry/{type}.go` | `Get{Type}(name)` returns the creator function |
|
||||
| Default registrations | `go-gost/x/` (init functions) | Most components register themselves in their package `init()` |
|
||||
|
||||
## CONVENTIONS
|
||||
- Thread-safe maps used for storage.
|
||||
- Names are case-sensitive (usually lowercase).
|
||||
- Components must be registered *before* the configuration parser runs (usually done via `import _ "..."` in `main.go`).
|
||||
@@ -2,11 +2,13 @@ package selector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
"github.com/go-gost/core/selector"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
)
|
||||
|
||||
type failFilter[T any] struct {
|
||||
@@ -24,6 +26,8 @@ func FailFilter[T any](maxFails int, timeout time.Duration) selector.Filter[T] {
|
||||
}
|
||||
|
||||
// Filter filters dead objects.
|
||||
// For single-node case, skip filtering to ensure availability (matches upstream).
|
||||
// For multi-node case, filter out failed nodes to enable failover.
|
||||
func (f *failFilter[T]) Filter(ctx context.Context, vs ...T) []T {
|
||||
if len(vs) <= 1 {
|
||||
return vs
|
||||
@@ -51,8 +55,21 @@ func (f *failFilter[T]) Filter(ctx context.Context, vs ...T) []T {
|
||||
|
||||
if mi, _ := any(v).(selector.Markable); mi != nil {
|
||||
if marker := mi.Marker(); marker != nil {
|
||||
if marker.Count() < int64(maxFails) ||
|
||||
time.Since(marker.Time()) >= failTimeout {
|
||||
count := marker.Count()
|
||||
timeSince := time.Since(marker.Time())
|
||||
passed := count < int64(maxFails) || timeSince >= failTimeout
|
||||
|
||||
// Debug logging for failover analysis
|
||||
nodeName := "unknown"
|
||||
nodeAddr := "unknown"
|
||||
if node, ok := any(v).(*chain.Node); ok {
|
||||
nodeName = node.Name
|
||||
nodeAddr = node.Addr
|
||||
}
|
||||
fmt.Printf("[FailFilter] node=%s addr=%s count=%d maxFails=%d timeSince=%v failTimeout=%v passed=%v\n",
|
||||
nodeName, nodeAddr, count, maxFails, timeSince, failTimeout, passed)
|
||||
|
||||
if passed {
|
||||
l = append(l, v)
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GlobalTrafficManager 全局流量管理器(所有服务共享)
|
||||
type GlobalTrafficManager struct {
|
||||
mu sync.RWMutex
|
||||
serviceTraffic map[string]*ServiceTraffic // key: 服务名, value: 流量数据
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
reportTicker *time.Ticker
|
||||
}
|
||||
|
||||
// ServiceTraffic 单个服务的流量累积
|
||||
type ServiceTraffic struct {
|
||||
mu sync.Mutex
|
||||
ServiceName string
|
||||
UpBytes int64 // 上行流量(累积)
|
||||
DownBytes int64 // 下行流量(累积)
|
||||
}
|
||||
|
||||
var (
|
||||
globalManager *GlobalTrafficManager
|
||||
globalManagerOnce sync.Once
|
||||
)
|
||||
|
||||
// GetGlobalTrafficManager 获取全局流量管理器单例
|
||||
func GetGlobalTrafficManager() *GlobalTrafficManager {
|
||||
globalManagerOnce.Do(func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
globalManager = &GlobalTrafficManager{
|
||||
serviceTraffic: make(map[string]*ServiceTraffic),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
reportTicker: time.NewTicker(5 * time.Second),
|
||||
}
|
||||
// 启动定时上报协程
|
||||
go globalManager.startReporting()
|
||||
})
|
||||
return globalManager
|
||||
}
|
||||
|
||||
// AddTraffic 添加流量到指定服务(由各服务调用)
|
||||
func (m *GlobalTrafficManager) AddTraffic(serviceName string, upBytes, downBytes int64) {
|
||||
if upBytes == 0 && downBytes == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// 获取或创建服务流量记录
|
||||
traffic, exists := m.serviceTraffic[serviceName]
|
||||
if !exists {
|
||||
traffic = &ServiceTraffic{
|
||||
ServiceName: serviceName,
|
||||
}
|
||||
m.serviceTraffic[serviceName] = traffic
|
||||
}
|
||||
|
||||
// 累加流量
|
||||
traffic.mu.Lock()
|
||||
traffic.UpBytes += upBytes
|
||||
traffic.DownBytes += downBytes
|
||||
traffic.mu.Unlock()
|
||||
}
|
||||
|
||||
// startReporting 启动定时上报协程(每5秒执行一次)
|
||||
func (m *GlobalTrafficManager) startReporting() {
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-m.reportTicker.C:
|
||||
m.collectAndReport()
|
||||
|
||||
case <-m.ctx.Done():
|
||||
fmt.Printf("⏹️ 全局流量上报器已停止\n")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// collectAndReport 收集所有服务流量并合并上报
|
||||
func (m *GlobalTrafficManager) collectAndReport() {
|
||||
m.mu.Lock()
|
||||
|
||||
// 如果没有流量,直接返回
|
||||
if len(m.serviceTraffic) == 0 {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// 复制当前所有流量数据(避免长时间持锁)
|
||||
trafficSnapshot := make(map[string]*ServiceTraffic)
|
||||
reportData := make(map[string]struct {
|
||||
up int64
|
||||
down int64
|
||||
})
|
||||
|
||||
for name, traffic := range m.serviceTraffic {
|
||||
traffic.mu.Lock()
|
||||
if traffic.UpBytes > 0 || traffic.DownBytes > 0 {
|
||||
trafficSnapshot[name] = traffic
|
||||
reportData[name] = struct {
|
||||
up int64
|
||||
down int64
|
||||
}{
|
||||
up: traffic.UpBytes,
|
||||
down: traffic.DownBytes,
|
||||
}
|
||||
}
|
||||
traffic.mu.Unlock()
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// 如果没有需要上报的流量,返回
|
||||
if len(reportData) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// 构建上报数据数组(保持每个服务独立)
|
||||
reportItems := make([]TrafficReportItem, 0, len(reportData))
|
||||
var totalUp, totalDown int64
|
||||
|
||||
for serviceName, data := range reportData {
|
||||
reportItems = append(reportItems, TrafficReportItem{
|
||||
N: serviceName, // 保持服务名不变
|
||||
U: data.up,
|
||||
D: data.down,
|
||||
})
|
||||
totalUp += data.up
|
||||
totalDown += data.down
|
||||
}
|
||||
|
||||
// 批量发送上报请求(一次HTTP请求包含所有服务)
|
||||
success, err := sendBatchTrafficReport(m.ctx, reportItems)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 全局流量上报失败: %v (总流量: ↑%d ↓%d, %d个服务)\n", err, totalUp, totalDown, len(reportItems))
|
||||
return
|
||||
}
|
||||
|
||||
if !success {
|
||||
fmt.Printf("⚠️ 全局流量上报未成功 (总流量: ↑%d ↓%d, %d个服务)\n", totalUp, totalDown, len(reportItems))
|
||||
return
|
||||
}
|
||||
|
||||
// 上报成功,清空已上报的流量
|
||||
m.clearReportedTraffic(reportData)
|
||||
}
|
||||
|
||||
// clearReportedTraffic 清空已成功上报的流量
|
||||
func (m *GlobalTrafficManager) clearReportedTraffic(reportedData map[string]struct {
|
||||
up int64
|
||||
down int64
|
||||
}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for serviceName, reported := range reportedData {
|
||||
if traffic, exists := m.serviceTraffic[serviceName]; exists {
|
||||
traffic.mu.Lock()
|
||||
// 减去已上报的流量
|
||||
traffic.UpBytes -= reported.up
|
||||
traffic.DownBytes -= reported.down
|
||||
|
||||
// 如果流量归零,从map中删除该服务记录(避免内存泄漏)
|
||||
if traffic.UpBytes <= 0 && traffic.DownBytes <= 0 {
|
||||
traffic.mu.Unlock()
|
||||
delete(m.serviceTraffic, serviceName)
|
||||
} else {
|
||||
traffic.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop 停止全局流量管理器
|
||||
func (m *GlobalTrafficManager) Stop() {
|
||||
if m.reportTicker != nil {
|
||||
m.reportTicker.Stop()
|
||||
}
|
||||
if m.cancel != nil {
|
||||
m.cancel()
|
||||
}
|
||||
fmt.Printf("🛑 全局流量管理器已停止\n")
|
||||
}
|
||||
|
||||
// GetServiceTraffic 获取指定服务的当前流量(用于调试)
|
||||
func (m *GlobalTrafficManager) GetServiceTraffic(serviceName string) (upBytes, downBytes int64) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if traffic, exists := m.serviceTraffic[serviceName]; exists {
|
||||
traffic.mu.Lock()
|
||||
upBytes = traffic.UpBytes
|
||||
downBytes = traffic.DownBytes
|
||||
traffic.mu.Unlock()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -403,19 +403,15 @@ func (s *defaultService) observeStats(ctx context.Context) {
|
||||
TotalErrs: st.Get(stats.KindTotalErrs),
|
||||
},
|
||||
}
|
||||
|
||||
// 将流量累积到全局管理器,而不是立即上报
|
||||
if outputBytes > 0 || inputBytes > 0 {
|
||||
reportItems := TrafficReportItem{
|
||||
N: s.name,
|
||||
U: int64(outputBytes),
|
||||
D: int64(inputBytes),
|
||||
}
|
||||
success, err := sendTrafficReport(ctx, reportItems)
|
||||
if err != nil {
|
||||
fmt.Printf("发送流量报告失败: %v", err)
|
||||
} else if success {
|
||||
if xstats, ok := st.(*xstats.Stats); ok {
|
||||
xstats.ResetTraffic(st.Get(stats.KindInputBytes)-inputBytes, st.Get(stats.KindOutputBytes)-outputBytes)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,8 +41,8 @@ func SetHTTPReportURL(addr string, secret string) {
|
||||
}
|
||||
}
|
||||
|
||||
// sendTrafficReport 发送流量报告到HTTP接口
|
||||
func sendTrafficReport(ctx context.Context, reportItems TrafficReportItem) (bool, error) {
|
||||
// sendBatchTrafficReport 批量发送多个服务的流量报告到HTTP接口
|
||||
func sendBatchTrafficReport(ctx context.Context, reportItems []TrafficReportItem) (bool, error) {
|
||||
jsonData, err := json.Marshal(reportItems)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("序列化报告数据失败: %v", err)
|
||||
@@ -112,6 +112,7 @@ func sendTrafficReport(ctx context.Context, reportItems TrafficReportItem) (bool
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// sendConfigReport 发送配置报告到HTTP接口
|
||||
func sendConfigReport(ctx context.Context) (bool, error) {
|
||||
if configReportURL == "" {
|
||||
|
||||
@@ -43,8 +43,8 @@ func updateChain(req updateChainRequest) error {
|
||||
|
||||
name := strings.TrimSpace(req.Chain)
|
||||
|
||||
if !registry.ChainRegistry().IsRegistered(name) {
|
||||
return errors.New("chain " + name + " not found")
|
||||
if registry.ChainRegistry().IsRegistered(name) {
|
||||
registry.ChainRegistry().Unregister(name)
|
||||
}
|
||||
|
||||
req.Data.Name = name
|
||||
@@ -54,19 +54,22 @@ func updateChain(req updateChainRequest) error {
|
||||
return errors.New("create chain " + name + " failed: " + err.Error())
|
||||
}
|
||||
|
||||
registry.ChainRegistry().Unregister(name)
|
||||
|
||||
if err := registry.ChainRegistry().Register(name, v); err != nil {
|
||||
return errors.New("chain " + name + " already exists")
|
||||
}
|
||||
|
||||
config.OnUpdate(func(c *config.Config) error {
|
||||
found := false
|
||||
for i := range c.Chains {
|
||||
if c.Chains[i].Name == name {
|
||||
c.Chains[i] = &req.Data
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
c.Chains = append(c.Chains, &req.Data)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -77,10 +80,9 @@ func deleteChain(req deleteChainRequest) error {
|
||||
|
||||
name := strings.TrimSpace(req.Chain)
|
||||
|
||||
if !registry.ChainRegistry().IsRegistered(name) {
|
||||
return errors.New("chain " + name + " not found")
|
||||
if registry.ChainRegistry().IsRegistered(name) {
|
||||
registry.ChainRegistry().Unregister(name)
|
||||
}
|
||||
registry.ChainRegistry().Unregister(name)
|
||||
|
||||
config.OnUpdate(func(c *config.Config) error {
|
||||
chains := c.Chains
|
||||
|
||||
@@ -2,24 +2,29 @@ package socket
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/go-gost/x/config"
|
||||
)
|
||||
|
||||
func saveConfig() {
|
||||
// configMutex 保护配置文件的并发写入
|
||||
var configMutex sync.Mutex
|
||||
|
||||
func saveConfig() error {
|
||||
configMutex.Lock()
|
||||
defer configMutex.Unlock()
|
||||
|
||||
file := "gost.json"
|
||||
|
||||
f, err := os.Create(file)
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if err := config.Global().Write(f, "json"); err != nil {
|
||||
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -37,27 +37,30 @@ func updateLimiter(req updateLimiterRequest) error {
|
||||
|
||||
name := strings.TrimSpace(req.Limiter)
|
||||
|
||||
if !registry.TrafficLimiterRegistry().IsRegistered(name) {
|
||||
return errors.New("limiter " + name + " not found")
|
||||
if registry.TrafficLimiterRegistry().IsRegistered(name) {
|
||||
registry.TrafficLimiterRegistry().Unregister(name)
|
||||
}
|
||||
|
||||
req.Data.Name = name
|
||||
|
||||
v := parser.ParseTrafficLimiter(&req.Data)
|
||||
|
||||
registry.TrafficLimiterRegistry().Unregister(name)
|
||||
|
||||
if err := registry.TrafficLimiterRegistry().Register(name, v); err != nil {
|
||||
return errors.New("limiter " + name + " already exists")
|
||||
}
|
||||
|
||||
config.OnUpdate(func(c *config.Config) error {
|
||||
found := false
|
||||
for i := range c.Limiters {
|
||||
if c.Limiters[i].Name == name {
|
||||
c.Limiters[i] = &req.Data
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
c.Limiters = append(c.Limiters, &req.Data)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -68,10 +71,9 @@ func deleteLimiter(req deleteLimiterRequest) error {
|
||||
|
||||
name := strings.TrimSpace(req.Limiter)
|
||||
|
||||
if !registry.TrafficLimiterRegistry().IsRegistered(name) {
|
||||
return errors.New("limiter " + name + " not found")
|
||||
if registry.TrafficLimiterRegistry().IsRegistered(name) {
|
||||
registry.TrafficLimiterRegistry().Unregister(name)
|
||||
}
|
||||
registry.TrafficLimiterRegistry().Unregister(name)
|
||||
|
||||
config.OnUpdate(func(c *config.Config) error {
|
||||
limiteres := c.Limiters
|
||||
|
||||
+41
-32
@@ -87,36 +87,32 @@ func updateServices(req updateServicesRequest) error {
|
||||
return errors.New("services list cannot be empty")
|
||||
}
|
||||
|
||||
// 第一阶段:验证所有服务存在
|
||||
for _, serviceConfig := range req.Data {
|
||||
name := strings.TrimSpace(serviceConfig.Name)
|
||||
// 第一阶段:验证所有服务名称有效性
|
||||
for i := range req.Data {
|
||||
name := strings.TrimSpace(req.Data[i].Name)
|
||||
if name == "" {
|
||||
return errors.New("service name is required")
|
||||
}
|
||||
serviceConfig.Name = name
|
||||
|
||||
old := registry.ServiceRegistry().Get(name)
|
||||
if old == nil {
|
||||
return errors.New("service " + name + " not found")
|
||||
}
|
||||
req.Data[i].Name = name
|
||||
}
|
||||
|
||||
// 第二阶段:按照原来的updateService逻辑,逐个更新服务
|
||||
for _, serviceConfig := range req.Data {
|
||||
name := strings.TrimSpace(serviceConfig.Name)
|
||||
serviceConfig.Name = name
|
||||
// 第二阶段:逐个更新服务(Upsert模式:存在则更新,不存在则创建)
|
||||
for i := range req.Data {
|
||||
serviceConfig := &req.Data[i]
|
||||
name := serviceConfig.Name
|
||||
|
||||
// 1. 获取旧服务
|
||||
old := registry.ServiceRegistry().Get(name)
|
||||
|
||||
// 2. 关闭旧服务
|
||||
old.Close()
|
||||
|
||||
// 3. 从注册表移除旧服务
|
||||
registry.ServiceRegistry().Unregister(name)
|
||||
// 2. 关闭旧服务 (如果存在)
|
||||
if old != nil {
|
||||
old.Close()
|
||||
// 3. 从注册表移除旧服务
|
||||
registry.ServiceRegistry().Unregister(name)
|
||||
}
|
||||
|
||||
// 4. 解析新服务配置
|
||||
svc, err := parser.ParseService(&serviceConfig)
|
||||
svc, err := parser.ParseService(serviceConfig)
|
||||
if err != nil {
|
||||
return errors.New("create service " + name + " failed: " + err.Error())
|
||||
}
|
||||
@@ -133,13 +129,20 @@ func updateServices(req updateServicesRequest) error {
|
||||
|
||||
// 第三阶段:更新配置
|
||||
config.OnUpdate(func(c *config.Config) error {
|
||||
for _, serviceConfig := range req.Data {
|
||||
for i := range c.Services {
|
||||
if c.Services[i].Name == serviceConfig.Name {
|
||||
c.Services[i] = &serviceConfig
|
||||
for i := range req.Data {
|
||||
// 创建副本以确保指针安全
|
||||
cfgCopy := req.Data[i]
|
||||
found := false
|
||||
for j := range c.Services {
|
||||
if c.Services[j].Name == cfgCopy.Name {
|
||||
c.Services[j] = &cfgCopy
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
c.Services = append(c.Services, &cfgCopy)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -158,22 +161,22 @@ func deleteServices(req deleteServicesRequest) error {
|
||||
name string
|
||||
service service.Service
|
||||
}
|
||||
var namesToRemove []string
|
||||
|
||||
for _, serviceName := range req.Services {
|
||||
name := strings.TrimSpace(serviceName)
|
||||
if name == "" {
|
||||
return errors.New("service name is required")
|
||||
}
|
||||
namesToRemove = append(namesToRemove, name)
|
||||
|
||||
svc := registry.ServiceRegistry().Get(name)
|
||||
if svc == nil {
|
||||
return errors.New("service " + name + " not found")
|
||||
if svc != nil {
|
||||
servicesToDelete = append(servicesToDelete, struct {
|
||||
name string
|
||||
service service.Service
|
||||
}{name, svc})
|
||||
}
|
||||
|
||||
servicesToDelete = append(servicesToDelete, struct {
|
||||
name string
|
||||
service service.Service
|
||||
}{name, svc})
|
||||
}
|
||||
|
||||
// 第二阶段:删除所有服务
|
||||
@@ -181,6 +184,12 @@ func deleteServices(req deleteServicesRequest) error {
|
||||
registry.ServiceRegistry().Unregister(std.name)
|
||||
std.service.Close()
|
||||
}
|
||||
// 确保所有请求删除的服务都从注册表中移除(即使之前未找到实例)
|
||||
for _, name := range namesToRemove {
|
||||
if registry.ServiceRegistry().IsRegistered(name) {
|
||||
registry.ServiceRegistry().Unregister(name)
|
||||
}
|
||||
}
|
||||
|
||||
// 第三阶段:更新配置
|
||||
config.OnUpdate(func(c *config.Config) error {
|
||||
@@ -188,8 +197,8 @@ func deleteServices(req deleteServicesRequest) error {
|
||||
c.Services = nil
|
||||
for _, s := range services {
|
||||
shouldDelete := false
|
||||
for _, std := range servicesToDelete {
|
||||
if s.Name == std.name {
|
||||
for _, name := range namesToRemove {
|
||||
if s.Name == name {
|
||||
shouldDelete = true
|
||||
break
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/service"
|
||||
"github.com/go-gost/x/internal/util/crypto"
|
||||
"github.com/go-gost/x/service"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/shirou/gopsutil/v3/cpu"
|
||||
"github.com/shirou/gopsutil/v3/host"
|
||||
@@ -87,6 +87,9 @@ type TcpPingResponse struct {
|
||||
|
||||
type WebSocketReporter struct {
|
||||
url string
|
||||
addr string // 保存服务器地址
|
||||
secret string // 保存密钥
|
||||
version string // 保存版本号
|
||||
conn *websocket.Conn
|
||||
reconnectTime time.Duration
|
||||
pingInterval time.Duration
|
||||
@@ -195,7 +198,25 @@ func (w *WebSocketReporter) connect() error {
|
||||
w.connecting = false
|
||||
}()
|
||||
|
||||
u, err := url.Parse(w.url)
|
||||
// 重新读取 config.json 获取最新的协议配置
|
||||
type LocalConfig struct {
|
||||
Addr string `json:"addr"`
|
||||
Secret string `json:"secret"`
|
||||
Http int `json:"http"`
|
||||
Tls int `json:"tls"`
|
||||
Socks int `json:"socks"`
|
||||
}
|
||||
|
||||
var cfg LocalConfig
|
||||
if b, err := os.ReadFile("config.json"); err == nil {
|
||||
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)
|
||||
}
|
||||
@@ -225,7 +246,7 @@ func (w *WebSocketReporter) connect() error {
|
||||
return nil
|
||||
})
|
||||
|
||||
fmt.Printf("✅ WebSocket连接建立成功\n")
|
||||
fmt.Printf("✅ WebSocket连接建立成功 (http=%d, tls=%d, socks=%d)\n", cfg.Http, cfg.Tls, cfg.Socks)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -445,7 +466,13 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
|
||||
}
|
||||
|
||||
if cmdMsg.Type != "call" {
|
||||
w.routeCommand(cmdMsg)
|
||||
// TcpPing 诊断命令异步执行,避免阻塞其他命令
|
||||
// 其他状态变更命令保持同步,确保顺序执行
|
||||
if cmdMsg.Type == "TcpPing" {
|
||||
go w.routeCommand(cmdMsg)
|
||||
} else {
|
||||
w.routeCommand(cmdMsg)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 处理普通消息
|
||||
@@ -456,7 +483,13 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
|
||||
return
|
||||
}
|
||||
if cmdMsg.Type != "call" {
|
||||
w.routeCommand(cmdMsg)
|
||||
// TcpPing 诊断命令异步执行,避免阻塞其他命令
|
||||
// 其他状态变更命令保持同步,确保顺序执行
|
||||
if cmdMsg.Type == "TcpPing" {
|
||||
go w.routeCommand(cmdMsg)
|
||||
} else {
|
||||
w.routeCommand(cmdMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,6 +509,7 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
|
||||
fmt.Println("🔔 收到命令: ", string(jsonBytes))
|
||||
var err error
|
||||
var response CommandResponse
|
||||
var needSaveConfig bool // 标记是否需要保存配置(只有状态变更命令才需要)
|
||||
|
||||
// 传递 requestId
|
||||
response.RequestId = cmd.RequestId
|
||||
@@ -485,65 +519,90 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
|
||||
case "AddService":
|
||||
err = w.handleAddService(cmd.Data)
|
||||
response.Type = "AddServiceResponse"
|
||||
needSaveConfig = true
|
||||
case "UpdateService":
|
||||
err = w.handleUpdateService(cmd.Data)
|
||||
response.Type = "UpdateServiceResponse"
|
||||
needSaveConfig = true
|
||||
case "DeleteService":
|
||||
err = w.handleDeleteService(cmd.Data)
|
||||
response.Type = "DeleteServiceResponse"
|
||||
needSaveConfig = true
|
||||
case "PauseService":
|
||||
err = w.handlePauseService(cmd.Data)
|
||||
response.Type = "PauseServiceResponse"
|
||||
needSaveConfig = true
|
||||
case "ResumeService":
|
||||
err = w.handleResumeService(cmd.Data)
|
||||
response.Type = "ResumeServiceResponse"
|
||||
needSaveConfig = true
|
||||
|
||||
// Chain 相关命令
|
||||
case "AddChains":
|
||||
err = w.handleAddChain(cmd.Data)
|
||||
response.Type = "AddChainsResponse"
|
||||
needSaveConfig = true
|
||||
case "UpdateChains":
|
||||
err = w.handleUpdateChain(cmd.Data)
|
||||
response.Type = "UpdateChainsResponse"
|
||||
needSaveConfig = true
|
||||
case "DeleteChains":
|
||||
err = w.handleDeleteChain(cmd.Data)
|
||||
response.Type = "DeleteChainsResponse"
|
||||
needSaveConfig = true
|
||||
|
||||
// Limiter 相关命令
|
||||
case "AddLimiters":
|
||||
err = w.handleAddLimiter(cmd.Data)
|
||||
response.Type = "AddLimitersResponse"
|
||||
needSaveConfig = true
|
||||
case "UpdateLimiters":
|
||||
err = w.handleUpdateLimiter(cmd.Data)
|
||||
response.Type = "UpdateLimitersResponse"
|
||||
needSaveConfig = true
|
||||
case "DeleteLimiters":
|
||||
err = w.handleDeleteLimiter(cmd.Data)
|
||||
response.Type = "DeleteLimitersResponse"
|
||||
needSaveConfig = true
|
||||
|
||||
// TCP Ping 诊断命令
|
||||
// TCP Ping 诊断命令(只读,不需要保存配置)
|
||||
case "TcpPing":
|
||||
var tcpPingResult TcpPingResponse
|
||||
tcpPingResult, err = w.handleTcpPing(cmd.Data)
|
||||
response.Type = "TcpPingResponse"
|
||||
response.Data = tcpPingResult
|
||||
// needSaveConfig = false (默认值)
|
||||
|
||||
// Protocol blocking switches
|
||||
case "SetProtocol":
|
||||
err = w.handleSetProtocol(cmd.Data)
|
||||
response.Type = "SetProtocolResponse"
|
||||
needSaveConfig = true
|
||||
|
||||
default:
|
||||
err = fmt.Errorf("未知命令类型: %s", cmd.Type)
|
||||
response.Type = "UnknownCommandResponse"
|
||||
}
|
||||
|
||||
// 只有状态变更命令才保存配置
|
||||
if needSaveConfig {
|
||||
if saveErr := saveConfig(); saveErr != nil {
|
||||
fmt.Printf("❌ 保存配置失败: %v\n", saveErr)
|
||||
if err == nil {
|
||||
err = fmt.Errorf("保存配置失败: %v", saveErr)
|
||||
} else {
|
||||
err = fmt.Errorf("%v; 保存配置失败: %v", err, saveErr)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("✅ 配置已保存到 gost.json")
|
||||
}
|
||||
}
|
||||
|
||||
// 发送响应
|
||||
if err != nil {
|
||||
saveConfig()
|
||||
response.Success = false
|
||||
response.Message = err.Error()
|
||||
} else {
|
||||
saveConfig()
|
||||
response.Success = true
|
||||
response.Message = "OK"
|
||||
}
|
||||
@@ -775,81 +834,81 @@ func (w *WebSocketReporter) handleDeleteLimiter(data interface{}) error {
|
||||
|
||||
// handleSetProtocol 处理设置屏蔽协议的命令
|
||||
func (w *WebSocketReporter) handleSetProtocol(data interface{}) error {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化协议设置失败: %v", err)
|
||||
}
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化协议设置失败: %v", err)
|
||||
}
|
||||
|
||||
// 支持 {"http":0/1, "tls":0/1, "socks":0/1}
|
||||
var req struct {
|
||||
HTTP *int `json:"http"`
|
||||
TLS *int `json:"tls"`
|
||||
SOCKS *int `json:"socks"`
|
||||
}
|
||||
if err := json.Unmarshal(jsonData, &req); err != nil {
|
||||
return fmt.Errorf("解析协议设置失败: %v", err)
|
||||
}
|
||||
// 支持 {"http":0/1, "tls":0/1, "socks":0/1}
|
||||
var req struct {
|
||||
HTTP *int `json:"http"`
|
||||
TLS *int `json:"tls"`
|
||||
SOCKS *int `json:"socks"`
|
||||
}
|
||||
if err := json.Unmarshal(jsonData, &req); err != nil {
|
||||
return fmt.Errorf("解析协议设置失败: %v", err)
|
||||
}
|
||||
|
||||
// 读取当前值作为默认
|
||||
httpVal, tlsVal, socksVal := 0, 0, 0
|
||||
// 读取当前值作为默认
|
||||
httpVal, tlsVal, socksVal := 0, 0, 0
|
||||
|
||||
if req.HTTP != nil {
|
||||
if *req.HTTP != 0 && *req.HTTP != 1 {
|
||||
return fmt.Errorf("http 取值必须为0或1")
|
||||
}
|
||||
httpVal = *req.HTTP
|
||||
}
|
||||
if req.TLS != nil {
|
||||
if *req.TLS != 0 && *req.TLS != 1 {
|
||||
return fmt.Errorf("tls 取值必须为0或1")
|
||||
}
|
||||
tlsVal = *req.TLS
|
||||
}
|
||||
if req.SOCKS != nil {
|
||||
if *req.SOCKS != 0 && *req.SOCKS != 1 {
|
||||
return fmt.Errorf("socks 取值必须为0或1")
|
||||
}
|
||||
socksVal = *req.SOCKS
|
||||
}
|
||||
if req.HTTP != nil {
|
||||
if *req.HTTP != 0 && *req.HTTP != 1 {
|
||||
return fmt.Errorf("http 取值必须为0或1")
|
||||
}
|
||||
httpVal = *req.HTTP
|
||||
}
|
||||
if req.TLS != nil {
|
||||
if *req.TLS != 0 && *req.TLS != 1 {
|
||||
return fmt.Errorf("tls 取值必须为0或1")
|
||||
}
|
||||
tlsVal = *req.TLS
|
||||
}
|
||||
if req.SOCKS != nil {
|
||||
if *req.SOCKS != 0 && *req.SOCKS != 1 {
|
||||
return fmt.Errorf("socks 取值必须为0或1")
|
||||
}
|
||||
socksVal = *req.SOCKS
|
||||
}
|
||||
|
||||
// 设置至 service,全量传递(未提供的值沿用0)
|
||||
service.SetProtocolBlock(httpVal, tlsVal, socksVal)
|
||||
// 设置至 service,全量传递(未提供的值沿用0)
|
||||
service.SetProtocolBlock(httpVal, tlsVal, socksVal)
|
||||
|
||||
// 同步写入本地 config.json
|
||||
if err := updateLocalConfigJSON(httpVal, tlsVal, socksVal); err != nil {
|
||||
return fmt.Errorf("写入config.json失败: %v", err)
|
||||
}
|
||||
return nil
|
||||
// 同步写入本地 config.json
|
||||
if err := updateLocalConfigJSON(httpVal, tlsVal, socksVal); err != nil {
|
||||
return fmt.Errorf("写入config.json失败: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateLocalConfigJSON 将 http/tls/socks 写入工作目录下的 config.json
|
||||
func updateLocalConfigJSON(httpVal int, tlsVal int, socksVal int) error {
|
||||
path := "config.json"
|
||||
path := "config.json"
|
||||
|
||||
// 读取现有配置
|
||||
type LocalConfig struct {
|
||||
Addr string `json:"addr"`
|
||||
Secret string `json:"secret"`
|
||||
Http int `json:"http"`
|
||||
Tls int `json:"tls"`
|
||||
Socks int `json:"socks"`
|
||||
}
|
||||
// 读取现有配置
|
||||
type LocalConfig struct {
|
||||
Addr string `json:"addr"`
|
||||
Secret string `json:"secret"`
|
||||
Http int `json:"http"`
|
||||
Tls int `json:"tls"`
|
||||
Socks int `json:"socks"`
|
||||
}
|
||||
|
||||
var cfg LocalConfig
|
||||
if b, err := os.ReadFile(path); err == nil {
|
||||
_ = json.Unmarshal(b, &cfg)
|
||||
}
|
||||
var cfg LocalConfig
|
||||
if b, err := os.ReadFile(path); err == nil {
|
||||
_ = json.Unmarshal(b, &cfg)
|
||||
}
|
||||
|
||||
cfg.Http = httpVal
|
||||
cfg.Tls = tlsVal
|
||||
cfg.Socks = socksVal
|
||||
cfg.Http = httpVal
|
||||
cfg.Tls = tlsVal
|
||||
cfg.Socks = socksVal
|
||||
|
||||
// 写回
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0644)
|
||||
// 写回
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
// handleCall 处理服务端的call回调消息
|
||||
@@ -1021,12 +1080,16 @@ func getMemoryInfo() MemoryInfo {
|
||||
// StartWebSocketReporterWithConfig 使用配置字段启动WebSocket报告器
|
||||
func StartWebSocketReporterWithConfig(addr string, secret string, http int, tls int, socks int, version string) *WebSocketReporter {
|
||||
|
||||
// 构建包含本机IP的WebSocket URL
|
||||
// 构建初始 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)
|
||||
|
||||
fmt.Printf("🔗 WebSocket连接URL: %s\n", fullURL)
|
||||
|
||||
reporter := NewWebSocketReporter(fullURL, secret)
|
||||
// 保存 addr, secret, version 供重连时使用
|
||||
reporter.addr = addr
|
||||
reporter.secret = secret
|
||||
reporter.version = version
|
||||
reporter.Start()
|
||||
return reporter
|
||||
}
|
||||
@@ -1107,6 +1170,32 @@ func tcpPingHost(ip string, port int, count int, timeoutMs int) (float64, float6
|
||||
|
||||
fmt.Printf("🔍 开始TCP ping测试: %s,次数: %d,超时: %dms\n", target, count, timeoutMs)
|
||||
|
||||
// 如果是域名,先解析一次DNS,避免每次连接都重新解析导致延迟累加
|
||||
if net.ParseIP(ip) == nil {
|
||||
// 是域名,需要解析
|
||||
fmt.Printf("🔍 检测到域名,正在解析DNS...\n")
|
||||
dnsStart := time.Now()
|
||||
|
||||
addrs, err := net.LookupHost(ip)
|
||||
dnsDuration := time.Since(dnsStart)
|
||||
|
||||
if err != nil {
|
||||
return 0, 100.0, fmt.Errorf("DNS解析失败: %v", err)
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return 0, 100.0, fmt.Errorf("DNS解析未返回任何IP地址")
|
||||
}
|
||||
|
||||
fmt.Printf("✅ DNS解析完成 (%.2fms),解析到 %d 个IP: %v\n",
|
||||
dnsDuration.Seconds()*1000, len(addrs), addrs)
|
||||
|
||||
// 使用第一个解析到的IP进行测试
|
||||
target = net.JoinHostPort(addrs[0], fmt.Sprintf("%d", port))
|
||||
fmt.Printf("🎯 使用IP地址进行测试: %s\n", target)
|
||||
} else {
|
||||
fmt.Printf("🎯 使用IP地址进行测试: %s\n", target)
|
||||
}
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
start := time.Now()
|
||||
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
-- phpMyAdmin SQL Dump
|
||||
-- version 5.2.0
|
||||
-- https://www.phpmyadmin.net/
|
||||
--
|
||||
-- 主机: localhost
|
||||
-- 生成日期: 2025-08-14 21:52:52
|
||||
-- 服务器版本: 5.7.40-log
|
||||
-- PHP 版本: 7.4.33
|
||||
|
||||
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
|
||||
START TRANSACTION;
|
||||
SET time_zone = "+00:00";
|
||||
|
||||
|
||||
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
|
||||
/*!40101 SET NAMES utf8mb4 */;
|
||||
|
||||
--
|
||||
-- 数据库: `gost`
|
||||
--
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- 表的结构 `forward`
|
||||
--
|
||||
|
||||
CREATE TABLE `forward` (
|
||||
`id` int(10) NOT NULL,
|
||||
`user_id` int(10) NOT NULL,
|
||||
`user_name` varchar(100) NOT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`tunnel_id` int(10) NOT NULL,
|
||||
`in_port` int(10) NOT NULL,
|
||||
`out_port` int(10) DEFAULT NULL,
|
||||
`remote_addr` longtext NOT NULL,
|
||||
`strategy` varchar(100) NOT NULL DEFAULT 'fifo',
|
||||
`interface_name` varchar(200) DEFAULT NULL,
|
||||
`in_flow` bigint(20) NOT NULL DEFAULT '0',
|
||||
`out_flow` bigint(20) NOT NULL DEFAULT '0',
|
||||
`created_time` bigint(20) NOT NULL,
|
||||
`updated_time` bigint(20) NOT NULL,
|
||||
`status` int(10) NOT NULL,
|
||||
`inx` int(10) NOT NULL DEFAULT '0'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- 表的结构 `node`
|
||||
--
|
||||
|
||||
CREATE TABLE `node` (
|
||||
`id` int(10) NOT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`secret` varchar(100) NOT NULL,
|
||||
`ip` longtext,
|
||||
`server_ip` varchar(100) NOT NULL,
|
||||
`port_sta` int(10) NOT NULL,
|
||||
`port_end` int(10) NOT NULL,
|
||||
`version` varchar(100) DEFAULT NULL,
|
||||
`http` int(10) NOT NULL DEFAULT '0',
|
||||
`tls` int(10) NOT NULL DEFAULT '0',
|
||||
`socks` int(10) NOT NULL DEFAULT '0',
|
||||
`created_time` bigint(20) NOT NULL,
|
||||
`updated_time` bigint(20) DEFAULT NULL,
|
||||
`status` int(10) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- 表的结构 `speed_limit`
|
||||
--
|
||||
|
||||
CREATE TABLE `speed_limit` (
|
||||
`id` int(10) NOT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`speed` int(10) NOT NULL,
|
||||
`tunnel_id` int(10) NOT NULL,
|
||||
`tunnel_name` varchar(100) NOT NULL,
|
||||
`created_time` bigint(20) NOT NULL,
|
||||
`updated_time` bigint(20) DEFAULT NULL,
|
||||
`status` int(10) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- 表的结构 `statistics_flow`
|
||||
--
|
||||
|
||||
CREATE TABLE `statistics_flow` (
|
||||
`id` int(10) NOT NULL,
|
||||
`user_id` int(10) NOT NULL,
|
||||
`flow` bigint(20) NOT NULL,
|
||||
`total_flow` bigint(20) NOT NULL,
|
||||
`time` varchar(100) NOT NULL,
|
||||
`created_time` bigint(20) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- 表的结构 `tunnel`
|
||||
--
|
||||
|
||||
CREATE TABLE `tunnel` (
|
||||
`id` int(10) NOT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`traffic_ratio` decimal(10,1) NOT NULL DEFAULT '1.0',
|
||||
`in_node_id` int(10) NOT NULL,
|
||||
`in_ip` varchar(100) NOT NULL,
|
||||
`out_node_id` int(10) NOT NULL,
|
||||
`out_ip` varchar(100) NOT NULL,
|
||||
`type` int(10) NOT NULL,
|
||||
`protocol` varchar(10) NOT NULL DEFAULT 'tls',
|
||||
`flow` int(10) NOT NULL,
|
||||
`tcp_listen_addr` varchar(100) NOT NULL DEFAULT '[::]',
|
||||
`udp_listen_addr` varchar(100) NOT NULL DEFAULT '[::]',
|
||||
`interface_name` varchar(200) DEFAULT NULL,
|
||||
`created_time` bigint(20) NOT NULL,
|
||||
`updated_time` bigint(20) NOT NULL,
|
||||
`status` int(10) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- 表的结构 `user`
|
||||
--
|
||||
|
||||
CREATE TABLE `user` (
|
||||
`id` int(10) NOT NULL,
|
||||
`user` varchar(100) NOT NULL,
|
||||
`pwd` varchar(100) NOT NULL,
|
||||
`role_id` int(10) NOT NULL,
|
||||
`exp_time` bigint(20) NOT NULL,
|
||||
`flow` bigint(20) NOT NULL,
|
||||
`in_flow` bigint(20) NOT NULL DEFAULT '0',
|
||||
`out_flow` bigint(20) NOT NULL DEFAULT '0',
|
||||
`flow_reset_time` bigint(20) NOT NULL,
|
||||
`num` int(10) NOT NULL,
|
||||
`created_time` bigint(20) NOT NULL,
|
||||
`updated_time` bigint(20) DEFAULT NULL,
|
||||
`status` int(10) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
--
|
||||
-- 转存表中的数据 `user`
|
||||
--
|
||||
|
||||
INSERT INTO `user` (`id`, `user`, `pwd`, `role_id`, `exp_time`, `flow`, `in_flow`, `out_flow`, `flow_reset_time`, `num`, `created_time`, `updated_time`, `status`) VALUES
|
||||
(1, 'admin_user', '3c85cdebade1c51cf64ca9f3c09d182d', 0, 2727251700000, 99999, 0, 0, 1, 99999, 1748914865000, 1754011744252, 1);
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- 表的结构 `user_tunnel`
|
||||
--
|
||||
|
||||
CREATE TABLE `user_tunnel` (
|
||||
`id` int(10) NOT NULL,
|
||||
`user_id` int(10) NOT NULL,
|
||||
`tunnel_id` int(10) NOT NULL,
|
||||
`speed_id` int(10) DEFAULT NULL,
|
||||
`num` int(10) NOT NULL,
|
||||
`flow` bigint(20) NOT NULL,
|
||||
`in_flow` bigint(20) NOT NULL DEFAULT '0',
|
||||
`out_flow` bigint(20) NOT NULL DEFAULT '0',
|
||||
`flow_reset_time` bigint(20) NOT NULL,
|
||||
`exp_time` bigint(20) NOT NULL,
|
||||
`status` int(10) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- 表的结构 `vite_config`
|
||||
--
|
||||
|
||||
CREATE TABLE `vite_config` (
|
||||
`id` int(10) NOT NULL,
|
||||
`name` varchar(200) NOT NULL,
|
||||
`value` varchar(200) NOT NULL,
|
||||
`time` bigint(20) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
--
|
||||
-- 转存表中的数据 `vite_config`
|
||||
--
|
||||
|
||||
INSERT INTO `vite_config` (`id`, `name`, `value`, `time`) VALUES
|
||||
(1, 'app_name', 'flux', 1755147963000);
|
||||
|
||||
--
|
||||
-- 转储表的索引
|
||||
--
|
||||
|
||||
--
|
||||
-- 表的索引 `forward`
|
||||
--
|
||||
ALTER TABLE `forward`
|
||||
ADD PRIMARY KEY (`id`);
|
||||
|
||||
--
|
||||
-- 表的索引 `node`
|
||||
--
|
||||
ALTER TABLE `node`
|
||||
ADD PRIMARY KEY (`id`);
|
||||
|
||||
--
|
||||
-- 表的索引 `speed_limit`
|
||||
--
|
||||
ALTER TABLE `speed_limit`
|
||||
ADD PRIMARY KEY (`id`);
|
||||
|
||||
--
|
||||
-- 表的索引 `statistics_flow`
|
||||
--
|
||||
ALTER TABLE `statistics_flow`
|
||||
ADD PRIMARY KEY (`id`);
|
||||
|
||||
--
|
||||
-- 表的索引 `tunnel`
|
||||
--
|
||||
ALTER TABLE `tunnel`
|
||||
ADD PRIMARY KEY (`id`);
|
||||
|
||||
--
|
||||
-- 表的索引 `user`
|
||||
--
|
||||
ALTER TABLE `user`
|
||||
ADD PRIMARY KEY (`id`);
|
||||
|
||||
--
|
||||
-- 表的索引 `user_tunnel`
|
||||
--
|
||||
ALTER TABLE `user_tunnel`
|
||||
ADD PRIMARY KEY (`id`);
|
||||
|
||||
--
|
||||
-- 表的索引 `vite_config`
|
||||
--
|
||||
ALTER TABLE `vite_config`
|
||||
ADD PRIMARY KEY (`id`),
|
||||
ADD UNIQUE KEY `name` (`name`);
|
||||
|
||||
--
|
||||
-- 在导出的表使用AUTO_INCREMENT
|
||||
--
|
||||
|
||||
--
|
||||
-- 使用表AUTO_INCREMENT `forward`
|
||||
--
|
||||
ALTER TABLE `forward`
|
||||
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
|
||||
|
||||
--
|
||||
-- 使用表AUTO_INCREMENT `node`
|
||||
--
|
||||
ALTER TABLE `node`
|
||||
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
|
||||
|
||||
--
|
||||
-- 使用表AUTO_INCREMENT `speed_limit`
|
||||
--
|
||||
ALTER TABLE `speed_limit`
|
||||
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
|
||||
|
||||
--
|
||||
-- 使用表AUTO_INCREMENT `statistics_flow`
|
||||
--
|
||||
ALTER TABLE `statistics_flow`
|
||||
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
|
||||
|
||||
--
|
||||
-- 使用表AUTO_INCREMENT `tunnel`
|
||||
--
|
||||
ALTER TABLE `tunnel`
|
||||
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
|
||||
|
||||
--
|
||||
-- 使用表AUTO_INCREMENT `user`
|
||||
--
|
||||
ALTER TABLE `user`
|
||||
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
|
||||
|
||||
--
|
||||
-- 使用表AUTO_INCREMENT `user_tunnel`
|
||||
--
|
||||
ALTER TABLE `user_tunnel`
|
||||
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
|
||||
|
||||
--
|
||||
-- 使用表AUTO_INCREMENT `vite_config`
|
||||
--
|
||||
ALTER TABLE `vite_config`
|
||||
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
|
||||
COMMIT;
|
||||
|
||||
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||
+155
-62
@@ -1,12 +1,108 @@
|
||||
#!/bin/bash
|
||||
# 下载地址
|
||||
DOWNLOAD_URL="https://github.com/bqlpfy/flux-panel/releases/download/gost-latest/gost"
|
||||
INSTALL_DIR="/etc/gost"
|
||||
|
||||
# GitHub repo used for release downloads
|
||||
REPO="Sagit-chu/flux-panel"
|
||||
|
||||
# 获取系统架构
|
||||
get_architecture() {
|
||||
ARCH=$(uname -m)
|
||||
case $ARCH in
|
||||
x86_64)
|
||||
echo "amd64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
echo "arm64"
|
||||
;;
|
||||
*)
|
||||
echo "amd64" # 默认使用 amd64
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 安装目录
|
||||
INSTALL_DIR="/etc/flux_agent"
|
||||
|
||||
# 识别国家(用于镜像加速)
|
||||
COUNTRY=$(curl -s https://ipinfo.io/country)
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
# 拼接 URL
|
||||
DOWNLOAD_URL="https://ghfast.top/${DOWNLOAD_URL}"
|
||||
fi
|
||||
|
||||
maybe_proxy_url() {
|
||||
local url="$1"
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
echo "https://gcode.hostcentral.cc/${url}"
|
||||
else
|
||||
echo "$url"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_latest_release_tag() {
|
||||
local effective_url tag api_tag latest_url api_url
|
||||
|
||||
latest_url="https://github.com/${REPO}/releases/latest"
|
||||
api_url="https://api.github.com/repos/${REPO}/releases/latest"
|
||||
|
||||
# 方式1:跟随重定向,取最终 URL 的最后一段作为 tag
|
||||
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$latest_url" 2>/dev/null || true)
|
||||
tag="${effective_url##*/}"
|
||||
if [[ -n "$tag" && "$tag" != "latest" ]]; then
|
||||
echo "$tag"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# CN 环境下可尝试通过镜像访问(不影响非 CN)
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$(maybe_proxy_url "$latest_url")" 2>/dev/null || true)
|
||||
tag="${effective_url##*/}"
|
||||
if [[ -n "$tag" && "$tag" != "latest" ]]; then
|
||||
echo "$tag"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# 方式2:GitHub API(无需 jq)
|
||||
api_tag=$(curl -fsSL "$api_url" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||
if [[ -n "$api_tag" ]]; then
|
||||
echo "$api_tag"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
api_tag=$(curl -fsSL "$(maybe_proxy_url "$api_url")" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
|
||||
if [[ -n "$api_tag" ]]; then
|
||||
echo "$api_tag"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
resolve_version() {
|
||||
if [[ -n "${VERSION:-}" ]]; then
|
||||
echo "$VERSION"
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "${FLUX_VERSION:-}" ]]; then
|
||||
echo "$FLUX_VERSION"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if resolve_latest_release_tag; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "❌ 无法获取最新版本号。你可以手动指定版本,例如:VERSION=<版本号> ./install.sh" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# 构建下载地址
|
||||
build_download_url() {
|
||||
local ARCH=$(get_architecture)
|
||||
echo "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/gost-${ARCH}"
|
||||
}
|
||||
|
||||
# 解析版本并构建下载地址
|
||||
RESOLVED_VERSION=$(resolve_version) || exit 1
|
||||
DOWNLOAD_URL=$(maybe_proxy_url "$(build_download_url)")
|
||||
|
||||
|
||||
|
||||
@@ -131,8 +227,8 @@ while getopts "a:s:" opt; do
|
||||
done
|
||||
|
||||
# 安装功能
|
||||
install_gost() {
|
||||
echo "🚀 开始安装 GOST..."
|
||||
install_flux_agent() {
|
||||
echo "🚀 开始安装 flux_agent..."
|
||||
get_config_params
|
||||
|
||||
# 检查并安装 tcpkill
|
||||
@@ -142,27 +238,27 @@ install_gost() {
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
# 停止并禁用已有服务
|
||||
if systemctl list-units --full -all | grep -Fq "gost.service"; then
|
||||
echo "🔍 检测到已存在的gost服务"
|
||||
systemctl stop gost 2>/dev/null && echo "🛑 停止服务"
|
||||
systemctl disable gost 2>/dev/null && echo "🚫 禁用自启"
|
||||
if systemctl list-units --full -all | grep -Fq "flux_agent.service"; then
|
||||
echo "🔍 检测到已存在的flux_agent服务"
|
||||
systemctl stop flux_agent 2>/dev/null && echo "🛑 停止服务"
|
||||
systemctl disable flux_agent 2>/dev/null && echo "🚫 禁用自启"
|
||||
fi
|
||||
|
||||
# 删除旧文件
|
||||
[[ -f "$INSTALL_DIR/gost" ]] && echo "🧹 删除旧文件 gost" && rm -f "$INSTALL_DIR/gost"
|
||||
[[ -f "$INSTALL_DIR/flux_agent" ]] && echo "🧹 删除旧文件 flux_agent" && rm -f "$INSTALL_DIR/flux_agent"
|
||||
|
||||
# 下载 gost
|
||||
echo "⬇️ 下载 gost 中..."
|
||||
curl -L "$DOWNLOAD_URL" -o "$INSTALL_DIR/gost"
|
||||
if [[ ! -f "$INSTALL_DIR/gost" || ! -s "$INSTALL_DIR/gost" ]]; then
|
||||
# 下载 flux_agent
|
||||
echo "⬇️ 下载 flux_agent 中..."
|
||||
curl -L "$DOWNLOAD_URL" -o "$INSTALL_DIR/flux_agent"
|
||||
if [[ ! -f "$INSTALL_DIR/flux_agent" || ! -s "$INSTALL_DIR/flux_agent" ]]; then
|
||||
echo "❌ 下载失败,请检查网络或下载链接。"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$INSTALL_DIR/gost"
|
||||
chmod +x "$INSTALL_DIR/flux_agent"
|
||||
echo "✅ 下载完成"
|
||||
|
||||
# 打印版本
|
||||
echo "🔎 gost 版本:$($INSTALL_DIR/gost -V)"
|
||||
echo "🔎 flux_agent 版本:$($INSTALL_DIR/flux_agent -V)"
|
||||
|
||||
# 写入 config.json (安装时总是创建新的)
|
||||
CONFIG_FILE="$INSTALL_DIR/config.json"
|
||||
@@ -189,16 +285,18 @@ EOF
|
||||
chmod 600 "$INSTALL_DIR"/*.json
|
||||
|
||||
# 创建 systemd 服务
|
||||
SERVICE_FILE="/etc/systemd/system/gost.service"
|
||||
SERVICE_FILE="/etc/systemd/system/flux_agent.service"
|
||||
cat > "$SERVICE_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=Gost Proxy Service
|
||||
Description=Flux_agent Proxy Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=$INSTALL_DIR
|
||||
ExecStart=$INSTALL_DIR/gost
|
||||
ExecStart=$INSTALL_DIR/flux_agent
|
||||
Restart=on-failure
|
||||
StandardOutput=null
|
||||
StandardError=null
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -206,27 +304,27 @@ EOF
|
||||
|
||||
# 启动服务
|
||||
systemctl daemon-reload
|
||||
systemctl enable gost
|
||||
systemctl start gost
|
||||
systemctl enable flux_agent
|
||||
systemctl start flux_agent
|
||||
|
||||
# 检查状态
|
||||
echo "🔄 检查服务状态..."
|
||||
if systemctl is-active --quiet gost; then
|
||||
echo "✅ 安装完成,gost服务已启动并设置为开机启动。"
|
||||
if systemctl is-active --quiet flux_agent; then
|
||||
echo "✅ 安装完成,flux_agent服务已启动并设置为开机启动。"
|
||||
echo "📁 配置目录: $INSTALL_DIR"
|
||||
echo "🔧 服务状态: $(systemctl is-active gost)"
|
||||
echo "🔧 服务状态: $(systemctl is-active flux_agent)"
|
||||
else
|
||||
echo "❌ gost服务启动失败,请执行以下命令查看日志:"
|
||||
echo "journalctl -u gost -f"
|
||||
echo "❌ flux_agent服务启动失败,请执行以下命令查看状态:"
|
||||
echo "systemctl status flux_agent --no-pager"
|
||||
fi
|
||||
}
|
||||
|
||||
# 更新功能
|
||||
update_gost() {
|
||||
echo "🔄 开始更新 GOST..."
|
||||
update_flux_agent() {
|
||||
echo "🔄 开始更新 flux_agent..."
|
||||
|
||||
if [[ ! -d "$INSTALL_DIR" ]]; then
|
||||
echo "❌ GOST 未安装,请先选择安装。"
|
||||
echo "❌ flux_agent 未安装,请先选择安装。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -237,52 +335,52 @@ update_gost() {
|
||||
|
||||
# 先下载新版本
|
||||
echo "⬇️ 下载最新版本..."
|
||||
curl -L "$DOWNLOAD_URL" -o "$INSTALL_DIR/gost.new"
|
||||
if [[ ! -f "$INSTALL_DIR/gost.new" || ! -s "$INSTALL_DIR/gost.new" ]]; then
|
||||
curl -L "$DOWNLOAD_URL" -o "$INSTALL_DIR/flux_agent.new"
|
||||
if [[ ! -f "$INSTALL_DIR/flux_agent.new" || ! -s "$INSTALL_DIR/flux_agent.new" ]]; then
|
||||
echo "❌ 下载失败。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 停止服务
|
||||
if systemctl list-units --full -all | grep -Fq "gost.service"; then
|
||||
echo "🛑 停止 gost 服务..."
|
||||
systemctl stop gost
|
||||
if systemctl list-units --full -all | grep -Fq "flux_agent.service"; then
|
||||
echo "🛑 停止 flux_agent 服务..."
|
||||
systemctl stop flux_agent
|
||||
fi
|
||||
|
||||
# 替换文件
|
||||
mv "$INSTALL_DIR/gost.new" "$INSTALL_DIR/gost"
|
||||
chmod +x "$INSTALL_DIR/gost"
|
||||
mv "$INSTALL_DIR/flux_agent.new" "$INSTALL_DIR/flux_agent"
|
||||
chmod +x "$INSTALL_DIR/flux_agent"
|
||||
|
||||
# 打印版本
|
||||
echo "🔎 新版本:$($INSTALL_DIR/gost -V)"
|
||||
echo "🔎 新版本:$($INSTALL_DIR/flux_agent -V)"
|
||||
|
||||
# 重启服务
|
||||
echo "🔄 重启服务..."
|
||||
systemctl start gost
|
||||
systemctl start flux_agent
|
||||
|
||||
echo "✅ 更新完成,服务已重新启动。"
|
||||
}
|
||||
|
||||
# 卸载功能
|
||||
uninstall_gost() {
|
||||
echo "🗑️ 开始卸载 GOST..."
|
||||
uninstall_flux_agent() {
|
||||
echo "🗑️ 开始卸载 flux_agent..."
|
||||
|
||||
read -p "确认卸载 GOST 吗?此操作将删除所有相关文件 (y/N): " confirm
|
||||
read -p "确认卸载 flux_agent 吗?此操作将删除所有相关文件 (y/N): " confirm
|
||||
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
|
||||
echo "❌ 取消卸载"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 停止并禁用服务
|
||||
if systemctl list-units --full -all | grep -Fq "gost.service"; then
|
||||
if systemctl list-units --full -all | grep -Fq "flux_agent.service"; then
|
||||
echo "🛑 停止并禁用服务..."
|
||||
systemctl stop gost 2>/dev/null
|
||||
systemctl disable gost 2>/dev/null
|
||||
systemctl stop flux_agent 2>/dev/null
|
||||
systemctl disable flux_agent 2>/dev/null
|
||||
fi
|
||||
|
||||
# 删除服务文件
|
||||
if [[ -f "/etc/systemd/system/gost.service" ]]; then
|
||||
rm -f "/etc/systemd/system/gost.service"
|
||||
if [[ -f "/etc/systemd/system/flux_agent.service" ]]; then
|
||||
rm -f "/etc/systemd/system/flux_agent.service"
|
||||
echo "🧹 删除服务文件"
|
||||
fi
|
||||
|
||||
@@ -302,7 +400,7 @@ uninstall_gost() {
|
||||
main() {
|
||||
# 如果提供了命令行参数,直接执行安装
|
||||
if [[ -n "$SERVER_ADDR" && -n "$SECRET" ]]; then
|
||||
install_gost
|
||||
install_flux_agent
|
||||
delete_self
|
||||
exit 0
|
||||
fi
|
||||
@@ -310,36 +408,31 @@ main() {
|
||||
# 显示交互式菜单
|
||||
while true; do
|
||||
show_menu
|
||||
read -p "请输入选项 (1-5): " choice
|
||||
read -p "请输入选项 (1-4): " choice
|
||||
|
||||
case $choice in
|
||||
1)
|
||||
install_gost
|
||||
install_flux_agent
|
||||
delete_self
|
||||
exit 0
|
||||
;;
|
||||
2)
|
||||
update_gost
|
||||
update_flux_agent
|
||||
delete_self
|
||||
exit 0
|
||||
;;
|
||||
3)
|
||||
uninstall_gost
|
||||
uninstall_flux_agent
|
||||
delete_self
|
||||
exit 0
|
||||
;;
|
||||
4)
|
||||
block_protocol
|
||||
delete_self
|
||||
exit 0
|
||||
;;
|
||||
5)
|
||||
echo "👋 退出脚本"
|
||||
delete_self
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "❌ 无效选项,请输入 1-5"
|
||||
echo "❌ 无效选项,请输入 1-4"
|
||||
echo ""
|
||||
;;
|
||||
esac
|
||||
@@ -347,4 +440,4 @@ main() {
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main
|
||||
main
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
site_name: FLVX 文档
|
||||
site_url: https://sagit-chu.github.io/flux-panel/
|
||||
site_description: FLVX官方文档
|
||||
site_author: Sagit-chu
|
||||
|
||||
theme:
|
||||
name: material
|
||||
language: zh
|
||||
palette:
|
||||
- scheme: default
|
||||
primary: indigo
|
||||
accent: indigo
|
||||
- scheme: slate
|
||||
primary: indigo
|
||||
accent: indigo
|
||||
toggle:
|
||||
icon: material/brightness-4
|
||||
name: Switch to dark mode
|
||||
features:
|
||||
- navigation.tabs
|
||||
- navigation.sections
|
||||
- navigation.top
|
||||
- search.suggest
|
||||
- search.highlight
|
||||
- content.code.copy
|
||||
|
||||
docs_dir: doc
|
||||
|
||||
nav:
|
||||
- 首页: index.md
|
||||
- 安装部署: install.md
|
||||
- 使用指南: usage.md
|
||||
- 常见问题: faq.md
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
- pymdownx.details
|
||||
- pymdownx.superfences
|
||||
- pymdownx.tabbed:
|
||||
alternate_style: true
|
||||
+125
-786
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
||||
# 构建阶段
|
||||
FROM maven:3.9.6-eclipse-temurin-21 AS build
|
||||
WORKDIR /app
|
||||
COPY pom.xml .
|
||||
COPY src ./src
|
||||
RUN mvn clean package -DskipTests
|
||||
|
||||
# 运行阶段
|
||||
FROM eclipse-temurin:21-jre
|
||||
WORKDIR /app
|
||||
ENV JAVA_OPTS="-Xmx512m -Xms256m -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"
|
||||
ENV LANG=en_US.UTF-8
|
||||
ENV LANGUAGE=en_US:en
|
||||
ENV LC_ALL=en_US.UTF-8
|
||||
|
||||
RUN sed -i 's/archive.ubuntu.com/mirrors.ustc.edu.cn/g' /etc/apt/sources.list \
|
||||
&& sed -i 's/security.ubuntu.com/mirrors.ustc.edu.cn/g' /etc/apt/sources.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends fontconfig fonts-dejavu \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /app/target/*.jar app.jar
|
||||
EXPOSE 6365
|
||||
|
||||
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
|
||||
@@ -1,181 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.admin</groupId>
|
||||
<artifactId>admin</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>admin</name>
|
||||
<description>admin</description>
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<spring-boot.version>2.7.18</spring-boot.version>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
</properties>
|
||||
<dependencies>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.fastjson2</groupId>
|
||||
<artifactId>fastjson2</artifactId>
|
||||
<version>2.0.43</version>
|
||||
</dependency>
|
||||
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.30</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.github.yulichang</groupId>
|
||||
<artifactId>mybatis-plus-join</artifactId>
|
||||
<version>1.3.0</version>
|
||||
</dependency>
|
||||
|
||||
|
||||
<dependency>
|
||||
<groupId>com.alibaba</groupId>
|
||||
<artifactId>fastjson</artifactId>
|
||||
<version>1.2.70</version>
|
||||
</dependency>
|
||||
|
||||
|
||||
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aop</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-boot-starter</artifactId>
|
||||
<version>3.4.1</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-generator</artifactId>
|
||||
<version>3.4.1</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<version>8.2.0</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-all</artifactId>
|
||||
<version>5.3.3</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
<version>3.11</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents</groupId>
|
||||
<artifactId>httpclient</artifactId>
|
||||
<version>4.5.14</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.freemarker</groupId>
|
||||
<artifactId>freemarker</artifactId>
|
||||
<version>2.3.31</version>
|
||||
</dependency>
|
||||
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- tianai-captcha Spring Boot Starter -->
|
||||
<dependency>
|
||||
<groupId>cloud.tianai.captcha</groupId>
|
||||
<artifactId>tianai-captcha-springboot-starter</artifactId>
|
||||
<version>1.5.2</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-dependencies</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
<configuration>
|
||||
<source>21</source>
|
||||
<target>21</target>
|
||||
<encoding>UTF-8</encoding>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.30</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
<configuration>
|
||||
<mainClass>com.admin.AdminApplication</mainClass>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>repackage</id>
|
||||
<goals>
|
||||
<goal>repackage</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -1,23 +0,0 @@
|
||||
package com.admin;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableAsync
|
||||
@EnableScheduling
|
||||
public class AdminApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(AdminApplication.class, args);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package com.admin;
|
||||
|
||||
import com.baomidou.mybatisplus.core.exceptions.MybatisPlusException;
|
||||
import com.baomidou.mybatisplus.core.toolkit.StringPool;
|
||||
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
|
||||
import com.baomidou.mybatisplus.generator.AutoGenerator;
|
||||
import com.baomidou.mybatisplus.generator.InjectionConfig;
|
||||
import com.baomidou.mybatisplus.generator.config.*;
|
||||
import com.baomidou.mybatisplus.generator.config.po.TableInfo;
|
||||
import com.baomidou.mybatisplus.generator.config.rules.NamingStrategy;
|
||||
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Scanner;
|
||||
|
||||
// 演示例子,执行 main 方法控制台输入模块表名回车自动生成对应项目目录中
|
||||
public class CodeGenerator {
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* 读取控制台内容
|
||||
* </p>
|
||||
*/
|
||||
public static String scanner(String tip) {
|
||||
Scanner scanner = new Scanner(System.in);
|
||||
StringBuilder help = new StringBuilder();
|
||||
help.append("请输入" + tip + ":");
|
||||
System.out.println(help.toString());
|
||||
if (scanner.hasNext()) {
|
||||
String ipt = scanner.next();
|
||||
if (StringUtils.isNotBlank(ipt)) {
|
||||
return ipt;
|
||||
}
|
||||
}
|
||||
throw new MybatisPlusException("请输入正确的" + tip + "!");
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
// 代码生成器
|
||||
AutoGenerator mpg = new AutoGenerator();
|
||||
|
||||
// 全局配置
|
||||
GlobalConfig gc = new GlobalConfig();
|
||||
String projectPath = System.getProperty("user.dir");
|
||||
gc.setOutputDir(projectPath + "/src/main/java");
|
||||
gc.setAuthor("QAQ");
|
||||
gc.setOpen(false);
|
||||
// gc.setSwagger2(true); 实体属性 Swagger2 注解
|
||||
gc.setServiceName("%sService");
|
||||
mpg.setGlobalConfig(gc);
|
||||
|
||||
// 数据源配置 - 使用环境变量
|
||||
DataSourceConfig dsc = new DataSourceConfig();
|
||||
String dbHost = System.getenv("DB_HOST");
|
||||
String dbName = System.getenv("DB_NAME");
|
||||
String dbUser = System.getenv("DB_USER");
|
||||
String dbPassword = System.getenv("DB_PASSWORD");
|
||||
|
||||
if (dbHost == null || dbName == null || dbUser == null || dbPassword == null) {
|
||||
throw new MybatisPlusException("请设置数据库环境变量: DB_HOST, DB_NAME, DB_USER, DB_PASSWORD");
|
||||
}
|
||||
|
||||
dsc.setUrl("jdbc:mysql://" + dbHost + "/" + dbName + "?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai");
|
||||
dsc.setDriverName("com.mysql.cj.jdbc.Driver");
|
||||
dsc.setUsername(dbUser);
|
||||
dsc.setPassword(dbPassword);
|
||||
mpg.setDataSource(dsc);
|
||||
|
||||
// 包配置
|
||||
PackageConfig pc = new PackageConfig();
|
||||
// pc.setModuleName(scanner("模块名"));
|
||||
pc.setParent("com.admin");
|
||||
mpg.setPackageInfo(pc);
|
||||
|
||||
// 自定义配置
|
||||
InjectionConfig cfg = new InjectionConfig() {
|
||||
@Override
|
||||
public void initMap() {
|
||||
// to do nothing
|
||||
}
|
||||
};
|
||||
|
||||
// 如果模板引擎是 freemarker
|
||||
String templatePath = "/templates/mapper.xml.ftl";
|
||||
// 如果模板引擎是 velocity
|
||||
// String templatePath = "/templates/mapper.xml.vm";
|
||||
|
||||
// 自定义输出配置
|
||||
List<FileOutConfig> focList = new ArrayList<>();
|
||||
// 自定义配置会被优先输出
|
||||
focList.add(new FileOutConfig(templatePath) {
|
||||
@Override
|
||||
public String outputFile(TableInfo tableInfo) {
|
||||
return projectPath + "/src/main/resources/mapper/" + pc.getModuleName()
|
||||
+ "/" + tableInfo.getEntityName() + "Mapper" + StringPool.DOT_XML;
|
||||
}
|
||||
});
|
||||
|
||||
cfg.setFileOutConfigList(focList);
|
||||
mpg.setCfg(cfg);
|
||||
|
||||
// 配置模板
|
||||
TemplateConfig templateConfig = new TemplateConfig();
|
||||
|
||||
|
||||
templateConfig.setXml(null);
|
||||
mpg.setTemplate(templateConfig);
|
||||
|
||||
// 策略配置
|
||||
StrategyConfig strategy = new StrategyConfig();
|
||||
strategy.setNaming(NamingStrategy.underline_to_camel);
|
||||
strategy.setColumnNaming(NamingStrategy.underline_to_camel);
|
||||
strategy.setSuperEntityClass("com.admin.entity.BaseEntity");
|
||||
strategy.setEntityLombokModel(true);
|
||||
strategy.setRestControllerStyle(true);
|
||||
// 公共父类
|
||||
strategy.setSuperControllerClass("com.admin.controller.BaseController");
|
||||
strategy.setSuperEntityColumns("id", "created_time", "updated_time", "status");
|
||||
strategy.setInclude(scanner("表名,多个英文逗号分割").split(","));
|
||||
strategy.setControllerMappingHyphenStyle(true);
|
||||
// strategy.setTablePrefix("sys_");//动态调整
|
||||
mpg.setStrategy(strategy);
|
||||
mpg.setTemplateEngine(new FreemarkerTemplateEngine());
|
||||
mpg.execute();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.admin.common.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 权限控制注解
|
||||
* 用于标记需要管理员权限的方法(role_id = 0)
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface RequireRole {
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.admin.common.aop;
|
||||
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Target({ElementType.METHOD})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface LogAnnotation {}
|
||||
@@ -1,193 +0,0 @@
|
||||
package com.admin.common.aop;
|
||||
|
||||
|
||||
import cn.hutool.core.util.ArrayUtil;
|
||||
import com.admin.common.utils.JwtUtil;
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.admin.common.utils.HttpContextUtils;
|
||||
import com.admin.common.utils.IpUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
import org.aspectj.lang.annotation.*;
|
||||
import org.aspectj.lang.reflect.CodeSignature;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
@Aspect
|
||||
@Slf4j
|
||||
public class LogAspect {
|
||||
|
||||
@Pointcut("@annotation(com.admin.common.aop.LogAnnotation)")
|
||||
public void pt() {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回后通知(@AfterReturning):在某连接点(joinpoint)
|
||||
* 正常完成后执行的通知:例如,一个方法没有抛出任何异常,正常返回
|
||||
* 方法执行完毕之后
|
||||
* 注意在这里不能使用ProceedingJoinPoint
|
||||
* 不然会报错ProceedingJoinPoint is only supported for around advice
|
||||
* crmAspect()指向需要控制的方法
|
||||
* returning 注解返回值
|
||||
*
|
||||
* @param joinPoint
|
||||
* @param returnValue 返回值
|
||||
* @throws Exception
|
||||
*/
|
||||
@AfterReturning(value = "pt()", returning = "returnValue")
|
||||
public void log(JoinPoint joinPoint, Object returnValue) throws Throwable {
|
||||
// 获取请求信息
|
||||
HttpServletRequest request = HttpContextUtils.getHttpServletRequest();
|
||||
|
||||
// 获取请求方法类型(POST/GET等)
|
||||
String requestMethod = request.getMethod();
|
||||
|
||||
// 获取用户ID
|
||||
String authorization = request.getHeader("Authorization") + "";
|
||||
Object user_id = "未登录"; // 请求用户的id
|
||||
if (!authorization.equals("null")) {
|
||||
user_id = JwtUtil.getUserIdFromToken(authorization);
|
||||
}
|
||||
|
||||
// 获取请求IP
|
||||
String ipAddr = IpUtils.getIpAddr(request);
|
||||
|
||||
// 获取方法签名信息
|
||||
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
|
||||
Method method = signature.getMethod();
|
||||
|
||||
// 获取控制器方法名
|
||||
String className = joinPoint.getTarget().getClass().getName();
|
||||
String methodName = signature.getName();
|
||||
String controllerMethod = className + "." + methodName;
|
||||
|
||||
|
||||
// 获取请求参数
|
||||
String requestParams = getRequestParams(joinPoint);
|
||||
|
||||
// 获取返回参数
|
||||
String responseParams = returnValue != null ? JSON.toJSONString(returnValue) : "无返回值";
|
||||
|
||||
// 合并为一条完整的日志信息
|
||||
String logMessage = String.format(
|
||||
"【请求日志】用户ID:[%s], IP地址:[%s], 请求方式:[%s], 控制器方法:[%s], 请求参数:[%s], 返回参数:[%s]", user_id, ipAddr, requestMethod, controllerMethod, requestParams, responseParams
|
||||
);
|
||||
|
||||
// 打印单条完整日志
|
||||
log.info(logMessage);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 抛出异常后通知(@AfterThrowing):方法抛出异常退出时执行的通知
|
||||
* 注意在这里不能使用ProceedingJoinPoint
|
||||
* 不然会报错ProceedingJoinPoint is only supported for around advice
|
||||
* throwing注解为错误信息
|
||||
*
|
||||
* @param joinPoint
|
||||
* @param ex
|
||||
*/
|
||||
@AfterThrowing(value = "pt()", throwing = "ex")
|
||||
public void recordLog(JoinPoint joinPoint, Exception ex) {
|
||||
try {
|
||||
// 获取请求信息
|
||||
HttpServletRequest request = HttpContextUtils.getHttpServletRequest();
|
||||
|
||||
// 获取请求方法类型(POST/GET等)
|
||||
String requestMethod = request.getMethod();
|
||||
|
||||
// 获取用户ID
|
||||
String authorization = request.getHeader("Authorization") + "";
|
||||
Object user_id = "未登录"; // 请求用户的id
|
||||
if (!authorization.equals("null")) {
|
||||
user_id = JwtUtil.getUserIdFromToken(authorization);
|
||||
}
|
||||
|
||||
// 获取请求IP
|
||||
String ipAddr = IpUtils.getIpAddr(request);
|
||||
|
||||
// 获取方法签名信息
|
||||
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
|
||||
Method method = signature.getMethod();
|
||||
|
||||
// 获取控制器方法名
|
||||
String className = joinPoint.getTarget().getClass().getName();
|
||||
String methodName = signature.getName();
|
||||
String controllerMethod = className + "." + methodName;
|
||||
|
||||
|
||||
|
||||
// 获取请求参数
|
||||
String requestParams = getRequestParams(joinPoint);
|
||||
|
||||
// 获取异常信息
|
||||
String exceptionMsg = ex != null ? ex.getMessage() : "未知异常";
|
||||
|
||||
// 合并为一条完整的异常日志信息
|
||||
String errorMessage = String.format(
|
||||
"【异常日志】用户ID:[%s], IP地址:[%s], 请求方式:[%s], 控制器方法:[%s], 请求参数:[%s], 异常信息:[%s]", user_id, ipAddr, requestMethod, controllerMethod, requestParams, exceptionMsg
|
||||
);
|
||||
|
||||
// 打印单条完整异常日志
|
||||
log.info(errorMessage, ex);
|
||||
} catch (Exception e) {
|
||||
log.info("记录异常日志时出错: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取请求参数
|
||||
*/
|
||||
private String getRequestParams(JoinPoint joinPoint) {
|
||||
try {
|
||||
Object[] args = joinPoint.getArgs();
|
||||
if (args.length == 0) {
|
||||
return "无参数";
|
||||
} else if (args[0] != null && args[0].toString().contains("SecurityContextHolderAwareRequestWrapper")) {
|
||||
return JSON.toJSONString(Arrays.toString(ArrayUtil.remove(args, 0)));
|
||||
} else {
|
||||
// 检查是否只有一个参数且已经是JSON字符串格式
|
||||
if (args.length == 1 && args[0] != null) {
|
||||
// 如果参数本身就是字符串且是JSON格式,直接返回
|
||||
if (args[0] instanceof String && ((String) args[0]).startsWith("{") && ((String) args[0]).endsWith("}")) {
|
||||
return (String) args[0];
|
||||
}
|
||||
|
||||
// 如果参数是普通对象,直接序列化
|
||||
try {
|
||||
return JSON.toJSONString(args[0]);
|
||||
} catch (Exception e) {
|
||||
// 如果序列化失败,再尝试使用参数名映射
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
String[] names = ((CodeSignature) joinPoint.getSignature()).getParameterNames();
|
||||
if (names != null) {
|
||||
map.put(names[0], args[0]);
|
||||
return JSON.toJSONString(map);
|
||||
}
|
||||
return JSON.toJSONString(args[0]);
|
||||
}
|
||||
} else {
|
||||
// 多个参数时,使用参数名映射
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
String[] names = ((CodeSignature) joinPoint.getSignature()).getParameterNames();
|
||||
if (names != null) {
|
||||
for (int i = 0; i < names.length; i++) {
|
||||
map.put(names[i], args[i]);
|
||||
}
|
||||
}
|
||||
return JSON.toJSONString(map);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return "获取参数失败: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package com.admin.common.aop;
|
||||
|
||||
import com.admin.common.annotation.RequireRole;
|
||||
import com.admin.common.lang.R;
|
||||
import com.admin.common.utils.JwtUtil;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* 权限控制切面
|
||||
* 处理 @RequireRole 注解,检查管理员权限(role_id = 0)
|
||||
* 注意:JWT拦截器已经验证了token的有效性,这里只需要检查权限
|
||||
*/
|
||||
@Aspect
|
||||
@Component
|
||||
public class RoleAspect {
|
||||
|
||||
@Around("@annotation(requireRole)")
|
||||
public Object checkRole(ProceedingJoinPoint joinPoint, RequireRole requireRole) throws Throwable {
|
||||
// 获取当前请求
|
||||
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attributes == null) {
|
||||
return R.err(500, "无法获取请求信息");
|
||||
}
|
||||
|
||||
HttpServletRequest request = attributes.getRequest();
|
||||
String token = request.getHeader("Authorization");
|
||||
|
||||
// JWT拦截器已经验证过token存在且有效,这里直接获取role_id
|
||||
Integer roleId = JwtUtil.getRoleIdFromToken(token);
|
||||
if (roleId == null) {
|
||||
return R.err(401, "无法获取用户权限信息");
|
||||
}
|
||||
|
||||
// 检查是否为管理员(role_id = 0)
|
||||
if (roleId != 0) {
|
||||
return R.err(403, "权限不足,仅管理员可操作");
|
||||
}
|
||||
|
||||
// 权限检查通过,执行原方法
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.admin.common.dto;
|
||||
|
||||
import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack;
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 验证码验证请求DTO
|
||||
*/
|
||||
@Data
|
||||
public class CaptchaVerifyDto {
|
||||
|
||||
private String id;
|
||||
|
||||
private ImageCaptchaTrack data;
|
||||
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package com.admin.common.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
@Data
|
||||
public class ChangePasswordDto {
|
||||
|
||||
@NotBlank(message = "新用户名不能为空")
|
||||
private String newUsername;
|
||||
|
||||
@NotBlank(message = "当前密码不能为空")
|
||||
private String currentPassword;
|
||||
|
||||
@NotBlank(message = "新密码不能为空")
|
||||
private String newPassword;
|
||||
|
||||
@NotBlank(message = "确认密码不能为空")
|
||||
private String confirmPassword;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.admin.common.dto;
|
||||
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ConfigItem {
|
||||
private String name;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.admin.common.dto;
|
||||
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class FlowDto {
|
||||
// 转发id_类型
|
||||
private String n;
|
||||
|
||||
// 上传流量
|
||||
private Long u;
|
||||
|
||||
// 下载流量
|
||||
private Long d;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package com.admin.common.dto;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import lombok.Data;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.validation.constraints.Min;
|
||||
import javax.validation.constraints.Max;
|
||||
|
||||
@Data
|
||||
public class ForwardDto {
|
||||
|
||||
@NotBlank(message = "转发名称不能为空")
|
||||
private String name;
|
||||
|
||||
@NotNull(message = "隧道ID不能为空")
|
||||
private Integer tunnelId;
|
||||
|
||||
@NotBlank(message = "远程地址不能为空")
|
||||
private String remoteAddr;
|
||||
|
||||
private String strategy;
|
||||
|
||||
/**
|
||||
* 入口端口(可选,为空时自动分配)
|
||||
*/
|
||||
@Min(value = 1, message = "端口号不能小于1")
|
||||
@Max(value = 65535, message = "端口号不能大于65535")
|
||||
private Integer inPort;
|
||||
|
||||
private String interfaceName;
|
||||
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package com.admin.common.dto;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import lombok.Data;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.validation.constraints.Min;
|
||||
import javax.validation.constraints.Max;
|
||||
|
||||
@Data
|
||||
public class ForwardUpdateDto {
|
||||
|
||||
@NotNull(message = "ID不能为空")
|
||||
private Long id;
|
||||
|
||||
@NotNull(message = "用户ID不能为空")
|
||||
private Integer userId;
|
||||
|
||||
@NotBlank(message = "转发名称不能为空")
|
||||
private String name;
|
||||
|
||||
@NotNull(message = "隧道ID不能为空")
|
||||
private Integer tunnelId;
|
||||
|
||||
@NotBlank(message = "远程地址不能为空")
|
||||
private String remoteAddr;
|
||||
|
||||
private String strategy;
|
||||
|
||||
/**
|
||||
* 入口端口(可选,为空时自动分配)
|
||||
*/
|
||||
@Min(value = 1, message = "端口号不能小于1")
|
||||
@Max(value = 65535, message = "端口号不能大于65535")
|
||||
private Integer inPort;
|
||||
|
||||
@TableField(updateStrategy = FieldStrategy.IGNORED)
|
||||
private String interfaceName;
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package com.admin.common.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* 转发信息及关联隧道信息DTO
|
||||
* </p>
|
||||
*
|
||||
* @author QAQ
|
||||
* @since 2025-06-03
|
||||
*/
|
||||
@Data
|
||||
public class ForwardWithTunnelDto {
|
||||
|
||||
/**
|
||||
* 转发记录ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
|
||||
/**
|
||||
* 转发名称
|
||||
*/
|
||||
private String name;
|
||||
|
||||
|
||||
/**
|
||||
* 入口端口
|
||||
*/
|
||||
private Integer inPort;
|
||||
|
||||
/**
|
||||
* 远程地址
|
||||
*/
|
||||
private String remoteAddr;
|
||||
|
||||
/**
|
||||
* 转发状态
|
||||
*/
|
||||
private Integer status;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private Long createdTime;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
private Long updatedTime;
|
||||
|
||||
// 以下为隧道相关字段
|
||||
|
||||
/**
|
||||
* 隧道名称
|
||||
*/
|
||||
private String tunnelName;
|
||||
|
||||
/**
|
||||
* 入口IP
|
||||
*/
|
||||
private String inIp;
|
||||
|
||||
private String userName;
|
||||
|
||||
|
||||
/**
|
||||
* 用户ID
|
||||
*/
|
||||
private Integer userId;
|
||||
/**
|
||||
* 隧道ID
|
||||
*/
|
||||
private Integer tunnelId;
|
||||
|
||||
/**
|
||||
* 入站流量(字节)
|
||||
*/
|
||||
private Long inFlow;
|
||||
|
||||
/**
|
||||
* 出站流量(字节)
|
||||
*/
|
||||
private Long outFlow;
|
||||
|
||||
private String strategy;
|
||||
|
||||
private Integer inx;
|
||||
|
||||
private String interfaceName;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user