mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-16 01:24:24 +08:00
Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -0,0 +1,68 @@
|
|||||||
|
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 Backend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: springboot-backend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Java 21
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
java-version: '21'
|
||||||
|
distribution: 'temurin'
|
||||||
|
cache: 'maven'
|
||||||
|
|
||||||
|
- name: Build with Maven
|
||||||
|
run: mvn clean package -DskipTests
|
||||||
|
|
||||||
|
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 .
|
||||||
+224
-116
@@ -1,97 +1,122 @@
|
|||||||
name: Build and Push Images Based on Version
|
name: Build and Push Images
|
||||||
|
|
||||||
# 在这里定义统一版本号
|
|
||||||
env:
|
env:
|
||||||
VERSION: "2.0.2-beta"
|
VERSION: "2.0.7-beta" # 分支推送时使用的默认版本
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
tags:
|
||||||
- main
|
- '[0-9]*' # 匹配 2.0.8, 2.0.8-beta 等格式
|
||||||
- beta
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-version:
|
check-version:
|
||||||
name: Check Version and Decide Build
|
name: Check Version and Decide Build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ env.VERSION }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
should_build: ${{ steps.check-tag.outputs.should_build }}
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Display version
|
- name: Determine version and build strategy
|
||||||
|
id: version
|
||||||
run: |
|
run: |
|
||||||
echo "Current version: ${{ env.VERSION }}"
|
# 镜像 owner 需要小写
|
||||||
|
IMAGE_OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
|
||||||
|
echo "image_owner=$IMAGE_OWNER" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Check if tag exists
|
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||||
id: check-tag
|
# Tag 触发:直接使用 tag 名作为版本,全量构建
|
||||||
run: |
|
VERSION="${{ github.ref_name }}"
|
||||||
if git rev-parse "${{ env.VERSION }}" >/dev/null 2>&1; then
|
echo "🏷️ Tag trigger detected: $VERSION"
|
||||||
echo "Tag ${{ env.VERSION }} already exists, skip build"
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "should_build=false" >> $GITHUB_OUTPUT
|
echo "is_tag=true" >> $GITHUB_OUTPUT
|
||||||
else
|
|
||||||
echo "Tag ${{ env.VERSION }} does not exist, will build all components"
|
|
||||||
echo "should_build=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
|
fi
|
||||||
|
|
||||||
build-gost:
|
build-gost:
|
||||||
name: Build & Compress GOST Binary
|
name: Build & Compress GOST Binary
|
||||||
needs: check-version
|
needs: check-version
|
||||||
if: needs.check-version.outputs.should_build == 'true'
|
if: needs.check-version.outputs.should_build_gost == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.21'
|
go-version: '1.23'
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cache/go-build
|
~/.cache/go-build
|
||||||
~/go/pkg/mod
|
~/go/pkg/mod
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
key: ${{ runner.os }}-go-${{ hashFiles('go-gost/go.sum') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-go-
|
${{ runner.os }}-go-
|
||||||
|
|
||||||
- name: Install UPX
|
- name: Install UPX
|
||||||
run: |
|
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
|
tar -xf upx-4.2.1-amd64_linux.tar.xz
|
||||||
sudo mv upx-4.2.1-amd64_linux/upx /usr/local/bin/
|
sudo mv upx-4.2.1-amd64_linux/upx /usr/local/bin/
|
||||||
rm -rf upx-4.2.1-amd64_linux*
|
rm -rf upx-4.2.1-amd64_linux*
|
||||||
|
|
||||||
- name: Build GOST binary (AMD64)
|
- name: Build GOST binary (AMD64)
|
||||||
working-directory: ./go-gost
|
working-directory: ./go-gost
|
||||||
run: |
|
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=${{ needs.check-version.outputs.version }}" -o gost-amd64
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o gost
|
|
||||||
|
|
||||||
- name: Build GOST binary (ARM64)
|
- name: Build GOST binary (ARM64)
|
||||||
working-directory: ./go-gost
|
working-directory: ./go-gost
|
||||||
run: |
|
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=${{ needs.check-version.outputs.version }}" -o gost-arm64
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o gost-arm64
|
|
||||||
|
|
||||||
- name: Compress with UPX (AMD64)
|
- name: Compress with UPX
|
||||||
working-directory: ./go-gost
|
|
||||||
run: |
|
|
||||||
upx --best --lzma gost
|
|
||||||
|
|
||||||
- name: Compress with UPX (ARM64)
|
|
||||||
working-directory: ./go-gost
|
working-directory: ./go-gost
|
||||||
run: |
|
run: |
|
||||||
|
upx --best --lzma gost-amd64
|
||||||
upx --best --lzma gost-arm64
|
upx --best --lzma gost-arm64
|
||||||
|
|
||||||
- name: Upload GOST AMD64 artifact
|
- name: Upload GOST AMD64 artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: gost-binary-amd64
|
name: gost-binary-amd64
|
||||||
path: ./go-gost/gost
|
path: ./go-gost/gost-amd64
|
||||||
|
|
||||||
- name: Upload GOST ARM64 artifact
|
- name: Upload GOST ARM64 artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -104,31 +129,40 @@ jobs:
|
|||||||
needs: check-version
|
needs: check-version
|
||||||
if: needs.check-version.outputs.should_build == 'true'
|
if: needs.check-version.outputs.should_build == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to DockerHub
|
- name: Log in to GitHub Container Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
registry: ${{ env.REGISTRY }}
|
||||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
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
|
- name: Build and push Vite Docker images
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ needs.check-version.outputs.version }}"
|
VERSION="${{ needs.check-version.outputs.version }}"
|
||||||
|
OWNER="${{ needs.check-version.outputs.image_owner }}"
|
||||||
|
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--platform linux/amd64,linux/arm64 \
|
--platform linux/amd64,linux/arm64 \
|
||||||
--push \
|
--push \
|
||||||
-t bqlpfy/vite-frontend:latest \
|
-t ${{ env.REGISTRY }}/${OWNER}/vite-frontend:latest \
|
||||||
-t bqlpfy/vite-frontend:${VERSION} \
|
-t ${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION} \
|
||||||
./vite-frontend
|
./vite-frontend
|
||||||
|
|
||||||
build-java:
|
build-java:
|
||||||
@@ -136,17 +170,20 @@ jobs:
|
|||||||
needs: check-version
|
needs: check-version
|
||||||
if: needs.check-version.outputs.should_build == 'true'
|
if: needs.check-version.outputs.should_build == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up JDK and Maven
|
- name: Set up JDK and Maven
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
java-version: 21
|
java-version: 21
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
|
|
||||||
- name: Cache Maven dependencies
|
- name: Cache Maven dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.m2
|
path: ~/.m2
|
||||||
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
|
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
|
||||||
@@ -157,33 +194,149 @@ jobs:
|
|||||||
run: mvn clean package -DskipTests
|
run: mvn clean package -DskipTests
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to DockerHub
|
- name: Log in to GitHub Container Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
registry: ${{ env.REGISTRY }}
|
||||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Java Docker images
|
- name: Build and push Java Docker images
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ needs.check-version.outputs.version }}"
|
VERSION="${{ needs.check-version.outputs.version }}"
|
||||||
|
OWNER="${{ needs.check-version.outputs.image_owner }}"
|
||||||
|
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--platform linux/amd64,linux/arm64 \
|
--platform linux/amd64,linux/arm64 \
|
||||||
--push \
|
--push \
|
||||||
-t bqlpfy/springboot-backend:latest \
|
-t ${{ env.REGISTRY }}/${OWNER}/springboot-backend:latest \
|
||||||
-t bqlpfy/springboot-backend:${VERSION} \
|
-t ${{ env.REGISTRY }}/${OWNER}/springboot-backend:${VERSION} \
|
||||||
./springboot-backend
|
./springboot-backend
|
||||||
|
|
||||||
create-release:
|
create-release:
|
||||||
name: Create Release and Tag
|
name: Create Release (Tag Only)
|
||||||
needs: [check-version, build-gost, build-vite, build-java]
|
needs: [check-version, build-gost, build-vite, build-java]
|
||||||
if: needs.check-version.outputs.should_build == 'true'
|
if: needs.check-version.outputs.is_tag == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- 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|bqlpfy/springboot-backend:[^[:space:]]*|${{ env.REGISTRY }}/${OWNER}/springboot-backend:${VERSION}|g" ./artifacts/docker-compose-v4.yml
|
||||||
|
sed -i "s|bqlpfy/vite-frontend:[^[:space:]]*|${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION}|g" ./artifacts/docker-compose-v4.yml
|
||||||
|
sed -i "s|bqlpfy/springboot-backend:[^[:space:]]*|${{ env.REGISTRY }}/${OWNER}/springboot-backend:${VERSION}|g" ./artifacts/docker-compose-v6.yml
|
||||||
|
sed -i "s|bqlpfy/vite-frontend:[^[:space:]]*|${{ 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: |
|
||||||
|
VERSION="${{ needs.check-version.outputs.version }}"
|
||||||
|
OWNER="${{ needs.check-version.outputs.image_owner }}"
|
||||||
|
|
||||||
|
# 获取 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")
|
||||||
|
|
||||||
|
# 创建 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}
|
||||||
|
|
||||||
|
## 📦 Docker Images
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
# Backend
|
||||||
|
docker pull ${{ env.REGISTRY }}/${OWNER}/springboot-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@v4
|
||||||
|
|
||||||
- name: Download GOST AMD64 binary
|
- name: Download GOST AMD64 binary
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
@@ -199,63 +352,18 @@ jobs:
|
|||||||
|
|
||||||
- name: Rename binaries
|
- name: Rename binaries
|
||||||
run: |
|
run: |
|
||||||
mv ./artifacts/amd64/gost ./artifacts/gost
|
mv ./artifacts/amd64/gost-amd64 ./artifacts/gost-amd64
|
||||||
mv ./artifacts/arm64/gost-arm64 ./artifacts/gost-arm64
|
mv ./artifacts/arm64/gost-arm64 ./artifacts/gost-arm64
|
||||||
|
|
||||||
- name: Create Release
|
- name: Update GOST binaries in Release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ needs.check-version.outputs.version }}"
|
VERSION="${{ needs.check-version.outputs.version }}"
|
||||||
|
|
||||||
# 创建并推送 tag
|
echo "🔄 更新 Release ${VERSION} 中的 GOST 二进制文件..."
|
||||||
git tag "${VERSION}" ${{ github.sha }}
|
|
||||||
git push origin "${VERSION}"
|
gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber
|
||||||
|
|
||||||
# 获取 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")
|
|
||||||
|
|
||||||
# 创建 release
|
|
||||||
gh release create "${VERSION}" \
|
|
||||||
--title "Release ${VERSION}" \
|
|
||||||
--notes "Version ${VERSION}
|
|
||||||
|
|
||||||
## 📝 Commit Information
|
|
||||||
- **Message**: ${COMMIT_MSG}
|
|
||||||
- **Author**: ${COMMIT_AUTHOR}
|
|
||||||
- **Date**: ${COMMIT_DATE}
|
|
||||||
- **Commit**: [\`${GITHUB_SHA:0:7}\`](https://github.com/${{ github.repository }}/commit/${{ github.sha }})
|
|
||||||
|
|
||||||
## 📦 Components
|
|
||||||
- **GOST Binary AMD64** - \`gost\` (Linux AMD64, compressed with UPX)
|
|
||||||
- **GOST Binary ARM64** - \`gost-arm64\` (Linux ARM64, compressed with UPX)
|
|
||||||
- **Vite Frontend** Docker Image: \`bqlpfy/vite-frontend:${VERSION}\` / \`bqlpfy/vite-frontend:latest\` (支持 AMD64/ARM64)
|
|
||||||
- **Spring Boot Backend** Docker Image: \`bqlpfy/springboot-backend:${VERSION}\` / \`bqlpfy/springboot-backend:latest\` (支持 AMD64/ARM64)
|
|
||||||
|
|
||||||
## 📥 Installation Files
|
|
||||||
- \`install.sh\` - GOST 客户端安装脚本
|
|
||||||
- \`panel_install.sh\` - 面板安装脚本
|
|
||||||
- \`docker-compose-v4.yml\` - Docker Compose 配置 (IPv4)
|
|
||||||
- \`docker-compose-v6.yml\` - Docker Compose 配置 (IPv6)
|
|
||||||
|
|
||||||
## ℹ️ Build Details
|
|
||||||
- Build date: $(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
||||||
- Branch: ${{ github.ref_name }}" \
|
|
||||||
--repo ${{ github.repository }}
|
|
||||||
|
|
||||||
# 上传所有文件到 release
|
|
||||||
echo "📤 上传 GOST 二进制文件..."
|
|
||||||
gh release upload "${VERSION}" ./artifacts/gost --clobber
|
|
||||||
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
|
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
|
||||||
|
|
||||||
echo "📤 上传安装脚本..."
|
echo "✅ GOST 二进制文件更新完成"
|
||||||
gh release upload "${VERSION}" ./install.sh --clobber
|
|
||||||
gh release upload "${VERSION}" ./panel_install.sh --clobber
|
|
||||||
|
|
||||||
echo "📤 上传 Docker Compose 配置文件..."
|
|
||||||
gh release upload "${VERSION}" ./docker-compose-v4.yml --clobber
|
|
||||||
gh release upload "${VERSION}" ./docker-compose-v6.yml --clobber
|
|
||||||
|
|
||||||
echo "✅ 所有文件已上传到 Release ${VERSION}"
|
|
||||||
|
|||||||
@@ -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,66 @@
|
|||||||
|
# PROJECT KNOWLEDGE BASE
|
||||||
|
|
||||||
|
**Generated:** Mon Feb 02 2026
|
||||||
|
**Commit:** 7ca01ab
|
||||||
|
**Branch:** beta
|
||||||
|
|
||||||
|
## OVERVIEW
|
||||||
|
Flux Panel is a traffic forwarding management system built on a forked GOST v3 stack. It ships as Dockerized Spring Boot (admin API) + 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)
|
||||||
|
├── springboot-backend/ # Java/Spring Boot admin API (SQLite/MyBatis)
|
||||||
|
├── vite-frontend/ # React/Vite dashboard (HeroUI + Tailwind)
|
||||||
|
├── android-app/ # Android WebView wrapper (optional)
|
||||||
|
├── ios-app/ # iOS WebView wrapper (optional)
|
||||||
|
├── 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 entry** | `springboot-backend/src/main/java/com/admin/AdminApplication.java` | Spring Boot app |
|
||||||
|
| **Admin API routes** | `springboot-backend/src/main/java/com/admin/controller/` | Mostly `/api/v1/*` controllers |
|
||||||
|
| **Admin auth** | `springboot-backend/src/main/java/com/admin/common/interceptor/JwtInterceptor.java` | Checks `Authorization` header |
|
||||||
|
| **Web UI routing** | `vite-frontend/src/App.tsx` | React Router v6 + ProtectedRoute/H5 layouts |
|
||||||
|
| **Web UI API client** | `vite-frontend/src/api/network.ts` | Axios `baseURL` + `Authorization` header |
|
||||||
|
| **Go agent entry** | `go-gost/main.go` | Reads panel `config.json` + starts gost services |
|
||||||
|
| **Go x fork** | `go-gost/x/` | Handlers/listeners/dialers + management API |
|
||||||
|
|
||||||
|
## 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,7 +1,5 @@
|
|||||||
|
|
||||||
# flux-panel转发面板 哆啦A梦转发面板
|
# flux-panel转发面板 哆啦A梦转发面板
|
||||||
|
|
||||||
|
|
||||||
本项目基于 [go-gost/gost](https://github.com/go-gost/gost) 和 [go-gost/x](https://github.com/go-gost/x) 两个开源库,实现了转发面板。
|
本项目基于 [go-gost/gost](https://github.com/go-gost/gost) 和 [go-gost/x](https://github.com/go-gost/x) 两个开源库,实现了转发面板。
|
||||||
---
|
---
|
||||||
## 特性
|
## 特性
|
||||||
@@ -20,12 +18,11 @@
|
|||||||
#### 快速部署
|
#### 快速部署
|
||||||
面板端:
|
面板端:
|
||||||
```bash
|
```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
|
```bash
|
||||||
curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 默认管理员账号
|
#### 默认管理员账号
|
||||||
@@ -58,17 +55,14 @@ curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/main/inst
|
|||||||
作者对因使用本项目所造成的任何直接或间接损失概不负责,亦不提供任何形式的担保、承诺或技术支持。
|
作者对因使用本项目所造成的任何直接或间接损失概不负责,亦不提供任何形式的担保、承诺或技术支持。
|
||||||
|
|
||||||
|
|
||||||
请务必在合法、合规、安全的前提下使用本项目。
|
请务必在合法、合规、安全的前提下使用本项目。
|
||||||
|
|
||||||
---
|
---
|
||||||
## ⭐ 喝杯咖啡!(USDT)
|
## ⭐ 喝杯咖啡!(USDT)
|
||||||
|
|
||||||
| 网络 | 地址 |
|
| 网络 | 地址 |
|
||||||
|------------|----------------------------------------------------------------------|
|
|------------|----------------------------------------------------------------------|
|
||||||
| BNB(BEP20) | `0x755492c03728851bbf855daa28a1e089f9aca4d1` |
|
| BNB(BEP20) | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
|
||||||
| TRC20 | `TYh2L3xxXpuJhAcBWnt3yiiADiCSJLgUm7` |
|
| TRC20 | `TM8VYdU3s3gSX5PC8swjAJrAzZFCHKqG2k` |
|
||||||
| Aptos | `0xf2f9fb14749457748506a8281628d556e8540d1eb586d202cd8b02b99d369ef8` |
|
| Aptos | `0x49427bfcba1006a346447430689b2307ac156316bb34850d1d3029ff9d118da5` |
|
||||||
|
| polygon | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
|
||||||
[](https://www.star-history.com/#bqlpfy/flux-panel&Date)
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
+13
-3
@@ -1,8 +1,12 @@
|
|||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
image: bqlpfy/springboot-backend:2.0.2-beta
|
image: ghcr.io/sagit-chu/springboot-backend:${FLUX_VERSION:-latest}
|
||||||
container_name: springboot-backend
|
container_name: springboot-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "20m"
|
||||||
environment:
|
environment:
|
||||||
DB_PATH: /app/data/gost.db
|
DB_PATH: /app/data/gost.db
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
@@ -15,6 +19,8 @@ services:
|
|||||||
- sqlite_data:/app/data
|
- sqlite_data:/app/data
|
||||||
networks:
|
networks:
|
||||||
- gost-network
|
- gost-network
|
||||||
|
stop_grace_period: 30s
|
||||||
|
stop_signal: SIGTERM
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "sh", "-c", "wget --no-verbose --tries=1 --spider http://localhost:6365/flow/test || exit 1"]
|
test: ["CMD", "sh", "-c", "wget --no-verbose --tries=1 --spider http://localhost:6365/flow/test || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -23,9 +29,13 @@ services:
|
|||||||
start_period: 60s
|
start_period: 60s
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: bqlpfy/vite-frontend:2.0.2-beta
|
image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}
|
||||||
container_name: vite-frontend
|
container_name: vite-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "20m"
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT}:80"
|
- "${FRONTEND_PORT}:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -50,4 +60,4 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
ipam:
|
ipam:
|
||||||
config:
|
config:
|
||||||
- subnet: 172.20.0.0/16
|
- subnet: 172.20.0.0/16
|
||||||
|
|||||||
+13
-3
@@ -1,8 +1,12 @@
|
|||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
image: bqlpfy/springboot-backend:2.0.2-beta
|
image: ghcr.io/sagit-chu/springboot-backend:${FLUX_VERSION:-latest}
|
||||||
container_name: springboot-backend
|
container_name: springboot-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "20m"
|
||||||
environment:
|
environment:
|
||||||
DB_PATH: /app/data/gost.db
|
DB_PATH: /app/data/gost.db
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
@@ -15,6 +19,8 @@ services:
|
|||||||
- sqlite_data:/app/data
|
- sqlite_data:/app/data
|
||||||
networks:
|
networks:
|
||||||
- gost-network
|
- gost-network
|
||||||
|
stop_grace_period: 30s
|
||||||
|
stop_signal: SIGTERM
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "sh", "-c", "wget --no-verbose --tries=1 --spider http://localhost:6365/flow/test || exit 1"]
|
test: ["CMD", "sh", "-c", "wget --no-verbose --tries=1 --spider http://localhost:6365/flow/test || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -23,9 +29,13 @@ services:
|
|||||||
start_period: 60s
|
start_period: 60s
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: bqlpfy/vite-frontend:2.0.2-beta
|
image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}
|
||||||
container_name: vite-frontend
|
container_name: vite-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "20m"
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT}:80"
|
- "${FRONTEND_PORT}:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -52,4 +62,4 @@ networks:
|
|||||||
ipam:
|
ipam:
|
||||||
config:
|
config:
|
||||||
- subnet: 172.20.0.0/16
|
- subnet: 172.20.0.0/16
|
||||||
- subnet: fd00:dead:beef::/48
|
- subnet: fd00:dead:beef::/48
|
||||||
|
|||||||
@@ -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()
|
log := xlogger.NewLogger()
|
||||||
logger.SetDefault(log)
|
logger.SetDefault(log)
|
||||||
|
|
||||||
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, "2.0.0")
|
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, version)
|
||||||
defer wsReporter.Stop()
|
defer wsReporter.Stop()
|
||||||
service.SetHTTPReportURL(config.Addr, config.Secret)
|
service.SetHTTPReportURL(config.Addr, config.Secret)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
var (
|
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 ./...
|
||||||
|
```
|
||||||
@@ -109,3 +109,23 @@ func LoggerFromContext(ctx context.Context) logger.Logger {
|
|||||||
v, _ := ctx.Value(keyLogger).(logger.Logger)
|
v, _ := ctx.Value(keyLogger).(logger.Logger)
|
||||||
return v
|
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 ./...
|
||||||
|
```
|
||||||
@@ -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{}
|
// Determine max retry attempts
|
||||||
if h.hop != nil {
|
maxRetries := h.md.maxRetries
|
||||||
target = h.hop.Select(ctx,
|
if maxRetries <= 0 {
|
||||||
hop.ProtocolSelectOption(proto),
|
// Default: try all available nodes
|
||||||
)
|
if nl, ok := h.hop.(hop.NodeList); ok {
|
||||||
}
|
maxRetries = len(nl.Nodes())
|
||||||
if target == nil {
|
}
|
||||||
err := errors.New("node not available")
|
if maxRetries <= 0 {
|
||||||
return err
|
maxRetries = 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addr := target.Addr
|
var triedNodes []string
|
||||||
if opts := target.Options(); opts != nil {
|
var lastErr error
|
||||||
switch opts.Network {
|
var cc net.Conn
|
||||||
case "unix":
|
|
||||||
network = opts.Network
|
h.options.Logger.Debugf("[handler.retry] starting retry loop: maxRetries=%d", maxRetries)
|
||||||
default:
|
|
||||||
if _, _, err := net.SplitHostPort(addr); err != nil {
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||||
addr += ":0"
|
// 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.Network = network
|
||||||
ro.Host = addr
|
ro.Host = addr
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
cc, err := h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, addr)
|
cc, err = h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, addr)
|
||||||
ro.Route = buf.String()
|
ro.Route = buf.String()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// TODO: the router itself may be failed due to the failed node in the router,
|
// Mark node as failed for future selections
|
||||||
// the dead marker may be a wrong operation.
|
if marker := target.Marker(); marker != nil {
|
||||||
if marker := target.Marker(); marker != nil {
|
marker.Mark()
|
||||||
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 {
|
func (h *forwardHandler) checkRateLimit(addr net.Addr) bool {
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ type metadata struct {
|
|||||||
privateKey crypto.PrivateKey
|
privateKey crypto.PrivateKey
|
||||||
alpn string
|
alpn string
|
||||||
mitmBypass bypass.Bypass
|
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) {
|
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.alpn = mdutil.GetString(md, "mitm.alpn")
|
||||||
h.md.mitmBypass = registry.BypassRegistry().Get(mdutil.GetString(md, "mitm.bypass"))
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,68 +204,101 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var target *chain.Node
|
// Determine max retry attempts
|
||||||
if host != "" {
|
maxRetries := h.md.maxRetries
|
||||||
target = &chain.Node{
|
if maxRetries <= 0 {
|
||||||
Addr: host,
|
// Default: try all available nodes
|
||||||
|
if nl, ok := h.hop.(hop.NodeList); ok {
|
||||||
|
maxRetries = len(nl.Nodes())
|
||||||
}
|
}
|
||||||
}
|
if maxRetries <= 0 {
|
||||||
if h.hop != nil {
|
maxRetries = 1
|
||||||
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:
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ro.Network = network
|
var triedNodes []string
|
||||||
ro.Host = target.Addr
|
var lastErr error
|
||||||
|
var cc net.Conn
|
||||||
|
|
||||||
log = log.WithFields(map[string]any{
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||||
"node": target.Name,
|
// Select a target node, excluding previously tried nodes
|
||||||
"dst": fmt.Sprintf("%s/%s", target.Addr, network),
|
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
|
if opts := target.Options(); opts != nil {
|
||||||
cc, err := h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, target.Addr)
|
switch opts.Network {
|
||||||
ro.Route = buf.String()
|
case "unix":
|
||||||
if err != nil {
|
network = opts.Network
|
||||||
log.Error(err)
|
default:
|
||||||
// TODO: the router itself may be failed due to the failed node in the router,
|
}
|
||||||
// the dead marker may be a wrong operation.
|
}
|
||||||
|
|
||||||
|
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 {
|
if marker := target.Marker(); marker != nil {
|
||||||
marker.Mark()
|
marker.Reset()
|
||||||
}
|
}
|
||||||
return err
|
defer cc.Close()
|
||||||
}
|
|
||||||
defer cc.Close()
|
cc = proxyproto.WrapClientConn(h.md.proxyProtocol, conn.RemoteAddr(), convertAddr(conn.LocalAddr()), cc)
|
||||||
if marker := target.Marker(); marker != nil {
|
|
||||||
marker.Reset()
|
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)
|
// All retries exhausted
|
||||||
|
if lastErr != nil {
|
||||||
t := time.Now()
|
return lastErr
|
||||||
log.Infof("%s <-> %s", conn.RemoteAddr(), target.Addr)
|
}
|
||||||
xnet.Transport(conn, cc)
|
return errors.New("all nodes failed")
|
||||||
log.WithFields(map[string]any{
|
|
||||||
"duration": time.Since(t),
|
|
||||||
}).Infof("%s >-< %s", conn.RemoteAddr(), target.Addr)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *forwardHandler) checkRateLimit(addr net.Addr) bool {
|
func (h *forwardHandler) checkRateLimit(addr net.Addr) bool {
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ type metadata struct {
|
|||||||
privateKey crypto.PrivateKey
|
privateKey crypto.PrivateKey
|
||||||
alpn string
|
alpn string
|
||||||
mitmBypass bypass.Bypass
|
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) {
|
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.alpn = mdutil.GetString(md, "mitm.alpn")
|
||||||
h.md.mitmBypass = registry.BypassRegistry().Get(mdutil.GetString(md, "mitm.bypass"))
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-4
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/go-gost/core/selector"
|
"github.com/go-gost/core/selector"
|
||||||
"github.com/go-gost/x/config"
|
"github.com/go-gost/x/config"
|
||||||
node_parser "github.com/go-gost/x/config/parsing/node"
|
node_parser "github.com/go-gost/x/config/parsing/node"
|
||||||
|
ctxvalue "github.com/go-gost/x/ctx"
|
||||||
"github.com/go-gost/x/internal/loader"
|
"github.com/go-gost/x/internal/loader"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -141,11 +142,28 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
|
|||||||
return nil
|
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
|
var nodes []*chain.Node
|
||||||
for _, node := range p.Nodes() {
|
for _, node := range p.Nodes() {
|
||||||
if node == nil {
|
if node == nil {
|
||||||
continue
|
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
|
// node level bypass
|
||||||
if node.Options().Bypass != nil &&
|
if node.Options().Bypass != nil &&
|
||||||
node.Options().Bypass.Contains(ctx, options.Network, options.Addr, bypass.WithHostOpton(options.Host)) {
|
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 {
|
if len(nodes) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if len(nodes) == 1 {
|
|
||||||
return nodes[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(nodes, func(i, j int) bool {
|
sort.Slice(nodes, func(i, j int) bool {
|
||||||
return nodes[i].Options().Priority > nodes[j].Options().Priority
|
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]
|
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 {
|
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]
|
return nodes[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -247,64 +247,98 @@ func (h *Sniffer) dial(ctx context.Context, conn net.Conn, req *http.Request, ho
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
node = &chain.Node{
|
// Determine max retry attempts
|
||||||
Addr: host,
|
maxRetries := 1
|
||||||
|
if nl, ok := ho.Hop.(hop.NodeList); ok {
|
||||||
|
maxRetries = len(nl.Nodes())
|
||||||
}
|
}
|
||||||
if ho.Hop != nil {
|
if maxRetries <= 0 {
|
||||||
node = ho.Hop.Select(ctx,
|
maxRetries = 1
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ro.Host = node.Addr
|
var triedNodes []string
|
||||||
ho.Log = ho.Log.WithFields(map[string]any{
|
var lastErr error
|
||||||
"node": node.Name,
|
|
||||||
"dst": node.Addr,
|
|
||||||
})
|
|
||||||
ho.Log.Debugf("find node for host %s -> %s(%s)", host, node.Name, node.Addr)
|
|
||||||
|
|
||||||
cc, err = dial(ctx, "tcp", node.Addr)
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||||
if err != nil {
|
// Select a node, excluding previously tried nodes
|
||||||
// TODO: the router itself may be failed due to the failed node in the router,
|
selectCtx := ctxvalue.ContextWithExcludeNodes(ctx, triedNodes)
|
||||||
// the dead marker may be a wrong operation.
|
|
||||||
if marker := node.Marker(); marker != nil {
|
node = chain.NewNode("", host)
|
||||||
marker.Mark()
|
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)
|
if node == nil {
|
||||||
res.Write(conn)
|
if lastErr != nil {
|
||||||
return
|
ho.Log.Warnf("node for %s not found after retries", host)
|
||||||
}
|
res.StatusCode = http.StatusBadGateway
|
||||||
if marker := node.Marker(); marker != nil {
|
ro.HTTP.StatusCode = res.StatusCode
|
||||||
marker.Reset()
|
res.Write(conn)
|
||||||
}
|
return nil, nil, lastErr
|
||||||
|
}
|
||||||
if tlsSettings := node.Options().TLS; tlsSettings != nil {
|
ho.Log.Warnf("node for %s not found", host)
|
||||||
cfg := &tls.Config{
|
res.StatusCode = http.StatusBadGateway
|
||||||
ServerName: tlsSettings.ServerName,
|
ro.HTTP.StatusCode = res.StatusCode
|
||||||
InsecureSkipVerify: !tlsSettings.Secure,
|
res.Write(conn)
|
||||||
|
return nil, nil, errors.New("node not available")
|
||||||
}
|
}
|
||||||
tls_util.SetTLSOptions(cfg, &config.TLSOptions{
|
|
||||||
MinVersion: tlsSettings.Options.MinVersion,
|
// Track this node as tried
|
||||||
MaxVersion: tlsSettings.Options.MaxVersion,
|
triedNodes = append(triedNodes, node.Addr)
|
||||||
CipherSuites: tlsSettings.Options.CipherSuites,
|
|
||||||
ALPN: tlsSettings.Options.ALPN,
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if host != "" {
|
|
||||||
node = &chain.Node{
|
|
||||||
Addr: host,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ro := ho.RecorderObject
|
ro := ho.RecorderObject
|
||||||
if ho.Hop != nil {
|
|
||||||
node = ho.Hop.Select(ctx,
|
// Determine max retry attempts
|
||||||
hop.ClientIPSelectOption(net.ParseIP(ro.ClientIP)),
|
maxRetries := 1
|
||||||
hop.HostSelectOption(host),
|
if nl, ok := ho.Hop.(hop.NodeList); ok {
|
||||||
hop.ProtocolSelectOption(sniffing.ProtoTLS),
|
maxRetries = len(nl.Nodes())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if node == nil {
|
if maxRetries <= 0 {
|
||||||
err = errors.New("node not available")
|
maxRetries = 1
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addr := node.Addr
|
var triedNodes []string
|
||||||
if opts := node.Options(); opts != nil {
|
var lastErr error
|
||||||
switch opts.Network {
|
|
||||||
case "unix":
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||||
ro.Network = opts.Network
|
// Select a node, excluding previously tried nodes
|
||||||
default:
|
selectCtx := ctxvalue.ContextWithExcludeNodes(ctx, triedNodes)
|
||||||
if _, _, err := net.SplitHostPort(addr); err != nil {
|
|
||||||
addr += ":443"
|
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{
|
ho.Log = ho.Log.WithFields(map[string]any{
|
||||||
"host": host,
|
"host": host,
|
||||||
"node": node.Name,
|
"node": node.Name,
|
||||||
"dst": fmt.Sprintf("%s/%s", addr, ro.Network),
|
"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,
|
|
||||||
})
|
})
|
||||||
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 {
|
func (h *Sniffer) terminateTLS(ctx context.Context, conn, cc net.Conn, clientHello *dissector.ClientHelloInfo, ho *HandleOptions) error {
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-gost/core/chain"
|
||||||
"github.com/go-gost/core/metadata"
|
"github.com/go-gost/core/metadata"
|
||||||
mdutil "github.com/go-gost/x/metadata/util"
|
|
||||||
"github.com/go-gost/core/selector"
|
"github.com/go-gost/core/selector"
|
||||||
|
mdutil "github.com/go-gost/x/metadata/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type failFilter[T any] struct {
|
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.
|
// 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 {
|
func (f *failFilter[T]) Filter(ctx context.Context, vs ...T) []T {
|
||||||
if len(vs) <= 1 {
|
if len(vs) <= 1 {
|
||||||
return vs
|
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 mi, _ := any(v).(selector.Markable); mi != nil {
|
||||||
if marker := mi.Marker(); marker != nil {
|
if marker := mi.Marker(); marker != nil {
|
||||||
if marker.Count() < int64(maxFails) ||
|
count := marker.Count()
|
||||||
time.Since(marker.Time()) >= failTimeout {
|
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)
|
l = append(l, v)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -2,11 +2,17 @@ package socket
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/go-gost/x/config"
|
"github.com/go-gost/x/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// configMutex 保护配置文件的并发写入
|
||||||
|
var configMutex sync.Mutex
|
||||||
|
|
||||||
func saveConfig() {
|
func saveConfig() {
|
||||||
|
configMutex.Lock()
|
||||||
|
defer configMutex.Unlock()
|
||||||
|
|
||||||
file := "gost.json"
|
file := "gost.json"
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-gost/x/config"
|
"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/internal/util/crypto"
|
||||||
|
"github.com/go-gost/x/service"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"github.com/shirou/gopsutil/v3/cpu"
|
"github.com/shirou/gopsutil/v3/cpu"
|
||||||
"github.com/shirou/gopsutil/v3/host"
|
"github.com/shirou/gopsutil/v3/host"
|
||||||
@@ -87,6 +87,9 @@ type TcpPingResponse struct {
|
|||||||
|
|
||||||
type WebSocketReporter struct {
|
type WebSocketReporter struct {
|
||||||
url string
|
url string
|
||||||
|
addr string // 保存服务器地址
|
||||||
|
secret string // 保存密钥
|
||||||
|
version string // 保存版本号
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
reconnectTime time.Duration
|
reconnectTime time.Duration
|
||||||
pingInterval time.Duration
|
pingInterval time.Duration
|
||||||
@@ -195,7 +198,25 @@ func (w *WebSocketReporter) connect() error {
|
|||||||
w.connecting = false
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("解析URL失败: %v", err)
|
return fmt.Errorf("解析URL失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -225,7 +246,7 @@ func (w *WebSocketReporter) connect() error {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
fmt.Printf("✅ WebSocket连接建立成功\n")
|
fmt.Printf("✅ WebSocket连接建立成功 (http=%d, tls=%d, socks=%d)\n", cfg.Http, cfg.Tls, cfg.Socks)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,7 +466,13 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cmdMsg.Type != "call" {
|
if cmdMsg.Type != "call" {
|
||||||
w.routeCommand(cmdMsg)
|
// TcpPing 诊断命令异步执行,避免阻塞其他命令
|
||||||
|
// 其他状态变更命令保持同步,确保顺序执行
|
||||||
|
if cmdMsg.Type == "TcpPing" {
|
||||||
|
go w.routeCommand(cmdMsg)
|
||||||
|
} else {
|
||||||
|
w.routeCommand(cmdMsg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 处理普通消息
|
// 处理普通消息
|
||||||
@@ -456,7 +483,13 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if cmdMsg.Type != "call" {
|
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))
|
fmt.Println("🔔 收到命令: ", string(jsonBytes))
|
||||||
var err error
|
var err error
|
||||||
var response CommandResponse
|
var response CommandResponse
|
||||||
|
var needSaveConfig bool // 标记是否需要保存配置(只有状态变更命令才需要)
|
||||||
|
|
||||||
// 传递 requestId
|
// 传递 requestId
|
||||||
response.RequestId = cmd.RequestId
|
response.RequestId = cmd.RequestId
|
||||||
@@ -485,65 +519,81 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
|
|||||||
case "AddService":
|
case "AddService":
|
||||||
err = w.handleAddService(cmd.Data)
|
err = w.handleAddService(cmd.Data)
|
||||||
response.Type = "AddServiceResponse"
|
response.Type = "AddServiceResponse"
|
||||||
|
needSaveConfig = true
|
||||||
case "UpdateService":
|
case "UpdateService":
|
||||||
err = w.handleUpdateService(cmd.Data)
|
err = w.handleUpdateService(cmd.Data)
|
||||||
response.Type = "UpdateServiceResponse"
|
response.Type = "UpdateServiceResponse"
|
||||||
|
needSaveConfig = true
|
||||||
case "DeleteService":
|
case "DeleteService":
|
||||||
err = w.handleDeleteService(cmd.Data)
|
err = w.handleDeleteService(cmd.Data)
|
||||||
response.Type = "DeleteServiceResponse"
|
response.Type = "DeleteServiceResponse"
|
||||||
|
needSaveConfig = true
|
||||||
case "PauseService":
|
case "PauseService":
|
||||||
err = w.handlePauseService(cmd.Data)
|
err = w.handlePauseService(cmd.Data)
|
||||||
response.Type = "PauseServiceResponse"
|
response.Type = "PauseServiceResponse"
|
||||||
|
needSaveConfig = true
|
||||||
case "ResumeService":
|
case "ResumeService":
|
||||||
err = w.handleResumeService(cmd.Data)
|
err = w.handleResumeService(cmd.Data)
|
||||||
response.Type = "ResumeServiceResponse"
|
response.Type = "ResumeServiceResponse"
|
||||||
|
needSaveConfig = true
|
||||||
|
|
||||||
// Chain 相关命令
|
// Chain 相关命令
|
||||||
case "AddChains":
|
case "AddChains":
|
||||||
err = w.handleAddChain(cmd.Data)
|
err = w.handleAddChain(cmd.Data)
|
||||||
response.Type = "AddChainsResponse"
|
response.Type = "AddChainsResponse"
|
||||||
|
needSaveConfig = true
|
||||||
case "UpdateChains":
|
case "UpdateChains":
|
||||||
err = w.handleUpdateChain(cmd.Data)
|
err = w.handleUpdateChain(cmd.Data)
|
||||||
response.Type = "UpdateChainsResponse"
|
response.Type = "UpdateChainsResponse"
|
||||||
|
needSaveConfig = true
|
||||||
case "DeleteChains":
|
case "DeleteChains":
|
||||||
err = w.handleDeleteChain(cmd.Data)
|
err = w.handleDeleteChain(cmd.Data)
|
||||||
response.Type = "DeleteChainsResponse"
|
response.Type = "DeleteChainsResponse"
|
||||||
|
needSaveConfig = true
|
||||||
|
|
||||||
// Limiter 相关命令
|
// Limiter 相关命令
|
||||||
case "AddLimiters":
|
case "AddLimiters":
|
||||||
err = w.handleAddLimiter(cmd.Data)
|
err = w.handleAddLimiter(cmd.Data)
|
||||||
response.Type = "AddLimitersResponse"
|
response.Type = "AddLimitersResponse"
|
||||||
|
needSaveConfig = true
|
||||||
case "UpdateLimiters":
|
case "UpdateLimiters":
|
||||||
err = w.handleUpdateLimiter(cmd.Data)
|
err = w.handleUpdateLimiter(cmd.Data)
|
||||||
response.Type = "UpdateLimitersResponse"
|
response.Type = "UpdateLimitersResponse"
|
||||||
|
needSaveConfig = true
|
||||||
case "DeleteLimiters":
|
case "DeleteLimiters":
|
||||||
err = w.handleDeleteLimiter(cmd.Data)
|
err = w.handleDeleteLimiter(cmd.Data)
|
||||||
response.Type = "DeleteLimitersResponse"
|
response.Type = "DeleteLimitersResponse"
|
||||||
|
needSaveConfig = true
|
||||||
|
|
||||||
// TCP Ping 诊断命令
|
// TCP Ping 诊断命令(只读,不需要保存配置)
|
||||||
case "TcpPing":
|
case "TcpPing":
|
||||||
var tcpPingResult TcpPingResponse
|
var tcpPingResult TcpPingResponse
|
||||||
tcpPingResult, err = w.handleTcpPing(cmd.Data)
|
tcpPingResult, err = w.handleTcpPing(cmd.Data)
|
||||||
response.Type = "TcpPingResponse"
|
response.Type = "TcpPingResponse"
|
||||||
response.Data = tcpPingResult
|
response.Data = tcpPingResult
|
||||||
|
// needSaveConfig = false (默认值)
|
||||||
|
|
||||||
// Protocol blocking switches
|
// Protocol blocking switches
|
||||||
case "SetProtocol":
|
case "SetProtocol":
|
||||||
err = w.handleSetProtocol(cmd.Data)
|
err = w.handleSetProtocol(cmd.Data)
|
||||||
response.Type = "SetProtocolResponse"
|
response.Type = "SetProtocolResponse"
|
||||||
|
needSaveConfig = true
|
||||||
|
|
||||||
default:
|
default:
|
||||||
err = fmt.Errorf("未知命令类型: %s", cmd.Type)
|
err = fmt.Errorf("未知命令类型: %s", cmd.Type)
|
||||||
response.Type = "UnknownCommandResponse"
|
response.Type = "UnknownCommandResponse"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 只有状态变更命令才保存配置
|
||||||
|
if needSaveConfig {
|
||||||
|
saveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
// 发送响应
|
// 发送响应
|
||||||
if err != nil {
|
if err != nil {
|
||||||
saveConfig()
|
|
||||||
response.Success = false
|
response.Success = false
|
||||||
response.Message = err.Error()
|
response.Message = err.Error()
|
||||||
} else {
|
} else {
|
||||||
saveConfig()
|
|
||||||
response.Success = true
|
response.Success = true
|
||||||
response.Message = "OK"
|
response.Message = "OK"
|
||||||
}
|
}
|
||||||
@@ -775,81 +825,81 @@ func (w *WebSocketReporter) handleDeleteLimiter(data interface{}) error {
|
|||||||
|
|
||||||
// handleSetProtocol 处理设置屏蔽协议的命令
|
// handleSetProtocol 处理设置屏蔽协议的命令
|
||||||
func (w *WebSocketReporter) handleSetProtocol(data interface{}) error {
|
func (w *WebSocketReporter) handleSetProtocol(data interface{}) error {
|
||||||
jsonData, err := json.Marshal(data)
|
jsonData, err := json.Marshal(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("序列化协议设置失败: %v", err)
|
return fmt.Errorf("序列化协议设置失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 支持 {"http":0/1, "tls":0/1, "socks":0/1}
|
// 支持 {"http":0/1, "tls":0/1, "socks":0/1}
|
||||||
var req struct {
|
var req struct {
|
||||||
HTTP *int `json:"http"`
|
HTTP *int `json:"http"`
|
||||||
TLS *int `json:"tls"`
|
TLS *int `json:"tls"`
|
||||||
SOCKS *int `json:"socks"`
|
SOCKS *int `json:"socks"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(jsonData, &req); err != nil {
|
if err := json.Unmarshal(jsonData, &req); err != nil {
|
||||||
return fmt.Errorf("解析协议设置失败: %v", err)
|
return fmt.Errorf("解析协议设置失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取当前值作为默认
|
// 读取当前值作为默认
|
||||||
httpVal, tlsVal, socksVal := 0, 0, 0
|
httpVal, tlsVal, socksVal := 0, 0, 0
|
||||||
|
|
||||||
if req.HTTP != nil {
|
if req.HTTP != nil {
|
||||||
if *req.HTTP != 0 && *req.HTTP != 1 {
|
if *req.HTTP != 0 && *req.HTTP != 1 {
|
||||||
return fmt.Errorf("http 取值必须为0或1")
|
return fmt.Errorf("http 取值必须为0或1")
|
||||||
}
|
}
|
||||||
httpVal = *req.HTTP
|
httpVal = *req.HTTP
|
||||||
}
|
}
|
||||||
if req.TLS != nil {
|
if req.TLS != nil {
|
||||||
if *req.TLS != 0 && *req.TLS != 1 {
|
if *req.TLS != 0 && *req.TLS != 1 {
|
||||||
return fmt.Errorf("tls 取值必须为0或1")
|
return fmt.Errorf("tls 取值必须为0或1")
|
||||||
}
|
}
|
||||||
tlsVal = *req.TLS
|
tlsVal = *req.TLS
|
||||||
}
|
}
|
||||||
if req.SOCKS != nil {
|
if req.SOCKS != nil {
|
||||||
if *req.SOCKS != 0 && *req.SOCKS != 1 {
|
if *req.SOCKS != 0 && *req.SOCKS != 1 {
|
||||||
return fmt.Errorf("socks 取值必须为0或1")
|
return fmt.Errorf("socks 取值必须为0或1")
|
||||||
}
|
}
|
||||||
socksVal = *req.SOCKS
|
socksVal = *req.SOCKS
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置至 service,全量传递(未提供的值沿用0)
|
// 设置至 service,全量传递(未提供的值沿用0)
|
||||||
service.SetProtocolBlock(httpVal, tlsVal, socksVal)
|
service.SetProtocolBlock(httpVal, tlsVal, socksVal)
|
||||||
|
|
||||||
// 同步写入本地 config.json
|
// 同步写入本地 config.json
|
||||||
if err := updateLocalConfigJSON(httpVal, tlsVal, socksVal); err != nil {
|
if err := updateLocalConfigJSON(httpVal, tlsVal, socksVal); err != nil {
|
||||||
return fmt.Errorf("写入config.json失败: %v", err)
|
return fmt.Errorf("写入config.json失败: %v", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateLocalConfigJSON 将 http/tls/socks 写入工作目录下的 config.json
|
// updateLocalConfigJSON 将 http/tls/socks 写入工作目录下的 config.json
|
||||||
func updateLocalConfigJSON(httpVal int, tlsVal int, socksVal int) error {
|
func updateLocalConfigJSON(httpVal int, tlsVal int, socksVal int) error {
|
||||||
path := "config.json"
|
path := "config.json"
|
||||||
|
|
||||||
// 读取现有配置
|
// 读取现有配置
|
||||||
type LocalConfig struct {
|
type LocalConfig struct {
|
||||||
Addr string `json:"addr"`
|
Addr string `json:"addr"`
|
||||||
Secret string `json:"secret"`
|
Secret string `json:"secret"`
|
||||||
Http int `json:"http"`
|
Http int `json:"http"`
|
||||||
Tls int `json:"tls"`
|
Tls int `json:"tls"`
|
||||||
Socks int `json:"socks"`
|
Socks int `json:"socks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var cfg LocalConfig
|
var cfg LocalConfig
|
||||||
if b, err := os.ReadFile(path); err == nil {
|
if b, err := os.ReadFile(path); err == nil {
|
||||||
_ = json.Unmarshal(b, &cfg)
|
_ = json.Unmarshal(b, &cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.Http = httpVal
|
cfg.Http = httpVal
|
||||||
cfg.Tls = tlsVal
|
cfg.Tls = tlsVal
|
||||||
cfg.Socks = socksVal
|
cfg.Socks = socksVal
|
||||||
|
|
||||||
// 写回
|
// 写回
|
||||||
data, err := json.MarshalIndent(cfg, "", " ")
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.WriteFile(path, data, 0644)
|
return os.WriteFile(path, data, 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleCall 处理服务端的call回调消息
|
// handleCall 处理服务端的call回调消息
|
||||||
@@ -1021,12 +1071,16 @@ func getMemoryInfo() MemoryInfo {
|
|||||||
// StartWebSocketReporterWithConfig 使用配置字段启动WebSocket报告器
|
// StartWebSocketReporterWithConfig 使用配置字段启动WebSocket报告器
|
||||||
func StartWebSocketReporterWithConfig(addr string, secret string, http int, tls int, socks int, version string) *WebSocketReporter {
|
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)
|
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)
|
fmt.Printf("🔗 WebSocket连接URL: %s\n", fullURL)
|
||||||
|
|
||||||
reporter := NewWebSocketReporter(fullURL, secret)
|
reporter := NewWebSocketReporter(fullURL, secret)
|
||||||
|
// 保存 addr, secret, version 供重连时使用
|
||||||
|
reporter.addr = addr
|
||||||
|
reporter.secret = secret
|
||||||
|
reporter.version = version
|
||||||
reporter.Start()
|
reporter.Start()
|
||||||
return reporter
|
return reporter
|
||||||
}
|
}
|
||||||
@@ -1107,6 +1161,32 @@ func tcpPingHost(ip string, port int, count int, timeoutMs int) (float64, float6
|
|||||||
|
|
||||||
fmt.Printf("🔍 开始TCP ping测试: %s,次数: %d,超时: %dms\n", target, count, timeoutMs)
|
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++ {
|
for i := 0; i < count; i++ {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
|
|||||||
+107
-9
@@ -1,12 +1,108 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# 下载地址
|
|
||||||
DOWNLOAD_URL="https://github.com/bqlpfy/flux-panel/releases/download/2.0.2-beta/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"
|
INSTALL_DIR="/etc/flux_agent"
|
||||||
|
|
||||||
|
# 识别国家(用于镜像加速)
|
||||||
COUNTRY=$(curl -s https://ipinfo.io/country)
|
COUNTRY=$(curl -s https://ipinfo.io/country)
|
||||||
if [ "$COUNTRY" = "CN" ]; then
|
|
||||||
# 拼接 URL
|
maybe_proxy_url() {
|
||||||
DOWNLOAD_URL="https://ghfast.top/${DOWNLOAD_URL}"
|
local url="$1"
|
||||||
fi
|
if [ "$COUNTRY" = "CN" ]; then
|
||||||
|
echo "https://ghfast.top/${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)")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -199,6 +295,8 @@ After=network.target
|
|||||||
WorkingDirectory=$INSTALL_DIR
|
WorkingDirectory=$INSTALL_DIR
|
||||||
ExecStart=$INSTALL_DIR/flux_agent
|
ExecStart=$INSTALL_DIR/flux_agent
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
|
StandardOutput=null
|
||||||
|
StandardError=null
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -216,8 +314,8 @@ EOF
|
|||||||
echo "📁 配置目录: $INSTALL_DIR"
|
echo "📁 配置目录: $INSTALL_DIR"
|
||||||
echo "🔧 服务状态: $(systemctl is-active flux_agent)"
|
echo "🔧 服务状态: $(systemctl is-active flux_agent)"
|
||||||
else
|
else
|
||||||
echo "❌ flux_agent服务启动失败,请执行以下命令查看日志:"
|
echo "❌ flux_agent服务启动失败,请执行以下命令查看状态:"
|
||||||
echo "journalctl -u flux_agent -f"
|
echo "systemctl status flux_agent --no-pager"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,4 +440,4 @@ main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 执行主函数
|
# 执行主函数
|
||||||
main
|
main
|
||||||
|
|||||||
+119
-9
@@ -7,16 +7,87 @@ export LC_ALL=C
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 全局下载地址配置
|
# GitHub repo used for release downloads
|
||||||
DOCKER_COMPOSEV4_URL="https://github.com/bqlpfy/flux-panel/releases/download/2.0.2-beta/docker-compose-v4.yml"
|
REPO="Sagit-chu/flux-panel"
|
||||||
DOCKER_COMPOSEV6_URL="https://github.com/bqlpfy/flux-panel/releases/download/2.0.2-beta/docker-compose-v6.yml"
|
|
||||||
|
|
||||||
COUNTRY=$(curl -s https://ipinfo.io/country)
|
COUNTRY=$(curl -s https://ipinfo.io/country)
|
||||||
if [ "$COUNTRY" = "CN" ]; then
|
|
||||||
# 拼接 URL
|
maybe_proxy_url() {
|
||||||
DOCKER_COMPOSEV4_URL="https://ghfast.top/${DOCKER_COMPOSEV4_URL}"
|
local url="$1"
|
||||||
DOCKER_COMPOSEV6_URL="https://ghfast.top/${DOCKER_COMPOSEV6_URL}"
|
if [ "$COUNTRY" = "CN" ]; then
|
||||||
fi
|
echo "https://ghfast.top/${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"
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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=<版本号> ./panel_install.sh" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 根据版本号设置 compose 下载地址
|
||||||
|
set_compose_urls_by_version() {
|
||||||
|
local version="$1"
|
||||||
|
DOCKER_COMPOSEV4_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${version}/docker-compose-v4.yml")
|
||||||
|
DOCKER_COMPOSEV6_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${version}/docker-compose-v6.yml")
|
||||||
|
}
|
||||||
|
|
||||||
|
# 全局下载地址配置(默认获取最新版本;也可用 VERSION=... 覆盖)
|
||||||
|
RESOLVED_VERSION=$(resolve_version) || exit 1
|
||||||
|
set_compose_urls_by_version "$RESOLVED_VERSION"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -155,6 +226,27 @@ generate_random() {
|
|||||||
LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c16
|
LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c16
|
||||||
}
|
}
|
||||||
|
|
||||||
|
upsert_env_var() {
|
||||||
|
local file="$1"
|
||||||
|
local key="$2"
|
||||||
|
local value="$3"
|
||||||
|
local tmp_file
|
||||||
|
|
||||||
|
tmp_file=$(mktemp)
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
awk -v k="$key" -v v="$value" '
|
||||||
|
BEGIN { found=0 }
|
||||||
|
$0 ~ ("^" k "=") { print k "=" v; found=1; next }
|
||||||
|
{ print }
|
||||||
|
END { if (!found) print k "=" v }
|
||||||
|
' "$file" > "$tmp_file"
|
||||||
|
else
|
||||||
|
printf '%s=%s\n' "$key" "$value" > "$tmp_file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mv "$tmp_file" "$file"
|
||||||
|
}
|
||||||
|
|
||||||
# 删除脚本自身
|
# 删除脚本自身
|
||||||
delete_self() {
|
delete_self() {
|
||||||
echo ""
|
echo ""
|
||||||
@@ -202,6 +294,7 @@ install_panel() {
|
|||||||
JWT_SECRET=$JWT_SECRET
|
JWT_SECRET=$JWT_SECRET
|
||||||
FRONTEND_PORT=$FRONTEND_PORT
|
FRONTEND_PORT=$FRONTEND_PORT
|
||||||
BACKEND_PORT=$BACKEND_PORT
|
BACKEND_PORT=$BACKEND_PORT
|
||||||
|
FLUX_VERSION=$RESOLVED_VERSION
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
echo "🚀 启动 docker 服务..."
|
echo "🚀 启动 docker 服务..."
|
||||||
@@ -222,6 +315,15 @@ update_panel() {
|
|||||||
echo "🔄 开始更新面板..."
|
echo "🔄 开始更新面板..."
|
||||||
check_docker
|
check_docker
|
||||||
|
|
||||||
|
echo "🔍 获取最新版本号..."
|
||||||
|
LATEST_VERSION=$(resolve_latest_release_tag) || {
|
||||||
|
echo "❌ 无法获取最新版本号,更新终止"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
echo "🆕 最新版本:$LATEST_VERSION"
|
||||||
|
set_compose_urls_by_version "$LATEST_VERSION"
|
||||||
|
upsert_env_var ".env" "FLUX_VERSION" "$LATEST_VERSION"
|
||||||
|
|
||||||
echo "🔽 下载最新配置文件..."
|
echo "🔽 下载最新配置文件..."
|
||||||
DOCKER_COMPOSE_URL=$(get_docker_compose_url)
|
DOCKER_COMPOSE_URL=$(get_docker_compose_url)
|
||||||
echo "📡 选择配置文件:$(basename "$DOCKER_COMPOSE_URL")"
|
echo "📡 选择配置文件:$(basename "$DOCKER_COMPOSE_URL")"
|
||||||
@@ -234,7 +336,15 @@ update_panel() {
|
|||||||
configure_docker_ipv6
|
configure_docker_ipv6
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "🛑 停止当前服务..."
|
# 先发送 SIGTERM 信号,让应用优雅关闭
|
||||||
|
docker stop -t 30 springboot-backend 2>/dev/null || true
|
||||||
|
docker stop -t 10 vite-frontend 2>/dev/null || true
|
||||||
|
|
||||||
|
# 等待 WAL 文件同步
|
||||||
|
echo "⏳ 等待数据同步..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# 然后再完全停止
|
||||||
$DOCKER_CMD down
|
$DOCKER_CMD down
|
||||||
|
|
||||||
echo "⬇️ 拉取最新镜像..."
|
echo "⬇️ 拉取最新镜像..."
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<factorypath>
|
||||||
|
<factorypathentry kind="VARJAR" id="M2_REPO/org/projectlombok/lombok/1.18.30/lombok-1.18.30.jar" enabled="true" runInBatchMode="false"/>
|
||||||
|
</factorypath>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# SPRINGBOOT BACKEND KNOWLEDGE BASE
|
||||||
|
|
||||||
|
**Generated:** Mon Feb 02 2026
|
||||||
|
|
||||||
|
## OVERVIEW
|
||||||
|
Admin API for Flux Panel. Manages users, tunnels, nodes, forwards, quotas, and speed limits.
|
||||||
|
**Stack:** Java 21, Spring Boot 2.7.18, SQLite, MyBatis Plus (+ join), FastJSON2.
|
||||||
|
|
||||||
|
## STRUCTURE
|
||||||
|
```
|
||||||
|
springboot-backend/
|
||||||
|
├── src/main/java/com/admin/
|
||||||
|
│ ├── controller/ # /api/v1/* endpoints
|
||||||
|
│ ├── entity/ # DB models
|
||||||
|
│ ├── mapper/ # MyBatis Plus mappers
|
||||||
|
│ ├── service/ # Business logic
|
||||||
|
│ ├── config/ # WebMvc/JWT/CORS/WebSocket config
|
||||||
|
│ └── common/ # DTOs, auth, exception handling, utilities
|
||||||
|
└── src/main/resources/
|
||||||
|
├── application.yml # Config (DB_PATH/JWT_SECRET/LOG_DIR)
|
||||||
|
├── mapper/ # XML mappers
|
||||||
|
├── schema.sql # Schema
|
||||||
|
└── data.sql # Seed data
|
||||||
|
```
|
||||||
|
|
||||||
|
## CONVENTIONS
|
||||||
|
- **DB**: SQLite URL is `jdbc:sqlite:${DB_PATH:/app/data/gost.db}` (`springboot-backend/src/main/resources/application.yml`).
|
||||||
|
- **Auth**: JWT in `Authorization` header; enforced by `com.admin.common.interceptor.JwtInterceptor` for `/api/**` (with explicit excludes in `com.admin.config.WebMvcConfig`).
|
||||||
|
- **Roles**: `@RequireRole` means admin-only (`role_id == 0`) via `com.admin.common.aop.RoleAspect`.
|
||||||
|
- **Responses**: Controllers return `com.admin.common.lang.R` (`code == 0` success).
|
||||||
|
- **CORS**: Allow-all origins; `Authorization` is exposed (`com.admin.config.WebMvcConfig`).
|
||||||
|
|
||||||
|
## COMMANDS
|
||||||
|
```bash
|
||||||
|
cd springboot-backend
|
||||||
|
mvn clean package
|
||||||
|
mvn test
|
||||||
|
java -jar target/admin-0.0.1-SNAPSHOT.jar
|
||||||
|
```
|
||||||
@@ -16,7 +16,7 @@ ENV LC_ALL=en_US.UTF-8
|
|||||||
RUN sed -i 's/archive.ubuntu.com/mirrors.ustc.edu.cn/g' /etc/apt/sources.list \
|
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 \
|
&& sed -i 's/security.ubuntu.com/mirrors.ustc.edu.cn/g' /etc/apt/sources.list \
|
||||||
&& apt-get update \
|
&& apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends fontconfig fonts-dejavu \
|
&& apt-get install -y --no-install-recommends fontconfig fonts-dejavu sqlite3 \
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# SPRINGBOOT BACKEND (com.admin) KNOWLEDGE BASE
|
||||||
|
|
||||||
|
## OVERVIEW
|
||||||
|
Primary Java code for the admin API. Controllers expose `/api/v1/*` endpoints and return `R` response envelopes.
|
||||||
|
|
||||||
|
## STRUCTURE
|
||||||
|
```
|
||||||
|
springboot-backend/src/main/java/com/admin/
|
||||||
|
├── controller/ # REST controllers (e.g., /api/v1/user)
|
||||||
|
├── service/ # Business logic interfaces + impl/
|
||||||
|
├── mapper/ # MyBatis Plus mappers
|
||||||
|
├── entity/ # DB entities
|
||||||
|
├── config/ # WebMvc/JWT/CORS/WebSocket config
|
||||||
|
└── common/ # DTOs, auth, exception handling, utilities
|
||||||
|
```
|
||||||
|
|
||||||
|
## WHERE TO LOOK
|
||||||
|
| Task | Location | Notes |
|
||||||
|
|------|----------|-------|
|
||||||
|
| User/login endpoints | `springboot-backend/src/main/java/com/admin/controller/UserController.java` | `/api/v1/user/*` |
|
||||||
|
| Auth enforcement | `springboot-backend/src/main/java/com/admin/config/WebMvcConfig.java` | Intercepts `/api/**`, excludes login/config/captcha |
|
||||||
|
| JWT validation | `springboot-backend/src/main/java/com/admin/common/interceptor/JwtInterceptor.java` | Requires `Authorization` header |
|
||||||
|
| Admin-only ops | `springboot-backend/src/main/java/com/admin/common/annotation/RequireRole.java` | Enforced by `RoleAspect` |
|
||||||
|
| Response envelope | `springboot-backend/src/main/java/com/admin/common/lang/R.java` | `code == 0` success |
|
||||||
|
| Global error handling | `springboot-backend/src/main/java/com/admin/common/exception/GlobalExceptionHandler.java` | Maps exceptions -> `R.err(...)` |
|
||||||
|
|
||||||
|
## CONVENTIONS
|
||||||
|
- Controllers are mostly `@PostMapping` (even for list/get/delete) and use `/api/v1/*` prefixes.
|
||||||
|
- JWT is custom (no 3p lib) and includes `role_id` in payload (`springboot-backend/src/main/java/com/admin/common/utils/JwtUtil.java`).
|
||||||
|
|
||||||
|
## ANTI-PATTERNS
|
||||||
|
- Do not change auth header format lightly: frontend expects `Authorization: <token>` (no `Bearer`).
|
||||||
@@ -13,6 +13,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
|||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableAsync
|
@EnableAsync
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
|
@MapperScan("com.admin.mapper")
|
||||||
public class AdminApplication {
|
public class AdminApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.admin.common.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import javax.validation.constraints.NotEmpty;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class BatchChangeTunnelDto {
|
||||||
|
|
||||||
|
@NotEmpty(message = "转发ID列表不能为空")
|
||||||
|
private List<Long> forwardIds;
|
||||||
|
|
||||||
|
@NotNull(message = "目标隧道ID不能为空")
|
||||||
|
private Long targetTunnelId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.admin.common.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import javax.validation.constraints.NotEmpty;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class BatchDeleteDto {
|
||||||
|
|
||||||
|
@NotEmpty(message = "ID列表不能为空")
|
||||||
|
private List<Long> ids;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.admin.common.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class BatchOperationResultDto {
|
||||||
|
|
||||||
|
private int successCount;
|
||||||
|
private int failCount;
|
||||||
|
private List<FailedItem> failedItems = new ArrayList<>();
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class FailedItem {
|
||||||
|
private Long id;
|
||||||
|
private String reason;
|
||||||
|
|
||||||
|
public FailedItem() {}
|
||||||
|
|
||||||
|
public FailedItem(Long id, String reason) {
|
||||||
|
this.id = id;
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addFailedItem(Long id, String reason) {
|
||||||
|
this.failedItems.add(new FailedItem(id, reason));
|
||||||
|
this.failCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void incrementSuccess() {
|
||||||
|
this.successCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAllSuccess() {
|
||||||
|
return failCount == 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.admin.common.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import javax.validation.constraints.NotEmpty;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class BatchRedeployDto {
|
||||||
|
|
||||||
|
@NotEmpty(message = "ID列表不能为空")
|
||||||
|
private List<Long> ids;
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ public class ForwardUpdateDto {
|
|||||||
@NotBlank(message = "转发名称不能为空")
|
@NotBlank(message = "转发名称不能为空")
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
private Integer tunnelId;
|
||||||
|
|
||||||
@NotBlank(message = "远程地址不能为空")
|
@NotBlank(message = "远程地址不能为空")
|
||||||
private String remoteAddr;
|
private String remoteAddr;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.admin.common.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import javax.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class GroupCreateDto {
|
||||||
|
|
||||||
|
@NotBlank(message = "分组名称不能为空")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
private Integer status;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.admin.common.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class GroupPermissionAssignDto {
|
||||||
|
|
||||||
|
@NotNull(message = "用户分组ID不能为空")
|
||||||
|
private Long userGroupId;
|
||||||
|
|
||||||
|
@NotNull(message = "隧道分组ID不能为空")
|
||||||
|
private Long tunnelGroupId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.admin.common.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class GroupPermissionDetailDto {
|
||||||
|
private Long id;
|
||||||
|
private Long userGroupId;
|
||||||
|
private String userGroupName;
|
||||||
|
private Long tunnelGroupId;
|
||||||
|
private String tunnelGroupName;
|
||||||
|
private Long createdTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.admin.common.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import javax.validation.constraints.NotBlank;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class GroupUpdateDto {
|
||||||
|
|
||||||
|
@NotNull(message = "分组ID不能为空")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@NotBlank(message = "分组名称不能为空")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
private Integer status;
|
||||||
|
}
|
||||||
@@ -16,6 +16,10 @@ public class NodeDto {
|
|||||||
@NotBlank(message = "服务器ip不能为空")
|
@NotBlank(message = "服务器ip不能为空")
|
||||||
private String serverIp;
|
private String serverIp;
|
||||||
|
|
||||||
|
private String serverIpV4;
|
||||||
|
|
||||||
|
private String serverIpV6;
|
||||||
|
|
||||||
@NotBlank(message = "可用端口不能为空")
|
@NotBlank(message = "可用端口不能为空")
|
||||||
private String port;
|
private String port;
|
||||||
|
|
||||||
@@ -25,4 +29,4 @@ public class NodeDto {
|
|||||||
|
|
||||||
private String udpListenAddr = "0.0.0.0";
|
private String udpListenAddr = "0.0.0.0";
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ public class NodeUpdateDto {
|
|||||||
@NotBlank(message = "服务器ip不能为空")
|
@NotBlank(message = "服务器ip不能为空")
|
||||||
private String serverIp;
|
private String serverIp;
|
||||||
|
|
||||||
|
private String serverIpV4;
|
||||||
|
|
||||||
|
private String serverIpV6;
|
||||||
|
|
||||||
@NotBlank(message = "可用port不能为空")
|
@NotBlank(message = "可用port不能为空")
|
||||||
private String port;
|
private String port;
|
||||||
|
|
||||||
@@ -28,4 +32,4 @@ public class NodeUpdateDto {
|
|||||||
private String tcpListenAddr = "0.0.0.0";
|
private String tcpListenAddr = "0.0.0.0";
|
||||||
|
|
||||||
private String udpListenAddr = "0.0.0.0";
|
private String udpListenAddr = "0.0.0.0";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import java.util.List;
|
|||||||
public class TunnelDetailDto {
|
public class TunnelDetailDto {
|
||||||
|
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display/order index for drag-sorting in admin UI.
|
||||||
|
* Lower value appears first.
|
||||||
|
*/
|
||||||
|
private Integer inx;
|
||||||
|
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
@@ -40,4 +46,3 @@ public class TunnelDetailDto {
|
|||||||
// 出口节点列表
|
// 出口节点列表
|
||||||
private List<ChainTunnel> outNodeId = new ArrayList<>();
|
private List<ChainTunnel> outNodeId = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package com.admin.common.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class TunnelGroupAssignTunnelsDto {
|
||||||
|
|
||||||
|
@NotNull(message = "隧道分组ID不能为空")
|
||||||
|
private Long groupId;
|
||||||
|
|
||||||
|
@NotNull(message = "隧道列表不能为空")
|
||||||
|
private List<Long> tunnelIds;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.admin.common.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class TunnelGroupDetailDto {
|
||||||
|
private Long id;
|
||||||
|
private String name;
|
||||||
|
private Integer status;
|
||||||
|
private Long createdTime;
|
||||||
|
private Long updatedTime;
|
||||||
|
private List<Long> tunnelIds = new ArrayList<>();
|
||||||
|
private List<String> tunnelNames = new ArrayList<>();
|
||||||
|
}
|
||||||
@@ -30,4 +30,13 @@ public class TunnelUpdateDto {
|
|||||||
@DecimalMin(value = "0.0", inclusive = false, message = "流量倍率必须大于0.0")
|
@DecimalMin(value = "0.0", inclusive = false, message = "流量倍率必须大于0.0")
|
||||||
@DecimalMax(value = "100.0", message = "流量倍率不能大于100.0")
|
@DecimalMax(value = "100.0", message = "流量倍率不能大于100.0")
|
||||||
private BigDecimal trafficRatio;
|
private BigDecimal trafficRatio;
|
||||||
|
|
||||||
|
// 入口节点配置(可选,为空时不更新节点配置)
|
||||||
|
private List<ChainTunnel> inNodeId;
|
||||||
|
|
||||||
|
// 转发链节点配置(二维数组,每一跳可有多个节点)
|
||||||
|
private List<List<ChainTunnel>> chainNodes;
|
||||||
|
|
||||||
|
// 出口节点配置
|
||||||
|
private List<ChainTunnel> outNodeId;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.admin.common.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class UserGroupAssignUsersDto {
|
||||||
|
|
||||||
|
@NotNull(message = "用户分组ID不能为空")
|
||||||
|
private Long groupId;
|
||||||
|
|
||||||
|
@NotNull(message = "用户列表不能为空")
|
||||||
|
private List<Long> userIds;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.admin.common.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class UserGroupDetailDto {
|
||||||
|
private Long id;
|
||||||
|
private String name;
|
||||||
|
private Integer status;
|
||||||
|
private Long createdTime;
|
||||||
|
private Long updatedTime;
|
||||||
|
private List<Long> userIds = new ArrayList<>();
|
||||||
|
private List<String> userNames = new ArrayList<>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.admin.common.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import javax.validation.Valid;
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
import javax.validation.constraints.NotEmpty;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class UserTunnelBatchAssignDto {
|
||||||
|
|
||||||
|
@NotNull(message = "用户ID不能为空")
|
||||||
|
private Integer userId;
|
||||||
|
|
||||||
|
@Valid
|
||||||
|
@NotEmpty(message = "隧道列表不能为空")
|
||||||
|
private List<TunnelAssignItem> tunnels;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class TunnelAssignItem {
|
||||||
|
@NotNull(message = "隧道ID不能为空")
|
||||||
|
private Integer tunnelId;
|
||||||
|
|
||||||
|
private Integer speedId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,28 +13,15 @@ public class UserTunnelDto {
|
|||||||
@NotNull(message = "隧道ID不能为空")
|
@NotNull(message = "隧道ID不能为空")
|
||||||
private Integer tunnelId;
|
private Integer tunnelId;
|
||||||
|
|
||||||
@NotNull(message = "流量限制不能为空")
|
|
||||||
@Min(value = 0, message = "流量限制不能小于0")
|
@Min(value = 0, message = "流量限制不能小于0")
|
||||||
private Long flow;
|
private Long flow;
|
||||||
|
|
||||||
@NotNull(message = "转发数量不能为空")
|
|
||||||
@Min(value = 0, message = "转发数量不能小于0")
|
@Min(value = 0, message = "转发数量不能小于0")
|
||||||
private Integer num;
|
private Integer num;
|
||||||
|
|
||||||
/**
|
|
||||||
* 流量重置时间(时间戳)
|
|
||||||
*/
|
|
||||||
@NotNull(message = "流量重置时间不能为空")
|
|
||||||
private Long flowResetTime;
|
private Long flowResetTime;
|
||||||
|
|
||||||
/**
|
|
||||||
* 到期时间(时间戳)
|
|
||||||
*/
|
|
||||||
@NotNull(message = "到期时间不能为空")
|
|
||||||
private Long expTime;
|
private Long expTime;
|
||||||
|
|
||||||
/**
|
|
||||||
* 限速规则ID(可选,null表示不限速)
|
|
||||||
*/
|
|
||||||
private Integer speedId;
|
private Integer speedId;
|
||||||
}
|
}
|
||||||
|
|||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package com.admin.common.migration;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.ApplicationArguments;
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight SQLite schema migration.
|
||||||
|
*
|
||||||
|
* Spring Boot SQL init uses CREATE TABLE IF NOT EXISTS, so existing installations
|
||||||
|
* won't automatically receive new columns. This runner adds missing columns in-place.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||||
|
public class SqliteSchemaMigration implements ApplicationRunner {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public SqliteSchemaMigration(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(ApplicationArguments args) {
|
||||||
|
ensureColumn("node", "inx", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
ensureColumn("tunnel", "inx", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
ensureTable("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)");
|
||||||
|
ensureTable("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)");
|
||||||
|
ensureTable("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)");
|
||||||
|
ensureTable("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)");
|
||||||
|
ensureTable("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)");
|
||||||
|
ensureTable("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)");
|
||||||
|
ensureColumn("group_permission_grant", "created_by_group", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_name ON tunnel_group(name)");
|
||||||
|
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_name ON user_group(name)");
|
||||||
|
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_tunnel_unique ON tunnel_group_tunnel(tunnel_group_id, tunnel_id)");
|
||||||
|
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_user_unique ON user_group_user(user_group_id, user_id)");
|
||||||
|
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_unique ON group_permission(user_group_id, tunnel_group_id)");
|
||||||
|
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_grant_unique ON group_permission_grant(user_group_id, tunnel_group_id, user_tunnel_id)");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureColumn(String table, String column, String columnDefinition) {
|
||||||
|
Set<String> columns = new HashSet<>(
|
||||||
|
jdbcTemplate.query(
|
||||||
|
"PRAGMA table_info(" + table + ")",
|
||||||
|
(rs, rowNum) -> rs.getString("name")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columns.contains(column)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Adding missing column {}.{}", table, column);
|
||||||
|
jdbcTemplate.execute(
|
||||||
|
"ALTER TABLE " + table + " ADD COLUMN " + column + " " + columnDefinition
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureTable(String ddl) {
|
||||||
|
jdbcTemplate.execute(ddl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.admin.common.task;
|
package com.admin.common.task;
|
||||||
|
|
||||||
|
import com.admin.common.dto.GostDto;
|
||||||
import com.admin.common.utils.GostUtil;
|
import com.admin.common.utils.GostUtil;
|
||||||
import com.admin.entity.*;
|
import com.admin.entity.*;
|
||||||
import com.admin.service.*;
|
import com.admin.service.*;
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package com.admin.common.utils;
|
package com.admin.common.utils;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.admin.common.dto.GostDto;
|
||||||
import com.admin.entity.*;
|
import com.admin.entity.*;
|
||||||
import com.alibaba.fastjson.JSONArray;
|
import com.alibaba.fastjson.JSONArray;
|
||||||
import com.alibaba.fastjson.JSONObject;
|
import com.alibaba.fastjson.JSONObject;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -12,27 +15,36 @@ import java.util.Objects;
|
|||||||
public class GostUtil {
|
public class GostUtil {
|
||||||
|
|
||||||
|
|
||||||
public static void AddLimiters(Long node_id, Long name, String speed) {
|
public static GostDto AddLimiters(Long node_id, Long name, String speed) {
|
||||||
JSONObject data = createLimiterData(name, speed);
|
JSONObject data = createLimiterData(name, speed);
|
||||||
WebSocketServer.send_msg(node_id, data, "AddLimiters");
|
GostDto gostDto = WebSocketServer.send_msg(node_id, data, "AddLimiters");
|
||||||
|
if (gostDto.getMsg().contains("exists")){
|
||||||
|
gostDto.setMsg("OK");
|
||||||
|
}
|
||||||
|
return gostDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void UpdateLimiters(Long node_id, Long name, String speed) {
|
public static GostDto UpdateLimiters(Long node_id, Long name, String speed) {
|
||||||
JSONObject data = createLimiterData(name, speed);
|
JSONObject data = createLimiterData(name, speed);
|
||||||
JSONObject req = new JSONObject();
|
JSONObject req = new JSONObject();
|
||||||
req.put("limiter", name + "");
|
req.put("limiter", name + "");
|
||||||
req.put("data", data);
|
req.put("data", data);
|
||||||
WebSocketServer.send_msg(node_id, req, "UpdateLimiters");
|
return WebSocketServer.send_msg(node_id, req, "UpdateLimiters");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void DeleteLimiters(Long node_id, Long name) {
|
public static GostDto DeleteLimiters(Long node_id, Long name) {
|
||||||
JSONObject req = new JSONObject();
|
JSONObject req = new JSONObject();
|
||||||
req.put("limiter", name + "");
|
req.put("limiter", name + "");
|
||||||
WebSocketServer.send_msg(node_id, req, "DeleteLimiters");
|
GostDto gostDto = WebSocketServer.send_msg(node_id, req, "DeleteLimiters");
|
||||||
|
if (gostDto.getMsg().contains("not found")){
|
||||||
|
gostDto.setMsg("OK");
|
||||||
|
}
|
||||||
|
return gostDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void AddChains(Long node_id, List<ChainTunnel> chainTunnels, Map<Long, Node> node_s) {
|
public static GostDto AddChains(Long node_id, List<ChainTunnel> chainTunnels, Map<Long, Node> node_s) {
|
||||||
JSONArray nodes = new JSONArray();
|
JSONArray nodes = new JSONArray();
|
||||||
|
Node fromNode = node_s.get(node_id);
|
||||||
for (ChainTunnel chainTunnel : chainTunnels) {
|
for (ChainTunnel chainTunnel : chainTunnels) {
|
||||||
JSONObject dialer = new JSONObject();
|
JSONObject dialer = new JSONObject();
|
||||||
dialer.put("type", chainTunnel.getProtocol());
|
dialer.put("type", chainTunnel.getProtocol());
|
||||||
@@ -43,19 +55,27 @@ public class GostUtil {
|
|||||||
Node node_info = node_s.get(chainTunnel.getNodeId());
|
Node node_info = node_s.get(chainTunnel.getNodeId());
|
||||||
JSONObject node = new JSONObject();
|
JSONObject node = new JSONObject();
|
||||||
node.put("name", "node_" + chainTunnel.getInx());
|
node.put("name", "node_" + chainTunnel.getInx());
|
||||||
node.put("addr", node_info.getServerIp() + ":" + chainTunnel.getPort());
|
|
||||||
|
String dialHost = (fromNode != null && node_info != null)
|
||||||
|
? selectDialHost(fromNode, node_info)
|
||||||
|
: (node_info != null ? node_info.getServerIp() : null);
|
||||||
|
node.put("addr", processServerAddress(dialHost + ":" + chainTunnel.getPort()));
|
||||||
node.put("connector", connector);
|
node.put("connector", connector);
|
||||||
node.put("dialer", dialer);
|
node.put("dialer", dialer);
|
||||||
|
|
||||||
if (StringUtils.isNotBlank(node_info.getInterfaceName())) {
|
|
||||||
node.put("interface", node_info.getInterfaceName());
|
|
||||||
}
|
|
||||||
|
|
||||||
nodes.add(node);
|
nodes.add(node);
|
||||||
}
|
}
|
||||||
JSONObject hop = new JSONObject();
|
JSONObject hop = new JSONObject();
|
||||||
hop.put("name", "hop_" + chainTunnels.getFirst().getTunnelId());
|
hop.put("name", "hop_" + chainTunnels.getFirst().getTunnelId());
|
||||||
|
|
||||||
|
// interface设置在转发链
|
||||||
|
if (StringUtils.isNotBlank(node_s.get(node_id).getInterfaceName())) {
|
||||||
|
hop.put("interface", node_s.get(node_id).getInterfaceName());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
JSONObject selector = new JSONObject();
|
JSONObject selector = new JSONObject();
|
||||||
selector.put("strategy", chainTunnels.getFirst().getStrategy());
|
selector.put("strategy", chainTunnels.getFirst().getStrategy());
|
||||||
selector.put("maxFails", 1);
|
selector.put("maxFails", 1);
|
||||||
@@ -72,24 +92,34 @@ public class GostUtil {
|
|||||||
data.put("name", "chains_" + chainTunnels.getFirst().getTunnelId());
|
data.put("name", "chains_" + chainTunnels.getFirst().getTunnelId());
|
||||||
data.put("hops", hops);
|
data.put("hops", hops);
|
||||||
|
|
||||||
WebSocketServer.send_msg(node_id, data, "AddChains");
|
GostDto gostDto = WebSocketServer.send_msg(node_id, data, "AddChains");
|
||||||
|
if (gostDto.getMsg().contains("exists")){
|
||||||
|
gostDto.setMsg("OK");
|
||||||
|
}
|
||||||
|
return gostDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void DeleteChains(Long node_id, String name) {
|
public static GostDto DeleteChains(Long node_id, String name) {
|
||||||
JSONObject data = new JSONObject();
|
JSONObject data = new JSONObject();
|
||||||
data.put("chain", name);
|
data.put("chain", name);
|
||||||
WebSocketServer.send_msg(node_id, data, "DeleteChains");
|
GostDto gostDto = WebSocketServer.send_msg(node_id, data, "DeleteChains");
|
||||||
|
if (gostDto.getMsg().contains("not found")){
|
||||||
|
gostDto.setMsg("OK");
|
||||||
|
}
|
||||||
|
return gostDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void AddChainService(Long node_id, ChainTunnel chainTunnel, Map<Long, Node> node_s) {
|
public static GostDto AddChainService(Long node_id, ChainTunnel chainTunnel, Map<Long, Node> node_s) {
|
||||||
JSONArray services = new JSONArray();
|
JSONArray services = new JSONArray();
|
||||||
Node node_info = node_s.get(chainTunnel.getNodeId());
|
Node node_info = node_s.get(chainTunnel.getNodeId());
|
||||||
JSONObject service_item = new JSONObject();
|
JSONObject service_item = new JSONObject();
|
||||||
service_item.put("name", chainTunnel.getTunnelId() + "_tls");
|
service_item.put("name", chainTunnel.getTunnelId() + "_tls");
|
||||||
service_item.put("addr", node_info.getTcpListenAddr() + ":" + chainTunnel.getPort());
|
service_item.put("addr", node_info.getTcpListenAddr() + ":" + chainTunnel.getPort());
|
||||||
if (StringUtils.isNotBlank(node_info.getInterfaceName())) {
|
|
||||||
|
// 只为出口节点(chainType=3)设置 interface
|
||||||
|
if (chainTunnel.getChainType() == 3 && StringUtils.isNotBlank(node_s.get(node_id).getInterfaceName())) {
|
||||||
JSONObject metadata = new JSONObject();
|
JSONObject metadata = new JSONObject();
|
||||||
metadata.put("interface", node_info.getInterfaceName());
|
metadata.put("interface", node_s.get(node_id).getInterfaceName());
|
||||||
service_item.put("metadata", metadata);
|
service_item.put("metadata", metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,16 +136,14 @@ public class GostUtil {
|
|||||||
|
|
||||||
services.add(service_item);
|
services.add(service_item);
|
||||||
|
|
||||||
WebSocketServer.send_msg(node_id, services, "AddService");
|
GostDto gostDto = WebSocketServer.send_msg(node_id, services, "AddService");
|
||||||
|
if (gostDto.getMsg().contains("exists")){
|
||||||
|
gostDto.setMsg("OK");
|
||||||
|
}
|
||||||
|
return gostDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void DeleteChainService(Long node_id, JSONArray services) {
|
public static GostDto AddAndUpdateService(String name, Integer limiter, Node node, Forward forward, ForwardPort forwardPort, Tunnel tunnel, String meth) {
|
||||||
JSONObject data = new JSONObject();
|
|
||||||
data.put("services", services);
|
|
||||||
WebSocketServer.send_msg(node_id, data, "DeleteService");
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void AddAndUpdateService(String name, Integer limiter, Node node, Forward forward, ForwardPort forwardPort, Tunnel tunnel, String meth) {
|
|
||||||
JSONArray services = new JSONArray();
|
JSONArray services = new JSONArray();
|
||||||
String[] protocols = {"tcp", "udp"};
|
String[] protocols = {"tcp", "udp"};
|
||||||
for (String protocol : protocols) {
|
for (String protocol : protocols) {
|
||||||
@@ -127,7 +155,8 @@ public class GostUtil {
|
|||||||
service.put("addr", node.getUdpListenAddr() + ":" + forwardPort.getPort());
|
service.put("addr", node.getUdpListenAddr() + ":" + forwardPort.getPort());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (StringUtils.isNotBlank(node.getInterfaceName())) {
|
// 只在端口转发时设置 interface(隧道转发时 interface 在转发链的节点上设置)
|
||||||
|
if (tunnel.getType() == 1 && StringUtils.isNotBlank(node.getInterfaceName())) {
|
||||||
JSONObject metadata = new JSONObject();
|
JSONObject metadata = new JSONObject();
|
||||||
metadata.put("interface", node.getInterfaceName());
|
metadata.put("interface", node.getInterfaceName());
|
||||||
service.put("metadata", metadata);
|
service.put("metadata", metadata);
|
||||||
@@ -155,22 +184,30 @@ public class GostUtil {
|
|||||||
|
|
||||||
services.add(service);
|
services.add(service);
|
||||||
}
|
}
|
||||||
WebSocketServer.send_msg(node.getId(), services, meth);
|
GostDto gostDto = WebSocketServer.send_msg(node.getId(), services, meth);
|
||||||
|
if (gostDto.getMsg().contains("exists")){
|
||||||
|
gostDto.setMsg("OK");
|
||||||
|
}
|
||||||
|
return gostDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void DeleteService(Long node_id, JSONArray services) {
|
public static GostDto DeleteService(Long node_id, JSONArray services) {
|
||||||
JSONObject data = new JSONObject();
|
JSONObject data = new JSONObject();
|
||||||
data.put("services", services);
|
data.put("services", services);
|
||||||
WebSocketServer.send_msg(node_id, data, "DeleteService");
|
GostDto gostDto = WebSocketServer.send_msg(node_id, data, "DeleteService");
|
||||||
|
if (gostDto.getMsg().contains("not found")){
|
||||||
|
gostDto.setMsg("OK");
|
||||||
|
}
|
||||||
|
return gostDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void PauseAndResumeService(Long node_id, String name, String meth) {
|
public static GostDto PauseAndResumeService(Long node_id, String name, String meth) {
|
||||||
JSONObject data = new JSONObject();
|
JSONObject data = new JSONObject();
|
||||||
JSONArray services = new JSONArray();
|
JSONArray services = new JSONArray();
|
||||||
services.add(name + "_tcp");
|
services.add(name + "_tcp");
|
||||||
services.add(name + "_udp");
|
services.add(name + "_udp");
|
||||||
data.put("services", services);
|
data.put("services", services);
|
||||||
WebSocketServer.send_msg(node_id, data, meth);
|
return WebSocketServer.send_msg(node_id, data, meth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -208,7 +245,7 @@ public class GostUtil {
|
|||||||
num++;
|
num++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (strategy == null || strategy.equals("")) {
|
if (strategy == null || strategy.isEmpty()) {
|
||||||
strategy = "fifo";
|
strategy = "fifo";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,5 +259,153 @@ public class GostUtil {
|
|||||||
return forwarder;
|
return forwarder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static String processServerAddress(String serverAddr) {
|
||||||
|
if (StrUtil.isBlank(serverAddr)) {
|
||||||
|
return serverAddr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经被方括号包裹,直接返回
|
||||||
|
if (serverAddr.startsWith("[")) {
|
||||||
|
return serverAddr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找最后一个冒号,分离主机和端口
|
||||||
|
int lastColonIndex = serverAddr.lastIndexOf(':');
|
||||||
|
if (lastColonIndex == -1) {
|
||||||
|
// 没有端口号,直接检查是否需要包裹
|
||||||
|
return isIPv6Address(serverAddr) ? "[" + serverAddr + "]" : serverAddr;
|
||||||
|
}
|
||||||
|
|
||||||
|
String host = serverAddr.substring(0, lastColonIndex);
|
||||||
|
String port = serverAddr.substring(lastColonIndex);
|
||||||
|
|
||||||
|
// 检查主机部分是否为IPv6地址
|
||||||
|
if (isIPv6Address(host)) {
|
||||||
|
return "[" + host + "]" + port;
|
||||||
|
}
|
||||||
|
|
||||||
|
return serverAddr;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isIPv6Address(String address) {
|
||||||
|
// IPv6地址包含多个冒号,至少2个
|
||||||
|
if (!address.contains(":")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算冒号数量,IPv6地址至少有2个冒号
|
||||||
|
long colonCount = address.chars().filter(ch -> ch == ':').count();
|
||||||
|
return colonCount >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v4 优先:当两端都有 v4 时选择 v4,否则尝试 v6。
|
||||||
|
* 用于节点之间建立链路(A -> B 需要选择 B 的地址族,且 A 需要支持该地址族)。
|
||||||
|
*/
|
||||||
|
public static String selectDialHost(Node fromNode, Node toNode) {
|
||||||
|
if (fromNode == null || toNode == null) {
|
||||||
|
throw new IllegalArgumentException("node is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean fromV4 = supportsV4(fromNode);
|
||||||
|
boolean fromV6 = supportsV6(fromNode);
|
||||||
|
boolean toV4 = supportsV4(toNode);
|
||||||
|
boolean toV6 = supportsV6(toNode);
|
||||||
|
|
||||||
|
if (fromV4 && toV4) {
|
||||||
|
return pickToAddressV4(toNode);
|
||||||
|
}
|
||||||
|
if (fromV6 && toV6) {
|
||||||
|
return pickToAddressV6(toNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException(
|
||||||
|
"节点链路不兼容:" + safeName(fromNode) + "(v4=" + fromV4 + ",v6=" + fromV6 + ") -> "
|
||||||
|
+ safeName(toNode) + "(v4=" + toV4 + ",v6=" + toV6 + ")"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safeName(Node node) {
|
||||||
|
if (node.getName() == null || node.getName().isBlank()) {
|
||||||
|
return "node_" + node.getId();
|
||||||
|
}
|
||||||
|
return node.getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean supportsV4(Node node) {
|
||||||
|
// New dual-stack fields take precedence over legacy serverIp.
|
||||||
|
// If user explicitly provided only v6, treat as v6-only.
|
||||||
|
if (StrUtil.isNotBlank(node.getServerIpV4())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StrUtil.isNotBlank(node.getServerIpV6())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String legacy = node.getServerIp();
|
||||||
|
if (StrUtil.isBlank(legacy)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
legacy = legacy.trim();
|
||||||
|
if (looksLikeIpv4(legacy)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (isIPv6Address(legacy)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 域名/其它:无法判断,按双栈处理以保持兼容
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean supportsV6(Node node) {
|
||||||
|
// New dual-stack fields take precedence over legacy serverIp.
|
||||||
|
// If user explicitly provided only v4, treat as v4-only.
|
||||||
|
if (StrUtil.isNotBlank(node.getServerIpV6())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StrUtil.isNotBlank(node.getServerIpV4())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String legacy = node.getServerIp();
|
||||||
|
if (StrUtil.isBlank(legacy)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
legacy = legacy.trim();
|
||||||
|
if (isIPv6Address(legacy)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (looksLikeIpv4(legacy)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 域名/其它:无法判断,按双栈处理以保持兼容
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String pickToAddressV4(Node toNode) {
|
||||||
|
if (StrUtil.isNotBlank(toNode.getServerIpV4())) {
|
||||||
|
return toNode.getServerIpV4().trim();
|
||||||
|
}
|
||||||
|
String legacy = toNode.getServerIp();
|
||||||
|
return legacy != null ? legacy.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String pickToAddressV6(Node toNode) {
|
||||||
|
if (StrUtil.isNotBlank(toNode.getServerIpV6())) {
|
||||||
|
return toNode.getServerIpV6().trim();
|
||||||
|
}
|
||||||
|
String legacy = toNode.getServerIp();
|
||||||
|
return legacy != null ? legacy.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean looksLikeIpv4(String value) {
|
||||||
|
Pattern ipv4 = Pattern.compile("^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
|
||||||
|
return ipv4.matcher(value).matches();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ public class WebSocketServer extends TextWebSocketHandler {
|
|||||||
String socks = (String) session.getAttributes().get("socks");
|
String socks = (String) session.getAttributes().get("socks");
|
||||||
|
|
||||||
log.info("节点 {} 尝试连接,开始处理连接逻辑", nodeId);
|
log.info("节点 {} 尝试连接,开始处理连接逻辑", nodeId);
|
||||||
|
log.info("屏蔽协议数据:http->{}, tls->{}, socks->{}", http, tls, socks);
|
||||||
|
|
||||||
// 检查是否已有该节点的连接,如果有则记录日志但直接覆盖
|
// 检查是否已有该节点的连接,如果有则记录日志但直接覆盖
|
||||||
WebSocketSession existingSession = nodeSessions.get(nodeId);
|
WebSocketSession existingSession = nodeSessions.get(nodeId);
|
||||||
|
|||||||
@@ -3,18 +3,28 @@ package com.admin.config;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.boot.ApplicationArguments;
|
import org.springframework.boot.ApplicationArguments;
|
||||||
import org.springframework.boot.ApplicationRunner;
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.annotation.PreDestroy;
|
||||||
import javax.sql.DataSource;
|
import javax.sql.DataSource;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
import java.sql.Statement;
|
import java.sql.Statement;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SQLite 数据库配置
|
* SQLite 数据库配置
|
||||||
* 启用 WAL (Write-Ahead Logging) 模式以提高并发性能
|
* 启用 WAL (Write-Ahead Logging) 模式以提高并发性能
|
||||||
|
* 添加定期 checkpoint 和优雅关闭处理
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
|
@EnableScheduling
|
||||||
public class SQLiteConfig implements ApplicationRunner {
|
public class SQLiteConfig implements ApplicationRunner {
|
||||||
|
|
||||||
private final DataSource dataSource;
|
private final DataSource dataSource;
|
||||||
@@ -33,10 +43,130 @@ public class SQLiteConfig implements ApplicationRunner {
|
|||||||
statement.execute("PRAGMA cache_size=-64000;"); // 64MB 缓存
|
statement.execute("PRAGMA cache_size=-64000;"); // 64MB 缓存
|
||||||
statement.execute("PRAGMA temp_store=MEMORY;");
|
statement.execute("PRAGMA temp_store=MEMORY;");
|
||||||
statement.execute("PRAGMA busy_timeout=5000;"); // 5秒超时
|
statement.execute("PRAGMA busy_timeout=5000;"); // 5秒超时
|
||||||
|
statement.execute("PRAGMA wal_autocheckpoint=1000;"); // 每1000页自动checkpoint
|
||||||
|
|
||||||
|
ensureNodeDualStackColumns(connection);
|
||||||
|
|
||||||
|
log.info("SQLite WAL mode configured successfully");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to configure SQLite database", e);
|
log.error("Failed to configure SQLite database", e);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
private void ensureNodeDualStackColumns(Connection connection) throws Exception {
|
||||||
|
Set<String> cols = getTableColumns(connection, "node");
|
||||||
|
if (cols.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureColumnIfMissing(connection, cols, "node", "server_ip_v4", "VARCHAR(100)");
|
||||||
|
ensureColumnIfMissing(connection, cols, "node", "server_ip_v6", "VARCHAR(100)");
|
||||||
|
|
||||||
|
backfillNodeDualStackColumns(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void backfillNodeDualStackColumns(Connection connection) throws Exception {
|
||||||
|
Pattern ipv4 = Pattern.compile("^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
|
||||||
|
|
||||||
|
try (Statement statement = connection.createStatement();
|
||||||
|
ResultSet rs = statement.executeQuery("SELECT id, server_ip, server_ip_v4, server_ip_v6 FROM node;");
|
||||||
|
PreparedStatement updV4 = connection.prepareStatement("UPDATE node SET server_ip_v4 = ? WHERE id = ?;");
|
||||||
|
PreparedStatement updV6 = connection.prepareStatement("UPDATE node SET server_ip_v6 = ? WHERE id = ?;")
|
||||||
|
) {
|
||||||
|
while (rs.next()) {
|
||||||
|
long id = rs.getLong("id");
|
||||||
|
String serverIp = rs.getString("server_ip");
|
||||||
|
String v4 = rs.getString("server_ip_v4");
|
||||||
|
String v6 = rs.getString("server_ip_v6");
|
||||||
|
|
||||||
|
if (serverIp == null || serverIp.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((v4 != null && !v4.isBlank()) || (v6 != null && !v6.isBlank())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String trimmed = serverIp.trim();
|
||||||
|
|
||||||
|
if (ipv4.matcher(trimmed).matches()) {
|
||||||
|
updV4.setString(1, trimmed);
|
||||||
|
updV4.setLong(2, id);
|
||||||
|
updV4.executeUpdate();
|
||||||
|
} else {
|
||||||
|
long colonCount = trimmed.chars().filter(ch -> ch == ':').count();
|
||||||
|
if (colonCount >= 2) {
|
||||||
|
updV6.setString(1, trimmed);
|
||||||
|
updV6.setLong(2, id);
|
||||||
|
updV6.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> getTableColumns(Connection connection, String table) throws Exception {
|
||||||
|
Set<String> cols = new HashSet<>();
|
||||||
|
try (Statement statement = connection.createStatement();
|
||||||
|
ResultSet rs = statement.executeQuery("PRAGMA table_info(" + table + ");")) {
|
||||||
|
while (rs.next()) {
|
||||||
|
String name = rs.getString("name");
|
||||||
|
if (name != null && !name.isBlank()) {
|
||||||
|
cols.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureColumnIfMissing(
|
||||||
|
Connection connection,
|
||||||
|
Set<String> existingColumns,
|
||||||
|
String table,
|
||||||
|
String column,
|
||||||
|
String type
|
||||||
|
) throws Exception {
|
||||||
|
if (existingColumns.contains(column)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try (Statement statement = connection.createStatement()) {
|
||||||
|
statement.execute("ALTER TABLE " + table + " ADD COLUMN " + column + " " + type + ";");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("SQLite schema updated: added {}.{}", table, column);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定期执行 checkpoint,确保 WAL 文件内容写入主数据库
|
||||||
|
* 每5分钟执行一次
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelay = 300000, initialDelay = 300000)
|
||||||
|
public void performCheckpoint() {
|
||||||
|
try (Connection connection = dataSource.getConnection();
|
||||||
|
Statement statement = connection.createStatement()) {
|
||||||
|
|
||||||
|
statement.execute("PRAGMA wal_checkpoint(TRUNCATE);");
|
||||||
|
log.debug("SQLite WAL checkpoint completed");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to perform SQLite checkpoint", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用关闭前执行最终的 checkpoint,确保所有数据都写入主数据库文件
|
||||||
|
*/
|
||||||
|
@PreDestroy
|
||||||
|
public void onShutdown() {
|
||||||
|
log.info("Performing final SQLite checkpoint before shutdown...");
|
||||||
|
try (Connection connection = dataSource.getConnection();
|
||||||
|
Statement statement = connection.createStatement()) {
|
||||||
|
|
||||||
|
// 强制执行 checkpoint,将所有 WAL 内容写入主数据库
|
||||||
|
statement.execute("PRAGMA wal_checkpoint(TRUNCATE);");
|
||||||
|
log.info("Final SQLite checkpoint completed successfully");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to perform final SQLite checkpoint", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import com.admin.common.task.CheckGostConfigAsync;
|
|||||||
import com.admin.common.utils.AESCrypto;
|
import com.admin.common.utils.AESCrypto;
|
||||||
import com.admin.common.utils.GostUtil;
|
import com.admin.common.utils.GostUtil;
|
||||||
import com.admin.entity.*;
|
import com.admin.entity.*;
|
||||||
|
import com.admin.service.ChainTunnelService;
|
||||||
import com.alibaba.fastjson.JSON;
|
import com.alibaba.fastjson.JSON;
|
||||||
import com.alibaba.fastjson.JSONArray;
|
import com.alibaba.fastjson.JSONArray;
|
||||||
import com.alibaba.fastjson.JSONObject;
|
import com.alibaba.fastjson.JSONObject;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||||
|
import org.springframework.context.annotation.Lazy;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
@@ -64,6 +66,10 @@ public class FlowController extends BaseController {
|
|||||||
@Resource
|
@Resource
|
||||||
CheckGostConfigAsync checkGostConfigAsync;
|
CheckGostConfigAsync checkGostConfigAsync;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
@Lazy
|
||||||
|
ChainTunnelService chainTunnelService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加密消息包装器
|
* 加密消息包装器
|
||||||
*/
|
*/
|
||||||
@@ -306,17 +312,14 @@ public class FlowController extends BaseController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void pauseService(List<Forward> forwardList, String name) {
|
public void pauseService(List<Forward> forwardList, String name) {
|
||||||
// for (Forward forward : forwardList) {
|
for (Forward forward : forwardList) {
|
||||||
// Tunnel tunnel = tunnelService.getById(forward.getTunnelId());
|
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", forward.getTunnelId()).eq("chain_type", 1));
|
||||||
// if (tunnel != null) {
|
for (ChainTunnel chainTunnel : chainTunnels) {
|
||||||
// GostUtil.PauseService(tunnel.getInNodeId(), name);
|
GostUtil.PauseAndResumeService(chainTunnel.getNodeId(), name, "PauseService");
|
||||||
// if (tunnel.getType() == 2) {
|
}
|
||||||
// GostUtil.PauseRemoteService(tunnel.getOutNodeId(), name);
|
forward.setStatus(0);
|
||||||
// }
|
forwardService.updateById(forward);
|
||||||
// }
|
}
|
||||||
// forward.setStatus(0);
|
|
||||||
// forwardService.updateById(forward);
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateForwardFlow(String forwardId, FlowDto flowStats) {
|
private void updateForwardFlow(String forwardId, FlowDto flowStats) {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package com.admin.controller;
|
package com.admin.controller;
|
||||||
|
|
||||||
import com.admin.common.aop.LogAnnotation;
|
import com.admin.common.aop.LogAnnotation;
|
||||||
import com.admin.common.annotation.RequireRole;
|
import com.admin.common.annotation.RequireRole;
|
||||||
import com.admin.common.dto.ForwardDto;
|
import com.admin.common.dto.BatchDeleteDto;
|
||||||
import com.admin.common.dto.ForwardUpdateDto;
|
import com.admin.common.dto.BatchRedeployDto;
|
||||||
import com.admin.common.lang.R;
|
import com.admin.common.dto.BatchChangeTunnelDto;
|
||||||
|
import com.admin.common.dto.ForwardDto;
|
||||||
|
import com.admin.common.dto.ForwardUpdateDto;
|
||||||
|
import com.admin.common.lang.R;
|
||||||
import com.admin.service.ForwardService;
|
import com.admin.service.ForwardService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
@@ -91,10 +94,40 @@ public class ForwardController extends BaseController {
|
|||||||
* @param params 包含forwards数组的参数,每个元素包含id和inx
|
* @param params 包含forwards数组的参数,每个元素包含id和inx
|
||||||
* @return 更新结果
|
* @return 更新结果
|
||||||
*/
|
*/
|
||||||
@LogAnnotation
|
@LogAnnotation
|
||||||
@PostMapping("/update-order")
|
@PostMapping("/update-order")
|
||||||
public R updateForwardOrder(@RequestBody Map<String, Object> params) {
|
public R updateForwardOrder(@RequestBody Map<String, Object> params) {
|
||||||
return forwardService.updateForwardOrder(params);
|
return forwardService.updateForwardOrder(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
@LogAnnotation
|
||||||
|
@PostMapping("/batch-delete")
|
||||||
|
public R batchDelete(@Validated @RequestBody BatchDeleteDto batchDeleteDto) {
|
||||||
|
return forwardService.batchDeleteForwards(batchDeleteDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@LogAnnotation
|
||||||
|
@PostMapping("/batch-pause")
|
||||||
|
public R batchPause(@Validated @RequestBody BatchDeleteDto batchDeleteDto) {
|
||||||
|
return forwardService.batchPauseForwards(batchDeleteDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@LogAnnotation
|
||||||
|
@PostMapping("/batch-resume")
|
||||||
|
public R batchResume(@Validated @RequestBody BatchDeleteDto batchDeleteDto) {
|
||||||
|
return forwardService.batchResumeForwards(batchDeleteDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@LogAnnotation
|
||||||
|
@PostMapping("/batch-redeploy")
|
||||||
|
public R batchRedeploy(@Validated @RequestBody BatchRedeployDto batchRedeployDto) {
|
||||||
|
return forwardService.batchRedeployForwards(batchRedeployDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@LogAnnotation
|
||||||
|
@PostMapping("/batch-change-tunnel")
|
||||||
|
public R batchChangeTunnel(@Validated @RequestBody BatchChangeTunnelDto batchChangeTunnelDto) {
|
||||||
|
return forwardService.batchChangeTunnel(batchChangeTunnelDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.admin.controller;
|
||||||
|
|
||||||
|
import com.admin.common.aop.LogAnnotation;
|
||||||
|
import com.admin.common.annotation.RequireRole;
|
||||||
|
import com.admin.common.dto.GroupCreateDto;
|
||||||
|
import com.admin.common.dto.GroupPermissionAssignDto;
|
||||||
|
import com.admin.common.dto.GroupUpdateDto;
|
||||||
|
import com.admin.common.dto.TunnelGroupAssignTunnelsDto;
|
||||||
|
import com.admin.common.dto.UserGroupAssignUsersDto;
|
||||||
|
import com.admin.common.lang.R;
|
||||||
|
import com.admin.service.GroupService;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.CrossOrigin;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@CrossOrigin
|
||||||
|
@RequestMapping("/api/v1/group")
|
||||||
|
public class GroupController {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private GroupService groupService;
|
||||||
|
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/tunnel/list")
|
||||||
|
public R tunnelGroupList() {
|
||||||
|
return groupService.getTunnelGroups();
|
||||||
|
}
|
||||||
|
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/tunnel/create")
|
||||||
|
public R createTunnelGroup(@Validated @RequestBody GroupCreateDto dto) {
|
||||||
|
return groupService.createTunnelGroup(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/tunnel/update")
|
||||||
|
public R updateTunnelGroup(@Validated @RequestBody GroupUpdateDto dto) {
|
||||||
|
return groupService.updateTunnelGroup(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/tunnel/delete")
|
||||||
|
public R deleteTunnelGroup(@RequestBody Map<String, Object> params) {
|
||||||
|
Long id = Long.valueOf(params.get("id").toString());
|
||||||
|
return groupService.deleteTunnelGroup(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/tunnel/assign")
|
||||||
|
public R assignTunnels(@Validated @RequestBody TunnelGroupAssignTunnelsDto dto) {
|
||||||
|
return groupService.assignTunnelsToGroup(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/user/list")
|
||||||
|
public R userGroupList() {
|
||||||
|
return groupService.getUserGroups();
|
||||||
|
}
|
||||||
|
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/user/create")
|
||||||
|
public R createUserGroup(@Validated @RequestBody GroupCreateDto dto) {
|
||||||
|
return groupService.createUserGroup(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/user/update")
|
||||||
|
public R updateUserGroup(@Validated @RequestBody GroupUpdateDto dto) {
|
||||||
|
return groupService.updateUserGroup(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/user/delete")
|
||||||
|
public R deleteUserGroup(@RequestBody Map<String, Object> params) {
|
||||||
|
Long id = Long.valueOf(params.get("id").toString());
|
||||||
|
return groupService.deleteUserGroup(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/user/assign")
|
||||||
|
public R assignUsers(@Validated @RequestBody UserGroupAssignUsersDto dto) {
|
||||||
|
return groupService.assignUsersToGroup(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/permission/list")
|
||||||
|
public R listPermissions() {
|
||||||
|
return groupService.getGroupPermissions();
|
||||||
|
}
|
||||||
|
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/permission/assign")
|
||||||
|
public R assignPermission(@Validated @RequestBody GroupPermissionAssignDto dto) {
|
||||||
|
return groupService.assignGroupPermission(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/permission/remove")
|
||||||
|
public R removePermission(@RequestBody Map<String, Object> params) {
|
||||||
|
Long id = Long.valueOf(params.get("id").toString());
|
||||||
|
return groupService.removeGroupPermission(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
package com.admin.controller;
|
package com.admin.controller;
|
||||||
|
|
||||||
|
|
||||||
import com.admin.common.annotation.RequireRole;
|
import com.admin.common.annotation.RequireRole;
|
||||||
import com.admin.common.aop.LogAnnotation;
|
import com.admin.common.aop.LogAnnotation;
|
||||||
import com.admin.common.dto.NodeDto;
|
import com.admin.common.dto.BatchDeleteDto;
|
||||||
import com.admin.common.dto.NodeUpdateDto;
|
import com.admin.common.dto.NodeDto;
|
||||||
import com.admin.common.lang.R;
|
import com.admin.common.dto.NodeUpdateDto;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import com.admin.common.lang.R;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -54,12 +55,31 @@ public class NodeController extends BaseController {
|
|||||||
return nodeService.deleteNode(id);
|
return nodeService.deleteNode(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@LogAnnotation
|
@LogAnnotation
|
||||||
@RequireRole
|
@RequireRole
|
||||||
@PostMapping("/install")
|
@PostMapping("/install")
|
||||||
public R getInstallCommand(@RequestBody Map<String, Object> params) {
|
public R getInstallCommand(@RequestBody Map<String, Object> params) {
|
||||||
Long id = Long.valueOf(params.get("id").toString());
|
Long id = Long.valueOf(params.get("id").toString());
|
||||||
return nodeService.getInstallCommand(id);
|
return nodeService.getInstallCommand(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
/**
|
||||||
|
* 更新节点排序
|
||||||
|
* @param params 包含nodes数组的参数,每个元素包含id和inx
|
||||||
|
* @return 更新结果
|
||||||
|
*/
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/update-order")
|
||||||
|
public R updateNodeOrder(@RequestBody Map<String, Object> params) {
|
||||||
|
return nodeService.updateNodeOrder(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/batch-delete")
|
||||||
|
public R batchDelete(@Validated @RequestBody BatchDeleteDto batchDeleteDto) {
|
||||||
|
return nodeService.batchDeleteNodes(batchDeleteDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ package com.admin.controller;
|
|||||||
|
|
||||||
import com.admin.common.aop.LogAnnotation;
|
import com.admin.common.aop.LogAnnotation;
|
||||||
import com.admin.common.annotation.RequireRole;
|
import com.admin.common.annotation.RequireRole;
|
||||||
|
import com.admin.common.dto.BatchDeleteDto;
|
||||||
|
import com.admin.common.dto.BatchRedeployDto;
|
||||||
import com.admin.common.dto.TunnelDto;
|
import com.admin.common.dto.TunnelDto;
|
||||||
import com.admin.common.dto.TunnelUpdateDto;
|
import com.admin.common.dto.TunnelUpdateDto;
|
||||||
|
|
||||||
|
import com.admin.common.dto.UserTunnelBatchAssignDto;
|
||||||
import com.admin.common.dto.UserTunnelDto;
|
import com.admin.common.dto.UserTunnelDto;
|
||||||
import com.admin.common.dto.UserTunnelQueryDto;
|
import com.admin.common.dto.UserTunnelQueryDto;
|
||||||
import com.admin.common.dto.UserTunnelUpdateDto;
|
import com.admin.common.dto.UserTunnelUpdateDto;
|
||||||
@@ -78,6 +81,13 @@ public class TunnelController extends BaseController {
|
|||||||
public R assignUserTunnel(@Validated @RequestBody UserTunnelDto userTunnelDto) {
|
public R assignUserTunnel(@Validated @RequestBody UserTunnelDto userTunnelDto) {
|
||||||
return userTunnelService.assignUserTunnel(userTunnelDto);
|
return userTunnelService.assignUserTunnel(userTunnelDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/user/batch-assign")
|
||||||
|
public R batchAssignUserTunnel(@Validated @RequestBody UserTunnelBatchAssignDto batchAssignDto) {
|
||||||
|
return userTunnelService.batchAssignUserTunnel(batchAssignDto);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询用户隧道权限列表
|
* 查询用户隧道权限列表
|
||||||
@@ -137,4 +147,30 @@ public class TunnelController extends BaseController {
|
|||||||
return tunnelService.diagnoseTunnel(tunnelId);
|
return tunnelService.diagnoseTunnel(tunnelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新隧道排序
|
||||||
|
* @param params 包含tunnels数组的参数,每个元素包含id和inx
|
||||||
|
* @return 更新结果
|
||||||
|
*/
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/update-order")
|
||||||
|
public R updateTunnelOrder(@RequestBody Map<String, Object> params) {
|
||||||
|
return tunnelService.updateTunnelOrder(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/batch-delete")
|
||||||
|
public R batchDelete(@Validated @RequestBody BatchDeleteDto batchDeleteDto) {
|
||||||
|
return tunnelService.batchDeleteTunnels(batchDeleteDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@LogAnnotation
|
||||||
|
@RequireRole
|
||||||
|
@PostMapping("/batch-redeploy")
|
||||||
|
public R batchRedeploy(@Validated @RequestBody BatchRedeployDto batchRedeployDto) {
|
||||||
|
return tunnelService.batchRedeployTunnels(batchRedeployDto);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.admin.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class GroupPermission implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Long userGroupId;
|
||||||
|
|
||||||
|
private Long tunnelGroupId;
|
||||||
|
|
||||||
|
private Long createdTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.admin.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class GroupPermissionGrant implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Long userGroupId;
|
||||||
|
|
||||||
|
private Long tunnelGroupId;
|
||||||
|
|
||||||
|
private Long userTunnelId;
|
||||||
|
|
||||||
|
private Integer createdByGroup;
|
||||||
|
|
||||||
|
private Long createdTime;
|
||||||
|
}
|
||||||
@@ -14,15 +14,19 @@ import lombok.EqualsAndHashCode;
|
|||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@EqualsAndHashCode(callSuper = true)
|
@EqualsAndHashCode(callSuper = true)
|
||||||
public class Node extends BaseEntity {
|
public class Node extends BaseEntity {
|
||||||
|
|
||||||
private static final long serialVersionUID = 1L;
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
private String secret;
|
private String secret;
|
||||||
|
|
||||||
private String serverIp;
|
private String serverIp;
|
||||||
|
|
||||||
|
private String serverIpV4;
|
||||||
|
|
||||||
|
private String serverIpV6;
|
||||||
|
|
||||||
private String version;
|
private String version;
|
||||||
|
|
||||||
@@ -38,6 +42,11 @@ public class Node extends BaseEntity {
|
|||||||
|
|
||||||
private String tcpListenAddr;
|
private String tcpListenAddr;
|
||||||
|
|
||||||
private String udpListenAddr;
|
private String udpListenAddr;
|
||||||
|
|
||||||
}
|
/**
|
||||||
|
* Display/order index for drag-sorting in admin UI.
|
||||||
|
* Lower value appears first.
|
||||||
|
*/
|
||||||
|
private Integer inx;
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import lombok.EqualsAndHashCode;
|
|||||||
@Data
|
@Data
|
||||||
@EqualsAndHashCode(callSuper = true)
|
@EqualsAndHashCode(callSuper = true)
|
||||||
@TableName(autoResultMap = true)
|
@TableName(autoResultMap = true)
|
||||||
public class Tunnel extends BaseEntity {
|
public class Tunnel extends BaseEntity {
|
||||||
|
|
||||||
private static final long serialVersionUID = 1L;
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
@@ -32,7 +32,13 @@ public class Tunnel extends BaseEntity {
|
|||||||
|
|
||||||
private int flow;
|
private int flow;
|
||||||
|
|
||||||
private BigDecimal trafficRatio;
|
private BigDecimal trafficRatio;
|
||||||
|
|
||||||
private String inIp;
|
private String inIp;
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* Display/order index for drag-sorting in admin UI.
|
||||||
|
* Lower value appears first.
|
||||||
|
*/
|
||||||
|
private Integer inx;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.admin.entity;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class TunnelGroup extends BaseEntity {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private String name;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.admin.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class TunnelGroupTunnel implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Long tunnelGroupId;
|
||||||
|
|
||||||
|
private Long tunnelId;
|
||||||
|
|
||||||
|
private Long createdTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.admin.entity;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class UserGroup extends BaseEntity {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private String name;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.admin.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class UserGroupUser implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Long userGroupId;
|
||||||
|
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
private Long createdTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.admin.mapper;
|
||||||
|
|
||||||
|
import com.admin.entity.GroupPermissionGrant;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
|
||||||
|
public interface GroupPermissionGrantMapper extends BaseMapper<GroupPermissionGrant> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.admin.mapper;
|
||||||
|
|
||||||
|
import com.admin.entity.GroupPermission;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
|
||||||
|
public interface GroupPermissionMapper extends BaseMapper<GroupPermission> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.admin.mapper;
|
||||||
|
|
||||||
|
import com.admin.entity.TunnelGroup;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
|
||||||
|
public interface TunnelGroupMapper extends BaseMapper<TunnelGroup> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.admin.mapper;
|
||||||
|
|
||||||
|
import com.admin.entity.TunnelGroupTunnel;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
|
||||||
|
public interface TunnelGroupTunnelMapper extends BaseMapper<TunnelGroupTunnel> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.admin.mapper;
|
||||||
|
|
||||||
|
import com.admin.entity.UserGroup;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
|
||||||
|
public interface UserGroupMapper extends BaseMapper<UserGroup> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.admin.mapper;
|
||||||
|
|
||||||
|
import com.admin.entity.UserGroupUser;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
|
||||||
|
public interface UserGroupUserMapper extends BaseMapper<UserGroupUser> {
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.admin.service;
|
package com.admin.service;
|
||||||
|
|
||||||
|
import com.admin.common.dto.BatchDeleteDto;
|
||||||
|
import com.admin.common.dto.BatchRedeployDto;
|
||||||
|
import com.admin.common.dto.BatchChangeTunnelDto;
|
||||||
import com.admin.common.dto.ForwardDto;
|
import com.admin.common.dto.ForwardDto;
|
||||||
import com.admin.common.dto.ForwardUpdateDto;
|
import com.admin.common.dto.ForwardUpdateDto;
|
||||||
import com.admin.common.lang.R;
|
import com.admin.common.lang.R;
|
||||||
@@ -80,4 +83,14 @@ public interface ForwardService extends IService<Forward> {
|
|||||||
* @return 更新结果
|
* @return 更新结果
|
||||||
*/
|
*/
|
||||||
R updateForwardOrder(Map<String, Object> params);
|
R updateForwardOrder(Map<String, Object> params);
|
||||||
|
|
||||||
|
R batchDeleteForwards(BatchDeleteDto batchDeleteDto);
|
||||||
|
|
||||||
|
R batchPauseForwards(BatchDeleteDto batchDeleteDto);
|
||||||
|
|
||||||
|
R batchResumeForwards(BatchDeleteDto batchDeleteDto);
|
||||||
|
|
||||||
|
R batchRedeployForwards(BatchRedeployDto batchRedeployDto);
|
||||||
|
|
||||||
|
R batchChangeTunnel(BatchChangeTunnelDto batchChangeTunnelDto);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.admin.service;
|
||||||
|
|
||||||
|
import com.admin.common.dto.GroupCreateDto;
|
||||||
|
import com.admin.common.dto.GroupPermissionAssignDto;
|
||||||
|
import com.admin.common.dto.GroupUpdateDto;
|
||||||
|
import com.admin.common.dto.TunnelGroupAssignTunnelsDto;
|
||||||
|
import com.admin.common.dto.UserGroupAssignUsersDto;
|
||||||
|
import com.admin.common.lang.R;
|
||||||
|
|
||||||
|
public interface GroupService {
|
||||||
|
|
||||||
|
R getTunnelGroups();
|
||||||
|
|
||||||
|
R createTunnelGroup(GroupCreateDto dto);
|
||||||
|
|
||||||
|
R updateTunnelGroup(GroupUpdateDto dto);
|
||||||
|
|
||||||
|
R deleteTunnelGroup(Long id);
|
||||||
|
|
||||||
|
R assignTunnelsToGroup(TunnelGroupAssignTunnelsDto dto);
|
||||||
|
|
||||||
|
R getUserGroups();
|
||||||
|
|
||||||
|
R createUserGroup(GroupCreateDto dto);
|
||||||
|
|
||||||
|
R updateUserGroup(GroupUpdateDto dto);
|
||||||
|
|
||||||
|
R deleteUserGroup(Long id);
|
||||||
|
|
||||||
|
R assignUsersToGroup(UserGroupAssignUsersDto dto);
|
||||||
|
|
||||||
|
R getGroupPermissions();
|
||||||
|
|
||||||
|
R assignGroupPermission(GroupPermissionAssignDto dto);
|
||||||
|
|
||||||
|
R removeGroupPermission(Long id);
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package com.admin.service;
|
package com.admin.service;
|
||||||
|
|
||||||
import com.admin.common.dto.NodeDto;
|
import com.admin.common.dto.BatchDeleteDto;
|
||||||
import com.admin.common.dto.NodeUpdateDto;
|
import com.admin.common.dto.NodeDto;
|
||||||
import com.admin.common.lang.R;
|
import com.admin.common.dto.NodeUpdateDto;
|
||||||
import com.admin.entity.Node;
|
import com.admin.common.lang.R;
|
||||||
import com.baomidou.mybatisplus.extension.service.IService;
|
import com.admin.entity.Node;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* <p>
|
* <p>
|
||||||
@@ -14,7 +17,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
|
|||||||
* @author QAQ
|
* @author QAQ
|
||||||
* @since 2025-06-03
|
* @since 2025-06-03
|
||||||
*/
|
*/
|
||||||
public interface NodeService extends IService<Node> {
|
public interface NodeService extends IService<Node> {
|
||||||
|
|
||||||
R createNode(NodeDto nodeDto);
|
R createNode(NodeDto nodeDto);
|
||||||
|
|
||||||
@@ -24,6 +27,14 @@ public interface NodeService extends IService<Node> {
|
|||||||
|
|
||||||
R deleteNode(Long id);
|
R deleteNode(Long id);
|
||||||
|
|
||||||
R getInstallCommand(Long id);
|
R getInstallCommand(Long id);
|
||||||
|
|
||||||
}
|
/**
|
||||||
|
* 更新节点排序(管理员)
|
||||||
|
* @param params 包含nodes数组的参数,每个元素包含id和inx
|
||||||
|
*/
|
||||||
|
R updateNodeOrder(Map<String, Object> params);
|
||||||
|
|
||||||
|
R batchDeleteNodes(BatchDeleteDto batchDeleteDto);
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package com.admin.service;
|
package com.admin.service;
|
||||||
|
|
||||||
import com.admin.common.dto.TunnelDto;
|
import com.admin.common.dto.BatchDeleteDto;
|
||||||
import com.admin.common.dto.TunnelUpdateDto;
|
import com.admin.common.dto.BatchRedeployDto;
|
||||||
|
import com.admin.common.dto.TunnelDto;
|
||||||
import com.admin.common.lang.R;
|
import com.admin.common.dto.TunnelUpdateDto;
|
||||||
import com.admin.entity.Tunnel;
|
|
||||||
import com.baomidou.mybatisplus.extension.service.IService;
|
import com.admin.common.lang.R;
|
||||||
|
import com.admin.entity.Tunnel;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* <p>
|
* <p>
|
||||||
@@ -15,7 +19,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
|
|||||||
* @author QAQ
|
* @author QAQ
|
||||||
* @since 2025-06-03
|
* @since 2025-06-03
|
||||||
*/
|
*/
|
||||||
public interface TunnelService extends IService<Tunnel> {
|
public interface TunnelService extends IService<Tunnel> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建隧道
|
* 创建隧道
|
||||||
@@ -55,7 +59,25 @@ public interface TunnelService extends IService<Tunnel> {
|
|||||||
* @param tunnelId 隧道ID
|
* @param tunnelId 隧道ID
|
||||||
* @return 诊断结果
|
* @return 诊断结果
|
||||||
*/
|
*/
|
||||||
R diagnoseTunnel(Long tunnelId);
|
R diagnoseTunnel(Long tunnelId);
|
||||||
|
|
||||||
Integer getNodePort(Long nodeId, Integer type, Integer port);
|
/**
|
||||||
}
|
* 更新隧道排序(管理员)
|
||||||
|
* @param params 包含tunnels数组的参数,每个元素包含id和inx
|
||||||
|
*/
|
||||||
|
R updateTunnelOrder(Map<String, Object> params);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除隧道
|
||||||
|
* @param batchDeleteDto 批量删除数据
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
R batchDeleteTunnels(BatchDeleteDto batchDeleteDto);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量重新下发隧道配置
|
||||||
|
* @param batchRedeployDto 批量重新下发数据
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
R batchRedeployTunnels(BatchRedeployDto batchRedeployDto);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,49 +1,23 @@
|
|||||||
package com.admin.service;
|
package com.admin.service;
|
||||||
|
|
||||||
import com.admin.common.dto.UserTunnelDto;
|
import com.admin.common.dto.UserTunnelBatchAssignDto;
|
||||||
import com.admin.common.dto.UserTunnelQueryDto;
|
import com.admin.common.dto.UserTunnelDto;
|
||||||
import com.admin.common.dto.UserTunnelUpdateDto;
|
import com.admin.common.dto.UserTunnelQueryDto;
|
||||||
import com.admin.common.lang.R;
|
import com.admin.common.dto.UserTunnelUpdateDto;
|
||||||
import com.admin.entity.UserTunnel;
|
import com.admin.common.lang.R;
|
||||||
import com.baomidou.mybatisplus.extension.service.IService;
|
import com.admin.entity.UserTunnel;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
/**
|
|
||||||
* <p>
|
public interface UserTunnelService extends IService<UserTunnel> {
|
||||||
* 用户隧道权限服务类
|
|
||||||
* </p>
|
R assignUserTunnel(UserTunnelDto userTunnelDto);
|
||||||
*
|
|
||||||
* @author QAQ
|
R batchAssignUserTunnel(UserTunnelBatchAssignDto batchAssignDto);
|
||||||
* @since 2025-06-03
|
|
||||||
*/
|
R getUserTunnelList(UserTunnelQueryDto queryDto);
|
||||||
public interface UserTunnelService extends IService<UserTunnel> {
|
|
||||||
|
R removeUserTunnel(Integer id);
|
||||||
/**
|
|
||||||
* 分配用户隧道权限
|
R updateUserTunnel(UserTunnelUpdateDto updateDto);
|
||||||
* @param userTunnelDto 用户隧道权限数据
|
|
||||||
* @return 结果
|
}
|
||||||
*/
|
|
||||||
R assignUserTunnel(UserTunnelDto userTunnelDto);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询用户隧道权限列表
|
|
||||||
* @param queryDto 查询条件
|
|
||||||
* @return 结果
|
|
||||||
*/
|
|
||||||
R getUserTunnelList(UserTunnelQueryDto queryDto);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除用户隧道权限
|
|
||||||
* @param id ID
|
|
||||||
* @return 结果
|
|
||||||
*/
|
|
||||||
R removeUserTunnel(Integer id);
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新用户隧道权限(包含流量、流量重置时间、到期时间)
|
|
||||||
* @param updateDto 更新数据
|
|
||||||
* @return 结果
|
|
||||||
*/
|
|
||||||
R updateUserTunnel(UserTunnelUpdateDto updateDto);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,606 @@
|
|||||||
|
package com.admin.service.impl;
|
||||||
|
|
||||||
|
import com.admin.common.dto.GroupCreateDto;
|
||||||
|
import com.admin.common.dto.GroupPermissionAssignDto;
|
||||||
|
import com.admin.common.dto.GroupPermissionDetailDto;
|
||||||
|
import com.admin.common.dto.GroupUpdateDto;
|
||||||
|
import com.admin.common.dto.TunnelGroupAssignTunnelsDto;
|
||||||
|
import com.admin.common.dto.TunnelGroupDetailDto;
|
||||||
|
import com.admin.common.dto.UserGroupAssignUsersDto;
|
||||||
|
import com.admin.common.dto.UserGroupDetailDto;
|
||||||
|
import com.admin.common.lang.R;
|
||||||
|
import com.admin.entity.GroupPermission;
|
||||||
|
import com.admin.entity.GroupPermissionGrant;
|
||||||
|
import com.admin.entity.Tunnel;
|
||||||
|
import com.admin.entity.TunnelGroup;
|
||||||
|
import com.admin.entity.TunnelGroupTunnel;
|
||||||
|
import com.admin.entity.User;
|
||||||
|
import com.admin.entity.UserGroup;
|
||||||
|
import com.admin.entity.UserGroupUser;
|
||||||
|
import com.admin.entity.UserTunnel;
|
||||||
|
import com.admin.mapper.GroupPermissionGrantMapper;
|
||||||
|
import com.admin.mapper.GroupPermissionMapper;
|
||||||
|
import com.admin.mapper.TunnelGroupMapper;
|
||||||
|
import com.admin.mapper.TunnelGroupTunnelMapper;
|
||||||
|
import com.admin.mapper.UserGroupMapper;
|
||||||
|
import com.admin.mapper.UserGroupUserMapper;
|
||||||
|
import com.admin.service.GroupService;
|
||||||
|
import com.admin.service.TunnelService;
|
||||||
|
import com.admin.service.UserService;
|
||||||
|
import com.admin.service.UserTunnelService;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class GroupServiceImpl implements GroupService {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private TunnelGroupMapper tunnelGroupMapper;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private UserGroupMapper userGroupMapper;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private TunnelGroupTunnelMapper tunnelGroupTunnelMapper;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private UserGroupUserMapper userGroupUserMapper;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private GroupPermissionMapper groupPermissionMapper;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private GroupPermissionGrantMapper groupPermissionGrantMapper;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private TunnelService tunnelService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private UserTunnelService userTunnelService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public R getTunnelGroups() {
|
||||||
|
List<TunnelGroup> groups = tunnelGroupMapper.selectList(new QueryWrapper<TunnelGroup>().orderByAsc("id"));
|
||||||
|
List<TunnelGroupTunnel> mappings = tunnelGroupTunnelMapper.selectList(new QueryWrapper<TunnelGroupTunnel>());
|
||||||
|
|
||||||
|
Map<Long, List<TunnelGroupTunnel>> mappingByGroupId = mappings.stream()
|
||||||
|
.collect(Collectors.groupingBy(TunnelGroupTunnel::getTunnelGroupId));
|
||||||
|
|
||||||
|
Set<Long> tunnelIds = mappings.stream().map(TunnelGroupTunnel::getTunnelId).collect(Collectors.toSet());
|
||||||
|
Map<Long, String> tunnelNameMap = buildTunnelNameMap(tunnelIds);
|
||||||
|
|
||||||
|
List<TunnelGroupDetailDto> result = new ArrayList<>();
|
||||||
|
for (TunnelGroup group : groups) {
|
||||||
|
TunnelGroupDetailDto dto = new TunnelGroupDetailDto();
|
||||||
|
dto.setId(group.getId());
|
||||||
|
dto.setName(group.getName());
|
||||||
|
dto.setStatus(group.getStatus());
|
||||||
|
dto.setCreatedTime(group.getCreatedTime());
|
||||||
|
dto.setUpdatedTime(group.getUpdatedTime());
|
||||||
|
|
||||||
|
List<TunnelGroupTunnel> groupMappings = mappingByGroupId.getOrDefault(group.getId(), Collections.emptyList());
|
||||||
|
List<Long> ids = groupMappings.stream().map(TunnelGroupTunnel::getTunnelId).collect(Collectors.toList());
|
||||||
|
List<String> names = ids.stream().map(tunnelNameMap::get).filter(name -> name != null && !name.isBlank()).collect(Collectors.toList());
|
||||||
|
dto.setTunnelIds(ids);
|
||||||
|
dto.setTunnelNames(names);
|
||||||
|
result.add(dto);
|
||||||
|
}
|
||||||
|
return R.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public R createTunnelGroup(GroupCreateDto dto) {
|
||||||
|
String name = dto.getName().trim();
|
||||||
|
int count = tunnelGroupMapper.selectCount(new QueryWrapper<TunnelGroup>().eq("name", name));
|
||||||
|
if (count > 0) {
|
||||||
|
return R.err("隧道分组名称已存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
TunnelGroup group = new TunnelGroup();
|
||||||
|
group.setName(name);
|
||||||
|
group.setStatus(normalizeStatus(dto.getStatus()));
|
||||||
|
group.setCreatedTime(now);
|
||||||
|
group.setUpdatedTime(now);
|
||||||
|
tunnelGroupMapper.insert(group);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public R updateTunnelGroup(GroupUpdateDto dto) {
|
||||||
|
TunnelGroup group = tunnelGroupMapper.selectById(dto.getId());
|
||||||
|
if (group == null) {
|
||||||
|
return R.err("隧道分组不存在");
|
||||||
|
}
|
||||||
|
String name = dto.getName().trim();
|
||||||
|
int count = tunnelGroupMapper.selectCount(new QueryWrapper<TunnelGroup>().eq("name", name).ne("id", dto.getId()));
|
||||||
|
if (count > 0) {
|
||||||
|
return R.err("隧道分组名称已存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
group.setName(name);
|
||||||
|
if (dto.getStatus() != null) {
|
||||||
|
group.setStatus(dto.getStatus());
|
||||||
|
}
|
||||||
|
group.setUpdatedTime(System.currentTimeMillis());
|
||||||
|
tunnelGroupMapper.updateById(group);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public R deleteTunnelGroup(Long id) {
|
||||||
|
TunnelGroup group = tunnelGroupMapper.selectById(id);
|
||||||
|
if (group == null) {
|
||||||
|
return R.err("隧道分组不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<GroupPermissionGrant> grants = groupPermissionGrantMapper.selectList(new QueryWrapper<GroupPermissionGrant>().eq("tunnel_group_id", id));
|
||||||
|
revokeGrantRecords(grants);
|
||||||
|
tunnelGroupTunnelMapper.delete(new QueryWrapper<TunnelGroupTunnel>().eq("tunnel_group_id", id));
|
||||||
|
groupPermissionMapper.delete(new QueryWrapper<GroupPermission>().eq("tunnel_group_id", id));
|
||||||
|
tunnelGroupMapper.deleteById(id);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public R assignTunnelsToGroup(TunnelGroupAssignTunnelsDto dto) {
|
||||||
|
TunnelGroup group = tunnelGroupMapper.selectById(dto.getGroupId());
|
||||||
|
if (group == null) {
|
||||||
|
return R.err("隧道分组不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<Long> tunnelIds = new LinkedHashSet<>(dto.getTunnelIds());
|
||||||
|
if (!tunnelIds.isEmpty()) {
|
||||||
|
List<Tunnel> tunnels = tunnelService.list(new QueryWrapper<Tunnel>().in("id", tunnelIds));
|
||||||
|
if (tunnels.size() != tunnelIds.size()) {
|
||||||
|
return R.err("隧道列表中存在无效ID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnelGroupTunnelMapper.delete(new QueryWrapper<TunnelGroupTunnel>().eq("tunnel_group_id", dto.getGroupId()));
|
||||||
|
|
||||||
|
if (!tunnelIds.isEmpty()) {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
for (Long tunnelId : tunnelIds) {
|
||||||
|
TunnelGroupTunnel relation = new TunnelGroupTunnel();
|
||||||
|
relation.setTunnelGroupId(dto.getGroupId());
|
||||||
|
relation.setTunnelId(tunnelId);
|
||||||
|
relation.setCreatedTime(now);
|
||||||
|
tunnelGroupTunnelMapper.insert(relation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncByTunnelGroup(dto.getGroupId());
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public R getUserGroups() {
|
||||||
|
List<UserGroup> groups = userGroupMapper.selectList(new QueryWrapper<UserGroup>().orderByAsc("id"));
|
||||||
|
List<UserGroupUser> mappings = userGroupUserMapper.selectList(new QueryWrapper<UserGroupUser>());
|
||||||
|
|
||||||
|
Map<Long, List<UserGroupUser>> mappingByGroupId = mappings.stream()
|
||||||
|
.collect(Collectors.groupingBy(UserGroupUser::getUserGroupId));
|
||||||
|
|
||||||
|
Set<Long> userIds = mappings.stream().map(UserGroupUser::getUserId).collect(Collectors.toSet());
|
||||||
|
Map<Long, String> userNameMap = buildUserNameMap(userIds);
|
||||||
|
|
||||||
|
List<UserGroupDetailDto> result = new ArrayList<>();
|
||||||
|
for (UserGroup group : groups) {
|
||||||
|
UserGroupDetailDto dto = new UserGroupDetailDto();
|
||||||
|
dto.setId(group.getId());
|
||||||
|
dto.setName(group.getName());
|
||||||
|
dto.setStatus(group.getStatus());
|
||||||
|
dto.setCreatedTime(group.getCreatedTime());
|
||||||
|
dto.setUpdatedTime(group.getUpdatedTime());
|
||||||
|
|
||||||
|
List<UserGroupUser> groupMappings = mappingByGroupId.getOrDefault(group.getId(), Collections.emptyList());
|
||||||
|
List<Long> ids = groupMappings.stream().map(UserGroupUser::getUserId).collect(Collectors.toList());
|
||||||
|
List<String> names = ids.stream().map(userNameMap::get).filter(name -> name != null && !name.isBlank()).collect(Collectors.toList());
|
||||||
|
dto.setUserIds(ids);
|
||||||
|
dto.setUserNames(names);
|
||||||
|
result.add(dto);
|
||||||
|
}
|
||||||
|
return R.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public R createUserGroup(GroupCreateDto dto) {
|
||||||
|
String name = dto.getName().trim();
|
||||||
|
int count = userGroupMapper.selectCount(new QueryWrapper<UserGroup>().eq("name", name));
|
||||||
|
if (count > 0) {
|
||||||
|
return R.err("用户分组名称已存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
UserGroup group = new UserGroup();
|
||||||
|
group.setName(name);
|
||||||
|
group.setStatus(normalizeStatus(dto.getStatus()));
|
||||||
|
group.setCreatedTime(now);
|
||||||
|
group.setUpdatedTime(now);
|
||||||
|
userGroupMapper.insert(group);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public R updateUserGroup(GroupUpdateDto dto) {
|
||||||
|
UserGroup group = userGroupMapper.selectById(dto.getId());
|
||||||
|
if (group == null) {
|
||||||
|
return R.err("用户分组不存在");
|
||||||
|
}
|
||||||
|
String name = dto.getName().trim();
|
||||||
|
int count = userGroupMapper.selectCount(new QueryWrapper<UserGroup>().eq("name", name).ne("id", dto.getId()));
|
||||||
|
if (count > 0) {
|
||||||
|
return R.err("用户分组名称已存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
group.setName(name);
|
||||||
|
if (dto.getStatus() != null) {
|
||||||
|
group.setStatus(dto.getStatus());
|
||||||
|
}
|
||||||
|
group.setUpdatedTime(System.currentTimeMillis());
|
||||||
|
userGroupMapper.updateById(group);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public R deleteUserGroup(Long id) {
|
||||||
|
UserGroup group = userGroupMapper.selectById(id);
|
||||||
|
if (group == null) {
|
||||||
|
return R.err("用户分组不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<GroupPermissionGrant> grants = groupPermissionGrantMapper.selectList(new QueryWrapper<GroupPermissionGrant>().eq("user_group_id", id));
|
||||||
|
revokeGrantRecords(grants);
|
||||||
|
userGroupUserMapper.delete(new QueryWrapper<UserGroupUser>().eq("user_group_id", id));
|
||||||
|
groupPermissionMapper.delete(new QueryWrapper<GroupPermission>().eq("user_group_id", id));
|
||||||
|
userGroupMapper.deleteById(id);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public R assignUsersToGroup(UserGroupAssignUsersDto dto) {
|
||||||
|
UserGroup group = userGroupMapper.selectById(dto.getGroupId());
|
||||||
|
if (group == null) {
|
||||||
|
return R.err("用户分组不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<Long> userIds = new LinkedHashSet<>(dto.getUserIds());
|
||||||
|
if (!userIds.isEmpty()) {
|
||||||
|
List<User> users = userService.list(new QueryWrapper<User>().in("id", userIds).ne("role_id", 0));
|
||||||
|
if (users.size() != userIds.size()) {
|
||||||
|
return R.err("用户列表中存在无效ID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
userGroupUserMapper.delete(new QueryWrapper<UserGroupUser>().eq("user_group_id", dto.getGroupId()));
|
||||||
|
|
||||||
|
if (!userIds.isEmpty()) {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
for (Long userId : userIds) {
|
||||||
|
UserGroupUser relation = new UserGroupUser();
|
||||||
|
relation.setUserGroupId(dto.getGroupId());
|
||||||
|
relation.setUserId(userId);
|
||||||
|
relation.setCreatedTime(now);
|
||||||
|
userGroupUserMapper.insert(relation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncByUserGroup(dto.getGroupId());
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public R getGroupPermissions() {
|
||||||
|
List<GroupPermission> permissions = groupPermissionMapper.selectList(new QueryWrapper<GroupPermission>().orderByDesc("id"));
|
||||||
|
if (permissions.isEmpty()) {
|
||||||
|
return R.ok(new ArrayList<GroupPermissionDetailDto>());
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<Long> userGroupIds = permissions.stream().map(GroupPermission::getUserGroupId).collect(Collectors.toSet());
|
||||||
|
Set<Long> tunnelGroupIds = permissions.stream().map(GroupPermission::getTunnelGroupId).collect(Collectors.toSet());
|
||||||
|
|
||||||
|
Map<Long, String> userGroupNameMap = userGroupMapper.selectList(new QueryWrapper<UserGroup>().in("id", userGroupIds)).stream()
|
||||||
|
.collect(Collectors.toMap(UserGroup::getId, UserGroup::getName));
|
||||||
|
Map<Long, String> tunnelGroupNameMap = tunnelGroupMapper.selectList(new QueryWrapper<TunnelGroup>().in("id", tunnelGroupIds)).stream()
|
||||||
|
.collect(Collectors.toMap(TunnelGroup::getId, TunnelGroup::getName));
|
||||||
|
|
||||||
|
List<GroupPermissionDetailDto> result = new ArrayList<>();
|
||||||
|
for (GroupPermission permission : permissions) {
|
||||||
|
GroupPermissionDetailDto dto = new GroupPermissionDetailDto();
|
||||||
|
dto.setId(permission.getId());
|
||||||
|
dto.setUserGroupId(permission.getUserGroupId());
|
||||||
|
dto.setTunnelGroupId(permission.getTunnelGroupId());
|
||||||
|
dto.setCreatedTime(permission.getCreatedTime());
|
||||||
|
dto.setUserGroupName(userGroupNameMap.get(permission.getUserGroupId()));
|
||||||
|
dto.setTunnelGroupName(tunnelGroupNameMap.get(permission.getTunnelGroupId()));
|
||||||
|
result.add(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
return R.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public R assignGroupPermission(GroupPermissionAssignDto dto) {
|
||||||
|
UserGroup userGroup = userGroupMapper.selectById(dto.getUserGroupId());
|
||||||
|
if (userGroup == null) {
|
||||||
|
return R.err("用户分组不存在");
|
||||||
|
}
|
||||||
|
TunnelGroup tunnelGroup = tunnelGroupMapper.selectById(dto.getTunnelGroupId());
|
||||||
|
if (tunnelGroup == null) {
|
||||||
|
return R.err("隧道分组不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
int existing = groupPermissionMapper.selectCount(new QueryWrapper<GroupPermission>()
|
||||||
|
.eq("user_group_id", dto.getUserGroupId())
|
||||||
|
.eq("tunnel_group_id", dto.getTunnelGroupId()));
|
||||||
|
if (existing == 0) {
|
||||||
|
GroupPermission permission = new GroupPermission();
|
||||||
|
permission.setUserGroupId(dto.getUserGroupId());
|
||||||
|
permission.setTunnelGroupId(dto.getTunnelGroupId());
|
||||||
|
permission.setCreatedTime(System.currentTimeMillis());
|
||||||
|
groupPermissionMapper.insert(permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
reconcilePermission(dto.getUserGroupId(), dto.getTunnelGroupId());
|
||||||
|
return existing > 0 ? R.ok("权限已存在,已完成同步") : R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public R removeGroupPermission(Long id) {
|
||||||
|
GroupPermission permission = groupPermissionMapper.selectById(id);
|
||||||
|
if (permission == null) {
|
||||||
|
return R.err("权限记录不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeByPermissionPair(permission.getUserGroupId(), permission.getTunnelGroupId());
|
||||||
|
groupPermissionMapper.deleteById(id);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void syncByUserGroup(Long userGroupId) {
|
||||||
|
List<GroupPermission> permissions = groupPermissionMapper.selectList(new QueryWrapper<GroupPermission>().eq("user_group_id", userGroupId));
|
||||||
|
for (GroupPermission permission : permissions) {
|
||||||
|
reconcilePermission(permission.getUserGroupId(), permission.getTunnelGroupId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void syncByTunnelGroup(Long tunnelGroupId) {
|
||||||
|
List<GroupPermission> permissions = groupPermissionMapper.selectList(new QueryWrapper<GroupPermission>().eq("tunnel_group_id", tunnelGroupId));
|
||||||
|
for (GroupPermission permission : permissions) {
|
||||||
|
reconcilePermission(permission.getUserGroupId(), permission.getTunnelGroupId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reconcilePermission(Long userGroupId, Long tunnelGroupId) {
|
||||||
|
Set<Long> userIds = userGroupUserMapper.selectList(new QueryWrapper<UserGroupUser>().eq("user_group_id", userGroupId)).stream()
|
||||||
|
.map(UserGroupUser::getUserId)
|
||||||
|
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||||
|
|
||||||
|
Set<Long> tunnelIds = tunnelGroupTunnelMapper.selectList(new QueryWrapper<TunnelGroupTunnel>().eq("tunnel_group_id", tunnelGroupId)).stream()
|
||||||
|
.map(TunnelGroupTunnel::getTunnelId)
|
||||||
|
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||||
|
|
||||||
|
Set<String> desiredKeys = new HashSet<>();
|
||||||
|
for (Long userId : userIds) {
|
||||||
|
for (Long tunnelId : tunnelIds) {
|
||||||
|
desiredKeys.add(permissionKey(userId, tunnelId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<GroupPermissionGrant> currentGrants = groupPermissionGrantMapper.selectList(
|
||||||
|
new QueryWrapper<GroupPermissionGrant>()
|
||||||
|
.eq("user_group_id", userGroupId)
|
||||||
|
.eq("tunnel_group_id", tunnelGroupId)
|
||||||
|
);
|
||||||
|
|
||||||
|
Set<Long> grantUserTunnelIds = currentGrants.stream().map(GroupPermissionGrant::getUserTunnelId).collect(Collectors.toSet());
|
||||||
|
Map<Long, UserTunnel> grantUserTunnelMap = new HashMap<>();
|
||||||
|
if (!grantUserTunnelIds.isEmpty()) {
|
||||||
|
List<UserTunnel> userTunnels = userTunnelService.list(new QueryWrapper<UserTunnel>().in("id", grantUserTunnelIds));
|
||||||
|
grantUserTunnelMap = userTunnels.stream().collect(Collectors.toMap(ut -> ut.getId().longValue(), Function.identity()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, UserTunnel> pairUserTunnelMap = new HashMap<>();
|
||||||
|
if (!userIds.isEmpty() && !tunnelIds.isEmpty()) {
|
||||||
|
List<UserTunnel> pairUserTunnels = userTunnelService.list(new QueryWrapper<UserTunnel>()
|
||||||
|
.in("user_id", userIds)
|
||||||
|
.in("tunnel_id", tunnelIds));
|
||||||
|
for (UserTunnel userTunnel : pairUserTunnels) {
|
||||||
|
pairUserTunnelMap.putIfAbsent(permissionKey(userTunnel.getUserId().longValue(), userTunnel.getTunnelId().longValue()), userTunnel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<Long> pairUserTunnelIds = pairUserTunnelMap.values().stream()
|
||||||
|
.map(ut -> ut.getId().longValue())
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
Map<Long, Long> totalGrantCountMap = buildGrantCountMap(pairUserTunnelIds);
|
||||||
|
Set<Long> groupManagedUserTunnelIds = buildGroupManagedUserTunnelIds(pairUserTunnelIds);
|
||||||
|
|
||||||
|
Set<Long> currentGrantUserTunnelIds = currentGrants.stream().map(GroupPermissionGrant::getUserTunnelId).collect(Collectors.toSet());
|
||||||
|
if (!desiredKeys.isEmpty()) {
|
||||||
|
Map<Long, User> userMap = userService.list(new QueryWrapper<User>().in("id", userIds)).stream()
|
||||||
|
.collect(Collectors.toMap(User::getId, Function.identity()));
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
for (Long userId : userIds) {
|
||||||
|
User user = userMap.get(userId);
|
||||||
|
if (user == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (Long tunnelId : tunnelIds) {
|
||||||
|
String pairKey = permissionKey(userId, tunnelId);
|
||||||
|
UserTunnel userTunnel = pairUserTunnelMap.get(pairKey);
|
||||||
|
if (userTunnel == null) {
|
||||||
|
userTunnel = createGroupManagedUserTunnel(userId, tunnelId, user);
|
||||||
|
pairUserTunnelMap.put(pairKey, userTunnel);
|
||||||
|
createGrant(userGroupId, tunnelGroupId, userTunnel.getId().longValue(), true, now);
|
||||||
|
currentGrantUserTunnelIds.add(userTunnel.getId().longValue());
|
||||||
|
totalGrantCountMap.put(userTunnel.getId().longValue(), 1L);
|
||||||
|
groupManagedUserTunnelIds.add(userTunnel.getId().longValue());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Long userTunnelId = userTunnel.getId().longValue();
|
||||||
|
if (currentGrantUserTunnelIds.contains(userTunnelId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
long existingGrantCount = totalGrantCountMap.getOrDefault(userTunnelId, 0L);
|
||||||
|
boolean createdByGroup = groupManagedUserTunnelIds.contains(userTunnelId);
|
||||||
|
createGrant(userGroupId, tunnelGroupId, userTunnelId, createdByGroup, now);
|
||||||
|
currentGrantUserTunnelIds.add(userTunnelId);
|
||||||
|
totalGrantCountMap.put(userTunnelId, existingGrantCount + 1L);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<GroupPermissionGrant> staleGrants = new ArrayList<>();
|
||||||
|
for (GroupPermissionGrant grant : currentGrants) {
|
||||||
|
UserTunnel userTunnel = grantUserTunnelMap.get(grant.getUserTunnelId());
|
||||||
|
boolean keep = false;
|
||||||
|
if (userTunnel != null) {
|
||||||
|
String key = permissionKey(userTunnel.getUserId().longValue(), userTunnel.getTunnelId().longValue());
|
||||||
|
keep = desiredKeys.contains(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!keep) {
|
||||||
|
staleGrants.add(grant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
revokeGrantRecords(staleGrants);
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserTunnel createGroupManagedUserTunnel(Long userId, Long tunnelId, User user) {
|
||||||
|
UserTunnel userTunnel = new UserTunnel();
|
||||||
|
userTunnel.setUserId(userId.intValue());
|
||||||
|
userTunnel.setTunnelId(tunnelId.intValue());
|
||||||
|
userTunnel.setStatus(1);
|
||||||
|
userTunnel.setInFlow(0L);
|
||||||
|
userTunnel.setOutFlow(0L);
|
||||||
|
userTunnel.setFlow(user.getFlow());
|
||||||
|
userTunnel.setNum(user.getNum());
|
||||||
|
userTunnel.setFlowResetTime(user.getFlowResetTime());
|
||||||
|
userTunnel.setExpTime(user.getExpTime());
|
||||||
|
userTunnelService.save(userTunnel);
|
||||||
|
return userTunnel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createGrant(Long userGroupId, Long tunnelGroupId, Long userTunnelId, boolean createdByGroup, long createdTime) {
|
||||||
|
int exists = groupPermissionGrantMapper.selectCount(new QueryWrapper<GroupPermissionGrant>()
|
||||||
|
.eq("user_group_id", userGroupId)
|
||||||
|
.eq("tunnel_group_id", tunnelGroupId)
|
||||||
|
.eq("user_tunnel_id", userTunnelId));
|
||||||
|
if (exists > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupPermissionGrant grant = new GroupPermissionGrant();
|
||||||
|
grant.setUserGroupId(userGroupId);
|
||||||
|
grant.setTunnelGroupId(tunnelGroupId);
|
||||||
|
grant.setUserTunnelId(userTunnelId);
|
||||||
|
grant.setCreatedByGroup(createdByGroup ? 1 : 0);
|
||||||
|
grant.setCreatedTime(createdTime);
|
||||||
|
groupPermissionGrantMapper.insert(grant);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void revokeByPermissionPair(Long userGroupId, Long tunnelGroupId) {
|
||||||
|
List<GroupPermissionGrant> grants = groupPermissionGrantMapper.selectList(new QueryWrapper<GroupPermissionGrant>()
|
||||||
|
.eq("user_group_id", userGroupId)
|
||||||
|
.eq("tunnel_group_id", tunnelGroupId));
|
||||||
|
revokeGrantRecords(grants);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void revokeGrantRecords(List<GroupPermissionGrant> grants) {
|
||||||
|
if (grants == null || grants.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<Long> candidateUserTunnelIds = new HashSet<>();
|
||||||
|
Set<Long> groupManagedCandidates = new HashSet<>();
|
||||||
|
for (GroupPermissionGrant grant : grants) {
|
||||||
|
candidateUserTunnelIds.add(grant.getUserTunnelId());
|
||||||
|
if (grant.getCreatedByGroup() != null && grant.getCreatedByGroup() == 1) {
|
||||||
|
groupManagedCandidates.add(grant.getUserTunnelId());
|
||||||
|
}
|
||||||
|
groupPermissionGrantMapper.deleteById(grant.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<Long> stillGrantedUserTunnelIds = groupPermissionGrantMapper.selectList(
|
||||||
|
new QueryWrapper<GroupPermissionGrant>().in("user_tunnel_id", candidateUserTunnelIds)
|
||||||
|
).stream()
|
||||||
|
.map(GroupPermissionGrant::getUserTunnelId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
for (Long userTunnelId : candidateUserTunnelIds) {
|
||||||
|
if (!stillGrantedUserTunnelIds.contains(userTunnelId) && groupManagedCandidates.contains(userTunnelId)) {
|
||||||
|
userTunnelService.removeUserTunnel(userTunnelId.intValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<Long> buildGroupManagedUserTunnelIds(Set<Long> userTunnelIds) {
|
||||||
|
if (userTunnelIds.isEmpty()) {
|
||||||
|
return Collections.emptySet();
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupPermissionGrantMapper.selectList(new QueryWrapper<GroupPermissionGrant>()
|
||||||
|
.in("user_tunnel_id", userTunnelIds)
|
||||||
|
.eq("created_by_group", 1))
|
||||||
|
.stream()
|
||||||
|
.map(GroupPermissionGrant::getUserTunnelId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Long, Long> buildGrantCountMap(Set<Long> userTunnelIds) {
|
||||||
|
if (userTunnelIds.isEmpty()) {
|
||||||
|
return new HashMap<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<GroupPermissionGrant> grants = groupPermissionGrantMapper.selectList(new QueryWrapper<GroupPermissionGrant>().in("user_tunnel_id", userTunnelIds));
|
||||||
|
Map<Long, Long> countMap = new HashMap<>();
|
||||||
|
for (GroupPermissionGrant grant : grants) {
|
||||||
|
countMap.merge(grant.getUserTunnelId(), 1L, Long::sum);
|
||||||
|
}
|
||||||
|
return countMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Long, String> buildTunnelNameMap(Set<Long> tunnelIds) {
|
||||||
|
if (tunnelIds.isEmpty()) {
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
return tunnelService.list(new QueryWrapper<Tunnel>().in("id", tunnelIds)).stream()
|
||||||
|
.collect(Collectors.toMap(Tunnel::getId, Tunnel::getName));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Long, String> buildUserNameMap(Set<Long> userIds) {
|
||||||
|
if (userIds.isEmpty()) {
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
return userService.list(new QueryWrapper<User>().in("id", userIds)).stream()
|
||||||
|
.collect(Collectors.toMap(User::getId, User::getUser));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String permissionKey(Long userId, Long tunnelId) {
|
||||||
|
return userId + "_" + tunnelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int normalizeStatus(Integer status) {
|
||||||
|
return status == null ? 1 : status;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,32 +2,44 @@ package com.admin.service.impl;
|
|||||||
|
|
||||||
import cn.hutool.core.util.IdUtil;
|
import cn.hutool.core.util.IdUtil;
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
import com.admin.common.dto.GostDto;
|
import com.admin.common.dto.BatchDeleteDto;
|
||||||
import com.admin.common.dto.NodeDto;
|
import com.admin.common.dto.BatchOperationResultDto;
|
||||||
import com.admin.common.dto.NodeUpdateDto;
|
import com.admin.common.dto.GostDto;
|
||||||
|
import com.admin.common.dto.NodeDto;
|
||||||
|
import com.admin.common.dto.NodeUpdateDto;
|
||||||
import com.admin.common.lang.R;
|
import com.admin.common.lang.R;
|
||||||
|
import com.admin.common.utils.GostUtil;
|
||||||
import com.admin.common.utils.WebSocketServer;
|
import com.admin.common.utils.WebSocketServer;
|
||||||
import com.admin.entity.*;
|
import com.admin.entity.*;
|
||||||
import com.admin.mapper.NodeMapper;
|
import com.admin.mapper.NodeMapper;
|
||||||
import com.admin.mapper.TunnelMapper;
|
import com.admin.mapper.TunnelMapper;
|
||||||
import com.admin.service.*;
|
import com.admin.service.*;
|
||||||
import com.alibaba.fastjson.JSONObject;
|
import com.alibaba.fastjson.JSONArray;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
import com.alibaba.fastjson.JSONObject;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
import org.springframework.beans.BeanUtils;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import org.springframework.context.annotation.Lazy;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
import org.springframework.stereotype.Service;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.BeanUtils;
|
||||||
|
import org.springframework.context.annotation.Lazy;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import java.util.List;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
@Service
|
||||||
|
@Slf4j
|
||||||
@Service
|
public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements NodeService {
|
||||||
public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements NodeService {
|
|
||||||
|
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
@@ -37,33 +49,71 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
|||||||
@Resource
|
@Resource
|
||||||
ViteConfigService viteConfigService;
|
ViteConfigService viteConfigService;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
ChainTunnelService chainTunnelService;
|
ChainTunnelService chainTunnelService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
ForwardPortService forwardPortService;
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public R createNode(NodeDto nodeDto) {
|
public R createNode(NodeDto nodeDto) {
|
||||||
validatePortRange(nodeDto.getPort());
|
validatePortRange(nodeDto.getPort());
|
||||||
Node node = new Node();
|
|
||||||
node.setSecret(IdUtil.simpleUUID());
|
String normalizedV4 = normalizeV4(nodeDto.getServerIpV4(), nodeDto.getServerIp());
|
||||||
node.setStatus(0);
|
String normalizedV6 = normalizeV6(nodeDto.getServerIpV6(), nodeDto.getServerIp());
|
||||||
node.setPort(nodeDto.getPort());
|
String primaryServerIp = pickPrimaryServerIp(normalizedV4, normalizedV6, nodeDto.getServerIp());
|
||||||
node.setName(nodeDto.getName());
|
|
||||||
node.setServerIp(nodeDto.getServerIp());
|
Node node = new Node();
|
||||||
long currentTime = System.currentTimeMillis();
|
node.setSecret(IdUtil.simpleUUID());
|
||||||
node.setCreatedTime(currentTime);
|
node.setStatus(0);
|
||||||
node.setUpdatedTime(currentTime);
|
node.setPort(nodeDto.getPort());
|
||||||
node.setInterfaceName(nodeDto.getInterfaceName());
|
node.setName(nodeDto.getName());
|
||||||
this.save(node);
|
node.setServerIp(primaryServerIp);
|
||||||
return R.ok();
|
node.setServerIpV4(normalizedV4);
|
||||||
}
|
node.setServerIpV6(normalizedV6);
|
||||||
|
long currentTime = System.currentTimeMillis();
|
||||||
|
node.setCreatedTime(currentTime);
|
||||||
|
node.setUpdatedTime(currentTime);
|
||||||
|
node.setInterfaceName(nodeDto.getInterfaceName());
|
||||||
|
this.save(node);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public R getAllNodes() {
|
public R getAllNodes() {
|
||||||
List<Node> nodeList = this.list(new QueryWrapper<Node>().orderByDesc("status"));
|
List<Node> nodeList = this.list(new QueryWrapper<Node>().orderByAsc("inx").orderByAsc("id"));
|
||||||
nodeList.forEach(node -> node.setSecret(null));
|
nodeList.forEach(node -> node.setSecret(null));
|
||||||
return R.ok(nodeList);
|
return R.ok(nodeList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public R updateNodeOrder(Map<String, Object> params) {
|
||||||
|
if (!params.containsKey("nodes")) {
|
||||||
|
return R.err("缺少nodes参数");
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> nodesList = (List<Map<String, Object>>) params.get("nodes");
|
||||||
|
if (nodesList == null || nodesList.isEmpty()) {
|
||||||
|
return R.err("nodes参数不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Node> nodesToUpdate = new ArrayList<>();
|
||||||
|
for (Map<String, Object> nodeData : nodesList) {
|
||||||
|
Long id = Long.valueOf(nodeData.get("id").toString());
|
||||||
|
Integer inx = Integer.valueOf(nodeData.get("inx").toString());
|
||||||
|
|
||||||
|
Node node = new Node();
|
||||||
|
node.setId(id);
|
||||||
|
node.setInx(inx);
|
||||||
|
nodesToUpdate.add(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateBatchById(nodesToUpdate);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public R updateNode(NodeUpdateDto nodeUpdateDto) {
|
public R updateNode(NodeUpdateDto nodeUpdateDto) {
|
||||||
@@ -95,25 +145,253 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
Node updateNode = buildUpdateNode(nodeUpdateDto);
|
Node updateNode = buildUpdateNode(nodeUpdateDto);
|
||||||
this.updateById(updateNode);
|
// Use LambdaUpdateWrapper to explicitly set nullable fields (serverIpV4/V6)
|
||||||
return R.ok();
|
// because updateById() skips null fields by default
|
||||||
}
|
LambdaUpdateWrapper<Node> wrapper = new LambdaUpdateWrapper<>();
|
||||||
|
wrapper.eq(Node::getId, updateNode.getId())
|
||||||
|
.set(Node::getName, updateNode.getName())
|
||||||
|
.set(Node::getServerIp, updateNode.getServerIp())
|
||||||
|
.set(Node::getServerIpV4, updateNode.getServerIpV4())
|
||||||
|
.set(Node::getServerIpV6, updateNode.getServerIpV6())
|
||||||
|
.set(Node::getPort, updateNode.getPort())
|
||||||
|
.set(Node::getHttp, updateNode.getHttp())
|
||||||
|
.set(Node::getTls, updateNode.getTls())
|
||||||
|
.set(Node::getSocks, updateNode.getSocks())
|
||||||
|
.set(Node::getInterfaceName, updateNode.getInterfaceName())
|
||||||
|
.set(Node::getTcpListenAddr, updateNode.getTcpListenAddr())
|
||||||
|
.set(Node::getUdpListenAddr, updateNode.getUdpListenAddr())
|
||||||
|
.set(Node::getUpdatedTime, updateNode.getUpdatedTime());
|
||||||
|
this.update(wrapper);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public R deleteNode(Long id) {
|
public R deleteNode(Long id) {
|
||||||
Node node = this.getById(id);
|
Node node = this.getById(id);
|
||||||
if (node == null) {
|
if (node == null) {
|
||||||
return R.err("节点不存在");
|
return R.err("节点不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
List<ChainTunnel> list = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("node_id", id).groupBy("tunnel_id"));
|
List<ChainTunnel> affected = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("node_id", id));
|
||||||
for (ChainTunnel tunnel : list) {
|
Map<Long, List<ChainTunnel>> byTunnelId = affected.stream()
|
||||||
tunnelService.deleteTunnel(tunnel.getTunnelId());
|
.filter(ct -> ct.getTunnelId() != null)
|
||||||
}
|
.collect(Collectors.groupingBy(ChainTunnel::getTunnelId));
|
||||||
this.removeById(id);
|
|
||||||
return R.ok();
|
for (Map.Entry<Long, List<ChainTunnel>> entry : byTunnelId.entrySet()) {
|
||||||
}
|
Long tunnelId = entry.getKey();
|
||||||
|
Tunnel tunnel = tunnelService.getById(tunnelId);
|
||||||
|
|
||||||
|
List<ChainTunnel> before = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnelId));
|
||||||
|
|
||||||
|
// Remove the node from the tunnel definition (do NOT delete the tunnel).
|
||||||
|
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnelId).eq("node_id", id));
|
||||||
|
|
||||||
|
if (tunnel == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ChainTunnel> after = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnelId));
|
||||||
|
Integer removedChainType = entry.getValue().isEmpty() ? null : entry.getValue().get(0).getChainType();
|
||||||
|
|
||||||
|
// Keep tunnel.inIp consistent when it was auto-derived from entry nodes.
|
||||||
|
String oldDerivedInIp = buildDerivedInIp(before);
|
||||||
|
String newDerivedInIp = buildDerivedInIp(after);
|
||||||
|
if (shouldUpdateTunnelInIp(tunnel.getInIp(), oldDerivedInIp)) {
|
||||||
|
updateTunnelInIp(tunnelId, newDerivedInIp);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean valid = isTunnelConfigValid(tunnel, after);
|
||||||
|
if (!valid) {
|
||||||
|
disableTunnelAndCleanupGostIfNeeded(tunnel, after, "node-delete");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For tunnel-forwarding (type=2), removing a chain/out node requires rebuilding config.
|
||||||
|
// Removing an entry node (chainType=1) does not affect remaining nodes' chain targets.
|
||||||
|
if (tunnel.getType() != null && tunnel.getType() == 2 && removedChainType != null && removedChainType != 1) {
|
||||||
|
try {
|
||||||
|
cleanupGostConfig(after, tunnelId);
|
||||||
|
rebuildGostConfig(after, tunnel);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to rebuild gost config after node delete. tunnelId={}, nodeId={}, err={}", tunnelId, id, e.getMessage(), e);
|
||||||
|
disableTunnelAndCleanupGostIfNeeded(tunnel, after, "node-delete:rebuild-failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove per-forward port allocations on this node (avoid orphan ForwardPort rows).
|
||||||
|
try {
|
||||||
|
forwardPortService.remove(new QueryWrapper<ForwardPort>().eq("node_id", id));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to cleanup forward ports when deleting node. nodeId={}, err={}", id, e.getMessage(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.removeById(id);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isTunnelConfigValid(Tunnel tunnel, List<ChainTunnel> chainTunnels) {
|
||||||
|
if (tunnel == null || chainTunnels == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
long inCount = chainTunnels.stream()
|
||||||
|
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
|
||||||
|
.count();
|
||||||
|
if (inCount <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tunnel.getType() != null && tunnel.getType() == 2) {
|
||||||
|
long outCount = chainTunnels.stream()
|
||||||
|
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 3)
|
||||||
|
.count();
|
||||||
|
return outCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean shouldUpdateTunnelInIp(String currentInIp, String oldDerivedInIp) {
|
||||||
|
if (StrUtil.isBlank(currentInIp)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (oldDerivedInIp == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Objects.equals(currentInIp, oldDerivedInIp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateTunnelInIp(Long tunnelId, String derivedInIp) {
|
||||||
|
Tunnel update = new Tunnel();
|
||||||
|
update.setId(tunnelId);
|
||||||
|
update.setInIp(derivedInIp == null ? "" : derivedInIp);
|
||||||
|
update.setUpdatedTime(System.currentTimeMillis());
|
||||||
|
tunnelService.updateById(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildDerivedInIp(List<ChainTunnel> chainTunnels) {
|
||||||
|
if (chainTunnels == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
List<ChainTunnel> inNodes = chainTunnels.stream()
|
||||||
|
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
if (inNodes.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder inIp = new StringBuilder();
|
||||||
|
for (ChainTunnel inNode : inNodes) {
|
||||||
|
Node n = this.getById(inNode.getNodeId());
|
||||||
|
if (n == null || StrUtil.isBlank(n.getServerIp())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
inIp.append(n.getServerIp()).append(",");
|
||||||
|
}
|
||||||
|
inIp.deleteCharAt(inIp.length() - 1);
|
||||||
|
return inIp.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void disableTunnelAndCleanupGostIfNeeded(Tunnel tunnel, List<ChainTunnel> remaining, String reason) {
|
||||||
|
try {
|
||||||
|
Tunnel update = new Tunnel();
|
||||||
|
update.setId(tunnel.getId());
|
||||||
|
update.setStatus(0);
|
||||||
|
update.setUpdatedTime(System.currentTimeMillis());
|
||||||
|
tunnelService.updateById(update);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to disable tunnel. tunnelId={}, reason={}, err={}", tunnel.getId(), reason, e.getMessage(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tunnel.getType() != null && tunnel.getType() == 2) {
|
||||||
|
try {
|
||||||
|
cleanupGostConfig(remaining, tunnel.getId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to cleanup gost config when disabling tunnel. tunnelId={}, reason={}, err={}", tunnel.getId(), reason, e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanupGostConfig(List<ChainTunnel> chainTunnels, Long tunnelId) {
|
||||||
|
if (chainTunnels == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (ChainTunnel chainTunnel : chainTunnels) {
|
||||||
|
if (chainTunnel.getChainType() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (chainTunnel.getChainType() == 1) {
|
||||||
|
GostUtil.DeleteChains(chainTunnel.getNodeId(), "chains_" + tunnelId);
|
||||||
|
} else if (chainTunnel.getChainType() == 2) {
|
||||||
|
GostUtil.DeleteChains(chainTunnel.getNodeId(), "chains_" + tunnelId);
|
||||||
|
JSONArray services = new JSONArray();
|
||||||
|
services.add(tunnelId + "_tls");
|
||||||
|
GostUtil.DeleteService(chainTunnel.getNodeId(), services);
|
||||||
|
} else if (chainTunnel.getChainType() == 3) {
|
||||||
|
JSONArray services = new JSONArray();
|
||||||
|
services.add(tunnelId + "_tls");
|
||||||
|
GostUtil.DeleteService(chainTunnel.getNodeId(), services);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rebuildGostConfig(List<ChainTunnel> chainTunnels, Tunnel tunnel) {
|
||||||
|
if (tunnel == null || chainTunnels == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<Long, Node> nodes = new HashMap<>();
|
||||||
|
for (ChainTunnel ct : chainTunnels) {
|
||||||
|
Node n = this.getById(ct.getNodeId());
|
||||||
|
if (n != null) {
|
||||||
|
nodes.put(n.getId(), n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ChainTunnel> inNodes = chainTunnels.stream()
|
||||||
|
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
Map<Integer, List<ChainTunnel>> chainNodesMap = chainTunnels.stream()
|
||||||
|
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 2)
|
||||||
|
.collect(Collectors.groupingBy(ct -> ct.getInx() != null ? ct.getInx() : 0));
|
||||||
|
|
||||||
|
List<List<ChainTunnel>> chainNodesList = chainNodesMap.entrySet().stream()
|
||||||
|
.sorted(Map.Entry.comparingByKey())
|
||||||
|
.map(Map.Entry::getValue)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
List<ChainTunnel> outNodes = chainTunnels.stream()
|
||||||
|
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 3)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
if (tunnel.getType() != null && tunnel.getType() == 2) {
|
||||||
|
for (ChainTunnel inNode : inNodes) {
|
||||||
|
if (chainNodesList.isEmpty()) {
|
||||||
|
GostUtil.AddChains(inNode.getNodeId(), outNodes, nodes);
|
||||||
|
} else {
|
||||||
|
GostUtil.AddChains(inNode.getNodeId(), chainNodesList.get(0), nodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < chainNodesList.size(); i++) {
|
||||||
|
for (ChainTunnel chainNode : chainNodesList.get(i)) {
|
||||||
|
if (i + 1 >= chainNodesList.size()) {
|
||||||
|
GostUtil.AddChains(chainNode.getNodeId(), outNodes, nodes);
|
||||||
|
} else {
|
||||||
|
GostUtil.AddChains(chainNode.getNodeId(), chainNodesList.get(i + 1), nodes);
|
||||||
|
}
|
||||||
|
GostUtil.AddChainService(chainNode.getNodeId(), chainNode, nodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ChainTunnel outNode : outNodes) {
|
||||||
|
GostUtil.AddChainService(outNode.getNodeId(), outNode, nodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -125,9 +403,9 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
|||||||
ViteConfig viteConfig = viteConfigService.getOne(new QueryWrapper<ViteConfig>().eq("name", "ip"));
|
ViteConfig viteConfig = viteConfigService.getOne(new QueryWrapper<ViteConfig>().eq("name", "ip"));
|
||||||
if (viteConfig == null) return R.err("请先前往网站配置中设置ip");
|
if (viteConfig == null) return R.err("请先前往网站配置中设置ip");
|
||||||
StringBuilder command = new StringBuilder();
|
StringBuilder command = new StringBuilder();
|
||||||
command.append("curl -L https://github.com/bqlpfy/flux-panel/releases/download/2.0.2-beta/install.sh")
|
command.append("curl -L https://github.com/Sagit-chu/flux-panel/releases/latest/download/install.sh")
|
||||||
.append(" -o ./install.sh && chmod +x ./install.sh && ");
|
.append(" -o ./install.sh && chmod +x ./install.sh && ");
|
||||||
String processedServerAddr = processServerAddress(viteConfig.getValue());
|
String processedServerAddr = GostUtil.processServerAddress(viteConfig.getValue());
|
||||||
command.append("./install.sh")
|
command.append("./install.sh")
|
||||||
.append(" -a ").append(processedServerAddr) // 服务器地址
|
.append(" -a ").append(processedServerAddr) // 服务器地址
|
||||||
.append(" -s ").append(node.getSecret()); // 节点密钥
|
.append(" -s ").append(node.getSecret()); // 节点密钥
|
||||||
@@ -136,90 +414,122 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private Node buildUpdateNode(NodeUpdateDto nodeUpdateDto) {
|
private Node buildUpdateNode(NodeUpdateDto nodeUpdateDto) {
|
||||||
validatePortRange(nodeUpdateDto.getPort());
|
validatePortRange(nodeUpdateDto.getPort());
|
||||||
Node node = new Node();
|
|
||||||
node.setId(nodeUpdateDto.getId());
|
String normalizedV4 = normalizeV4(nodeUpdateDto.getServerIpV4(), nodeUpdateDto.getServerIp());
|
||||||
node.setName(nodeUpdateDto.getName());
|
String normalizedV6 = normalizeV6(nodeUpdateDto.getServerIpV6(), nodeUpdateDto.getServerIp());
|
||||||
node.setServerIp(nodeUpdateDto.getServerIp());
|
String primaryServerIp = pickPrimaryServerIp(normalizedV4, normalizedV6, nodeUpdateDto.getServerIp());
|
||||||
node.setPort(nodeUpdateDto.getPort());
|
|
||||||
node.setHttp(nodeUpdateDto.getHttp());
|
Node node = new Node();
|
||||||
node.setTls(nodeUpdateDto.getTls());
|
node.setId(nodeUpdateDto.getId());
|
||||||
node.setSocks(nodeUpdateDto.getSocks());
|
node.setName(nodeUpdateDto.getName());
|
||||||
node.setUpdatedTime(System.currentTimeMillis());
|
node.setServerIp(primaryServerIp);
|
||||||
node.setInterfaceName(nodeUpdateDto.getInterfaceName());
|
node.setServerIpV4(normalizedV4);
|
||||||
node.setTcpListenAddr(nodeUpdateDto.getTcpListenAddr());
|
node.setServerIpV6(normalizedV6);
|
||||||
node.setUdpListenAddr(nodeUpdateDto.getUdpListenAddr());
|
node.setPort(nodeUpdateDto.getPort());
|
||||||
return node;
|
node.setHttp(nodeUpdateDto.getHttp());
|
||||||
}
|
node.setTls(nodeUpdateDto.getTls());
|
||||||
|
node.setSocks(nodeUpdateDto.getSocks());
|
||||||
|
node.setUpdatedTime(System.currentTimeMillis());
|
||||||
|
node.setInterfaceName(nodeUpdateDto.getInterfaceName());
|
||||||
|
node.setTcpListenAddr(nodeUpdateDto.getTcpListenAddr());
|
||||||
|
node.setUdpListenAddr(nodeUpdateDto.getUdpListenAddr());
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String pickPrimaryServerIp(String serverIpV4, String serverIpV6, String fallback) {
|
||||||
|
if (StrUtil.isNotBlank(serverIpV4)) {
|
||||||
|
return serverIpV4.trim();
|
||||||
|
}
|
||||||
|
if (StrUtil.isNotBlank(serverIpV6)) {
|
||||||
|
return serverIpV6.trim();
|
||||||
|
}
|
||||||
|
return fallback != null ? fallback.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeV4(String serverIpV4, String legacyServerIp) {
|
||||||
|
if (StrUtil.isNotBlank(serverIpV4)) {
|
||||||
|
return serverIpV4.trim();
|
||||||
|
}
|
||||||
|
if (StrUtil.isNotBlank(legacyServerIp) && looksLikeIpv4(legacyServerIp.trim())) {
|
||||||
|
return legacyServerIp.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeV6(String serverIpV6, String legacyServerIp) {
|
||||||
|
if (StrUtil.isNotBlank(serverIpV6)) {
|
||||||
|
return serverIpV6.trim();
|
||||||
|
}
|
||||||
|
if (StrUtil.isNotBlank(legacyServerIp) && looksLikeIpv6(legacyServerIp.trim())) {
|
||||||
|
return legacyServerIp.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean looksLikeIpv4(String value) {
|
||||||
|
// 仅用于判定地址族(不解析域名)
|
||||||
|
Pattern ipv4 = Pattern.compile("^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
|
||||||
|
return ipv4.matcher(value).matches();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean looksLikeIpv6(String value) {
|
||||||
|
// 粗略判定 IPv6(与 GostUtil.processServerAddress 一致思路)
|
||||||
|
long colonCount = value.chars().filter(ch -> ch == ':').count();
|
||||||
|
return colonCount >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private void validatePortRange(String port) {
|
private void validatePortRange(String port) {
|
||||||
Pattern PORT_PATTERN = Pattern.compile( "([0-9]{1,5})(-([0-9]{1,5}))?");
|
Pattern PORT_PATTERN = Pattern.compile( "([0-9]{1,5})(-([0-9]{1,5}))?");
|
||||||
if (port == null || port.isEmpty()) {
|
if (port == null || port.isEmpty()) {
|
||||||
throw new RuntimeException("可用端口不合法");
|
throw new RuntimeException("可用端口不合法");
|
||||||
}
|
}
|
||||||
String[] parts = port.split(",");
|
String[] parts = port.split(",");
|
||||||
for (String part : parts) {
|
for (String part : parts) {
|
||||||
part = part.trim();
|
part = part.trim();
|
||||||
if (!PORT_PATTERN.matcher(part).matches()) {
|
if (!PORT_PATTERN.matcher(part).matches()) {
|
||||||
throw new RuntimeException("可用端口不合法");
|
throw new RuntimeException("可用端口不合法");
|
||||||
}
|
}
|
||||||
if (part.contains("-")) {
|
if (part.contains("-")) {
|
||||||
String[] range = part.split("-");
|
String[] range = part.split("-");
|
||||||
int start = Integer.parseInt(range[0]);
|
int start = Integer.parseInt(range[0]);
|
||||||
int end = Integer.parseInt(range[1]);
|
int end = Integer.parseInt(range[1]);
|
||||||
if (start < 0 || end < 0 || end > 65535 || start > end) {
|
if (start < 0 || end < 0 || end > 65535 || start > end) {
|
||||||
throw new RuntimeException("可用端口不合法");
|
throw new RuntimeException("可用端口不合法");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
int ports = Integer.parseInt(part);
|
int ports = Integer.parseInt(part);
|
||||||
if (ports < 0 || ports > 65535) {
|
if (ports < 0 || ports > 65535) {
|
||||||
throw new RuntimeException("可用端口不合法");
|
throw new RuntimeException("可用端口不合法");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
private String processServerAddress(String serverAddr) {
|
@Transactional
|
||||||
if (StrUtil.isBlank(serverAddr)) {
|
public R batchDeleteNodes(BatchDeleteDto batchDeleteDto) {
|
||||||
return serverAddr;
|
BatchOperationResultDto result = new BatchOperationResultDto();
|
||||||
}
|
|
||||||
|
for (Long id : batchDeleteDto.getIds()) {
|
||||||
// 如果已经被方括号包裹,直接返回
|
try {
|
||||||
if (serverAddr.startsWith("[")) {
|
R deleteResult = deleteNode(id);
|
||||||
return serverAddr;
|
if (deleteResult.getCode() == 0) {
|
||||||
}
|
result.incrementSuccess();
|
||||||
|
} else {
|
||||||
// 查找最后一个冒号,分离主机和端口
|
result.addFailedItem(id, deleteResult.getMsg());
|
||||||
int lastColonIndex = serverAddr.lastIndexOf(':');
|
}
|
||||||
if (lastColonIndex == -1) {
|
} catch (Exception e) {
|
||||||
// 没有端口号,直接检查是否需要包裹
|
result.addFailedItem(id, e.getMessage());
|
||||||
return isIPv6Address(serverAddr) ? "[" + serverAddr + "]" : serverAddr;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String host = serverAddr.substring(0, lastColonIndex);
|
return R.ok(result);
|
||||||
String port = serverAddr.substring(lastColonIndex);
|
}
|
||||||
|
|
||||||
// 检查主机部分是否为IPv6地址
|
|
||||||
if (isIPv6Address(host)) {
|
|
||||||
return "[" + host + "]" + port;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return serverAddr;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private boolean isIPv6Address(String address) {
|
|
||||||
// IPv6地址包含多个冒号,至少2个
|
|
||||||
if (!address.contains(":")) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算冒号数量,IPv6地址至少有2个冒号
|
|
||||||
long colonCount = address.chars().filter(ch -> ch == ':').count();
|
|
||||||
return colonCount >= 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.admin.common.utils.GostUtil;
|
|||||||
import com.admin.entity.*;
|
import com.admin.entity.*;
|
||||||
import com.admin.mapper.SpeedLimitMapper;
|
import com.admin.mapper.SpeedLimitMapper;
|
||||||
import com.admin.service.*;
|
import com.admin.service.*;
|
||||||
|
import com.alibaba.fastjson.JSONObject;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -19,6 +20,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -65,11 +67,23 @@ public class SpeedLimitServiceImpl extends ServiceImpl<SpeedLimitMapper, SpeedLi
|
|||||||
|
|
||||||
String speedInMBps = convertBitsToMBps(speedLimit.getSpeed());
|
String speedInMBps = convertBitsToMBps(speedLimit.getSpeed());
|
||||||
|
|
||||||
|
List<Long> limit_success = new ArrayList<>();
|
||||||
|
|
||||||
List<ChainTunnel> tunnelList = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", speedLimit.getTunnelId()));
|
List<ChainTunnel> tunnelList = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", speedLimit.getTunnelId()));
|
||||||
for (ChainTunnel chainTunnel : tunnelList) {
|
for (ChainTunnel chainTunnel : tunnelList) {
|
||||||
Node node = nodeService.getById(chainTunnel.getNodeId());
|
Node node = nodeService.getById(chainTunnel.getNodeId());
|
||||||
if (node != null) {
|
if (node != null) {
|
||||||
GostUtil.AddLimiters(node.getId(),speedLimit.getId(),speedInMBps);
|
GostDto gostDto = GostUtil.AddLimiters(node.getId(), speedLimit.getId(), speedInMBps);
|
||||||
|
if (Objects.equals(gostDto.getMsg(), "OK")){
|
||||||
|
limit_success.add(node.getId());
|
||||||
|
}else {
|
||||||
|
this.removeById(speedLimit.getId());
|
||||||
|
for (Long node_id : limit_success) {
|
||||||
|
GostDto deleteLimiters = GostUtil.DeleteLimiters(node_id, speedLimit.getId());
|
||||||
|
System.out.println(deleteLimiters);
|
||||||
|
}
|
||||||
|
return R.err(gostDto.getMsg());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return R.ok();
|
return R.ok();
|
||||||
@@ -94,7 +108,8 @@ public class SpeedLimitServiceImpl extends ServiceImpl<SpeedLimitMapper, SpeedLi
|
|||||||
for (ChainTunnel chainTunnel : tunnelList) {
|
for (ChainTunnel chainTunnel : tunnelList) {
|
||||||
Node node = nodeService.getById(chainTunnel.getNodeId());
|
Node node = nodeService.getById(chainTunnel.getNodeId());
|
||||||
if (node != null) {
|
if (node != null) {
|
||||||
GostUtil.UpdateLimiters(node.getId(),speedLimit.getId(),speedInMBps);
|
GostDto gostDto = GostUtil.UpdateLimiters(node.getId(), speedLimit.getId(), speedInMBps);
|
||||||
|
if (!Objects.equals(gostDto.getMsg(), "OK")) return R.err(gostDto.getMsg());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.updateById(speedLimit);
|
this.updateById(speedLimit);
|
||||||
@@ -116,7 +131,8 @@ public class SpeedLimitServiceImpl extends ServiceImpl<SpeedLimitMapper, SpeedLi
|
|||||||
for (ChainTunnel chainTunnel : tunnelList) {
|
for (ChainTunnel chainTunnel : tunnelList) {
|
||||||
Node node = nodeService.getById(chainTunnel.getNodeId());
|
Node node = nodeService.getById(chainTunnel.getNodeId());
|
||||||
if (node != null) {
|
if (node != null) {
|
||||||
GostUtil.DeleteLimiters(node.getId(),speedLimit.getId());
|
GostDto gostDto = GostUtil.DeleteLimiters(node.getId(), speedLimit.getId());
|
||||||
|
if (!Objects.equals(gostDto.getMsg(), "OK"))return R.err(gostDto.getMsg());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.removeById(id);
|
this.removeById(id);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,8 @@ import com.admin.common.utils.JwtUtil;
|
|||||||
import com.admin.common.utils.Md5Util;
|
import com.admin.common.utils.Md5Util;
|
||||||
import com.admin.entity.*;
|
import com.admin.entity.*;
|
||||||
import com.admin.mapper.UserMapper;
|
import com.admin.mapper.UserMapper;
|
||||||
|
import com.admin.mapper.GroupPermissionGrantMapper;
|
||||||
|
import com.admin.mapper.UserGroupUserMapper;
|
||||||
import com.admin.service.*;
|
import com.admin.service.*;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
@@ -56,6 +58,12 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
|||||||
@Resource
|
@Resource
|
||||||
StatisticsFlowService statisticsFlowService;
|
StatisticsFlowService statisticsFlowService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
GroupPermissionGrantMapper groupPermissionGrantMapper;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
UserGroupUserMapper userGroupUserMapper;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
@Lazy
|
@Lazy
|
||||||
ForwardPortService forwardPortService;
|
ForwardPortService forwardPortService;
|
||||||
@@ -140,8 +148,16 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
|||||||
for (Forward forward : forwardList) {
|
for (Forward forward : forwardList) {
|
||||||
forwardService.deleteForward(forward.getId());
|
forwardService.deleteForward(forward.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<UserTunnel> userTunnels = userTunnelService.list(new QueryWrapper<UserTunnel>().eq("user_id", id));
|
||||||
|
if (!userTunnels.isEmpty()) {
|
||||||
|
List<Integer> userTunnelIds = userTunnels.stream().map(UserTunnel::getId).toList();
|
||||||
|
groupPermissionGrantMapper.delete(new QueryWrapper<com.admin.entity.GroupPermissionGrant>().in("user_tunnel_id", userTunnelIds));
|
||||||
|
}
|
||||||
|
|
||||||
forwardService.remove(new QueryWrapper<Forward>().eq("user_id", id));
|
forwardService.remove(new QueryWrapper<Forward>().eq("user_id", id));
|
||||||
userTunnelService.remove(new QueryWrapper<UserTunnel>().eq("user_id", id));
|
userTunnelService.remove(new QueryWrapper<UserTunnel>().eq("user_id", id));
|
||||||
|
userGroupUserMapper.delete(new QueryWrapper<UserGroupUser>().eq("user_id", id));
|
||||||
statisticsFlowService.remove(new QueryWrapper<StatisticsFlow>().eq("user_id", id));
|
statisticsFlowService.remove(new QueryWrapper<StatisticsFlow>().eq("user_id", id));
|
||||||
this.removeById(id);
|
this.removeById(id);
|
||||||
return R.ok();
|
return R.ok();
|
||||||
@@ -264,44 +280,61 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
|||||||
for (UserPackageDto.UserForwardDetailDto forward : forwards) {
|
for (UserPackageDto.UserForwardDetailDto forward : forwards) {
|
||||||
Tunnel tunnel = tunnelService.getById(forward.getTunnelId());
|
Tunnel tunnel = tunnelService.getById(forward.getTunnelId());
|
||||||
if (tunnel == null) continue;
|
if (tunnel == null) continue;
|
||||||
|
|
||||||
List<ForwardPort> forwardPorts = forwardPortService.list(
|
List<ForwardPort> forwardPorts = forwardPortService.list(
|
||||||
new QueryWrapper<ForwardPort>().eq("forward_id", forward.getId())
|
new QueryWrapper<ForwardPort>().eq("forward_id", forward.getId())
|
||||||
);
|
);
|
||||||
if (forwardPorts.isEmpty()) continue;
|
if (forwardPorts.isEmpty()) continue;
|
||||||
|
|
||||||
boolean useTunnelInIp = tunnel.getInIp() != null && !tunnel.getInIp().trim().isEmpty();
|
boolean useTunnelInIp = tunnel.getInIp() != null && !tunnel.getInIp().trim().isEmpty();
|
||||||
List<String> ipList = new ArrayList<>();
|
java.util.Set<String> ipPortSet = new java.util.LinkedHashSet<>();
|
||||||
List<Integer> portList = new ArrayList<>();
|
|
||||||
if (useTunnelInIp) {
|
if (useTunnelInIp) {
|
||||||
|
// 使用隧道的inIp(求笛卡尔积)
|
||||||
|
List<String> ipList = new ArrayList<>();
|
||||||
|
List<Integer> portList = new ArrayList<>();
|
||||||
|
|
||||||
String[] tunnelInIps = tunnel.getInIp().split(",");
|
String[] tunnelInIps = tunnel.getInIp().split(",");
|
||||||
for (String ip : tunnelInIps) {
|
for (String ip : tunnelInIps) {
|
||||||
if (ip != null && !ip.trim().isEmpty()) {
|
if (ip != null && !ip.trim().isEmpty()) {
|
||||||
ipList.add(ip.trim());
|
ipList.add(ip.trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
for (ForwardPort forwardPort : forwardPorts) {
|
for (ForwardPort forwardPort : forwardPorts) {
|
||||||
Node node = nodeService.getById(forwardPort.getNodeId());
|
if (forwardPort.getPort() != null) {
|
||||||
if (node != null && node.getServerIp() != null) {
|
portList.add(forwardPort.getPort());
|
||||||
ipList.add(node.getServerIp());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
for (ForwardPort forwardPort : forwardPorts) {
|
List<String> uniqueIps = ipList.stream().distinct().toList();
|
||||||
if (forwardPort.getPort() != null) {
|
List<Integer> uniquePorts = portList.stream().distinct().toList();
|
||||||
portList.add(forwardPort.getPort());
|
|
||||||
}
|
for (String ip : uniqueIps) {
|
||||||
}
|
for (Integer port : uniquePorts) {
|
||||||
List<String> uniqueIps = ipList.stream().distinct().toList();
|
ipPortSet.add(ip + ":" + port);
|
||||||
List<Integer> uniquePorts = portList.stream().distinct().toList();
|
}
|
||||||
java.util.Set<String> ipPortSet = new java.util.LinkedHashSet<>();
|
}
|
||||||
for (String ip : uniqueIps) {
|
|
||||||
for (Integer port : uniquePorts) {
|
if (!uniquePorts.isEmpty()) {
|
||||||
ipPortSet.add(ip + ":" + port);
|
forward.setInPort(uniquePorts.getFirst());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 使用节点的serverIp(一对一,不求笛卡尔积)
|
||||||
|
for (ForwardPort forwardPort : forwardPorts) {
|
||||||
|
Node node = nodeService.getById(forwardPort.getNodeId());
|
||||||
|
if (node != null && node.getServerIp() != null && forwardPort.getPort() != null) {
|
||||||
|
ipPortSet.add(node.getServerIp() + ":" + forwardPort.getPort());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!forwardPorts.isEmpty() && forwardPorts.getFirst().getPort() != null) {
|
||||||
|
forward.setInPort(forwardPorts.getFirst().getPort());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ipPortSet.isEmpty()) {
|
if (!ipPortSet.isEmpty()) {
|
||||||
forward.setInIp(String.join(",", ipPortSet));
|
forward.setInIp(String.join(",", ipPortSet));
|
||||||
forward.setInPort(uniquePorts.getFirst());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+193
-120
@@ -1,120 +1,193 @@
|
|||||||
package com.admin.service.impl;
|
package com.admin.service.impl;
|
||||||
|
|
||||||
import com.admin.common.dto.*;
|
import com.admin.common.dto.*;
|
||||||
import com.admin.common.lang.R;
|
import com.admin.common.lang.R;
|
||||||
import com.admin.entity.UserTunnel;
|
import com.admin.entity.GroupPermissionGrant;
|
||||||
import com.admin.mapper.TunnelMapper;
|
import com.admin.entity.User;
|
||||||
import com.admin.mapper.UserTunnelMapper;
|
import com.admin.entity.UserTunnel;
|
||||||
import com.admin.service.TunnelService;
|
import com.admin.mapper.GroupPermissionGrantMapper;
|
||||||
import com.admin.service.UserTunnelService;
|
import com.admin.mapper.UserTunnelMapper;
|
||||||
import com.admin.service.ForwardService;
|
import com.admin.service.UserService;
|
||||||
import com.admin.service.NodeService;
|
import com.admin.service.UserTunnelService;
|
||||||
import com.admin.common.utils.GostUtil;
|
import com.admin.service.ForwardService;
|
||||||
import com.admin.entity.Forward;
|
import com.admin.entity.Forward;
|
||||||
import com.admin.entity.Tunnel;
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||||
import com.admin.entity.Node;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
import org.springframework.beans.BeanUtils;
|
||||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
import org.springframework.context.annotation.Lazy;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import org.springframework.stereotype.Service;
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
|
||||||
import org.springframework.beans.BeanUtils;
|
import javax.annotation.Resource;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import java.util.ArrayList;
|
||||||
import org.springframework.context.annotation.Lazy;
|
import java.util.LinkedHashMap;
|
||||||
import org.springframework.stereotype.Service;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import javax.annotation.Resource;
|
import java.util.Set;
|
||||||
import java.util.List;
|
import java.util.stream.Collectors;
|
||||||
import java.util.Map;
|
|
||||||
|
@Service
|
||||||
/**
|
public class UserTunnelServiceImpl extends ServiceImpl<UserTunnelMapper, UserTunnel> implements UserTunnelService {
|
||||||
* <p>
|
|
||||||
* 用户隧道权限服务实现类
|
@Resource
|
||||||
* 提供用户隧道权限的分配、查询、更新和删除功能
|
@Lazy
|
||||||
* 支持流量限制、数量限制、过期时间和限速规则的管理
|
private ForwardService forwardService;
|
||||||
* </p>
|
|
||||||
*
|
@Resource
|
||||||
* @author QAQ
|
@Lazy
|
||||||
* @since 2025-06-03
|
private UserService userService;
|
||||||
*/
|
|
||||||
@Service
|
@Resource
|
||||||
public class UserTunnelServiceImpl extends ServiceImpl<UserTunnelMapper, UserTunnel> implements UserTunnelService {
|
private GroupPermissionGrantMapper groupPermissionGrantMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
@Resource
|
public R assignUserTunnel(UserTunnelDto userTunnelDto) {
|
||||||
@Lazy
|
int count = this.count(new QueryWrapper<UserTunnel>()
|
||||||
private ForwardService forwardService;
|
.eq("user_id", userTunnelDto.getUserId())
|
||||||
|
.eq("tunnel_id", userTunnelDto.getTunnelId()));
|
||||||
@Override
|
if (count > 0) return R.err("该用户已拥有此隧道权限");
|
||||||
public R assignUserTunnel(UserTunnelDto userTunnelDto) {
|
|
||||||
int count = this.count(new QueryWrapper<UserTunnel>().eq("user_id", userTunnelDto.getUserId()).eq("tunnel_id", userTunnelDto.getTunnelId()));
|
User user = userService.getById(userTunnelDto.getUserId());
|
||||||
if (count > 0) return R.err("该用户已拥有此隧道权限");
|
if (user == null) return R.err("用户不存在");
|
||||||
UserTunnel userTunnel = new UserTunnel();
|
|
||||||
BeanUtils.copyProperties(userTunnelDto, userTunnel);
|
UserTunnel userTunnel = new UserTunnel();
|
||||||
userTunnel.setStatus(1);
|
userTunnel.setUserId(userTunnelDto.getUserId());
|
||||||
this.save(userTunnel);
|
userTunnel.setTunnelId(userTunnelDto.getTunnelId());
|
||||||
return R.ok();
|
userTunnel.setSpeedId(userTunnelDto.getSpeedId());
|
||||||
}
|
userTunnel.setStatus(1);
|
||||||
|
userTunnel.setInFlow(0L);
|
||||||
@Override
|
userTunnel.setOutFlow(0L);
|
||||||
public R getUserTunnelList(UserTunnelQueryDto queryDto) {
|
|
||||||
List<UserTunnelWithDetailDto> userTunnelWithDetails = this.baseMapper.getUserTunnelWithDetails(queryDto.getUserId());
|
userTunnel.setFlow(userTunnelDto.getFlow() != null ? userTunnelDto.getFlow() : user.getFlow());
|
||||||
return R.ok(userTunnelWithDetails);
|
userTunnel.setNum(userTunnelDto.getNum() != null ? userTunnelDto.getNum() : user.getNum());
|
||||||
}
|
userTunnel.setFlowResetTime(userTunnelDto.getFlowResetTime() != null ? userTunnelDto.getFlowResetTime() : user.getFlowResetTime());
|
||||||
|
userTunnel.setExpTime(userTunnelDto.getExpTime() != null ? userTunnelDto.getExpTime() : user.getExpTime());
|
||||||
@Override
|
|
||||||
public R removeUserTunnel(Integer id) {
|
this.save(userTunnel);
|
||||||
UserTunnel userTunnel = this.getById(id);
|
return R.ok();
|
||||||
if (userTunnel == null) return R.err("未找到对应的用户隧道权限记录");
|
}
|
||||||
|
|
||||||
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>().eq("user_id", userTunnel.getUserId()).eq("tunnel_id", userTunnel.getTunnelId()));
|
@Override
|
||||||
for (Forward forward : forwardList) {
|
public R batchAssignUserTunnel(UserTunnelBatchAssignDto batchAssignDto) {
|
||||||
forwardService.deleteForward(forward.getId());
|
User user = userService.getById(batchAssignDto.getUserId());
|
||||||
}
|
if (user == null) return R.err("用户不存在");
|
||||||
this.removeById(id);
|
|
||||||
return R.ok();
|
Map<Integer, UserTunnelBatchAssignDto.TunnelAssignItem> uniqueTunnels = new LinkedHashMap<>();
|
||||||
}
|
for (UserTunnelBatchAssignDto.TunnelAssignItem item : batchAssignDto.getTunnels()) {
|
||||||
|
uniqueTunnels.putIfAbsent(item.getTunnelId(), item);
|
||||||
@Override
|
}
|
||||||
public R updateUserTunnel(UserTunnelUpdateDto updateDto) {
|
|
||||||
UserTunnel userTunnel = this.getById(updateDto.getId());
|
Set<Integer> existingTunnelIds = this.list(
|
||||||
if (userTunnel == null) return R.err("隧道不存在");
|
new QueryWrapper<UserTunnel>()
|
||||||
boolean speedChanged = hasSpeedChanged(userTunnel.getSpeedId(), updateDto.getSpeedId());
|
.eq("user_id", batchAssignDto.getUserId())
|
||||||
userTunnel.setFlow(updateDto.getFlow());
|
.in("tunnel_id", uniqueTunnels.keySet())
|
||||||
userTunnel.setNum(updateDto.getNum());
|
).stream().map(UserTunnel::getTunnelId).collect(Collectors.toSet());
|
||||||
updateOptionalProperty(userTunnel::setFlowResetTime, updateDto.getFlowResetTime());
|
|
||||||
updateOptionalProperty(userTunnel::setExpTime, updateDto.getExpTime());
|
List<UserTunnel> toSave = new ArrayList<>();
|
||||||
updateOptionalProperty(userTunnel::setStatus, updateDto.getStatus());
|
List<Integer> skippedIds = new ArrayList<>();
|
||||||
userTunnel.setSpeedId(updateDto.getSpeedId());
|
|
||||||
this.updateById(userTunnel);
|
for (UserTunnelBatchAssignDto.TunnelAssignItem item : uniqueTunnels.values()) {
|
||||||
if (speedChanged) {
|
if (existingTunnelIds.contains(item.getTunnelId())) {
|
||||||
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>().eq("user_id", userTunnel.getUserId()).eq("tunnel_id", userTunnel.getTunnelId()));
|
skippedIds.add(item.getTunnelId());
|
||||||
for (Forward forward : forwardList) {
|
continue;
|
||||||
ForwardUpdateDto forwardUpdateDto = new ForwardUpdateDto();
|
}
|
||||||
forwardUpdateDto.setId(forward.getId());
|
|
||||||
forwardUpdateDto.setUserId(forward.getUserId());
|
UserTunnel ut = new UserTunnel();
|
||||||
forwardUpdateDto.setName(forward.getName());
|
ut.setUserId(batchAssignDto.getUserId());
|
||||||
forwardUpdateDto.setRemoteAddr(forward.getRemoteAddr());
|
ut.setTunnelId(item.getTunnelId());
|
||||||
forwardUpdateDto.setStrategy(forward.getStrategy());
|
ut.setSpeedId(item.getSpeedId());
|
||||||
forwardService.updateForward(forwardUpdateDto);
|
ut.setStatus(1);
|
||||||
}
|
ut.setInFlow(0L);
|
||||||
}
|
ut.setOutFlow(0L);
|
||||||
return R.err("用户隧道权限更新失败");
|
ut.setFlow(user.getFlow());
|
||||||
}
|
ut.setNum(user.getNum());
|
||||||
|
ut.setFlowResetTime(user.getFlowResetTime());
|
||||||
private <T> void updateOptionalProperty(java.util.function.Consumer<T> setter, T value) {
|
ut.setExpTime(user.getExpTime());
|
||||||
if (value != null) {
|
toSave.add(ut);
|
||||||
setter.accept(value);
|
}
|
||||||
}
|
|
||||||
}
|
if (toSave.isEmpty()) {
|
||||||
|
return R.err("所选隧道用户均已拥有权限");
|
||||||
private boolean hasSpeedChanged(Integer oldSpeedId, Integer newSpeedId) {
|
}
|
||||||
if (oldSpeedId == null && newSpeedId == null) {
|
|
||||||
return false;
|
this.saveBatch(toSave);
|
||||||
}
|
|
||||||
if (oldSpeedId == null || newSpeedId == null) {
|
if (!skippedIds.isEmpty()) {
|
||||||
return true;
|
return R.ok("成功分配 " + toSave.size() + " 个隧道,跳过 " + skippedIds.size() + " 个已有权限的隧道");
|
||||||
}
|
}
|
||||||
return !oldSpeedId.equals(newSpeedId);
|
return R.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
@Override
|
||||||
|
public R getUserTunnelList(UserTunnelQueryDto queryDto) {
|
||||||
|
List<UserTunnelWithDetailDto> userTunnelWithDetails = this.baseMapper.getUserTunnelWithDetails(queryDto.getUserId());
|
||||||
|
return R.ok(userTunnelWithDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public R removeUserTunnel(Integer id) {
|
||||||
|
UserTunnel userTunnel = this.getById(id);
|
||||||
|
if (userTunnel == null) return R.err("未找到对应的用户隧道权限记录");
|
||||||
|
|
||||||
|
int grantCount = groupPermissionGrantMapper.selectCount(
|
||||||
|
new QueryWrapper<GroupPermissionGrant>().eq("user_tunnel_id", id)
|
||||||
|
);
|
||||||
|
if (grantCount > 0) {
|
||||||
|
return R.err("该隧道权限由分组授权,请先调整分组权限或分组成员");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>()
|
||||||
|
.eq("user_id", userTunnel.getUserId())
|
||||||
|
.eq("tunnel_id", userTunnel.getTunnelId()));
|
||||||
|
for (Forward forward : forwardList) {
|
||||||
|
forwardService.deleteForward(forward.getId());
|
||||||
|
}
|
||||||
|
this.removeById(id);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public R updateUserTunnel(UserTunnelUpdateDto updateDto) {
|
||||||
|
UserTunnel userTunnel = this.getById(updateDto.getId());
|
||||||
|
if (userTunnel == null) return R.err("隧道不存在");
|
||||||
|
boolean speedChanged = hasSpeedChanged(userTunnel.getSpeedId(), updateDto.getSpeedId());
|
||||||
|
userTunnel.setFlow(updateDto.getFlow());
|
||||||
|
userTunnel.setNum(updateDto.getNum());
|
||||||
|
updateOptionalProperty(userTunnel::setFlowResetTime, updateDto.getFlowResetTime());
|
||||||
|
updateOptionalProperty(userTunnel::setExpTime, updateDto.getExpTime());
|
||||||
|
updateOptionalProperty(userTunnel::setStatus, updateDto.getStatus());
|
||||||
|
userTunnel.setSpeedId(updateDto.getSpeedId());
|
||||||
|
this.updateById(userTunnel);
|
||||||
|
if (speedChanged) {
|
||||||
|
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>()
|
||||||
|
.eq("user_id", userTunnel.getUserId())
|
||||||
|
.eq("tunnel_id", userTunnel.getTunnelId()));
|
||||||
|
for (Forward forward : forwardList) {
|
||||||
|
ForwardUpdateDto forwardUpdateDto = new ForwardUpdateDto();
|
||||||
|
forwardUpdateDto.setId(forward.getId());
|
||||||
|
forwardUpdateDto.setUserId(forward.getUserId());
|
||||||
|
forwardUpdateDto.setName(forward.getName());
|
||||||
|
forwardUpdateDto.setRemoteAddr(forward.getRemoteAddr());
|
||||||
|
forwardUpdateDto.setStrategy(forward.getStrategy());
|
||||||
|
forwardService.updateForward(forwardUpdateDto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> void updateOptionalProperty(java.util.function.Consumer<T> setter, T value) {
|
||||||
|
if (value != null) {
|
||||||
|
setter.accept(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasSpeedChanged(Integer oldSpeedId, Integer newSpeedId) {
|
||||||
|
if (oldSpeedId == null && newSpeedId == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (oldSpeedId == null || newSpeedId == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !oldSpeedId.equals(newSpeedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ CREATE TABLE IF NOT EXISTS node (
|
|||||||
name VARCHAR(100) NOT NULL,
|
name VARCHAR(100) NOT NULL,
|
||||||
secret VARCHAR(100) NOT NULL,
|
secret VARCHAR(100) NOT NULL,
|
||||||
server_ip VARCHAR(100) NOT NULL,
|
server_ip VARCHAR(100) NOT NULL,
|
||||||
|
server_ip_v4 VARCHAR(100),
|
||||||
|
server_ip_v6 VARCHAR(100),
|
||||||
port TEXT NOT NULL,
|
port TEXT NOT NULL,
|
||||||
interface_name VARCHAR(200),
|
interface_name VARCHAR(200),
|
||||||
version VARCHAR(100),
|
version VARCHAR(100),
|
||||||
@@ -39,7 +41,8 @@ CREATE TABLE IF NOT EXISTS node (
|
|||||||
updated_time INTEGER,
|
updated_time INTEGER,
|
||||||
status INTEGER NOT NULL,
|
status INTEGER NOT NULL,
|
||||||
tcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
|
tcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
|
||||||
udp_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 (
|
CREATE TABLE IF NOT EXISTS speed_limit (
|
||||||
@@ -72,7 +75,8 @@ CREATE TABLE IF NOT EXISTS tunnel (
|
|||||||
created_time INTEGER NOT NULL,
|
created_time INTEGER NOT NULL,
|
||||||
updated_time INTEGER NOT NULL,
|
updated_time INTEGER NOT NULL,
|
||||||
status INTEGER NOT NULL,
|
status INTEGER NOT NULL,
|
||||||
in_ip TEXT
|
in_ip TEXT,
|
||||||
|
inx INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS chain_tunnel (
|
CREATE TABLE IF NOT EXISTS chain_tunnel (
|
||||||
@@ -117,10 +121,62 @@ CREATE TABLE IF NOT EXISTS user_tunnel (
|
|||||||
status 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 TABLE IF NOT EXISTS vite_config (
|
CREATE TABLE IF NOT EXISTS vite_config (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name VARCHAR(200) NOT NULL UNIQUE,
|
name VARCHAR(200) NOT NULL UNIQUE,
|
||||||
value VARCHAR(200) NOT NULL,
|
value VARCHAR(200) NOT NULL,
|
||||||
time INTEGER NOT NULL
|
time INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package com.admin;
|
||||||
|
|
||||||
|
import com.admin.common.lang.R;
|
||||||
|
import com.admin.entity.Node;
|
||||||
|
import com.admin.entity.Tunnel;
|
||||||
|
import com.admin.service.NodeService;
|
||||||
|
import com.admin.service.TunnelService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
@SpringBootTest(properties = {
|
||||||
|
// use a local sqlite file for tests (resolved via ${DB_PATH} placeholder)
|
||||||
|
"DB_PATH=./target/test-gost-ordering.db",
|
||||||
|
})
|
||||||
|
class OrderingUpdateTests {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private NodeService nodeService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private TunnelService tunnelService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void cleanup() {
|
||||||
|
// keep it simple; other tables may have foreign references in real runs
|
||||||
|
jdbcTemplate.execute("DELETE FROM node");
|
||||||
|
jdbcTemplate.execute("DELETE FROM tunnel");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateNodeOrder_updatesInx() {
|
||||||
|
Node n1 = new Node();
|
||||||
|
n1.setName("n1");
|
||||||
|
n1.setSecret("s1");
|
||||||
|
n1.setServerIp("127.0.0.1");
|
||||||
|
n1.setPort("1000-2000");
|
||||||
|
n1.setInterfaceName("");
|
||||||
|
n1.setHttp(0);
|
||||||
|
n1.setTls(0);
|
||||||
|
n1.setSocks(0);
|
||||||
|
n1.setTcpListenAddr("[::]");
|
||||||
|
n1.setUdpListenAddr("[::]");
|
||||||
|
n1.setStatus(0);
|
||||||
|
n1.setInx(0);
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
n1.setCreatedTime(now);
|
||||||
|
n1.setUpdatedTime(now);
|
||||||
|
assertTrue(nodeService.save(n1));
|
||||||
|
|
||||||
|
Node n2 = new Node();
|
||||||
|
n2.setName("n2");
|
||||||
|
n2.setSecret("s2");
|
||||||
|
n2.setServerIp("127.0.0.2");
|
||||||
|
n2.setPort("1000-2000");
|
||||||
|
n2.setInterfaceName("");
|
||||||
|
n2.setHttp(0);
|
||||||
|
n2.setTls(0);
|
||||||
|
n2.setSocks(0);
|
||||||
|
n2.setTcpListenAddr("[::]");
|
||||||
|
n2.setUdpListenAddr("[::]");
|
||||||
|
n2.setStatus(0);
|
||||||
|
n2.setInx(0);
|
||||||
|
n2.setCreatedTime(now);
|
||||||
|
n2.setUpdatedTime(now);
|
||||||
|
assertTrue(nodeService.save(n2));
|
||||||
|
|
||||||
|
Node n3 = new Node();
|
||||||
|
n3.setName("n3");
|
||||||
|
n3.setSecret("s3");
|
||||||
|
n3.setServerIp("127.0.0.3");
|
||||||
|
n3.setPort("1000-2000");
|
||||||
|
n3.setInterfaceName("");
|
||||||
|
n3.setHttp(0);
|
||||||
|
n3.setTls(0);
|
||||||
|
n3.setSocks(0);
|
||||||
|
n3.setTcpListenAddr("[::]");
|
||||||
|
n3.setUdpListenAddr("[::]");
|
||||||
|
n3.setStatus(0);
|
||||||
|
n3.setInx(0);
|
||||||
|
n3.setCreatedTime(now);
|
||||||
|
n3.setUpdatedTime(now);
|
||||||
|
assertTrue(nodeService.save(n3));
|
||||||
|
|
||||||
|
List<Map<String, Object>> nodes = new ArrayList<>();
|
||||||
|
nodes.add(mapIdInx(n2.getId(), 0));
|
||||||
|
nodes.add(mapIdInx(n1.getId(), 1));
|
||||||
|
nodes.add(mapIdInx(n3.getId(), 2));
|
||||||
|
|
||||||
|
Map<String, Object> params = new HashMap<>();
|
||||||
|
params.put("nodes", nodes);
|
||||||
|
|
||||||
|
R res = nodeService.updateNodeOrder(params);
|
||||||
|
assertEquals(0, res.getCode());
|
||||||
|
|
||||||
|
assertEquals(1, nodeService.getById(n1.getId()).getInx());
|
||||||
|
assertEquals(0, nodeService.getById(n2.getId()).getInx());
|
||||||
|
assertEquals(2, nodeService.getById(n3.getId()).getInx());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateTunnelOrder_updatesInx() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
|
||||||
|
Tunnel t1 = new Tunnel();
|
||||||
|
t1.setName("t1");
|
||||||
|
t1.setType(1);
|
||||||
|
t1.setFlow(1);
|
||||||
|
t1.setTrafficRatio(new BigDecimal("1.0"));
|
||||||
|
t1.setInIp("");
|
||||||
|
t1.setStatus(1);
|
||||||
|
t1.setInx(0);
|
||||||
|
t1.setCreatedTime(now);
|
||||||
|
t1.setUpdatedTime(now);
|
||||||
|
assertTrue(tunnelService.save(t1));
|
||||||
|
|
||||||
|
Tunnel t2 = new Tunnel();
|
||||||
|
t2.setName("t2");
|
||||||
|
t2.setType(2);
|
||||||
|
t2.setFlow(2);
|
||||||
|
t2.setTrafficRatio(new BigDecimal("1.0"));
|
||||||
|
t2.setInIp("");
|
||||||
|
t2.setStatus(1);
|
||||||
|
t2.setInx(0);
|
||||||
|
t2.setCreatedTime(now);
|
||||||
|
t2.setUpdatedTime(now);
|
||||||
|
assertTrue(tunnelService.save(t2));
|
||||||
|
|
||||||
|
List<Map<String, Object>> tunnels = new ArrayList<>();
|
||||||
|
tunnels.add(mapIdInx(t2.getId(), 0));
|
||||||
|
tunnels.add(mapIdInx(t1.getId(), 1));
|
||||||
|
|
||||||
|
Map<String, Object> params = new HashMap<>();
|
||||||
|
params.put("tunnels", tunnels);
|
||||||
|
|
||||||
|
R res = tunnelService.updateTunnelOrder(params);
|
||||||
|
assertEquals(0, res.getCode());
|
||||||
|
|
||||||
|
assertEquals(1, tunnelService.getById(t1.getId()).getInx());
|
||||||
|
assertEquals(0, tunnelService.getById(t2.getId()).getInx());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> mapIdInx(Long id, int inx) {
|
||||||
|
Map<String, Object> m = new HashMap<>();
|
||||||
|
m.put("id", id);
|
||||||
|
m.put("inx", inx);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# VITE FRONTEND KNOWLEDGE BASE
|
||||||
|
|
||||||
|
**Generated:** Mon Feb 02 2026
|
||||||
|
|
||||||
|
## OVERVIEW
|
||||||
|
Web management console for Flux Panel.
|
||||||
|
**Stack:** React 18, Vite 5, TypeScript, TailwindCSS 4, HeroUI.
|
||||||
|
|
||||||
|
## STRUCTURE
|
||||||
|
```
|
||||||
|
vite-frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── pages/ # Route views (some very large single-file pages)
|
||||||
|
│ ├── components/ # Reusable UI parts
|
||||||
|
│ ├── layouts/ # Admin vs H5 layouts
|
||||||
|
│ ├── api/ # API functions + axios wrapper
|
||||||
|
│ ├── config/ # Site config (title, repo, version)
|
||||||
|
│ └── utils/ # Auth/JWT + WebView helpers
|
||||||
|
├── vite.config.ts # base '/', host 0.0.0.0:3000; build minify/treeshake disabled
|
||||||
|
├── eslint.config.mjs # ESLint 9 flat config
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## CONVENTIONS
|
||||||
|
- **Routing**: React Router v6 routes in `vite-frontend/src/App.tsx`.
|
||||||
|
- **Auth**: JWT stored as `localStorage.token`; sent as `Authorization` header (no prefix) in `vite-frontend/src/api/network.ts`.
|
||||||
|
- **Base URL**: Defaults to `/api/v1/` (or `VITE_API_BASE`); WebView mode selects a panel address via `vite-frontend/src/utils/panel.ts`.
|
||||||
|
- **UI**: HeroUI provider + theme + toast wired in `vite-frontend/src/provider.tsx`.
|
||||||
|
|
||||||
|
## COMMANDS
|
||||||
|
```bash
|
||||||
|
cd vite-frontend
|
||||||
|
npm run dev
|
||||||
|
npm run build
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
@@ -4,7 +4,7 @@ FROM node:20.19.0 AS builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
"@react-aria/visually-hidden": "3.8.25",
|
"@react-aria/visually-hidden": "3.8.25",
|
||||||
"@react-types/shared": "3.30.0",
|
"@react-types/shared": "3.30.0",
|
||||||
"@tailwindcss/postcss": "4.1.11",
|
"@tailwindcss/postcss": "4.1.11",
|
||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/react-beautiful-dnd": "^13.1.8",
|
"@types/react-beautiful-dnd": "^13.1.8",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
@@ -54,7 +54,8 @@
|
|||||||
"react-beautiful-dnd": "^13.1.1",
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-hot-toast": "^2.5.2",
|
"react-hot-toast": "^2.5.2",
|
||||||
"react-router-dom": "6.23.0",
|
"react-is": "^19.2.4",
|
||||||
|
"react-router-dom": "6.30.3",
|
||||||
"recharts": "^3.1.1",
|
"recharts": "^3.1.1",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
"tailwind-variants": "1.0.0",
|
"tailwind-variants": "1.0.0",
|
||||||
@@ -63,14 +64,14 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "1.2.8",
|
"@eslint/compat": "1.2.8",
|
||||||
"@eslint/eslintrc": "3.3.1",
|
"@eslint/eslintrc": "3.3.1",
|
||||||
"@eslint/js": "9.25.1",
|
"@eslint/js": "9.39.2",
|
||||||
"@types/node": "^24.3.0",
|
"@types/node": "^24.3.0",
|
||||||
"@types/react": "18.3.3",
|
"@types/react": "18.3.3",
|
||||||
"@types/react-dom": "18.3.0",
|
"@types/react-dom": "18.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "8.31.1",
|
"@typescript-eslint/eslint-plugin": "8.31.1",
|
||||||
"@typescript-eslint/parser": "8.31.1",
|
"@typescript-eslint/parser": "8.31.1",
|
||||||
"@vitejs/plugin-react": "4.4.1",
|
"@vitejs/plugin-react": "^5.1.3",
|
||||||
"eslint": "9.25.1",
|
"eslint": "9.39.2",
|
||||||
"eslint-config-prettier": "9.1.0",
|
"eslint-config-prettier": "9.1.0",
|
||||||
"eslint-plugin-import": "2.31.0",
|
"eslint-plugin-import": "2.31.0",
|
||||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||||
@@ -83,7 +84,7 @@
|
|||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"prettier": "3.5.3",
|
"prettier": "3.5.3",
|
||||||
"typescript": "5.6.3",
|
"typescript": "5.6.3",
|
||||||
"vite": "5.4.11",
|
"vite": "npm:rolldown-vite@^7.3.1",
|
||||||
"vite-tsconfig-paths": "4.3.2"
|
"vite-tsconfig-paths": "^6.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# VITE FRONTEND (src) KNOWLEDGE BASE
|
||||||
|
|
||||||
|
## OVERVIEW
|
||||||
|
React app entry + routing + providers. This is where UI architecture decisions live.
|
||||||
|
|
||||||
|
## STRUCTURE
|
||||||
|
```
|
||||||
|
vite-frontend/src/
|
||||||
|
├── main.tsx # ReactDOM + BrowserRouter + Provider
|
||||||
|
├── provider.tsx # HeroUI + theme + toaster + i18n wrapper
|
||||||
|
├── App.tsx # Routes + ProtectedRoute + H5 layout selection
|
||||||
|
├── api/ # Axios wrapper + typed endpoint helpers
|
||||||
|
├── pages/ # Route views (large)
|
||||||
|
├── layouts/ # Admin/H5 page chrome
|
||||||
|
├── components/ # Shared UI components
|
||||||
|
├── utils/ # JWT parsing + auth helpers + WebView utilities
|
||||||
|
└── styles/ # globals.css
|
||||||
|
```
|
||||||
|
|
||||||
|
## WHERE TO LOOK
|
||||||
|
| Task | Location | Notes |
|
||||||
|
|------|----------|-------|
|
||||||
|
| Route definitions | `vite-frontend/src/App.tsx` | React Router v6 |
|
||||||
|
| API client baseURL | `vite-frontend/src/api/network.ts` | `/api/v1/` + token header |
|
||||||
|
| Token decoding | `vite-frontend/src/utils/jwt.ts` | Checks `exp` vs now |
|
||||||
|
| Role checks | `vite-frontend/src/utils/auth.ts` | `isAdmin()` is `role_id == 0` |
|
||||||
|
| WebView integration | `vite-frontend/src/api/network.ts` | Panel address selection in WebView mode |
|
||||||
|
|
||||||
|
## CONVENTIONS
|
||||||
|
- Token is stored in `localStorage.token` and sent as `Authorization` header (raw token string).
|
||||||
|
- H5 mode detection is in `vite-frontend/src/App.tsx` (screen/user-agent/query param `h5=true`).
|
||||||
|
|
||||||
|
## COMMANDS
|
||||||
|
```bash
|
||||||
|
cd vite-frontend
|
||||||
|
npm run dev
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
+77
-60
@@ -8,15 +8,14 @@ import ForwardPage from "@/pages/forward";
|
|||||||
import TunnelPage from "@/pages/tunnel";
|
import TunnelPage from "@/pages/tunnel";
|
||||||
import NodePage from "@/pages/node";
|
import NodePage from "@/pages/node";
|
||||||
import UserPage from "@/pages/user";
|
import UserPage from "@/pages/user";
|
||||||
|
import GroupPage from "@/pages/group";
|
||||||
import ProfilePage from "@/pages/profile";
|
import ProfilePage from "@/pages/profile";
|
||||||
import LimitPage from "@/pages/limit";
|
import LimitPage from "@/pages/limit";
|
||||||
import ConfigPage from "@/pages/config";
|
import ConfigPage from "@/pages/config";
|
||||||
import { SettingsPage } from "@/pages/settings";
|
import { SettingsPage } from "@/pages/settings";
|
||||||
|
|
||||||
import AdminLayout from "@/layouts/admin";
|
import AdminLayout from "@/layouts/admin";
|
||||||
import H5Layout from "@/layouts/h5";
|
import H5Layout from "@/layouts/h5";
|
||||||
import H5SimpleLayout from "@/layouts/h5-simple";
|
import H5SimpleLayout from "@/layouts/h5-simple";
|
||||||
|
|
||||||
import { isLoggedIn } from "@/utils/auth";
|
import { isLoggedIn } from "@/utils/auth";
|
||||||
import { siteConfig } from "@/config/site";
|
import { siteConfig } from "@/config/site";
|
||||||
|
|
||||||
@@ -27,11 +26,14 @@ const useH5Mode = () => {
|
|||||||
// 检测移动设备或小屏幕
|
// 检测移动设备或小屏幕
|
||||||
const isMobile = window.innerWidth <= 768;
|
const isMobile = window.innerWidth <= 768;
|
||||||
// 检测是否为移动端浏览器
|
// 检测是否为移动端浏览器
|
||||||
const isMobileBrowser = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
const isMobileBrowser =
|
||||||
|
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||||
|
navigator.userAgent,
|
||||||
|
);
|
||||||
// 检测URL参数是否包含h5模式
|
// 检测URL参数是否包含h5模式
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const isH5Param = urlParams.get('h5') === 'true';
|
const isH5Param = urlParams.get("h5") === "true";
|
||||||
|
|
||||||
return isMobile || isMobileBrowser || isH5Param;
|
return isMobile || isMobileBrowser || isH5Param;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,39 +44,50 @@ const useH5Mode = () => {
|
|||||||
// 检测移动设备或小屏幕
|
// 检测移动设备或小屏幕
|
||||||
const isMobile = window.innerWidth <= 768;
|
const isMobile = window.innerWidth <= 768;
|
||||||
// 检测是否为移动端浏览器
|
// 检测是否为移动端浏览器
|
||||||
const isMobileBrowser = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
const isMobileBrowser =
|
||||||
|
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||||
|
navigator.userAgent,
|
||||||
|
);
|
||||||
// 检测URL参数是否包含h5模式
|
// 检测URL参数是否包含h5模式
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const isH5Param = urlParams.get('h5') === 'true';
|
const isH5Param = urlParams.get("h5") === "true";
|
||||||
|
|
||||||
setIsH5(isMobile || isMobileBrowser || isH5Param);
|
setIsH5(isMobile || isMobileBrowser || isH5Param);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('resize', checkH5Mode);
|
window.addEventListener("resize", checkH5Mode);
|
||||||
|
|
||||||
return () => window.removeEventListener('resize', checkH5Mode);
|
return () => window.removeEventListener("resize", checkH5Mode);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return isH5;
|
return isH5;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 简化的路由保护组件 - 使用 React Router 导航避免循环
|
// 简化的路由保护组件 - 使用 React Router 导航避免循环
|
||||||
const ProtectedRoute = ({ children, useSimpleLayout = false, skipLayout = false }: { children: React.ReactNode, useSimpleLayout?: boolean, skipLayout?: boolean }) => {
|
const ProtectedRoute = ({
|
||||||
|
children,
|
||||||
|
useSimpleLayout = false,
|
||||||
|
skipLayout = false,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
useSimpleLayout?: boolean;
|
||||||
|
skipLayout?: boolean;
|
||||||
|
}) => {
|
||||||
const authenticated = isLoggedIn();
|
const authenticated = isLoggedIn();
|
||||||
const isH5 = useH5Mode();
|
const isH5 = useH5Mode();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
// 使用 React Router 导航,避免无限跳转
|
// 使用 React Router 导航,避免无限跳转
|
||||||
navigate('/', { replace: true });
|
navigate("/", { replace: true });
|
||||||
}
|
}
|
||||||
}, [authenticated, navigate]);
|
}, [authenticated, navigate]);
|
||||||
|
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen bg-white dark:bg-black">
|
<div className="flex items-center justify-center min-h-screen bg-white dark:bg-black">
|
||||||
<div className="text-lg text-gray-700 dark:text-gray-200"></div>
|
<div className="text-lg text-gray-700 dark:text-gray-200" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -86,6 +99,7 @@ const ProtectedRoute = ({ children, useSimpleLayout = false, skipLayout = false
|
|||||||
|
|
||||||
// 根据模式和页面类型选择布局
|
// 根据模式和页面类型选择布局
|
||||||
let Layout;
|
let Layout;
|
||||||
|
|
||||||
if (isH5 && useSimpleLayout) {
|
if (isH5 && useSimpleLayout) {
|
||||||
Layout = H5SimpleLayout;
|
Layout = H5SimpleLayout;
|
||||||
} else if (isH5) {
|
} else if (isH5) {
|
||||||
@@ -93,31 +107,30 @@ const ProtectedRoute = ({ children, useSimpleLayout = false, skipLayout = false
|
|||||||
} else {
|
} else {
|
||||||
Layout = AdminLayout;
|
Layout = AdminLayout;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Layout>{children}</Layout>;
|
return <Layout>{children}</Layout>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// 登录页面路由组件 - 已登录则重定向到dashboard
|
// 登录页面路由组件 - 已登录则重定向到dashboard
|
||||||
const LoginRoute = () => {
|
const LoginRoute = () => {
|
||||||
const authenticated = isLoggedIn();
|
const authenticated = isLoggedIn();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
// 使用 React Router 导航,避免无限跳转
|
// 使用 React Router 导航,避免无限跳转
|
||||||
navigate('/dashboard', { replace: true });
|
navigate("/dashboard", { replace: true });
|
||||||
}
|
}
|
||||||
}, [authenticated, navigate]);
|
}, [authenticated, navigate]);
|
||||||
|
|
||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-black">
|
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-black">
|
||||||
<div className="text-lg text-gray-700 dark:text-gray-200"></div>
|
<div className="text-lg text-gray-700 dark:text-gray-200" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <IndexPage />;
|
return <IndexPage />;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,19 +138,18 @@ function App() {
|
|||||||
// 立即设置页面标题(使用已从缓存读取的配置)
|
// 立即设置页面标题(使用已从缓存读取的配置)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = siteConfig.name;
|
document.title = siteConfig.name;
|
||||||
|
|
||||||
// 异步检查是否有配置更新
|
// 异步检查是否有配置更新
|
||||||
const checkTitleUpdate = async () => {
|
const checkTitleUpdate = async () => {
|
||||||
try {
|
try {
|
||||||
// 引入必要的函数
|
// 引入必要的函数
|
||||||
const { getCachedConfig } = await import('@/config/site');
|
const { getCachedConfig } = await import("@/config/site");
|
||||||
const cachedAppName = await getCachedConfig('app_name');
|
const cachedAppName = await getCachedConfig("app_name");
|
||||||
|
|
||||||
if (cachedAppName && cachedAppName !== document.title) {
|
if (cachedAppName && cachedAppName !== document.title) {
|
||||||
document.title = cachedAppName;
|
document.title = cachedAppName;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {}
|
||||||
console.warn('检查标题更新失败:', error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 延迟检查,避免阻塞初始渲染
|
// 延迟检查,避免阻塞初始渲染
|
||||||
@@ -148,83 +160,88 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<LoginRoute />} />
|
<Route element={<LoginRoute />} path="/" />
|
||||||
<Route
|
<Route
|
||||||
path="/change-password"
|
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute skipLayout={true}>
|
<ProtectedRoute skipLayout={true}>
|
||||||
<ChangePasswordPage />
|
<ChangePasswordPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
|
path="/change-password"
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard"
|
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<DashboardPage />
|
<DashboardPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
|
path="/dashboard"
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/forward"
|
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<ForwardPage />
|
<ForwardPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
|
path="/forward"
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/tunnel"
|
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<TunnelPage />
|
<TunnelPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
|
path="/tunnel"
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/node"
|
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<NodePage />
|
<NodePage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
|
path="/node"
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/user"
|
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute useSimpleLayout={true}>
|
<ProtectedRoute useSimpleLayout={true}>
|
||||||
<UserPage />
|
<UserPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
|
path="/user"
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/profile"
|
element={
|
||||||
|
<ProtectedRoute useSimpleLayout={true}>
|
||||||
|
<GroupPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
path="/group"
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<ProfilePage />
|
<ProfilePage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
|
path="/profile"
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/limit"
|
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute useSimpleLayout={true}>
|
<ProtectedRoute useSimpleLayout={true}>
|
||||||
<LimitPage />
|
<LimitPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
|
path="/limit"
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/config"
|
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute useSimpleLayout={true}>
|
<ProtectedRoute useSimpleLayout={true}>
|
||||||
<ConfigPage />
|
<ConfigPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
path="/config"
|
||||||
<Route
|
|
||||||
path="/settings"
|
|
||||||
element={<SettingsPage />}
|
|
||||||
/>
|
/>
|
||||||
|
<Route element={<SettingsPage />} path="/settings" />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# VITE FRONTEND (src/api) KNOWLEDGE BASE
|
||||||
|
|
||||||
|
## OVERVIEW
|
||||||
|
API client layer. Wraps axios and normalizes backend responses (`{ code, msg, data }`).
|
||||||
|
|
||||||
|
## WHERE TO LOOK
|
||||||
|
| Task | Location | Notes |
|
||||||
|
|------|----------|-------|
|
||||||
|
| Axios wrapper | `vite-frontend/src/api/network.ts` | Sets `axios.defaults.baseURL`; adds `Authorization` header |
|
||||||
|
| BaseURL init (WebView vs web) | `vite-frontend/src/api/network.ts` | WebView mode calls `getPanelAddresses()` |
|
||||||
|
| Endpoint functions | `vite-frontend/src/api/index.ts` | Mostly `Network.post("/…")` |
|
||||||
|
|
||||||
|
## CONVENTIONS
|
||||||
|
- Default baseURL is `/api/v1/` (or `${VITE_API_BASE}/api/v1/`).
|
||||||
|
- In WebView mode, baseURL is derived from the selected panel address; if unset, requests return `code: -1` with a “set panel address” message.
|
||||||
|
- 401 responses clear localStorage and redirect to `/`.
|
||||||
+127
-29
@@ -1,4 +1,4 @@
|
|||||||
import Network from './network';
|
import Network from "./network";
|
||||||
|
|
||||||
// 登陆相关接口
|
// 登陆相关接口
|
||||||
export interface LoginData {
|
export interface LoginData {
|
||||||
@@ -14,11 +14,13 @@ export interface LoginResponse {
|
|||||||
requirePasswordChange?: boolean;
|
requirePasswordChange?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const login = (data: LoginData) => Network.post<LoginResponse>("/user/login", data);
|
export const login = (data: LoginData) =>
|
||||||
|
Network.post<LoginResponse>("/user/login", data);
|
||||||
|
|
||||||
// 用户CRUD操作 - 全部使用POST请求
|
// 用户CRUD操作 - 全部使用POST请求
|
||||||
export const createUser = (data: any) => Network.post("/user/create", data);
|
export const createUser = (data: any) => Network.post("/user/create", data);
|
||||||
export const getAllUsers = (pageData: any = {}) => Network.post("/user/list", pageData);
|
export const getAllUsers = (pageData: any = {}) =>
|
||||||
|
Network.post("/user/list", pageData);
|
||||||
export const updateUser = (data: any) => Network.post("/user/update", data);
|
export const updateUser = (data: any) => Network.post("/user/update", data);
|
||||||
export const deleteUser = (id: number) => Network.post("/user/delete", { id });
|
export const deleteUser = (id: number) => Network.post("/user/delete", { id });
|
||||||
export const getUserPackageInfo = () => Network.post("/user/package");
|
export const getUserPackageInfo = () => Network.post("/user/package");
|
||||||
@@ -28,64 +30,160 @@ export const createNode = (data: any) => Network.post("/node/create", data);
|
|||||||
export const getNodeList = () => Network.post("/node/list");
|
export const getNodeList = () => Network.post("/node/list");
|
||||||
export const updateNode = (data: any) => Network.post("/node/update", data);
|
export const updateNode = (data: any) => Network.post("/node/update", data);
|
||||||
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
|
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
|
||||||
export const getNodeInstallCommand = (id: number) => Network.post("/node/install", { id });
|
export const getNodeInstallCommand = (id: number) =>
|
||||||
|
Network.post("/node/install", { id });
|
||||||
|
export const updateNodeOrder = (data: {
|
||||||
|
nodes: Array<{ id: number; inx: number }>;
|
||||||
|
}) => Network.post("/node/update-order", data);
|
||||||
export const checkNodeStatus = (nodeId?: number) => {
|
export const checkNodeStatus = (nodeId?: number) => {
|
||||||
const params = nodeId ? { nodeId } : {};
|
const params = nodeId ? { nodeId } : {};
|
||||||
|
|
||||||
return Network.post("/node/check-status", params);
|
return Network.post("/node/check-status", params);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 隧道CRUD操作 - 全部使用POST请求
|
// 隧道CRUD操作 - 全部使用POST请求
|
||||||
export const createTunnel = (data: any) => Network.post("/tunnel/create", data);
|
export const createTunnel = (data: any) => Network.post("/tunnel/create", data);
|
||||||
export const getTunnelList = () => Network.post("/tunnel/list");
|
export const getTunnelList = () => Network.post("/tunnel/list");
|
||||||
export const getTunnelById = (id: number) => Network.post("/tunnel/get", { id });
|
export const getTunnelById = (id: number) =>
|
||||||
|
Network.post("/tunnel/get", { id });
|
||||||
export const updateTunnel = (data: any) => Network.post("/tunnel/update", data);
|
export const updateTunnel = (data: any) => Network.post("/tunnel/update", data);
|
||||||
export const deleteTunnel = (id: number) => Network.post("/tunnel/delete", { id });
|
export const deleteTunnel = (id: number) =>
|
||||||
export const diagnoseTunnel = (tunnelId: number) => Network.post("/tunnel/diagnose", { tunnelId });
|
Network.post("/tunnel/delete", { id });
|
||||||
|
export const diagnoseTunnel = (tunnelId: number) =>
|
||||||
|
Network.post("/tunnel/diagnose", { tunnelId });
|
||||||
|
export const updateTunnelOrder = (data: {
|
||||||
|
tunnels: Array<{ id: number; inx: number }>;
|
||||||
|
}) => Network.post("/tunnel/update-order", data);
|
||||||
|
|
||||||
// 用户隧道权限管理操作 - 全部使用POST请求
|
// 用户隧道权限管理操作 - 全部使用POST请求
|
||||||
export const assignUserTunnel = (data: any) => Network.post("/tunnel/user/assign", data);
|
export const assignUserTunnel = (data: any) =>
|
||||||
export const getUserTunnelList = (queryData: any = {}) => Network.post("/tunnel/user/list", queryData);
|
Network.post("/tunnel/user/assign", data);
|
||||||
export const removeUserTunnel = (params: any) => Network.post("/tunnel/user/remove", params);
|
export const batchAssignUserTunnel = (data: {
|
||||||
export const updateUserTunnel = (data: any) => Network.post("/tunnel/user/update", data);
|
userId: number;
|
||||||
|
tunnels: Array<{ tunnelId: number; speedId?: number | null }>;
|
||||||
|
}) => Network.post("/tunnel/user/batch-assign", data);
|
||||||
|
export const getUserTunnelList = (queryData: any = {}) =>
|
||||||
|
Network.post("/tunnel/user/list", queryData);
|
||||||
|
export const removeUserTunnel = (params: any) =>
|
||||||
|
Network.post("/tunnel/user/remove", params);
|
||||||
|
export const updateUserTunnel = (data: any) =>
|
||||||
|
Network.post("/tunnel/user/update", data);
|
||||||
export const userTunnel = () => Network.post("/tunnel/user/tunnel");
|
export const userTunnel = () => Network.post("/tunnel/user/tunnel");
|
||||||
|
|
||||||
// 转发CRUD操作 - 全部使用POST请求
|
// 转发CRUD操作 - 全部使用POST请求
|
||||||
export const createForward = (data: any) => Network.post("/forward/create", data);
|
export const createForward = (data: any) =>
|
||||||
|
Network.post("/forward/create", data);
|
||||||
export const getForwardList = () => Network.post("/forward/list");
|
export const getForwardList = () => Network.post("/forward/list");
|
||||||
export const updateForward = (data: any) => Network.post("/forward/update", data);
|
export const updateForward = (data: any) =>
|
||||||
export const deleteForward = (id: number) => Network.post("/forward/delete", { id });
|
Network.post("/forward/update", data);
|
||||||
export const forceDeleteForward = (id: number) => Network.post("/forward/force-delete", { id });
|
export const deleteForward = (id: number) =>
|
||||||
|
Network.post("/forward/delete", { id });
|
||||||
|
export const forceDeleteForward = (id: number) =>
|
||||||
|
Network.post("/forward/force-delete", { id });
|
||||||
|
|
||||||
// 转发服务控制操作 - 通过Java后端接口
|
// 转发服务控制操作 - 通过Java后端接口
|
||||||
export const pauseForwardService = (forwardId: number) => Network.post("/forward/pause", { id: forwardId });
|
export const pauseForwardService = (forwardId: number) =>
|
||||||
export const resumeForwardService = (forwardId: number) => Network.post("/forward/resume", { id: forwardId });
|
Network.post("/forward/pause", { id: forwardId });
|
||||||
|
export const resumeForwardService = (forwardId: number) =>
|
||||||
|
Network.post("/forward/resume", { id: forwardId });
|
||||||
|
|
||||||
// 转发诊断操作
|
// 转发诊断操作
|
||||||
export const diagnoseForward = (forwardId: number) => Network.post("/forward/diagnose", { forwardId });
|
export const diagnoseForward = (forwardId: number) =>
|
||||||
|
Network.post("/forward/diagnose", { forwardId });
|
||||||
|
|
||||||
// 转发排序操作
|
// 转发排序操作
|
||||||
export const updateForwardOrder = (data: { forwards: Array<{ id: number; inx: number }> }) => Network.post("/forward/update-order", data);
|
export const updateForwardOrder = (data: {
|
||||||
|
forwards: Array<{ id: number; inx: number }>;
|
||||||
|
}) => Network.post("/forward/update-order", data);
|
||||||
|
|
||||||
// 限速规则CRUD操作 - 全部使用POST请求
|
// 限速规则CRUD操作 - 全部使用POST请求
|
||||||
export const createSpeedLimit = (data: any) => Network.post("/speed-limit/create", data);
|
export const createSpeedLimit = (data: any) =>
|
||||||
|
Network.post("/speed-limit/create", data);
|
||||||
export const getSpeedLimitList = () => Network.post("/speed-limit/list");
|
export const getSpeedLimitList = () => Network.post("/speed-limit/list");
|
||||||
export const updateSpeedLimit = (data: any) => Network.post("/speed-limit/update", data);
|
export const updateSpeedLimit = (data: any) =>
|
||||||
export const deleteSpeedLimit = (id: number) => Network.post("/speed-limit/delete", { id });
|
Network.post("/speed-limit/update", data);
|
||||||
|
export const deleteSpeedLimit = (id: number) =>
|
||||||
|
Network.post("/speed-limit/delete", { id });
|
||||||
|
|
||||||
// 修改密码接口
|
// 修改密码接口
|
||||||
export const updatePassword = (data: any) => Network.post("/user/updatePassword", data);
|
export const updatePassword = (data: any) =>
|
||||||
|
Network.post("/user/updatePassword", data);
|
||||||
|
|
||||||
// 重置流量接口
|
// 重置流量接口
|
||||||
export const resetUserFlow = (data: { id: number; type: number }) => Network.post("/user/reset", data);
|
export const resetUserFlow = (data: { id: number; type: number }) =>
|
||||||
|
Network.post("/user/reset", data);
|
||||||
|
|
||||||
// 网站配置相关接口
|
// 网站配置相关接口
|
||||||
export const getConfigs = () => Network.post("/config/list");
|
export const getConfigs = () => Network.post("/config/list");
|
||||||
export const getConfigByName = (name: string) => Network.post("/config/get", { name });
|
export const getConfigByName = (name: string) =>
|
||||||
export const updateConfigs = (configMap: Record<string, string>) => Network.post("/config/update", configMap);
|
Network.post("/config/get", { name });
|
||||||
export const updateConfig = (name: string, value: string) => Network.post("/config/update-single", { name, value });
|
export const updateConfigs = (configMap: Record<string, string>) =>
|
||||||
|
Network.post("/config/update", configMap);
|
||||||
|
export const updateConfig = (name: string, value: string) =>
|
||||||
|
Network.post("/config/update-single", { name, value });
|
||||||
|
|
||||||
// 验证码相关接口
|
// 验证码相关接口
|
||||||
export const checkCaptcha = () => Network.post("/captcha/check");
|
export const checkCaptcha = () => Network.post("/captcha/check");
|
||||||
export const generateCaptcha = () => Network.post(`/captcha/generate`);
|
export const generateCaptcha = () => Network.post(`/captcha/generate`);
|
||||||
export const verifyCaptcha = (data: { captchaId: string; trackData: string }) => Network.post("/captcha/verify", data);
|
export const verifyCaptcha = (data: { captchaId: string; trackData: string }) =>
|
||||||
|
Network.post("/captcha/verify", data);
|
||||||
|
|
||||||
|
// 批量操作接口
|
||||||
|
export const batchDeleteForwards = (ids: number[]) =>
|
||||||
|
Network.post("/forward/batch-delete", { ids });
|
||||||
|
export const batchPauseForwards = (ids: number[]) =>
|
||||||
|
Network.post("/forward/batch-pause", { ids });
|
||||||
|
export const batchResumeForwards = (ids: number[]) =>
|
||||||
|
Network.post("/forward/batch-resume", { ids });
|
||||||
|
export const batchDeleteTunnels = (ids: number[]) =>
|
||||||
|
Network.post("/tunnel/batch-delete", { ids });
|
||||||
|
export const batchDeleteNodes = (ids: number[]) =>
|
||||||
|
Network.post("/node/batch-delete", { ids });
|
||||||
|
export const batchRedeployForwards = (ids: number[]) =>
|
||||||
|
Network.post("/forward/batch-redeploy", { ids });
|
||||||
|
export const batchRedeployTunnels = (ids: number[]) =>
|
||||||
|
Network.post("/tunnel/batch-redeploy", { ids });
|
||||||
|
export const batchChangeTunnel = (data: {
|
||||||
|
forwardIds: number[];
|
||||||
|
targetTunnelId: number;
|
||||||
|
}) => Network.post("/forward/batch-change-tunnel", data);
|
||||||
|
|
||||||
|
// 分组与权限分配接口
|
||||||
|
export const getTunnelGroupList = () => Network.post("/group/tunnel/list");
|
||||||
|
export const createTunnelGroup = (data: { name: string; status?: number }) =>
|
||||||
|
Network.post("/group/tunnel/create", data);
|
||||||
|
export const updateTunnelGroup = (data: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status?: number;
|
||||||
|
}) => Network.post("/group/tunnel/update", data);
|
||||||
|
export const deleteTunnelGroup = (id: number) =>
|
||||||
|
Network.post("/group/tunnel/delete", { id });
|
||||||
|
export const assignTunnelsToGroup = (data: {
|
||||||
|
groupId: number;
|
||||||
|
tunnelIds: number[];
|
||||||
|
}) => Network.post("/group/tunnel/assign", data);
|
||||||
|
|
||||||
|
export const getUserGroupList = () => Network.post("/group/user/list");
|
||||||
|
export const createUserGroup = (data: { name: string; status?: number }) =>
|
||||||
|
Network.post("/group/user/create", data);
|
||||||
|
export const updateUserGroup = (data: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status?: number;
|
||||||
|
}) => Network.post("/group/user/update", data);
|
||||||
|
export const deleteUserGroup = (id: number) =>
|
||||||
|
Network.post("/group/user/delete", { id });
|
||||||
|
export const assignUsersToGroup = (data: {
|
||||||
|
groupId: number;
|
||||||
|
userIds: number[];
|
||||||
|
}) => Network.post("/group/user/assign", data);
|
||||||
|
|
||||||
|
export const getGroupPermissionList = () =>
|
||||||
|
Network.post("/group/permission/list");
|
||||||
|
export const assignGroupPermission = (data: {
|
||||||
|
userGroupId: number;
|
||||||
|
tunnelGroupId: number;
|
||||||
|
}) => Network.post("/group/permission/assign", data);
|
||||||
|
export const removeGroupPermission = (id: number) =>
|
||||||
|
Network.post("/group/permission/remove", { id });
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user