mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 23:56:36 +08:00
Compare commits
109 Commits
2.0.2-beta
...
2.0.16
| Author | SHA1 | Date | |
|---|---|---|---|
| 507893dc55 | |||
| 652b44e08e | |||
| 95b3803745 | |||
| 0b85cd2af1 | |||
| 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:
|
||||
VERSION: "2.0.2-beta"
|
||||
VERSION: "2.0.7-beta" # 分支推送时使用的默认版本
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- beta
|
||||
tags:
|
||||
- '[0-9]*' # 匹配 2.0.8, 2.0.8-beta 等格式
|
||||
|
||||
jobs:
|
||||
check-version:
|
||||
name: Check Version and Decide Build
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ env.VERSION }}
|
||||
should_build: ${{ steps.check-tag.outputs.should_build }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
should_build: ${{ steps.version.outputs.should_build }}
|
||||
should_build_gost: ${{ steps.version.outputs.should_build_gost }}
|
||||
is_tag: ${{ steps.version.outputs.is_tag }}
|
||||
image_owner: ${{ steps.version.outputs.image_owner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Display version
|
||||
- name: Determine version and build strategy
|
||||
id: version
|
||||
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
|
||||
id: check-tag
|
||||
run: |
|
||||
if git rev-parse "${{ env.VERSION }}" >/dev/null 2>&1; then
|
||||
echo "Tag ${{ env.VERSION }} already exists, skip build"
|
||||
echo "should_build=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Tag ${{ env.VERSION }} does not exist, will build all components"
|
||||
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
# Tag 触发:直接使用 tag 名作为版本,全量构建
|
||||
VERSION="${{ github.ref_name }}"
|
||||
echo "🏷️ Tag trigger detected: $VERSION"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "is_tag=true" >> $GITHUB_OUTPUT
|
||||
echo "should_build=true" >> $GITHUB_OUTPUT
|
||||
echo "should_build_gost=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
# 分支触发:使用 env.VERSION,检查是否需要构建
|
||||
VERSION="${{ env.VERSION }}"
|
||||
echo "🌿 Branch trigger detected, using version: $VERSION"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "is_tag=false" >> $GITHUB_OUTPUT
|
||||
|
||||
# 检查 tag 是否已存在
|
||||
if git rev-parse "$VERSION" >/dev/null 2>&1; then
|
||||
echo "Tag $VERSION already exists"
|
||||
echo "should_build=false" >> $GITHUB_OUTPUT
|
||||
|
||||
# 检查 go-gost 目录是否有变化
|
||||
TAG_COMMIT=$(git rev-list -n 1 "$VERSION")
|
||||
if git diff --quiet --ignore-all-space --ignore-blank-lines $TAG_COMMIT HEAD -- go-gost/ 2>/dev/null; then
|
||||
echo "✅ GOST files unchanged since tag"
|
||||
echo "should_build_gost=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "🔄 Detected changes in go-gost directory"
|
||||
git diff --stat $TAG_COMMIT HEAD -- go-gost/ || true
|
||||
echo "should_build_gost=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
else
|
||||
echo "Tag $VERSION does not exist, will build all components"
|
||||
echo "should_build=true" >> $GITHUB_OUTPUT
|
||||
echo "should_build_gost=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
fi
|
||||
|
||||
build-gost:
|
||||
name: Build & Compress GOST Binary
|
||||
needs: check-version
|
||||
if: needs.check-version.outputs.should_build == 'true'
|
||||
if: needs.check-version.outputs.should_build_gost == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
go-version: '1.23'
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('go-gost/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Install UPX
|
||||
run: |
|
||||
wget https://github.com/upx/upx/releases/download/v4.2.1/upx-4.2.1-amd64_linux.tar.xz
|
||||
wget -q https://github.com/upx/upx/releases/download/v4.2.1/upx-4.2.1-amd64_linux.tar.xz
|
||||
tar -xf upx-4.2.1-amd64_linux.tar.xz
|
||||
sudo mv upx-4.2.1-amd64_linux/upx /usr/local/bin/
|
||||
rm -rf upx-4.2.1-amd64_linux*
|
||||
|
||||
- name: Build GOST binary (AMD64)
|
||||
working-directory: ./go-gost
|
||||
run: |
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o gost
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=${{ needs.check-version.outputs.version }}" -o gost-amd64
|
||||
|
||||
- name: Build GOST binary (ARM64)
|
||||
working-directory: ./go-gost
|
||||
run: |
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o gost-arm64
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=${{ needs.check-version.outputs.version }}" -o gost-arm64
|
||||
|
||||
- name: Compress with UPX (AMD64)
|
||||
working-directory: ./go-gost
|
||||
run: |
|
||||
upx --best --lzma gost
|
||||
|
||||
- name: Compress with UPX (ARM64)
|
||||
- name: Compress with UPX
|
||||
working-directory: ./go-gost
|
||||
run: |
|
||||
upx --best --lzma gost-amd64
|
||||
upx --best --lzma gost-arm64
|
||||
|
||||
- name: Upload GOST AMD64 artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gost-binary-amd64
|
||||
path: ./go-gost/gost
|
||||
path: ./go-gost/gost-amd64
|
||||
|
||||
- name: Upload GOST ARM64 artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -104,31 +129,40 @@ jobs:
|
||||
needs: check-version
|
||||
if: needs.check-version.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Prepare build args
|
||||
run: |
|
||||
VERSION="${{ needs.check-version.outputs.version }}"
|
||||
{
|
||||
echo "VITE_GITHUB_REPO=https://github.com/${{ github.repository }}"
|
||||
echo "VITE_APP_VERSION=$VERSION"
|
||||
} > ./vite-frontend/.env.production
|
||||
|
||||
- name: Build and push Vite Docker images
|
||||
run: |
|
||||
VERSION="${{ needs.check-version.outputs.version }}"
|
||||
OWNER="${{ needs.check-version.outputs.image_owner }}"
|
||||
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
-t bqlpfy/vite-frontend:latest \
|
||||
-t bqlpfy/vite-frontend:${VERSION} \
|
||||
-t ${{ env.REGISTRY }}/${OWNER}/vite-frontend:latest \
|
||||
-t ${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION} \
|
||||
./vite-frontend
|
||||
|
||||
build-java:
|
||||
@@ -136,17 +170,20 @@ jobs:
|
||||
needs: check-version
|
||||
if: needs.check-version.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK and Maven
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: 21
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Cache Maven dependencies
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.m2
|
||||
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
|
||||
@@ -157,33 +194,149 @@ jobs:
|
||||
run: mvn clean package -DskipTests
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Java Docker images
|
||||
run: |
|
||||
VERSION="${{ needs.check-version.outputs.version }}"
|
||||
OWNER="${{ needs.check-version.outputs.image_owner }}"
|
||||
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
-t bqlpfy/springboot-backend:latest \
|
||||
-t bqlpfy/springboot-backend:${VERSION} \
|
||||
-t ${{ env.REGISTRY }}/${OWNER}/springboot-backend:latest \
|
||||
-t ${{ env.REGISTRY }}/${OWNER}/springboot-backend:${VERSION} \
|
||||
./springboot-backend
|
||||
|
||||
create-release:
|
||||
name: Create Release and Tag
|
||||
name: Create Release (Tag Only)
|
||||
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
|
||||
permissions:
|
||||
contents: write
|
||||
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
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -199,63 +352,18 @@ jobs:
|
||||
|
||||
- name: Rename binaries
|
||||
run: |
|
||||
mv ./artifacts/amd64/gost ./artifacts/gost
|
||||
mv ./artifacts/amd64/gost-amd64 ./artifacts/gost-amd64
|
||||
mv ./artifacts/arm64/gost-arm64 ./artifacts/gost-arm64
|
||||
|
||||
- name: Create Release
|
||||
- name: Update GOST binaries in Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
VERSION="${{ needs.check-version.outputs.version }}"
|
||||
|
||||
# 创建并推送 tag
|
||||
git tag "${VERSION}" ${{ github.sha }}
|
||||
git push origin "${VERSION}"
|
||||
|
||||
# 获取 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
|
||||
|
||||
echo "🔄 更新 Release ${VERSION} 中的 GOST 二进制文件..."
|
||||
|
||||
gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber
|
||||
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
|
||||
|
||||
echo "📤 上传安装脚本..."
|
||||
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}"
|
||||
|
||||
echo "✅ GOST 二进制文件更新完成"
|
||||
|
||||
@@ -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梦转发面板
|
||||
|
||||
|
||||
本项目基于 [go-gost/gost](https://github.com/go-gost/gost) 和 [go-gost/x](https://github.com/go-gost/x) 两个开源库,实现了转发面板。
|
||||
---
|
||||
## 特性
|
||||
@@ -20,12 +18,11 @@
|
||||
#### 快速部署
|
||||
面板端:
|
||||
```bash
|
||||
curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
|
||||
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
|
||||
```
|
||||
节点端:
|
||||
```bash
|
||||
curl -L https://raw.githubusercontent.com/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)
|
||||
|
||||
| 网络 | 地址 |
|
||||
|------------|----------------------------------------------------------------------|
|
||||
| BNB(BEP20) | `0x755492c03728851bbf855daa28a1e089f9aca4d1` |
|
||||
| TRC20 | `TYh2L3xxXpuJhAcBWnt3yiiADiCSJLgUm7` |
|
||||
| Aptos | `0xf2f9fb14749457748506a8281628d556e8540d1eb586d202cd8b02b99d369ef8` |
|
||||
|
||||
[](https://www.star-history.com/#bqlpfy/flux-panel&Date)
|
||||
|
||||
|
||||
| BNB(BEP20) | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
|
||||
| TRC20 | `TM8VYdU3s3gSX5PC8swjAJrAzZFCHKqG2k` |
|
||||
| Aptos | `0x49427bfcba1006a346447430689b2307ac156316bb34850d1d3029ff9d118da5` |
|
||||
| polygon | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
|
||||
|
||||
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:
|
||||
backend:
|
||||
image: bqlpfy/springboot-backend:2.0.2-beta
|
||||
image: ghcr.io/sagit-chu/springboot-backend:${FLUX_VERSION:-latest}
|
||||
container_name: springboot-backend
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
environment:
|
||||
DB_PATH: /app/data/gost.db
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
@@ -15,6 +19,8 @@ services:
|
||||
- sqlite_data:/app/data
|
||||
networks:
|
||||
- gost-network
|
||||
stop_grace_period: 30s
|
||||
stop_signal: SIGTERM
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-c", "wget --no-verbose --tries=1 --spider http://localhost:6365/flow/test || exit 1"]
|
||||
interval: 30s
|
||||
@@ -23,9 +29,13 @@ services:
|
||||
start_period: 60s
|
||||
|
||||
frontend:
|
||||
image: bqlpfy/vite-frontend:2.0.2-beta
|
||||
image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}
|
||||
container_name: vite-frontend
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
ports:
|
||||
- "${FRONTEND_PORT}:80"
|
||||
depends_on:
|
||||
@@ -50,4 +60,4 @@ networks:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
- subnet: 172.20.0.0/16
|
||||
|
||||
+13
-3
@@ -1,8 +1,12 @@
|
||||
services:
|
||||
backend:
|
||||
image: bqlpfy/springboot-backend:2.0.2-beta
|
||||
image: ghcr.io/sagit-chu/springboot-backend:${FLUX_VERSION:-latest}
|
||||
container_name: springboot-backend
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
environment:
|
||||
DB_PATH: /app/data/gost.db
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
@@ -15,6 +19,8 @@ services:
|
||||
- sqlite_data:/app/data
|
||||
networks:
|
||||
- gost-network
|
||||
stop_grace_period: 30s
|
||||
stop_signal: SIGTERM
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-c", "wget --no-verbose --tries=1 --spider http://localhost:6365/flow/test || exit 1"]
|
||||
interval: 30s
|
||||
@@ -23,9 +29,13 @@ services:
|
||||
start_period: 60s
|
||||
|
||||
frontend:
|
||||
image: bqlpfy/vite-frontend:2.0.2-beta
|
||||
image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}
|
||||
container_name: vite-frontend
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
ports:
|
||||
- "${FRONTEND_PORT}:80"
|
||||
depends_on:
|
||||
@@ -52,4 +62,4 @@ networks:
|
||||
ipam:
|
||||
config:
|
||||
- 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()
|
||||
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()
|
||||
service.SetHTTPReportURL(config.Addr, config.Secret)
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
package main
|
||||
|
||||
var (
|
||||
version = "3.1.0"
|
||||
version = "dev"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# GO-GOST/X KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Local fork of `github.com/go-gost/x` used by `go-gost/` via `replace github.com/go-gost/x => ./x`. Most protocol/runtime behavior changes happen here.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
go-gost/x/
|
||||
├── api/ # Gin management API + embedded swagger docs
|
||||
├── config/ # Config model + parsing/load/reload
|
||||
├── connector/ # Outbound connect implementations
|
||||
├── dialer/ # Outbound dialers (tcp/tls/ws/quic/...)
|
||||
├── handler/ # Protocol handlers (socks/http/tunnel/relay/...)
|
||||
├── listener/ # Inbound listeners (tcp/udp/tun/tap/redirect/...)
|
||||
├── limiter/ # Traffic/rate/conn limiters
|
||||
├── registry/ # Registries for services/handlers/listeners/etc
|
||||
├── service/ # Service wrappers + reporting hooks
|
||||
├── socket/ # WebSocket reporter / panel integration
|
||||
└── internal/ # Shared internals (grpc proto, net utils, sniffing, tls, ...)
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Management API routes/auth | `go-gost/x/api/api.go` | `/docs`, `/config/*`; BasicAuth + interceptor |
|
||||
| Service config parsing | `go-gost/x/config/parsing/` | Converts config to running services |
|
||||
| Add a handler | `go-gost/x/handler/` | Per-protocol subdirs |
|
||||
| Add a listener/dialer | `go-gost/x/listener/`, `go-gost/x/dialer/` | Transport variants |
|
||||
| Panel reporting | `go-gost/x/socket/` | WebSocket + HTTP report URL hooks |
|
||||
|
||||
## CONVENTIONS
|
||||
- `go-gost/x/` is a standalone Go module (`go-gost/x/go.mod`); run go tooling from this dir when debugging module resolution.
|
||||
- Generated gRPC/proto code lives under `go-gost/x/internal/util/grpc/proto/`.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- Do not edit generated files in `go-gost/x/internal/util/grpc/proto/` (`*.pb.go`, `*_grpc.pb.go`).
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost/x
|
||||
go test ./...
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
# GO-GOST/X API KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Gin-based management API for reading/writing config and controlling services at runtime.
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Route registration | `go-gost/x/api/api.go` | `Register(*gin.Engine, *Options)` |
|
||||
| Auth gating | `go-gost/x/api/middleware.go` | Drops non-BasicAuth requests; optional auther check |
|
||||
| Service CRUD + pause/resume | `go-gost/x/api/config_service.go` | Uses registry + `config.OnUpdate(...)` |
|
||||
| Swagger spec | `go-gost/x/api/swagger.yaml` | Served at `/docs` via embedded FS |
|
||||
|
||||
## CONVENTIONS
|
||||
- CORS is `AllowAllOrigins: true` (see `go-gost/x/api/api.go`).
|
||||
- Requests without a valid Basic `Authorization` header are silently dropped (connection hijack + close) by `GlobalInterceptor()`.
|
||||
- Many operations mutate the in-memory config via `config.OnUpdate(...)` after starting/stopping services.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost/x
|
||||
go test ./...
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
# GO-GOST/X CONFIG KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Config model + parsing/loading pipeline for the `go-gost/x` runtime. This is the bridge between `gost.json`/`gost.yaml` and in-memory registries/services.
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Config structs + global state | `go-gost/x/config/config.go` | `Global()`, `Set()`, `OnUpdate()` |
|
||||
| Default config file search | `go-gost/x/config/config.go` | Viper `SetConfigName("gost")` + paths `/etc/gost/`, `$HOME/.gost/`, `.` |
|
||||
| Registry wiring | `go-gost/x/config/loader/loader.go` | Parses config sections and registers into registries |
|
||||
| Metadata keys | `go-gost/x/config/parsing/parse.go` | `MDKey*` constants used by parsers |
|
||||
| Config parser behavior | `go-gost/x/config/parsing/parser/parser.go` | CLI/env overrides; loads `gost.*` when empty |
|
||||
|
||||
## CONVENTIONS
|
||||
- Default config file is named `gost` (e.g. `gost.json`) and is discovered via viper search paths.
|
||||
- Runtime config mutations should go through `config.OnUpdate(...)` so changes are applied under the global mutex.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost/x
|
||||
go test ./...
|
||||
```
|
||||
@@ -109,3 +109,23 @@ func LoggerFromContext(ctx context.Context) logger.Logger {
|
||||
v, _ := ctx.Value(keyLogger).(logger.Logger)
|
||||
return v
|
||||
}
|
||||
|
||||
// excludeNodesKey saves the list of node addresses to exclude during selection.
|
||||
// This is used for failover retry logic - when a node fails, it gets added to
|
||||
// the exclude list so the next Select() call will skip it.
|
||||
type excludeNodesKey struct{}
|
||||
|
||||
var (
|
||||
keyExcludeNodes = &excludeNodesKey{}
|
||||
)
|
||||
|
||||
// ContextWithExcludeNodes returns a context with the list of node addresses to exclude.
|
||||
func ContextWithExcludeNodes(ctx context.Context, nodes []string) context.Context {
|
||||
return context.WithValue(ctx, keyExcludeNodes, nodes)
|
||||
}
|
||||
|
||||
// ExcludeNodesFromContext returns the list of node addresses to exclude from selection.
|
||||
func ExcludeNodesFromContext(ctx context.Context) []string {
|
||||
v, _ := ctx.Value(keyExcludeNodes).([]string)
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# GO-GOST/X DIALERS KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Outbound dialers (client-side connection establishment) used by connectors/handlers.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
go-gost/x/dialer/
|
||||
├── direct/ # Baseline dialer
|
||||
├── tcp/
|
||||
├── udp/
|
||||
├── tls/
|
||||
├── ws/
|
||||
├── quic/
|
||||
├── http2/
|
||||
├── http3/
|
||||
├── ssh/
|
||||
├── wg/ # WireGuard dialer
|
||||
└── ...
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Pick a dialer | `go-gost/x/dialer/` | One subdir per transport |
|
||||
| TCP baseline | `go-gost/x/dialer/tcp/dialer.go` | Reference implementation |
|
||||
|
||||
## CONVENTIONS
|
||||
- Dialer implementations typically live in `dialer.go` with a paired `metadata.go` (e.g. `go-gost/x/dialer/tcp/`).
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost/x
|
||||
go test ./...
|
||||
```
|
||||
@@ -0,0 +1,32 @@
|
||||
# GO-GOST/X HANDLERS KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Protocol handlers (server-side request handling) used by services defined in the GOST config.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
go-gost/x/handler/
|
||||
├── http/ # handler.go + metadata.go (+ udp.go)
|
||||
├── socks/ # SOCKS variants
|
||||
├── tunnel/ # Tunnel forwarding
|
||||
├── relay/ # Relay forwarding
|
||||
├── redirect/ # TCP/UDP redirect handlers
|
||||
├── router/ # Routing/association entrypoints
|
||||
└── ...
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Find a protocol handler | `go-gost/x/handler/` | Subdir per protocol (`http`, `socks`, `tunnel`, ...) |
|
||||
| HTTP specifics | `go-gost/x/handler/http/handler.go` | Implements HTTP proxy behavior |
|
||||
| SOCKS specifics | `go-gost/x/handler/socks/` | v4/v5 implementations |
|
||||
|
||||
## CONVENTIONS
|
||||
- Handler implementations typically live in `handler.go` with a paired `metadata.go` (e.g. `go-gost/x/handler/http/`).
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost/x
|
||||
go test ./...
|
||||
```
|
||||
@@ -176,51 +176,98 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand
|
||||
}
|
||||
}
|
||||
|
||||
target := &chain.Node{}
|
||||
if h.hop != nil {
|
||||
target = h.hop.Select(ctx,
|
||||
hop.ProtocolSelectOption(proto),
|
||||
)
|
||||
}
|
||||
if target == nil {
|
||||
err := errors.New("node not available")
|
||||
return err
|
||||
// Determine max retry attempts
|
||||
maxRetries := h.md.maxRetries
|
||||
if maxRetries <= 0 {
|
||||
// Default: try all available nodes
|
||||
if nl, ok := h.hop.(hop.NodeList); ok {
|
||||
maxRetries = len(nl.Nodes())
|
||||
}
|
||||
if maxRetries <= 0 {
|
||||
maxRetries = 1
|
||||
}
|
||||
}
|
||||
|
||||
addr := target.Addr
|
||||
if opts := target.Options(); opts != nil {
|
||||
switch opts.Network {
|
||||
case "unix":
|
||||
network = opts.Network
|
||||
default:
|
||||
if _, _, err := net.SplitHostPort(addr); err != nil {
|
||||
addr += ":0"
|
||||
var triedNodes []string
|
||||
var lastErr error
|
||||
var cc net.Conn
|
||||
|
||||
h.options.Logger.Debugf("[handler.retry] starting retry loop: maxRetries=%d", maxRetries)
|
||||
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
// Select a target node, excluding previously tried nodes
|
||||
selectCtx := ctxvalue.ContextWithExcludeNodes(ctx, triedNodes)
|
||||
var target *chain.Node
|
||||
if h.hop != nil {
|
||||
target = h.hop.Select(selectCtx,
|
||||
hop.ProtocolSelectOption(proto),
|
||||
)
|
||||
}
|
||||
if target == nil {
|
||||
h.options.Logger.Debugf("[handler.retry] attempt=%d target=nil, triedNodes=%v", attempt, triedNodes)
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return errors.New("node not available")
|
||||
}
|
||||
|
||||
h.options.Logger.Debugf("[handler.retry] attempt=%d selected node=%s addr=%s", attempt, target.Name, target.Addr)
|
||||
|
||||
// Track this node as tried
|
||||
triedNodes = append(triedNodes, target.Addr)
|
||||
|
||||
addr := target.Addr
|
||||
if opts := target.Options(); opts != nil {
|
||||
switch opts.Network {
|
||||
case "unix":
|
||||
network = opts.Network
|
||||
default:
|
||||
if _, _, err := net.SplitHostPort(addr); err != nil {
|
||||
addr += ":0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ro.Network = network
|
||||
ro.Host = addr
|
||||
ro.Network = network
|
||||
ro.Host = addr
|
||||
|
||||
var buf bytes.Buffer
|
||||
cc, err := h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, addr)
|
||||
ro.Route = buf.String()
|
||||
if err != nil {
|
||||
// TODO: the router itself may be failed due to the failed node in the router,
|
||||
// the dead marker may be a wrong operation.
|
||||
if marker := target.Marker(); marker != nil {
|
||||
marker.Mark()
|
||||
var buf bytes.Buffer
|
||||
cc, err = h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, addr)
|
||||
ro.Route = buf.String()
|
||||
if err != nil {
|
||||
// Mark node as failed for future selections
|
||||
if marker := target.Marker(); marker != nil {
|
||||
marker.Mark()
|
||||
h.options.Logger.Debugf("[handler.retry] attempt=%d dial failed, marked node=%s count=%d err=%v",
|
||||
attempt, target.Addr, marker.Count(), err)
|
||||
}
|
||||
lastErr = err
|
||||
// Try next node
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
if marker := target.Marker(); marker != nil {
|
||||
marker.Reset()
|
||||
}
|
||||
defer cc.Close()
|
||||
|
||||
xnet.Transport(conn, cc)
|
||||
// Success - reset marker and proceed
|
||||
if marker := target.Marker(); marker != nil {
|
||||
marker.Reset()
|
||||
}
|
||||
defer cc.Close()
|
||||
|
||||
return nil
|
||||
if err := xnet.Transport(conn, cc); err != nil {
|
||||
if marker := target.Marker(); marker != nil {
|
||||
marker.Mark()
|
||||
h.options.Logger.Debugf("[handler.transport] transport failed, marked node=%s count=%d err=%v",
|
||||
target.Addr, marker.Count(), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return errors.New("all nodes failed")
|
||||
}
|
||||
|
||||
func (h *forwardHandler) checkRateLimit(addr net.Addr) bool {
|
||||
|
||||
@@ -25,6 +25,12 @@ type metadata struct {
|
||||
privateKey crypto.PrivateKey
|
||||
alpn string
|
||||
mitmBypass bypass.Bypass
|
||||
|
||||
// maxRetries specifies the maximum number of failover retry attempts.
|
||||
// When a target node fails, the handler will try the next available node.
|
||||
// 0 means use the total number of available nodes (try all nodes once).
|
||||
// Default: 0 (try all available nodes)
|
||||
maxRetries int
|
||||
}
|
||||
|
||||
func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) {
|
||||
@@ -56,5 +62,8 @@ func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) {
|
||||
h.md.alpn = mdutil.GetString(md, "mitm.alpn")
|
||||
h.md.mitmBypass = registry.BypassRegistry().Get(mdutil.GetString(md, "mitm.bypass"))
|
||||
|
||||
// maxRetries: 0 means try all available nodes (default behavior)
|
||||
h.md.maxRetries = mdutil.GetInt(md, "maxRetries", "retry.max")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -204,68 +204,101 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand
|
||||
}
|
||||
}
|
||||
|
||||
var target *chain.Node
|
||||
if host != "" {
|
||||
target = &chain.Node{
|
||||
Addr: host,
|
||||
// Determine max retry attempts
|
||||
maxRetries := h.md.maxRetries
|
||||
if maxRetries <= 0 {
|
||||
// Default: try all available nodes
|
||||
if nl, ok := h.hop.(hop.NodeList); ok {
|
||||
maxRetries = len(nl.Nodes())
|
||||
}
|
||||
}
|
||||
if h.hop != nil {
|
||||
target = h.hop.Select(ctx,
|
||||
hop.ProtocolSelectOption(proto),
|
||||
)
|
||||
}
|
||||
if target == nil {
|
||||
err := errors.New("node not available")
|
||||
log.Error(err)
|
||||
return err
|
||||
}
|
||||
|
||||
if opts := target.Options(); opts != nil {
|
||||
switch opts.Network {
|
||||
case "unix":
|
||||
network = opts.Network
|
||||
default:
|
||||
if maxRetries <= 0 {
|
||||
maxRetries = 1
|
||||
}
|
||||
}
|
||||
|
||||
ro.Network = network
|
||||
ro.Host = target.Addr
|
||||
var triedNodes []string
|
||||
var lastErr error
|
||||
var cc net.Conn
|
||||
|
||||
log = log.WithFields(map[string]any{
|
||||
"node": target.Name,
|
||||
"dst": fmt.Sprintf("%s/%s", target.Addr, network),
|
||||
})
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
// Select a target node, excluding previously tried nodes
|
||||
selectCtx := ctxvalue.ContextWithExcludeNodes(ctx, triedNodes)
|
||||
var target *chain.Node
|
||||
if host != "" {
|
||||
target = chain.NewNode("", host)
|
||||
}
|
||||
if h.hop != nil {
|
||||
target = h.hop.Select(selectCtx,
|
||||
hop.ProtocolSelectOption(proto),
|
||||
)
|
||||
}
|
||||
if target == nil {
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
err := errors.New("node not available")
|
||||
log.Error(err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("%s >> %s", conn.RemoteAddr(), target.Addr)
|
||||
// Track this node as tried
|
||||
triedNodes = append(triedNodes, target.Addr)
|
||||
|
||||
var buf bytes.Buffer
|
||||
cc, err := h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, target.Addr)
|
||||
ro.Route = buf.String()
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
// TODO: the router itself may be failed due to the failed node in the router,
|
||||
// the dead marker may be a wrong operation.
|
||||
if opts := target.Options(); opts != nil {
|
||||
switch opts.Network {
|
||||
case "unix":
|
||||
network = opts.Network
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
ro.Network = network
|
||||
ro.Host = target.Addr
|
||||
|
||||
targetLog := log.WithFields(map[string]any{
|
||||
"node": target.Name,
|
||||
"dst": fmt.Sprintf("%s/%s", target.Addr, network),
|
||||
})
|
||||
|
||||
targetLog.Debugf("%s >> %s", conn.RemoteAddr(), target.Addr)
|
||||
|
||||
var buf bytes.Buffer
|
||||
cc, err = h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, target.Addr)
|
||||
ro.Route = buf.String()
|
||||
if err != nil {
|
||||
targetLog.Error(err)
|
||||
// Mark node as failed for future selections
|
||||
if marker := target.Marker(); marker != nil {
|
||||
marker.Mark()
|
||||
}
|
||||
lastErr = err
|
||||
// Try next node
|
||||
continue
|
||||
}
|
||||
|
||||
// Success - reset marker and proceed
|
||||
if marker := target.Marker(); marker != nil {
|
||||
marker.Mark()
|
||||
marker.Reset()
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer cc.Close()
|
||||
if marker := target.Marker(); marker != nil {
|
||||
marker.Reset()
|
||||
defer cc.Close()
|
||||
|
||||
cc = proxyproto.WrapClientConn(h.md.proxyProtocol, conn.RemoteAddr(), convertAddr(conn.LocalAddr()), cc)
|
||||
|
||||
t := time.Now()
|
||||
targetLog.Infof("%s <-> %s", conn.RemoteAddr(), target.Addr)
|
||||
xnet.Transport(conn, cc)
|
||||
targetLog.WithFields(map[string]any{
|
||||
"duration": time.Since(t),
|
||||
}).Infof("%s >-< %s", conn.RemoteAddr(), target.Addr)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
cc = proxyproto.WrapClientConn(h.md.proxyProtocol, conn.RemoteAddr(), convertAddr(conn.LocalAddr()), cc)
|
||||
|
||||
t := time.Now()
|
||||
log.Infof("%s <-> %s", conn.RemoteAddr(), target.Addr)
|
||||
xnet.Transport(conn, cc)
|
||||
log.WithFields(map[string]any{
|
||||
"duration": time.Since(t),
|
||||
}).Infof("%s >-< %s", conn.RemoteAddr(), target.Addr)
|
||||
|
||||
return nil
|
||||
// All retries exhausted
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return errors.New("all nodes failed")
|
||||
}
|
||||
|
||||
func (h *forwardHandler) checkRateLimit(addr net.Addr) bool {
|
||||
|
||||
@@ -26,6 +26,12 @@ type metadata struct {
|
||||
privateKey crypto.PrivateKey
|
||||
alpn string
|
||||
mitmBypass bypass.Bypass
|
||||
|
||||
// maxRetries specifies the maximum number of failover retry attempts.
|
||||
// When a target node fails, the handler will try the next available node.
|
||||
// 0 means use the total number of available nodes (try all nodes once).
|
||||
// Default: 0 (try all available nodes)
|
||||
maxRetries int
|
||||
}
|
||||
|
||||
func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) {
|
||||
@@ -57,5 +63,9 @@ func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) {
|
||||
}
|
||||
h.md.alpn = mdutil.GetString(md, "mitm.alpn")
|
||||
h.md.mitmBypass = registry.BypassRegistry().Get(mdutil.GetString(md, "mitm.bypass"))
|
||||
|
||||
// maxRetries: 0 means try all available nodes (default behavior)
|
||||
h.md.maxRetries = mdutil.GetInt(md, "maxRetries", "retry.max")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
+35
-4
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/go-gost/core/selector"
|
||||
"github.com/go-gost/x/config"
|
||||
node_parser "github.com/go-gost/x/config/parsing/node"
|
||||
ctxvalue "github.com/go-gost/x/ctx"
|
||||
"github.com/go-gost/x/internal/loader"
|
||||
)
|
||||
|
||||
@@ -141,11 +142,28 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get list of nodes to exclude (for failover retry)
|
||||
excludeNodes := ctxvalue.ExcludeNodesFromContext(ctx)
|
||||
excludeSet := make(map[string]bool)
|
||||
for _, addr := range excludeNodes {
|
||||
excludeSet[addr] = true
|
||||
}
|
||||
|
||||
// Debug logging for failover analysis
|
||||
log.Debugf("[hop.Select] excludeNodes=%v, totalNodes=%d", excludeNodes, len(p.Nodes()))
|
||||
|
||||
var nodes []*chain.Node
|
||||
for _, node := range p.Nodes() {
|
||||
if node == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip nodes in the exclude list (failover retry)
|
||||
if excludeSet[node.Addr] || excludeSet[node.Name] {
|
||||
log.Debugf("node %s(%s) excluded for failover retry", node.Name, node.Addr)
|
||||
continue
|
||||
}
|
||||
|
||||
// node level bypass
|
||||
if node.Options().Bypass != nil &&
|
||||
node.Options().Bypass.Contains(ctx, options.Network, options.Addr, bypass.WithHostOpton(options.Host)) {
|
||||
@@ -177,9 +195,6 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
|
||||
if len(nodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(nodes) == 1 {
|
||||
return nodes[0]
|
||||
}
|
||||
|
||||
sort.Slice(nodes, func(i, j int) bool {
|
||||
return nodes[i].Options().Priority > nodes[j].Options().Priority
|
||||
@@ -189,9 +204,25 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
|
||||
return nodes[0]
|
||||
}
|
||||
|
||||
// Use selector with FailFilter for proper failover.
|
||||
// FailFilter will exclude recently-failed nodes, allowing traffic to
|
||||
// be routed to healthy alternatives.
|
||||
// Note: FailFilter has a safety guard (len <= 1 returns as-is) to ensure
|
||||
// the last remaining node is never permanently blocked.
|
||||
if s := p.options.selector; s != nil {
|
||||
return s.Select(ctx, nodes...)
|
||||
log.Debugf("[hop.Select] calling selector.Select with %d nodes", len(nodes))
|
||||
if node := s.Select(ctx, nodes...); node != nil {
|
||||
log.Debugf("[hop.Select] selected node=%s addr=%s", node.Name, node.Addr)
|
||||
return node
|
||||
}
|
||||
// All nodes filtered out by FailFilter - all are marked as failed.
|
||||
// Return nil to signal "no healthy nodes available" to the caller.
|
||||
// The handler's retry loop will handle this appropriately.
|
||||
log.Debugf("all %d nodes filtered out by FailFilter, no healthy nodes available", len(nodes))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fallback: return first node if no selector configured
|
||||
return nodes[0]
|
||||
}
|
||||
|
||||
|
||||
@@ -247,64 +247,98 @@ func (h *Sniffer) dial(ctx context.Context, conn net.Conn, req *http.Request, ho
|
||||
}
|
||||
}
|
||||
|
||||
node = &chain.Node{
|
||||
Addr: host,
|
||||
// Determine max retry attempts
|
||||
maxRetries := 1
|
||||
if nl, ok := ho.Hop.(hop.NodeList); ok {
|
||||
maxRetries = len(nl.Nodes())
|
||||
}
|
||||
if ho.Hop != nil {
|
||||
node = ho.Hop.Select(ctx,
|
||||
hop.ClientIPSelectOption(net.ParseIP(ro.ClientIP)),
|
||||
hop.ProtocolSelectOption(sniffing.ProtoHTTP),
|
||||
hop.HostSelectOption(host),
|
||||
hop.MethodSelectOption(req.Method),
|
||||
hop.PathSelectOption(req.URL.Path),
|
||||
hop.QuerySelectOption(req.URL.Query()),
|
||||
hop.HeaderSelectOption(req.Header),
|
||||
)
|
||||
}
|
||||
if node == nil {
|
||||
ho.Log.Warnf("node for %s not found", host)
|
||||
res.StatusCode = http.StatusBadGateway
|
||||
ro.HTTP.StatusCode = res.StatusCode
|
||||
res.Write(conn)
|
||||
return nil, nil, errors.New("node not available")
|
||||
if maxRetries <= 0 {
|
||||
maxRetries = 1
|
||||
}
|
||||
|
||||
ro.Host = node.Addr
|
||||
ho.Log = ho.Log.WithFields(map[string]any{
|
||||
"node": node.Name,
|
||||
"dst": node.Addr,
|
||||
})
|
||||
ho.Log.Debugf("find node for host %s -> %s(%s)", host, node.Name, node.Addr)
|
||||
var triedNodes []string
|
||||
var lastErr error
|
||||
|
||||
cc, err = dial(ctx, "tcp", node.Addr)
|
||||
if err != nil {
|
||||
// TODO: the router itself may be failed due to the failed node in the router,
|
||||
// the dead marker may be a wrong operation.
|
||||
if marker := node.Marker(); marker != nil {
|
||||
marker.Mark()
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
// Select a node, excluding previously tried nodes
|
||||
selectCtx := ctxvalue.ContextWithExcludeNodes(ctx, triedNodes)
|
||||
|
||||
node = chain.NewNode("", host)
|
||||
if ho.Hop != nil {
|
||||
node = ho.Hop.Select(selectCtx,
|
||||
hop.ClientIPSelectOption(net.ParseIP(ro.ClientIP)),
|
||||
hop.ProtocolSelectOption(sniffing.ProtoHTTP),
|
||||
hop.HostSelectOption(host),
|
||||
hop.MethodSelectOption(req.Method),
|
||||
hop.PathSelectOption(req.URL.Path),
|
||||
hop.QuerySelectOption(req.URL.Query()),
|
||||
hop.HeaderSelectOption(req.Header),
|
||||
)
|
||||
}
|
||||
ho.Log.Warnf("connect to node %s(%s) failed: %v", node.Name, node.Addr, err)
|
||||
res.Write(conn)
|
||||
return
|
||||
}
|
||||
if marker := node.Marker(); marker != nil {
|
||||
marker.Reset()
|
||||
}
|
||||
|
||||
if tlsSettings := node.Options().TLS; tlsSettings != nil {
|
||||
cfg := &tls.Config{
|
||||
ServerName: tlsSettings.ServerName,
|
||||
InsecureSkipVerify: !tlsSettings.Secure,
|
||||
if node == nil {
|
||||
if lastErr != nil {
|
||||
ho.Log.Warnf("node for %s not found after retries", host)
|
||||
res.StatusCode = http.StatusBadGateway
|
||||
ro.HTTP.StatusCode = res.StatusCode
|
||||
res.Write(conn)
|
||||
return nil, nil, lastErr
|
||||
}
|
||||
ho.Log.Warnf("node for %s not found", host)
|
||||
res.StatusCode = http.StatusBadGateway
|
||||
ro.HTTP.StatusCode = res.StatusCode
|
||||
res.Write(conn)
|
||||
return nil, nil, errors.New("node not available")
|
||||
}
|
||||
tls_util.SetTLSOptions(cfg, &config.TLSOptions{
|
||||
MinVersion: tlsSettings.Options.MinVersion,
|
||||
MaxVersion: tlsSettings.Options.MaxVersion,
|
||||
CipherSuites: tlsSettings.Options.CipherSuites,
|
||||
ALPN: tlsSettings.Options.ALPN,
|
||||
|
||||
// Track this node as tried
|
||||
triedNodes = append(triedNodes, node.Addr)
|
||||
|
||||
ro.Host = node.Addr
|
||||
ho.Log = ho.Log.WithFields(map[string]any{
|
||||
"node": node.Name,
|
||||
"dst": node.Addr,
|
||||
})
|
||||
cc = tls.Client(cc, cfg)
|
||||
ho.Log.Debugf("find node for host %s -> %s(%s)", host, node.Name, node.Addr)
|
||||
|
||||
cc, err = dial(ctx, "tcp", node.Addr)
|
||||
if err != nil {
|
||||
// Mark node as failed for future selections
|
||||
if marker := node.Marker(); marker != nil {
|
||||
marker.Mark()
|
||||
}
|
||||
ho.Log.Warnf("connect to node %s(%s) failed: %v, trying next node", node.Name, node.Addr, err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
// Success - reset marker
|
||||
if marker := node.Marker(); marker != nil {
|
||||
marker.Reset()
|
||||
}
|
||||
|
||||
if tlsSettings := node.Options().TLS; tlsSettings != nil {
|
||||
cfg := &tls.Config{
|
||||
ServerName: tlsSettings.ServerName,
|
||||
InsecureSkipVerify: !tlsSettings.Secure,
|
||||
}
|
||||
tls_util.SetTLSOptions(cfg, &config.TLSOptions{
|
||||
MinVersion: tlsSettings.Options.MinVersion,
|
||||
MaxVersion: tlsSettings.Options.MaxVersion,
|
||||
CipherSuites: tlsSettings.Options.CipherSuites,
|
||||
ALPN: tlsSettings.Options.ALPN,
|
||||
})
|
||||
cc = tls.Client(cc, cfg)
|
||||
}
|
||||
return node, cc, nil
|
||||
}
|
||||
return
|
||||
|
||||
// All retries exhausted
|
||||
ho.Log.Warnf("all nodes failed for host %s", host)
|
||||
res.Write(conn)
|
||||
if lastErr != nil {
|
||||
return nil, nil, lastErr
|
||||
}
|
||||
return nil, nil, errors.New("all nodes failed")
|
||||
}
|
||||
|
||||
func (h *Sniffer) serveH2(ctx context.Context, conn net.Conn, ho *HandleOptions) error {
|
||||
@@ -847,74 +881,105 @@ func (h *Sniffer) dialTLS(ctx context.Context, host string, ho *HandleOptions) (
|
||||
return
|
||||
}
|
||||
|
||||
if host != "" {
|
||||
node = &chain.Node{
|
||||
Addr: host,
|
||||
}
|
||||
}
|
||||
|
||||
ro := ho.RecorderObject
|
||||
if ho.Hop != nil {
|
||||
node = ho.Hop.Select(ctx,
|
||||
hop.ClientIPSelectOption(net.ParseIP(ro.ClientIP)),
|
||||
hop.HostSelectOption(host),
|
||||
hop.ProtocolSelectOption(sniffing.ProtoTLS),
|
||||
)
|
||||
|
||||
// Determine max retry attempts
|
||||
maxRetries := 1
|
||||
if nl, ok := ho.Hop.(hop.NodeList); ok {
|
||||
maxRetries = len(nl.Nodes())
|
||||
}
|
||||
if node == nil {
|
||||
err = errors.New("node not available")
|
||||
return
|
||||
if maxRetries <= 0 {
|
||||
maxRetries = 1
|
||||
}
|
||||
|
||||
addr := node.Addr
|
||||
if opts := node.Options(); opts != nil {
|
||||
switch opts.Network {
|
||||
case "unix":
|
||||
ro.Network = opts.Network
|
||||
default:
|
||||
if _, _, err := net.SplitHostPort(addr); err != nil {
|
||||
addr += ":443"
|
||||
var triedNodes []string
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
// Select a node, excluding previously tried nodes
|
||||
selectCtx := ctxvalue.ContextWithExcludeNodes(ctx, triedNodes)
|
||||
|
||||
node = nil
|
||||
if host != "" {
|
||||
node = chain.NewNode("", host)
|
||||
}
|
||||
if ho.Hop != nil {
|
||||
node = ho.Hop.Select(selectCtx,
|
||||
hop.ClientIPSelectOption(net.ParseIP(ro.ClientIP)),
|
||||
hop.HostSelectOption(host),
|
||||
hop.ProtocolSelectOption(sniffing.ProtoTLS),
|
||||
)
|
||||
}
|
||||
if node == nil {
|
||||
if lastErr != nil {
|
||||
ho.Log.Warnf("node for %s not found after retries", host)
|
||||
return nil, nil, lastErr
|
||||
}
|
||||
ho.Log.Warnf("node for %s not found", host)
|
||||
return nil, nil, errors.New("node not available")
|
||||
}
|
||||
|
||||
// Track this node as tried
|
||||
triedNodes = append(triedNodes, node.Addr)
|
||||
|
||||
addr := node.Addr
|
||||
if opts := node.Options(); opts != nil {
|
||||
switch opts.Network {
|
||||
case "unix":
|
||||
ro.Network = opts.Network
|
||||
default:
|
||||
if _, _, err := net.SplitHostPort(addr); err != nil {
|
||||
addr += ":443"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ro.Host = addr
|
||||
ro.Host = addr
|
||||
|
||||
ho.Log = ho.Log.WithFields(map[string]any{
|
||||
"host": host,
|
||||
"node": node.Name,
|
||||
"dst": fmt.Sprintf("%s/%s", addr, ro.Network),
|
||||
})
|
||||
ho.Log.Debugf("find node for host %s -> %s(%s)", host, node.Name, addr)
|
||||
|
||||
cc, err = dial(ctx, ro.Network, addr)
|
||||
if err != nil {
|
||||
// TODO: the router itself may be failed due to the failed node in the router,
|
||||
// the dead marker may be a wrong operation.
|
||||
if marker := node.Marker(); marker != nil {
|
||||
marker.Mark()
|
||||
}
|
||||
ho.Log.Warnf("connect to node %s(%s) failed: %v", node.Name, node.Addr, err)
|
||||
return
|
||||
}
|
||||
|
||||
if marker := node.Marker(); marker != nil {
|
||||
marker.Reset()
|
||||
}
|
||||
|
||||
if tlsSettings := node.Options().TLS; tlsSettings != nil {
|
||||
cfg := &tls.Config{
|
||||
ServerName: tlsSettings.ServerName,
|
||||
InsecureSkipVerify: !tlsSettings.Secure,
|
||||
}
|
||||
tls_util.SetTLSOptions(cfg, &config.TLSOptions{
|
||||
MinVersion: tlsSettings.Options.MinVersion,
|
||||
MaxVersion: tlsSettings.Options.MaxVersion,
|
||||
CipherSuites: tlsSettings.Options.CipherSuites,
|
||||
ALPN: tlsSettings.Options.ALPN,
|
||||
ho.Log = ho.Log.WithFields(map[string]any{
|
||||
"host": host,
|
||||
"node": node.Name,
|
||||
"dst": fmt.Sprintf("%s/%s", addr, ro.Network),
|
||||
})
|
||||
cc = tls.Client(cc, cfg)
|
||||
ho.Log.Debugf("find node for host %s -> %s(%s)", host, node.Name, addr)
|
||||
|
||||
cc, err = dial(ctx, ro.Network, addr)
|
||||
if err != nil {
|
||||
// Mark node as failed for future selections
|
||||
if marker := node.Marker(); marker != nil {
|
||||
marker.Mark()
|
||||
}
|
||||
ho.Log.Warnf("connect to node %s(%s) failed: %v, trying next node", node.Name, node.Addr, err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
// Success - reset marker
|
||||
if marker := node.Marker(); marker != nil {
|
||||
marker.Reset()
|
||||
}
|
||||
|
||||
if tlsSettings := node.Options().TLS; tlsSettings != nil {
|
||||
cfg := &tls.Config{
|
||||
ServerName: tlsSettings.ServerName,
|
||||
InsecureSkipVerify: !tlsSettings.Secure,
|
||||
}
|
||||
tls_util.SetTLSOptions(cfg, &config.TLSOptions{
|
||||
MinVersion: tlsSettings.Options.MinVersion,
|
||||
MaxVersion: tlsSettings.Options.MaxVersion,
|
||||
CipherSuites: tlsSettings.Options.CipherSuites,
|
||||
ALPN: tlsSettings.Options.ALPN,
|
||||
})
|
||||
cc = tls.Client(cc, cfg)
|
||||
}
|
||||
return node, cc, nil
|
||||
}
|
||||
return
|
||||
|
||||
// All retries exhausted
|
||||
ho.Log.Warnf("all nodes failed for host %s", host)
|
||||
if lastErr != nil {
|
||||
return nil, nil, lastErr
|
||||
}
|
||||
return nil, nil, errors.New("all nodes failed")
|
||||
}
|
||||
|
||||
func (h *Sniffer) terminateTLS(ctx context.Context, conn, cc net.Conn, clientHello *dissector.ClientHelloInfo, ho *HandleOptions) error {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# GO-GOST/X LISTENERS KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Inbound listeners (transport-level accept loops) used by services defined in the GOST config.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
go-gost/x/listener/
|
||||
├── tcp/ # listener.go + metadata.go
|
||||
├── udp/
|
||||
├── tls/
|
||||
├── ws/
|
||||
├── quic/
|
||||
├── redirect/ # tcp/ + udp/
|
||||
├── tun/ # TUN device listener
|
||||
├── tap/ # TAP device listener
|
||||
└── ...
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Listener registry | `go-gost/x/listener/` | One subdir per transport |
|
||||
| TCP baseline | `go-gost/x/listener/tcp/listener.go` | Reference for other transports |
|
||||
| Redirect listeners | `go-gost/x/listener/redirect/` | Per-protocol accept + redirect |
|
||||
| TUN/TAP | `go-gost/x/listener/tun/`, `go-gost/x/listener/tap/` | Virtual interface listeners |
|
||||
|
||||
## CONVENTIONS
|
||||
- Listener implementations typically live in `listener.go` with a paired `metadata.go` (e.g. `go-gost/x/listener/tcp/`).
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost/x
|
||||
go test ./...
|
||||
```
|
||||
@@ -0,0 +1,29 @@
|
||||
# GO-GOST REGISTRY KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Wed Feb 04 2026
|
||||
|
||||
## OVERVIEW
|
||||
Central registration point for all pluggable GOST components (handlers, listeners, dialers, etc.).
|
||||
Allows the configuration system to resolve string types (e.g., "socks5") to actual Go implementations.
|
||||
|
||||
## STRUCTURE
|
||||
One file per component type, exporting a standard Registry interface.
|
||||
```
|
||||
go-gost/x/registry/
|
||||
├── handler.go # RegisterHandler(name, newFunc)
|
||||
├── listener.go # RegisterListener(name, newFunc)
|
||||
├── dialer.go # RegisterDialer(name, newFunc)
|
||||
└── ... # Same pattern for auth, bypass, admission
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Register a new component | `go-gost/x/registry/{type}.go` | Use `Register{Type}(name, creator)` |
|
||||
| Component lookup | `go-gost/x/registry/{type}.go` | `Get{Type}(name)` returns the creator function |
|
||||
| Default registrations | `go-gost/x/` (init functions) | Most components register themselves in their package `init()` |
|
||||
|
||||
## CONVENTIONS
|
||||
- Thread-safe maps used for storage.
|
||||
- Names are case-sensitive (usually lowercase).
|
||||
- Components must be registered *before* the configuration parser runs (usually done via `import _ "..."` in `main.go`).
|
||||
@@ -2,11 +2,13 @@ package selector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/metadata"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
"github.com/go-gost/core/selector"
|
||||
mdutil "github.com/go-gost/x/metadata/util"
|
||||
)
|
||||
|
||||
type failFilter[T any] struct {
|
||||
@@ -24,6 +26,8 @@ func FailFilter[T any](maxFails int, timeout time.Duration) selector.Filter[T] {
|
||||
}
|
||||
|
||||
// Filter filters dead objects.
|
||||
// For single-node case, skip filtering to ensure availability (matches upstream).
|
||||
// For multi-node case, filter out failed nodes to enable failover.
|
||||
func (f *failFilter[T]) Filter(ctx context.Context, vs ...T) []T {
|
||||
if len(vs) <= 1 {
|
||||
return vs
|
||||
@@ -51,8 +55,21 @@ func (f *failFilter[T]) Filter(ctx context.Context, vs ...T) []T {
|
||||
|
||||
if mi, _ := any(v).(selector.Markable); mi != nil {
|
||||
if marker := mi.Marker(); marker != nil {
|
||||
if marker.Count() < int64(maxFails) ||
|
||||
time.Since(marker.Time()) >= failTimeout {
|
||||
count := marker.Count()
|
||||
timeSince := time.Since(marker.Time())
|
||||
passed := count < int64(maxFails) || timeSince >= failTimeout
|
||||
|
||||
// Debug logging for failover analysis
|
||||
nodeName := "unknown"
|
||||
nodeAddr := "unknown"
|
||||
if node, ok := any(v).(*chain.Node); ok {
|
||||
nodeName = node.Name
|
||||
nodeAddr = node.Addr
|
||||
}
|
||||
fmt.Printf("[FailFilter] node=%s addr=%s count=%d maxFails=%d timeSince=%v failTimeout=%v passed=%v\n",
|
||||
nodeName, nodeAddr, count, maxFails, timeSince, failTimeout, passed)
|
||||
|
||||
if passed {
|
||||
l = append(l, v)
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -2,11 +2,17 @@ package socket
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/go-gost/x/config"
|
||||
)
|
||||
|
||||
// configMutex 保护配置文件的并发写入
|
||||
var configMutex sync.Mutex
|
||||
|
||||
func saveConfig() {
|
||||
configMutex.Lock()
|
||||
defer configMutex.Unlock()
|
||||
|
||||
file := "gost.json"
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/service"
|
||||
"github.com/go-gost/x/internal/util/crypto"
|
||||
"github.com/go-gost/x/service"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/shirou/gopsutil/v3/cpu"
|
||||
"github.com/shirou/gopsutil/v3/host"
|
||||
@@ -87,6 +87,9 @@ type TcpPingResponse struct {
|
||||
|
||||
type WebSocketReporter struct {
|
||||
url string
|
||||
addr string // 保存服务器地址
|
||||
secret string // 保存密钥
|
||||
version string // 保存版本号
|
||||
conn *websocket.Conn
|
||||
reconnectTime time.Duration
|
||||
pingInterval time.Duration
|
||||
@@ -195,7 +198,25 @@ func (w *WebSocketReporter) connect() error {
|
||||
w.connecting = false
|
||||
}()
|
||||
|
||||
u, err := url.Parse(w.url)
|
||||
// 重新读取 config.json 获取最新的协议配置
|
||||
type LocalConfig struct {
|
||||
Addr string `json:"addr"`
|
||||
Secret string `json:"secret"`
|
||||
Http int `json:"http"`
|
||||
Tls int `json:"tls"`
|
||||
Socks int `json:"socks"`
|
||||
}
|
||||
|
||||
var cfg LocalConfig
|
||||
if b, err := os.ReadFile("config.json"); err == nil {
|
||||
json.Unmarshal(b, &cfg)
|
||||
}
|
||||
|
||||
// 使用最新的配置重新构建 URL
|
||||
currentURL := "ws://" + w.addr + "/system-info?type=1&secret=" + w.secret + "&version=" + w.version +
|
||||
"&http=" + strconv.Itoa(cfg.Http) + "&tls=" + strconv.Itoa(cfg.Tls) + "&socks=" + strconv.Itoa(cfg.Socks)
|
||||
|
||||
u, err := url.Parse(currentURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析URL失败: %v", err)
|
||||
}
|
||||
@@ -225,7 +246,7 @@ func (w *WebSocketReporter) connect() error {
|
||||
return nil
|
||||
})
|
||||
|
||||
fmt.Printf("✅ WebSocket连接建立成功\n")
|
||||
fmt.Printf("✅ WebSocket连接建立成功 (http=%d, tls=%d, socks=%d)\n", cfg.Http, cfg.Tls, cfg.Socks)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -445,7 +466,13 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
|
||||
}
|
||||
|
||||
if cmdMsg.Type != "call" {
|
||||
w.routeCommand(cmdMsg)
|
||||
// TcpPing 诊断命令异步执行,避免阻塞其他命令
|
||||
// 其他状态变更命令保持同步,确保顺序执行
|
||||
if cmdMsg.Type == "TcpPing" {
|
||||
go w.routeCommand(cmdMsg)
|
||||
} else {
|
||||
w.routeCommand(cmdMsg)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 处理普通消息
|
||||
@@ -456,7 +483,13 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
|
||||
return
|
||||
}
|
||||
if cmdMsg.Type != "call" {
|
||||
w.routeCommand(cmdMsg)
|
||||
// TcpPing 诊断命令异步执行,避免阻塞其他命令
|
||||
// 其他状态变更命令保持同步,确保顺序执行
|
||||
if cmdMsg.Type == "TcpPing" {
|
||||
go w.routeCommand(cmdMsg)
|
||||
} else {
|
||||
w.routeCommand(cmdMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,6 +509,7 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
|
||||
fmt.Println("🔔 收到命令: ", string(jsonBytes))
|
||||
var err error
|
||||
var response CommandResponse
|
||||
var needSaveConfig bool // 标记是否需要保存配置(只有状态变更命令才需要)
|
||||
|
||||
// 传递 requestId
|
||||
response.RequestId = cmd.RequestId
|
||||
@@ -485,65 +519,81 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
|
||||
case "AddService":
|
||||
err = w.handleAddService(cmd.Data)
|
||||
response.Type = "AddServiceResponse"
|
||||
needSaveConfig = true
|
||||
case "UpdateService":
|
||||
err = w.handleUpdateService(cmd.Data)
|
||||
response.Type = "UpdateServiceResponse"
|
||||
needSaveConfig = true
|
||||
case "DeleteService":
|
||||
err = w.handleDeleteService(cmd.Data)
|
||||
response.Type = "DeleteServiceResponse"
|
||||
needSaveConfig = true
|
||||
case "PauseService":
|
||||
err = w.handlePauseService(cmd.Data)
|
||||
response.Type = "PauseServiceResponse"
|
||||
needSaveConfig = true
|
||||
case "ResumeService":
|
||||
err = w.handleResumeService(cmd.Data)
|
||||
response.Type = "ResumeServiceResponse"
|
||||
needSaveConfig = true
|
||||
|
||||
// Chain 相关命令
|
||||
case "AddChains":
|
||||
err = w.handleAddChain(cmd.Data)
|
||||
response.Type = "AddChainsResponse"
|
||||
needSaveConfig = true
|
||||
case "UpdateChains":
|
||||
err = w.handleUpdateChain(cmd.Data)
|
||||
response.Type = "UpdateChainsResponse"
|
||||
needSaveConfig = true
|
||||
case "DeleteChains":
|
||||
err = w.handleDeleteChain(cmd.Data)
|
||||
response.Type = "DeleteChainsResponse"
|
||||
needSaveConfig = true
|
||||
|
||||
// Limiter 相关命令
|
||||
case "AddLimiters":
|
||||
err = w.handleAddLimiter(cmd.Data)
|
||||
response.Type = "AddLimitersResponse"
|
||||
needSaveConfig = true
|
||||
case "UpdateLimiters":
|
||||
err = w.handleUpdateLimiter(cmd.Data)
|
||||
response.Type = "UpdateLimitersResponse"
|
||||
needSaveConfig = true
|
||||
case "DeleteLimiters":
|
||||
err = w.handleDeleteLimiter(cmd.Data)
|
||||
response.Type = "DeleteLimitersResponse"
|
||||
needSaveConfig = true
|
||||
|
||||
// TCP Ping 诊断命令
|
||||
// TCP Ping 诊断命令(只读,不需要保存配置)
|
||||
case "TcpPing":
|
||||
var tcpPingResult TcpPingResponse
|
||||
tcpPingResult, err = w.handleTcpPing(cmd.Data)
|
||||
response.Type = "TcpPingResponse"
|
||||
response.Data = tcpPingResult
|
||||
// needSaveConfig = false (默认值)
|
||||
|
||||
// Protocol blocking switches
|
||||
case "SetProtocol":
|
||||
err = w.handleSetProtocol(cmd.Data)
|
||||
response.Type = "SetProtocolResponse"
|
||||
needSaveConfig = true
|
||||
|
||||
default:
|
||||
err = fmt.Errorf("未知命令类型: %s", cmd.Type)
|
||||
response.Type = "UnknownCommandResponse"
|
||||
}
|
||||
|
||||
// 只有状态变更命令才保存配置
|
||||
if needSaveConfig {
|
||||
saveConfig()
|
||||
}
|
||||
|
||||
// 发送响应
|
||||
if err != nil {
|
||||
saveConfig()
|
||||
response.Success = false
|
||||
response.Message = err.Error()
|
||||
} else {
|
||||
saveConfig()
|
||||
response.Success = true
|
||||
response.Message = "OK"
|
||||
}
|
||||
@@ -775,81 +825,81 @@ func (w *WebSocketReporter) handleDeleteLimiter(data interface{}) error {
|
||||
|
||||
// handleSetProtocol 处理设置屏蔽协议的命令
|
||||
func (w *WebSocketReporter) handleSetProtocol(data interface{}) error {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化协议设置失败: %v", err)
|
||||
}
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化协议设置失败: %v", err)
|
||||
}
|
||||
|
||||
// 支持 {"http":0/1, "tls":0/1, "socks":0/1}
|
||||
var req struct {
|
||||
HTTP *int `json:"http"`
|
||||
TLS *int `json:"tls"`
|
||||
SOCKS *int `json:"socks"`
|
||||
}
|
||||
if err := json.Unmarshal(jsonData, &req); err != nil {
|
||||
return fmt.Errorf("解析协议设置失败: %v", err)
|
||||
}
|
||||
// 支持 {"http":0/1, "tls":0/1, "socks":0/1}
|
||||
var req struct {
|
||||
HTTP *int `json:"http"`
|
||||
TLS *int `json:"tls"`
|
||||
SOCKS *int `json:"socks"`
|
||||
}
|
||||
if err := json.Unmarshal(jsonData, &req); err != nil {
|
||||
return fmt.Errorf("解析协议设置失败: %v", err)
|
||||
}
|
||||
|
||||
// 读取当前值作为默认
|
||||
httpVal, tlsVal, socksVal := 0, 0, 0
|
||||
// 读取当前值作为默认
|
||||
httpVal, tlsVal, socksVal := 0, 0, 0
|
||||
|
||||
if req.HTTP != nil {
|
||||
if *req.HTTP != 0 && *req.HTTP != 1 {
|
||||
return fmt.Errorf("http 取值必须为0或1")
|
||||
}
|
||||
httpVal = *req.HTTP
|
||||
}
|
||||
if req.TLS != nil {
|
||||
if *req.TLS != 0 && *req.TLS != 1 {
|
||||
return fmt.Errorf("tls 取值必须为0或1")
|
||||
}
|
||||
tlsVal = *req.TLS
|
||||
}
|
||||
if req.SOCKS != nil {
|
||||
if *req.SOCKS != 0 && *req.SOCKS != 1 {
|
||||
return fmt.Errorf("socks 取值必须为0或1")
|
||||
}
|
||||
socksVal = *req.SOCKS
|
||||
}
|
||||
if req.HTTP != nil {
|
||||
if *req.HTTP != 0 && *req.HTTP != 1 {
|
||||
return fmt.Errorf("http 取值必须为0或1")
|
||||
}
|
||||
httpVal = *req.HTTP
|
||||
}
|
||||
if req.TLS != nil {
|
||||
if *req.TLS != 0 && *req.TLS != 1 {
|
||||
return fmt.Errorf("tls 取值必须为0或1")
|
||||
}
|
||||
tlsVal = *req.TLS
|
||||
}
|
||||
if req.SOCKS != nil {
|
||||
if *req.SOCKS != 0 && *req.SOCKS != 1 {
|
||||
return fmt.Errorf("socks 取值必须为0或1")
|
||||
}
|
||||
socksVal = *req.SOCKS
|
||||
}
|
||||
|
||||
// 设置至 service,全量传递(未提供的值沿用0)
|
||||
service.SetProtocolBlock(httpVal, tlsVal, socksVal)
|
||||
// 设置至 service,全量传递(未提供的值沿用0)
|
||||
service.SetProtocolBlock(httpVal, tlsVal, socksVal)
|
||||
|
||||
// 同步写入本地 config.json
|
||||
if err := updateLocalConfigJSON(httpVal, tlsVal, socksVal); err != nil {
|
||||
return fmt.Errorf("写入config.json失败: %v", err)
|
||||
}
|
||||
return nil
|
||||
// 同步写入本地 config.json
|
||||
if err := updateLocalConfigJSON(httpVal, tlsVal, socksVal); err != nil {
|
||||
return fmt.Errorf("写入config.json失败: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateLocalConfigJSON 将 http/tls/socks 写入工作目录下的 config.json
|
||||
func updateLocalConfigJSON(httpVal int, tlsVal int, socksVal int) error {
|
||||
path := "config.json"
|
||||
path := "config.json"
|
||||
|
||||
// 读取现有配置
|
||||
type LocalConfig struct {
|
||||
Addr string `json:"addr"`
|
||||
Secret string `json:"secret"`
|
||||
Http int `json:"http"`
|
||||
Tls int `json:"tls"`
|
||||
Socks int `json:"socks"`
|
||||
}
|
||||
// 读取现有配置
|
||||
type LocalConfig struct {
|
||||
Addr string `json:"addr"`
|
||||
Secret string `json:"secret"`
|
||||
Http int `json:"http"`
|
||||
Tls int `json:"tls"`
|
||||
Socks int `json:"socks"`
|
||||
}
|
||||
|
||||
var cfg LocalConfig
|
||||
if b, err := os.ReadFile(path); err == nil {
|
||||
_ = json.Unmarshal(b, &cfg)
|
||||
}
|
||||
var cfg LocalConfig
|
||||
if b, err := os.ReadFile(path); err == nil {
|
||||
_ = json.Unmarshal(b, &cfg)
|
||||
}
|
||||
|
||||
cfg.Http = httpVal
|
||||
cfg.Tls = tlsVal
|
||||
cfg.Socks = socksVal
|
||||
cfg.Http = httpVal
|
||||
cfg.Tls = tlsVal
|
||||
cfg.Socks = socksVal
|
||||
|
||||
// 写回
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0644)
|
||||
// 写回
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
// handleCall 处理服务端的call回调消息
|
||||
@@ -1021,12 +1071,16 @@ func getMemoryInfo() MemoryInfo {
|
||||
// StartWebSocketReporterWithConfig 使用配置字段启动WebSocket报告器
|
||||
func StartWebSocketReporterWithConfig(addr string, secret string, http int, tls int, socks int, version string) *WebSocketReporter {
|
||||
|
||||
// 构建包含本机IP的WebSocket URL
|
||||
// 构建初始 WebSocket URL
|
||||
fullURL := "ws://" + addr + "/system-info?type=1&secret=" + secret + "&version=" + version + "&http=" + strconv.Itoa(http) + "&tls=" + strconv.Itoa(tls) + "&socks=" + strconv.Itoa(socks)
|
||||
|
||||
fmt.Printf("🔗 WebSocket连接URL: %s\n", fullURL)
|
||||
|
||||
reporter := NewWebSocketReporter(fullURL, secret)
|
||||
// 保存 addr, secret, version 供重连时使用
|
||||
reporter.addr = addr
|
||||
reporter.secret = secret
|
||||
reporter.version = version
|
||||
reporter.Start()
|
||||
return reporter
|
||||
}
|
||||
@@ -1107,6 +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)
|
||||
|
||||
// 如果是域名,先解析一次DNS,避免每次连接都重新解析导致延迟累加
|
||||
if net.ParseIP(ip) == nil {
|
||||
// 是域名,需要解析
|
||||
fmt.Printf("🔍 检测到域名,正在解析DNS...\n")
|
||||
dnsStart := time.Now()
|
||||
|
||||
addrs, err := net.LookupHost(ip)
|
||||
dnsDuration := time.Since(dnsStart)
|
||||
|
||||
if err != nil {
|
||||
return 0, 100.0, fmt.Errorf("DNS解析失败: %v", err)
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return 0, 100.0, fmt.Errorf("DNS解析未返回任何IP地址")
|
||||
}
|
||||
|
||||
fmt.Printf("✅ DNS解析完成 (%.2fms),解析到 %d 个IP: %v\n",
|
||||
dnsDuration.Seconds()*1000, len(addrs), addrs)
|
||||
|
||||
// 使用第一个解析到的IP进行测试
|
||||
target = net.JoinHostPort(addrs[0], fmt.Sprintf("%d", port))
|
||||
fmt.Printf("🎯 使用IP地址进行测试: %s\n", target)
|
||||
} else {
|
||||
fmt.Printf("🎯 使用IP地址进行测试: %s\n", target)
|
||||
}
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
start := time.Now()
|
||||
|
||||
|
||||
+107
-9
@@ -1,12 +1,108 @@
|
||||
#!/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"
|
||||
|
||||
# 识别国家(用于镜像加速)
|
||||
COUNTRY=$(curl -s https://ipinfo.io/country)
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
# 拼接 URL
|
||||
DOWNLOAD_URL="https://ghfast.top/${DOWNLOAD_URL}"
|
||||
fi
|
||||
|
||||
maybe_proxy_url() {
|
||||
local url="$1"
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
echo "https://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
|
||||
ExecStart=$INSTALL_DIR/flux_agent
|
||||
Restart=on-failure
|
||||
StandardOutput=null
|
||||
StandardError=null
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -216,8 +314,8 @@ EOF
|
||||
echo "📁 配置目录: $INSTALL_DIR"
|
||||
echo "🔧 服务状态: $(systemctl is-active flux_agent)"
|
||||
else
|
||||
echo "❌ flux_agent服务启动失败,请执行以下命令查看日志:"
|
||||
echo "journalctl -u flux_agent -f"
|
||||
echo "❌ flux_agent服务启动失败,请执行以下命令查看状态:"
|
||||
echo "systemctl status flux_agent --no-pager"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -342,4 +440,4 @@ main() {
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main
|
||||
main
|
||||
|
||||
+119
-9
@@ -7,16 +7,87 @@ export LC_ALL=C
|
||||
|
||||
|
||||
|
||||
# 全局下载地址配置
|
||||
DOCKER_COMPOSEV4_URL="https://github.com/bqlpfy/flux-panel/releases/download/2.0.2-beta/docker-compose-v4.yml"
|
||||
DOCKER_COMPOSEV6_URL="https://github.com/bqlpfy/flux-panel/releases/download/2.0.2-beta/docker-compose-v6.yml"
|
||||
# GitHub repo used for release downloads
|
||||
REPO="Sagit-chu/flux-panel"
|
||||
|
||||
COUNTRY=$(curl -s https://ipinfo.io/country)
|
||||
if [ "$COUNTRY" = "CN" ]; then
|
||||
# 拼接 URL
|
||||
DOCKER_COMPOSEV4_URL="https://ghfast.top/${DOCKER_COMPOSEV4_URL}"
|
||||
DOCKER_COMPOSEV6_URL="https://ghfast.top/${DOCKER_COMPOSEV6_URL}"
|
||||
fi
|
||||
|
||||
maybe_proxy_url() {
|
||||
local url="$1"
|
||||
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"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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() {
|
||||
echo ""
|
||||
@@ -202,6 +294,7 @@ install_panel() {
|
||||
JWT_SECRET=$JWT_SECRET
|
||||
FRONTEND_PORT=$FRONTEND_PORT
|
||||
BACKEND_PORT=$BACKEND_PORT
|
||||
FLUX_VERSION=$RESOLVED_VERSION
|
||||
EOF
|
||||
|
||||
echo "🚀 启动 docker 服务..."
|
||||
@@ -222,6 +315,15 @@ update_panel() {
|
||||
echo "🔄 开始更新面板..."
|
||||
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 "🔽 下载最新配置文件..."
|
||||
DOCKER_COMPOSE_URL=$(get_docker_compose_url)
|
||||
echo "📡 选择配置文件:$(basename "$DOCKER_COMPOSE_URL")"
|
||||
@@ -234,7 +336,15 @@ update_panel() {
|
||||
configure_docker_ipv6
|
||||
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
|
||||
|
||||
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 \
|
||||
&& sed -i 's/security.ubuntu.com/mirrors.ustc.edu.cn/g' /etc/apt/sources.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends fontconfig fonts-dejavu \
|
||||
&& apt-get install -y --no-install-recommends fontconfig fonts-dejavu sqlite3 \
|
||||
&& apt-get clean \
|
||||
&& 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
|
||||
@EnableAsync
|
||||
@EnableScheduling
|
||||
@MapperScan("com.admin.mapper")
|
||||
public class AdminApplication {
|
||||
|
||||
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 = "转发名称不能为空")
|
||||
private String name;
|
||||
|
||||
private Integer tunnelId;
|
||||
|
||||
@NotBlank(message = "远程地址不能为空")
|
||||
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不能为空")
|
||||
private String serverIp;
|
||||
|
||||
private String serverIpV4;
|
||||
|
||||
private String serverIpV6;
|
||||
|
||||
@NotBlank(message = "可用端口不能为空")
|
||||
private String port;
|
||||
|
||||
@@ -25,4 +29,4 @@ public class NodeDto {
|
||||
|
||||
private String udpListenAddr = "0.0.0.0";
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ public class NodeUpdateDto {
|
||||
@NotBlank(message = "服务器ip不能为空")
|
||||
private String serverIp;
|
||||
|
||||
private String serverIpV4;
|
||||
|
||||
private String serverIpV6;
|
||||
|
||||
@NotBlank(message = "可用port不能为空")
|
||||
private String port;
|
||||
|
||||
@@ -28,4 +32,4 @@ public class NodeUpdateDto {
|
||||
private String tcpListenAddr = "0.0.0.0";
|
||||
|
||||
private String udpListenAddr = "0.0.0.0";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@ import java.util.List;
|
||||
public class TunnelDetailDto {
|
||||
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* Display/order index for drag-sorting in admin UI.
|
||||
* Lower value appears first.
|
||||
*/
|
||||
private Integer inx;
|
||||
|
||||
private String name;
|
||||
|
||||
@@ -40,4 +46,3 @@ public class TunnelDetailDto {
|
||||
// 出口节点列表
|
||||
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")
|
||||
@DecimalMax(value = "100.0", message = "流量倍率不能大于100.0")
|
||||
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不能为空")
|
||||
private Integer tunnelId;
|
||||
|
||||
@NotNull(message = "流量限制不能为空")
|
||||
@Min(value = 0, message = "流量限制不能小于0")
|
||||
private Long flow;
|
||||
|
||||
@NotNull(message = "转发数量不能为空")
|
||||
@Min(value = 0, message = "转发数量不能小于0")
|
||||
private Integer num;
|
||||
|
||||
/**
|
||||
* 流量重置时间(时间戳)
|
||||
*/
|
||||
@NotNull(message = "流量重置时间不能为空")
|
||||
private Long flowResetTime;
|
||||
|
||||
/**
|
||||
* 到期时间(时间戳)
|
||||
*/
|
||||
@NotNull(message = "到期时间不能为空")
|
||||
private Long expTime;
|
||||
|
||||
/**
|
||||
* 限速规则ID(可选,null表示不限速)
|
||||
*/
|
||||
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;
|
||||
|
||||
import com.admin.common.dto.GostDto;
|
||||
import com.admin.common.utils.GostUtil;
|
||||
import com.admin.entity.*;
|
||||
import com.admin.service.*;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.admin.common.utils;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.admin.common.dto.GostDto;
|
||||
import com.admin.entity.*;
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -12,27 +15,36 @@ import java.util.Objects;
|
||||
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);
|
||||
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 req = new JSONObject();
|
||||
req.put("limiter", name + "");
|
||||
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();
|
||||
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();
|
||||
Node fromNode = node_s.get(node_id);
|
||||
for (ChainTunnel chainTunnel : chainTunnels) {
|
||||
JSONObject dialer = new JSONObject();
|
||||
dialer.put("type", chainTunnel.getProtocol());
|
||||
@@ -43,19 +55,27 @@ public class GostUtil {
|
||||
Node node_info = node_s.get(chainTunnel.getNodeId());
|
||||
JSONObject node = new JSONObject();
|
||||
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("dialer", dialer);
|
||||
|
||||
if (StringUtils.isNotBlank(node_info.getInterfaceName())) {
|
||||
node.put("interface", node_info.getInterfaceName());
|
||||
}
|
||||
|
||||
|
||||
nodes.add(node);
|
||||
}
|
||||
JSONObject hop = new JSONObject();
|
||||
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();
|
||||
selector.put("strategy", chainTunnels.getFirst().getStrategy());
|
||||
selector.put("maxFails", 1);
|
||||
@@ -72,24 +92,34 @@ public class GostUtil {
|
||||
data.put("name", "chains_" + chainTunnels.getFirst().getTunnelId());
|
||||
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();
|
||||
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();
|
||||
Node node_info = node_s.get(chainTunnel.getNodeId());
|
||||
JSONObject service_item = new JSONObject();
|
||||
service_item.put("name", chainTunnel.getTunnelId() + "_tls");
|
||||
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();
|
||||
metadata.put("interface", node_info.getInterfaceName());
|
||||
metadata.put("interface", node_s.get(node_id).getInterfaceName());
|
||||
service_item.put("metadata", metadata);
|
||||
}
|
||||
|
||||
@@ -106,16 +136,14 @@ public class GostUtil {
|
||||
|
||||
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) {
|
||||
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) {
|
||||
public static GostDto AddAndUpdateService(String name, Integer limiter, Node node, Forward forward, ForwardPort forwardPort, Tunnel tunnel, String meth) {
|
||||
JSONArray services = new JSONArray();
|
||||
String[] protocols = {"tcp", "udp"};
|
||||
for (String protocol : protocols) {
|
||||
@@ -127,7 +155,8 @@ public class GostUtil {
|
||||
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();
|
||||
metadata.put("interface", node.getInterfaceName());
|
||||
service.put("metadata", metadata);
|
||||
@@ -155,22 +184,30 @@ public class GostUtil {
|
||||
|
||||
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();
|
||||
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();
|
||||
JSONArray services = new JSONArray();
|
||||
services.add(name + "_tcp");
|
||||
services.add(name + "_udp");
|
||||
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++;
|
||||
}
|
||||
|
||||
if (strategy == null || strategy.equals("")) {
|
||||
if (strategy == null || strategy.isEmpty()) {
|
||||
strategy = "fifo";
|
||||
}
|
||||
|
||||
@@ -222,5 +259,153 @@ public class GostUtil {
|
||||
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");
|
||||
|
||||
log.info("节点 {} 尝试连接,开始处理连接逻辑", nodeId);
|
||||
log.info("屏蔽协议数据:http->{}, tls->{}, socks->{}", http, tls, socks);
|
||||
|
||||
// 检查是否已有该节点的连接,如果有则记录日志但直接覆盖
|
||||
WebSocketSession existingSession = nodeSessions.get(nodeId);
|
||||
|
||||
@@ -3,18 +3,28 @@ package com.admin.config;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.annotation.PreDestroy;
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* SQLite 数据库配置
|
||||
* 启用 WAL (Write-Ahead Logging) 模式以提高并发性能
|
||||
* 添加定期 checkpoint 和优雅关闭处理
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@EnableScheduling
|
||||
public class SQLiteConfig implements ApplicationRunner {
|
||||
|
||||
private final DataSource dataSource;
|
||||
@@ -33,10 +43,130 @@ public class SQLiteConfig implements ApplicationRunner {
|
||||
statement.execute("PRAGMA cache_size=-64000;"); // 64MB 缓存
|
||||
statement.execute("PRAGMA temp_store=MEMORY;");
|
||||
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) {
|
||||
log.error("Failed to configure SQLite database", 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.GostUtil;
|
||||
import com.admin.entity.*;
|
||||
import com.admin.service.ChainTunnelService;
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -64,6 +66,10 @@ public class FlowController extends BaseController {
|
||||
@Resource
|
||||
CheckGostConfigAsync checkGostConfigAsync;
|
||||
|
||||
@Resource
|
||||
@Lazy
|
||||
ChainTunnelService chainTunnelService;
|
||||
|
||||
/**
|
||||
* 加密消息包装器
|
||||
*/
|
||||
@@ -306,17 +312,14 @@ public class FlowController extends BaseController {
|
||||
}
|
||||
|
||||
public void pauseService(List<Forward> forwardList, String name) {
|
||||
// for (Forward forward : forwardList) {
|
||||
// Tunnel tunnel = tunnelService.getById(forward.getTunnelId());
|
||||
// if (tunnel != null) {
|
||||
// GostUtil.PauseService(tunnel.getInNodeId(), name);
|
||||
// if (tunnel.getType() == 2) {
|
||||
// GostUtil.PauseRemoteService(tunnel.getOutNodeId(), name);
|
||||
// }
|
||||
// }
|
||||
// forward.setStatus(0);
|
||||
// forwardService.updateById(forward);
|
||||
// }
|
||||
for (Forward forward : forwardList) {
|
||||
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", forward.getTunnelId()).eq("chain_type", 1));
|
||||
for (ChainTunnel chainTunnel : chainTunnels) {
|
||||
GostUtil.PauseAndResumeService(chainTunnel.getNodeId(), name, "PauseService");
|
||||
}
|
||||
forward.setStatus(0);
|
||||
forwardService.updateById(forward);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateForwardFlow(String forwardId, FlowDto flowStats) {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.admin.controller;
|
||||
|
||||
import com.admin.common.aop.LogAnnotation;
|
||||
import com.admin.common.annotation.RequireRole;
|
||||
import com.admin.common.dto.ForwardDto;
|
||||
import com.admin.common.dto.ForwardUpdateDto;
|
||||
import com.admin.common.lang.R;
|
||||
import com.admin.common.aop.LogAnnotation;
|
||||
import com.admin.common.annotation.RequireRole;
|
||||
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.ForwardUpdateDto;
|
||||
import com.admin.common.lang.R;
|
||||
import com.admin.service.ForwardService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
@@ -91,10 +94,40 @@ public class ForwardController extends BaseController {
|
||||
* @param params 包含forwards数组的参数,每个元素包含id和inx
|
||||
* @return 更新结果
|
||||
*/
|
||||
@LogAnnotation
|
||||
@PostMapping("/update-order")
|
||||
public R updateForwardOrder(@RequestBody Map<String, Object> params) {
|
||||
return forwardService.updateForwardOrder(params);
|
||||
}
|
||||
|
||||
}
|
||||
@LogAnnotation
|
||||
@PostMapping("/update-order")
|
||||
public R updateForwardOrder(@RequestBody Map<String, Object> 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;
|
||||
|
||||
|
||||
import com.admin.common.annotation.RequireRole;
|
||||
import com.admin.common.aop.LogAnnotation;
|
||||
import com.admin.common.dto.NodeDto;
|
||||
import com.admin.common.dto.NodeUpdateDto;
|
||||
import com.admin.common.lang.R;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
package com.admin.controller;
|
||||
|
||||
|
||||
import com.admin.common.annotation.RequireRole;
|
||||
import com.admin.common.aop.LogAnnotation;
|
||||
import com.admin.common.dto.BatchDeleteDto;
|
||||
import com.admin.common.dto.NodeDto;
|
||||
import com.admin.common.dto.NodeUpdateDto;
|
||||
import com.admin.common.lang.R;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@@ -54,12 +55,31 @@ public class NodeController extends BaseController {
|
||||
return nodeService.deleteNode(id);
|
||||
}
|
||||
|
||||
@LogAnnotation
|
||||
@RequireRole
|
||||
@PostMapping("/install")
|
||||
public R getInstallCommand(@RequestBody Map<String, Object> params) {
|
||||
Long id = Long.valueOf(params.get("id").toString());
|
||||
return nodeService.getInstallCommand(id);
|
||||
}
|
||||
|
||||
}
|
||||
@LogAnnotation
|
||||
@RequireRole
|
||||
@PostMapping("/install")
|
||||
public R getInstallCommand(@RequestBody Map<String, Object> params) {
|
||||
Long id = Long.valueOf(params.get("id").toString());
|
||||
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.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.TunnelUpdateDto;
|
||||
|
||||
import com.admin.common.dto.UserTunnelBatchAssignDto;
|
||||
import com.admin.common.dto.UserTunnelDto;
|
||||
import com.admin.common.dto.UserTunnelQueryDto;
|
||||
import com.admin.common.dto.UserTunnelUpdateDto;
|
||||
@@ -78,6 +81,13 @@ public class TunnelController extends BaseController {
|
||||
public R assignUserTunnel(@Validated @RequestBody UserTunnelDto 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新隧道排序
|
||||
* @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
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class Node extends BaseEntity {
|
||||
public class Node extends BaseEntity {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private String name;
|
||||
|
||||
private String secret;
|
||||
|
||||
private String serverIp;
|
||||
private String secret;
|
||||
|
||||
private String serverIp;
|
||||
|
||||
private String serverIpV4;
|
||||
|
||||
private String serverIpV6;
|
||||
|
||||
private String version;
|
||||
|
||||
@@ -38,6 +42,11 @@ public class Node extends BaseEntity {
|
||||
|
||||
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
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName(autoResultMap = true)
|
||||
public class Tunnel extends BaseEntity {
|
||||
public class Tunnel extends BaseEntity {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@@ -32,7 +32,13 @@ public class Tunnel extends BaseEntity {
|
||||
|
||||
private int flow;
|
||||
|
||||
private BigDecimal trafficRatio;
|
||||
|
||||
private String inIp;
|
||||
}
|
||||
private BigDecimal trafficRatio;
|
||||
|
||||
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;
|
||||
|
||||
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.ForwardUpdateDto;
|
||||
import com.admin.common.lang.R;
|
||||
@@ -80,4 +83,14 @@ public interface ForwardService extends IService<Forward> {
|
||||
* @return 更新结果
|
||||
*/
|
||||
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;
|
||||
|
||||
import com.admin.common.dto.NodeDto;
|
||||
import com.admin.common.dto.NodeUpdateDto;
|
||||
import com.admin.common.lang.R;
|
||||
import com.admin.entity.Node;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
package com.admin.service;
|
||||
|
||||
import com.admin.common.dto.BatchDeleteDto;
|
||||
import com.admin.common.dto.NodeDto;
|
||||
import com.admin.common.dto.NodeUpdateDto;
|
||||
import com.admin.common.lang.R;
|
||||
import com.admin.entity.Node;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
@@ -14,7 +17,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
|
||||
* @author QAQ
|
||||
* @since 2025-06-03
|
||||
*/
|
||||
public interface NodeService extends IService<Node> {
|
||||
public interface NodeService extends IService<Node> {
|
||||
|
||||
R createNode(NodeDto nodeDto);
|
||||
|
||||
@@ -24,6 +27,14 @@ public interface NodeService extends IService<Node> {
|
||||
|
||||
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;
|
||||
|
||||
import com.admin.common.dto.TunnelDto;
|
||||
import com.admin.common.dto.TunnelUpdateDto;
|
||||
|
||||
import com.admin.common.lang.R;
|
||||
import com.admin.entity.Tunnel;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
package com.admin.service;
|
||||
|
||||
import com.admin.common.dto.BatchDeleteDto;
|
||||
import com.admin.common.dto.BatchRedeployDto;
|
||||
import com.admin.common.dto.TunnelDto;
|
||||
import com.admin.common.dto.TunnelUpdateDto;
|
||||
|
||||
import com.admin.common.lang.R;
|
||||
import com.admin.entity.Tunnel;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
@@ -15,7 +19,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
|
||||
* @author QAQ
|
||||
* @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
|
||||
* @return 诊断结果
|
||||
*/
|
||||
R diagnoseTunnel(Long tunnelId);
|
||||
|
||||
Integer getNodePort(Long nodeId, Integer type, Integer port);
|
||||
}
|
||||
R diagnoseTunnel(Long tunnelId);
|
||||
|
||||
/**
|
||||
* 更新隧道排序(管理员)
|
||||
* @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;
|
||||
|
||||
import com.admin.common.dto.UserTunnelDto;
|
||||
import com.admin.common.dto.UserTunnelQueryDto;
|
||||
import com.admin.common.dto.UserTunnelUpdateDto;
|
||||
import com.admin.common.lang.R;
|
||||
import com.admin.entity.UserTunnel;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* 用户隧道权限服务类
|
||||
* </p>
|
||||
*
|
||||
* @author QAQ
|
||||
* @since 2025-06-03
|
||||
*/
|
||||
public interface UserTunnelService extends IService<UserTunnel> {
|
||||
|
||||
/**
|
||||
* 分配用户隧道权限
|
||||
* @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);
|
||||
|
||||
}
|
||||
package com.admin.service;
|
||||
|
||||
import com.admin.common.dto.UserTunnelBatchAssignDto;
|
||||
import com.admin.common.dto.UserTunnelDto;
|
||||
import com.admin.common.dto.UserTunnelQueryDto;
|
||||
import com.admin.common.dto.UserTunnelUpdateDto;
|
||||
import com.admin.common.lang.R;
|
||||
import com.admin.entity.UserTunnel;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
|
||||
public interface UserTunnelService extends IService<UserTunnel> {
|
||||
|
||||
R assignUserTunnel(UserTunnelDto userTunnelDto);
|
||||
|
||||
R batchAssignUserTunnel(UserTunnelBatchAssignDto batchAssignDto);
|
||||
|
||||
R getUserTunnelList(UserTunnelQueryDto queryDto);
|
||||
|
||||
R removeUserTunnel(Integer id);
|
||||
|
||||
R updateUserTunnel(UserTunnelUpdateDto updateDto);
|
||||
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,633 @@
|
||||
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 org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
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
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
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
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
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
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
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);
|
||||
Long userTunnelId = resolveUserTunnelId(userTunnel, userId, tunnelId);
|
||||
pairUserTunnelMap.put(pairKey, userTunnel);
|
||||
createGrant(userGroupId, tunnelGroupId, userTunnelId, true, now);
|
||||
currentGrantUserTunnelIds.add(userTunnelId);
|
||||
totalGrantCountMap.put(userTunnelId, 1L);
|
||||
groupManagedUserTunnelIds.add(userTunnelId);
|
||||
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());
|
||||
boolean saved = userTunnelService.save(userTunnel);
|
||||
if (!saved) {
|
||||
throw new IllegalStateException("创建用户隧道权限失败: userId=" + userId + ", tunnelId=" + tunnelId);
|
||||
}
|
||||
return userTunnel;
|
||||
}
|
||||
|
||||
private Long resolveUserTunnelId(UserTunnel userTunnel, Long userId, Long tunnelId) {
|
||||
if (userTunnel.getId() != null) {
|
||||
return userTunnel.getId().longValue();
|
||||
}
|
||||
|
||||
UserTunnel persisted = userTunnelService.getOne(
|
||||
new QueryWrapper<UserTunnel>()
|
||||
.eq("user_id", userId.intValue())
|
||||
.eq("tunnel_id", tunnelId.intValue())
|
||||
.orderByDesc("id")
|
||||
.last("LIMIT 1")
|
||||
);
|
||||
if (persisted == null || persisted.getId() == null) {
|
||||
throw new IllegalStateException("获取用户隧道权限ID失败: userId=" + userId + ", tunnelId=" + tunnelId);
|
||||
}
|
||||
userTunnel.setId(persisted.getId());
|
||||
return persisted.getId().longValue();
|
||||
}
|
||||
|
||||
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 new HashSet<>();
|
||||
}
|
||||
|
||||
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.StrUtil;
|
||||
import com.admin.common.dto.GostDto;
|
||||
import com.admin.common.dto.NodeDto;
|
||||
import com.admin.common.dto.NodeUpdateDto;
|
||||
import com.admin.common.dto.BatchDeleteDto;
|
||||
import com.admin.common.dto.BatchOperationResultDto;
|
||||
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.utils.GostUtil;
|
||||
import com.admin.common.utils.WebSocketServer;
|
||||
import com.admin.entity.*;
|
||||
import com.admin.mapper.NodeMapper;
|
||||
import com.admin.mapper.TunnelMapper;
|
||||
import com.admin.service.*;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.admin.mapper.NodeMapper;
|
||||
import com.admin.mapper.TunnelMapper;
|
||||
import com.admin.service.*;
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
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 java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
|
||||
@Service
|
||||
public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements NodeService {
|
||||
@Service
|
||||
@Slf4j
|
||||
public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements NodeService {
|
||||
|
||||
|
||||
@Resource
|
||||
@@ -37,33 +49,71 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
||||
@Resource
|
||||
ViteConfigService viteConfigService;
|
||||
|
||||
@Resource
|
||||
ChainTunnelService chainTunnelService;
|
||||
@Resource
|
||||
ChainTunnelService chainTunnelService;
|
||||
|
||||
@Resource
|
||||
ForwardPortService forwardPortService;
|
||||
|
||||
|
||||
@Override
|
||||
public R createNode(NodeDto nodeDto) {
|
||||
validatePortRange(nodeDto.getPort());
|
||||
Node node = new Node();
|
||||
node.setSecret(IdUtil.simpleUUID());
|
||||
node.setStatus(0);
|
||||
node.setPort(nodeDto.getPort());
|
||||
node.setName(nodeDto.getName());
|
||||
node.setServerIp(nodeDto.getServerIp());
|
||||
long currentTime = System.currentTimeMillis();
|
||||
node.setCreatedTime(currentTime);
|
||||
node.setUpdatedTime(currentTime);
|
||||
node.setInterfaceName(nodeDto.getInterfaceName());
|
||||
this.save(node);
|
||||
return R.ok();
|
||||
}
|
||||
public R createNode(NodeDto nodeDto) {
|
||||
validatePortRange(nodeDto.getPort());
|
||||
|
||||
String normalizedV4 = normalizeV4(nodeDto.getServerIpV4(), nodeDto.getServerIp());
|
||||
String normalizedV6 = normalizeV6(nodeDto.getServerIpV6(), nodeDto.getServerIp());
|
||||
String primaryServerIp = pickPrimaryServerIp(normalizedV4, normalizedV6, nodeDto.getServerIp());
|
||||
|
||||
Node node = new Node();
|
||||
node.setSecret(IdUtil.simpleUUID());
|
||||
node.setStatus(0);
|
||||
node.setPort(nodeDto.getPort());
|
||||
node.setName(nodeDto.getName());
|
||||
node.setServerIp(primaryServerIp);
|
||||
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
|
||||
public R getAllNodes() {
|
||||
List<Node> nodeList = this.list(new QueryWrapper<Node>().orderByDesc("status"));
|
||||
nodeList.forEach(node -> node.setSecret(null));
|
||||
return R.ok(nodeList);
|
||||
}
|
||||
@Override
|
||||
public R getAllNodes() {
|
||||
List<Node> nodeList = this.list(new QueryWrapper<Node>().orderByAsc("inx").orderByAsc("id"));
|
||||
nodeList.forEach(node -> node.setSecret(null));
|
||||
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
|
||||
public R updateNode(NodeUpdateDto nodeUpdateDto) {
|
||||
@@ -95,25 +145,253 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
||||
|
||||
|
||||
|
||||
Node updateNode = buildUpdateNode(nodeUpdateDto);
|
||||
this.updateById(updateNode);
|
||||
return R.ok();
|
||||
}
|
||||
Node updateNode = buildUpdateNode(nodeUpdateDto);
|
||||
// Use LambdaUpdateWrapper to explicitly set nullable fields (serverIpV4/V6)
|
||||
// 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
|
||||
public R deleteNode(Long id) {
|
||||
Node node = this.getById(id);
|
||||
if (node == null) {
|
||||
return R.err("节点不存在");
|
||||
}
|
||||
|
||||
List<ChainTunnel> list = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("node_id", id).groupBy("tunnel_id"));
|
||||
for (ChainTunnel tunnel : list) {
|
||||
tunnelService.deleteTunnel(tunnel.getTunnelId());
|
||||
}
|
||||
this.removeById(id);
|
||||
return R.ok();
|
||||
}
|
||||
@Override
|
||||
public R deleteNode(Long id) {
|
||||
Node node = this.getById(id);
|
||||
if (node == null) {
|
||||
return R.err("节点不存在");
|
||||
}
|
||||
|
||||
List<ChainTunnel> affected = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("node_id", id));
|
||||
Map<Long, List<ChainTunnel>> byTunnelId = affected.stream()
|
||||
.filter(ct -> ct.getTunnelId() != null)
|
||||
.collect(Collectors.groupingBy(ChainTunnel::getTunnelId));
|
||||
|
||||
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
|
||||
@@ -125,9 +403,9 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
||||
ViteConfig viteConfig = viteConfigService.getOne(new QueryWrapper<ViteConfig>().eq("name", "ip"));
|
||||
if (viteConfig == null) return R.err("请先前往网站配置中设置ip");
|
||||
StringBuilder command = new StringBuilder();
|
||||
command.append("curl -L https://github.com/bqlpfy/flux-panel/releases/download/2.0.2-beta/install.sh")
|
||||
.append(" -o ./install.sh && chmod +x ./install.sh && ");
|
||||
String processedServerAddr = processServerAddress(viteConfig.getValue());
|
||||
command.append("curl -L https://github.com/Sagit-chu/flux-panel/releases/latest/download/install.sh")
|
||||
.append(" -o ./install.sh && chmod +x ./install.sh && ");
|
||||
String processedServerAddr = GostUtil.processServerAddress(viteConfig.getValue());
|
||||
command.append("./install.sh")
|
||||
.append(" -a ").append(processedServerAddr) // 服务器地址
|
||||
.append(" -s ").append(node.getSecret()); // 节点密钥
|
||||
@@ -136,90 +414,122 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
|
||||
}
|
||||
|
||||
|
||||
private Node buildUpdateNode(NodeUpdateDto nodeUpdateDto) {
|
||||
validatePortRange(nodeUpdateDto.getPort());
|
||||
Node node = new Node();
|
||||
node.setId(nodeUpdateDto.getId());
|
||||
node.setName(nodeUpdateDto.getName());
|
||||
node.setServerIp(nodeUpdateDto.getServerIp());
|
||||
node.setPort(nodeUpdateDto.getPort());
|
||||
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 Node buildUpdateNode(NodeUpdateDto nodeUpdateDto) {
|
||||
validatePortRange(nodeUpdateDto.getPort());
|
||||
|
||||
String normalizedV4 = normalizeV4(nodeUpdateDto.getServerIpV4(), nodeUpdateDto.getServerIp());
|
||||
String normalizedV6 = normalizeV6(nodeUpdateDto.getServerIpV6(), nodeUpdateDto.getServerIp());
|
||||
String primaryServerIp = pickPrimaryServerIp(normalizedV4, normalizedV6, nodeUpdateDto.getServerIp());
|
||||
|
||||
Node node = new Node();
|
||||
node.setId(nodeUpdateDto.getId());
|
||||
node.setName(nodeUpdateDto.getName());
|
||||
node.setServerIp(primaryServerIp);
|
||||
node.setServerIpV4(normalizedV4);
|
||||
node.setServerIpV6(normalizedV6);
|
||||
node.setPort(nodeUpdateDto.getPort());
|
||||
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) {
|
||||
Pattern PORT_PATTERN = Pattern.compile( "([0-9]{1,5})(-([0-9]{1,5}))?");
|
||||
if (port == null || port.isEmpty()) {
|
||||
throw new RuntimeException("可用端口不合法");
|
||||
}
|
||||
String[] parts = port.split(",");
|
||||
for (String part : parts) {
|
||||
part = part.trim();
|
||||
if (!PORT_PATTERN.matcher(part).matches()) {
|
||||
throw new RuntimeException("可用端口不合法");
|
||||
}
|
||||
if (part.contains("-")) {
|
||||
String[] range = part.split("-");
|
||||
int start = Integer.parseInt(range[0]);
|
||||
int end = Integer.parseInt(range[1]);
|
||||
if (start < 0 || end < 0 || end > 65535 || start > end) {
|
||||
throw new RuntimeException("可用端口不合法");
|
||||
}
|
||||
} else {
|
||||
int ports = Integer.parseInt(part);
|
||||
if (ports < 0 || ports > 65535) {
|
||||
throw new RuntimeException("可用端口不合法");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private 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 boolean isIPv6Address(String address) {
|
||||
// IPv6地址包含多个冒号,至少2个
|
||||
if (!address.contains(":")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 计算冒号数量,IPv6地址至少有2个冒号
|
||||
long colonCount = address.chars().filter(ch -> ch == ':').count();
|
||||
return colonCount >= 2;
|
||||
}
|
||||
|
||||
}
|
||||
private void validatePortRange(String port) {
|
||||
Pattern PORT_PATTERN = Pattern.compile( "([0-9]{1,5})(-([0-9]{1,5}))?");
|
||||
if (port == null || port.isEmpty()) {
|
||||
throw new RuntimeException("可用端口不合法");
|
||||
}
|
||||
String[] parts = port.split(",");
|
||||
for (String part : parts) {
|
||||
part = part.trim();
|
||||
if (!PORT_PATTERN.matcher(part).matches()) {
|
||||
throw new RuntimeException("可用端口不合法");
|
||||
}
|
||||
if (part.contains("-")) {
|
||||
String[] range = part.split("-");
|
||||
int start = Integer.parseInt(range[0]);
|
||||
int end = Integer.parseInt(range[1]);
|
||||
if (start < 0 || end < 0 || end > 65535 || start > end) {
|
||||
throw new RuntimeException("可用端口不合法");
|
||||
}
|
||||
} else {
|
||||
int ports = Integer.parseInt(part);
|
||||
if (ports < 0 || ports > 65535) {
|
||||
throw new RuntimeException("可用端口不合法");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public R batchDeleteNodes(BatchDeleteDto batchDeleteDto) {
|
||||
BatchOperationResultDto result = new BatchOperationResultDto();
|
||||
|
||||
for (Long id : batchDeleteDto.getIds()) {
|
||||
try {
|
||||
R deleteResult = deleteNode(id);
|
||||
if (deleteResult.getCode() == 0) {
|
||||
result.incrementSuccess();
|
||||
} else {
|
||||
result.addFailedItem(id, deleteResult.getMsg());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
result.addFailedItem(id, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return R.ok(result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.admin.common.utils.GostUtil;
|
||||
import com.admin.entity.*;
|
||||
import com.admin.mapper.SpeedLimitMapper;
|
||||
import com.admin.service.*;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import lombok.Data;
|
||||
@@ -19,6 +20,7 @@ import org.springframework.stereotype.Service;
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
@@ -65,11 +67,23 @@ public class SpeedLimitServiceImpl extends ServiceImpl<SpeedLimitMapper, SpeedLi
|
||||
|
||||
String speedInMBps = convertBitsToMBps(speedLimit.getSpeed());
|
||||
|
||||
List<Long> limit_success = new ArrayList<>();
|
||||
|
||||
List<ChainTunnel> tunnelList = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", speedLimit.getTunnelId()));
|
||||
for (ChainTunnel chainTunnel : tunnelList) {
|
||||
Node node = nodeService.getById(chainTunnel.getNodeId());
|
||||
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();
|
||||
@@ -94,7 +108,8 @@ public class SpeedLimitServiceImpl extends ServiceImpl<SpeedLimitMapper, SpeedLi
|
||||
for (ChainTunnel chainTunnel : tunnelList) {
|
||||
Node node = nodeService.getById(chainTunnel.getNodeId());
|
||||
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);
|
||||
@@ -116,7 +131,8 @@ public class SpeedLimitServiceImpl extends ServiceImpl<SpeedLimitMapper, SpeedLi
|
||||
for (ChainTunnel chainTunnel : tunnelList) {
|
||||
Node node = nodeService.getById(chainTunnel.getNodeId());
|
||||
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);
|
||||
|
||||
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.entity.*;
|
||||
import com.admin.mapper.UserMapper;
|
||||
import com.admin.mapper.GroupPermissionGrantMapper;
|
||||
import com.admin.mapper.UserGroupUserMapper;
|
||||
import com.admin.service.*;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
@@ -56,6 +58,12 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
@Resource
|
||||
StatisticsFlowService statisticsFlowService;
|
||||
|
||||
@Resource
|
||||
GroupPermissionGrantMapper groupPermissionGrantMapper;
|
||||
|
||||
@Resource
|
||||
UserGroupUserMapper userGroupUserMapper;
|
||||
|
||||
@Resource
|
||||
@Lazy
|
||||
ForwardPortService forwardPortService;
|
||||
@@ -140,8 +148,16 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
for (Forward forward : forwardList) {
|
||||
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));
|
||||
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));
|
||||
this.removeById(id);
|
||||
return R.ok();
|
||||
@@ -264,44 +280,61 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
for (UserPackageDto.UserForwardDetailDto forward : forwards) {
|
||||
Tunnel tunnel = tunnelService.getById(forward.getTunnelId());
|
||||
if (tunnel == null) continue;
|
||||
|
||||
List<ForwardPort> forwardPorts = forwardPortService.list(
|
||||
new QueryWrapper<ForwardPort>().eq("forward_id", forward.getId())
|
||||
);
|
||||
if (forwardPorts.isEmpty()) continue;
|
||||
|
||||
boolean useTunnelInIp = tunnel.getInIp() != null && !tunnel.getInIp().trim().isEmpty();
|
||||
List<String> ipList = new ArrayList<>();
|
||||
List<Integer> portList = new ArrayList<>();
|
||||
java.util.Set<String> ipPortSet = new java.util.LinkedHashSet<>();
|
||||
|
||||
if (useTunnelInIp) {
|
||||
// 使用隧道的inIp(求笛卡尔积)
|
||||
List<String> ipList = new ArrayList<>();
|
||||
List<Integer> portList = new ArrayList<>();
|
||||
|
||||
String[] tunnelInIps = tunnel.getInIp().split(",");
|
||||
for (String ip : tunnelInIps) {
|
||||
if (ip != null && !ip.trim().isEmpty()) {
|
||||
ipList.add(ip.trim());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
for (ForwardPort forwardPort : forwardPorts) {
|
||||
Node node = nodeService.getById(forwardPort.getNodeId());
|
||||
if (node != null && node.getServerIp() != null) {
|
||||
ipList.add(node.getServerIp());
|
||||
if (forwardPort.getPort() != null) {
|
||||
portList.add(forwardPort.getPort());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (ForwardPort forwardPort : forwardPorts) {
|
||||
if (forwardPort.getPort() != null) {
|
||||
portList.add(forwardPort.getPort());
|
||||
}
|
||||
}
|
||||
List<String> uniqueIps = ipList.stream().distinct().toList();
|
||||
List<Integer> uniquePorts = portList.stream().distinct().toList();
|
||||
java.util.Set<String> ipPortSet = new java.util.LinkedHashSet<>();
|
||||
for (String ip : uniqueIps) {
|
||||
for (Integer port : uniquePorts) {
|
||||
ipPortSet.add(ip + ":" + port);
|
||||
|
||||
List<String> uniqueIps = ipList.stream().distinct().toList();
|
||||
List<Integer> uniquePorts = portList.stream().distinct().toList();
|
||||
|
||||
for (String ip : uniqueIps) {
|
||||
for (Integer port : uniquePorts) {
|
||||
ipPortSet.add(ip + ":" + port);
|
||||
}
|
||||
}
|
||||
|
||||
if (!uniquePorts.isEmpty()) {
|
||||
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()) {
|
||||
forward.setInIp(String.join(",", ipPortSet));
|
||||
forward.setInPort(uniquePorts.getFirst());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+193
-120
@@ -1,120 +1,193 @@
|
||||
package com.admin.service.impl;
|
||||
|
||||
import com.admin.common.dto.*;
|
||||
import com.admin.common.lang.R;
|
||||
import com.admin.entity.UserTunnel;
|
||||
import com.admin.mapper.TunnelMapper;
|
||||
import com.admin.mapper.UserTunnelMapper;
|
||||
import com.admin.service.TunnelService;
|
||||
import com.admin.service.UserTunnelService;
|
||||
import com.admin.service.ForwardService;
|
||||
import com.admin.service.NodeService;
|
||||
import com.admin.common.utils.GostUtil;
|
||||
import com.admin.entity.Forward;
|
||||
import com.admin.entity.Tunnel;
|
||||
import com.admin.entity.Node;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* 用户隧道权限服务实现类
|
||||
* 提供用户隧道权限的分配、查询、更新和删除功能
|
||||
* 支持流量限制、数量限制、过期时间和限速规则的管理
|
||||
* </p>
|
||||
*
|
||||
* @author QAQ
|
||||
* @since 2025-06-03
|
||||
*/
|
||||
@Service
|
||||
public class UserTunnelServiceImpl extends ServiceImpl<UserTunnelMapper, UserTunnel> implements UserTunnelService {
|
||||
|
||||
|
||||
@Resource
|
||||
@Lazy
|
||||
private ForwardService forwardService;
|
||||
|
||||
@Override
|
||||
public R assignUserTunnel(UserTunnelDto userTunnelDto) {
|
||||
int count = this.count(new QueryWrapper<UserTunnel>().eq("user_id", userTunnelDto.getUserId()).eq("tunnel_id", userTunnelDto.getTunnelId()));
|
||||
if (count > 0) return R.err("该用户已拥有此隧道权限");
|
||||
UserTunnel userTunnel = new UserTunnel();
|
||||
BeanUtils.copyProperties(userTunnelDto, userTunnel);
|
||||
userTunnel.setStatus(1);
|
||||
this.save(userTunnel);
|
||||
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("未找到对应的用户隧道权限记录");
|
||||
|
||||
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.err("用户隧道权限更新失败");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
package com.admin.service.impl;
|
||||
|
||||
import com.admin.common.dto.*;
|
||||
import com.admin.common.lang.R;
|
||||
import com.admin.entity.GroupPermissionGrant;
|
||||
import com.admin.entity.User;
|
||||
import com.admin.entity.UserTunnel;
|
||||
import com.admin.mapper.GroupPermissionGrantMapper;
|
||||
import com.admin.mapper.UserTunnelMapper;
|
||||
import com.admin.service.UserService;
|
||||
import com.admin.service.UserTunnelService;
|
||||
import com.admin.service.ForwardService;
|
||||
import com.admin.entity.Forward;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class UserTunnelServiceImpl extends ServiceImpl<UserTunnelMapper, UserTunnel> implements UserTunnelService {
|
||||
|
||||
@Resource
|
||||
@Lazy
|
||||
private ForwardService forwardService;
|
||||
|
||||
@Resource
|
||||
@Lazy
|
||||
private UserService userService;
|
||||
|
||||
@Resource
|
||||
private GroupPermissionGrantMapper groupPermissionGrantMapper;
|
||||
|
||||
@Override
|
||||
public R assignUserTunnel(UserTunnelDto userTunnelDto) {
|
||||
int count = this.count(new QueryWrapper<UserTunnel>()
|
||||
.eq("user_id", userTunnelDto.getUserId())
|
||||
.eq("tunnel_id", userTunnelDto.getTunnelId()));
|
||||
if (count > 0) return R.err("该用户已拥有此隧道权限");
|
||||
|
||||
User user = userService.getById(userTunnelDto.getUserId());
|
||||
if (user == null) return R.err("用户不存在");
|
||||
|
||||
UserTunnel userTunnel = new UserTunnel();
|
||||
userTunnel.setUserId(userTunnelDto.getUserId());
|
||||
userTunnel.setTunnelId(userTunnelDto.getTunnelId());
|
||||
userTunnel.setSpeedId(userTunnelDto.getSpeedId());
|
||||
userTunnel.setStatus(1);
|
||||
userTunnel.setInFlow(0L);
|
||||
userTunnel.setOutFlow(0L);
|
||||
|
||||
userTunnel.setFlow(userTunnelDto.getFlow() != null ? userTunnelDto.getFlow() : user.getFlow());
|
||||
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());
|
||||
|
||||
this.save(userTunnel);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@Override
|
||||
public R batchAssignUserTunnel(UserTunnelBatchAssignDto batchAssignDto) {
|
||||
User user = userService.getById(batchAssignDto.getUserId());
|
||||
if (user == null) return R.err("用户不存在");
|
||||
|
||||
Map<Integer, UserTunnelBatchAssignDto.TunnelAssignItem> uniqueTunnels = new LinkedHashMap<>();
|
||||
for (UserTunnelBatchAssignDto.TunnelAssignItem item : batchAssignDto.getTunnels()) {
|
||||
uniqueTunnels.putIfAbsent(item.getTunnelId(), item);
|
||||
}
|
||||
|
||||
Set<Integer> existingTunnelIds = this.list(
|
||||
new QueryWrapper<UserTunnel>()
|
||||
.eq("user_id", batchAssignDto.getUserId())
|
||||
.in("tunnel_id", uniqueTunnels.keySet())
|
||||
).stream().map(UserTunnel::getTunnelId).collect(Collectors.toSet());
|
||||
|
||||
List<UserTunnel> toSave = new ArrayList<>();
|
||||
List<Integer> skippedIds = new ArrayList<>();
|
||||
|
||||
for (UserTunnelBatchAssignDto.TunnelAssignItem item : uniqueTunnels.values()) {
|
||||
if (existingTunnelIds.contains(item.getTunnelId())) {
|
||||
skippedIds.add(item.getTunnelId());
|
||||
continue;
|
||||
}
|
||||
|
||||
UserTunnel ut = new UserTunnel();
|
||||
ut.setUserId(batchAssignDto.getUserId());
|
||||
ut.setTunnelId(item.getTunnelId());
|
||||
ut.setSpeedId(item.getSpeedId());
|
||||
ut.setStatus(1);
|
||||
ut.setInFlow(0L);
|
||||
ut.setOutFlow(0L);
|
||||
ut.setFlow(user.getFlow());
|
||||
ut.setNum(user.getNum());
|
||||
ut.setFlowResetTime(user.getFlowResetTime());
|
||||
ut.setExpTime(user.getExpTime());
|
||||
toSave.add(ut);
|
||||
}
|
||||
|
||||
if (toSave.isEmpty()) {
|
||||
return R.err("所选隧道用户均已拥有权限");
|
||||
}
|
||||
|
||||
this.saveBatch(toSave);
|
||||
|
||||
if (!skippedIds.isEmpty()) {
|
||||
return R.ok("成功分配 " + toSave.size() + " 个隧道,跳过 " + skippedIds.size() + " 个已有权限的隧道");
|
||||
}
|
||||
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,
|
||||
secret VARCHAR(100) NOT NULL,
|
||||
server_ip VARCHAR(100) NOT NULL,
|
||||
server_ip_v4 VARCHAR(100),
|
||||
server_ip_v6 VARCHAR(100),
|
||||
port TEXT NOT NULL,
|
||||
interface_name VARCHAR(200),
|
||||
version VARCHAR(100),
|
||||
@@ -39,7 +41,8 @@ CREATE TABLE IF NOT EXISTS node (
|
||||
updated_time INTEGER,
|
||||
status INTEGER NOT NULL,
|
||||
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 (
|
||||
@@ -72,7 +75,8 @@ CREATE TABLE IF NOT EXISTS tunnel (
|
||||
created_time INTEGER NOT NULL,
|
||||
updated_time 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 (
|
||||
@@ -117,10 +121,62 @@ CREATE TABLE IF NOT EXISTS user_tunnel (
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(200) NOT NULL UNIQUE,
|
||||
value VARCHAR(200) 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
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"@react-aria/visually-hidden": "3.8.25",
|
||||
"@react-types/shared": "3.30.0",
|
||||
"@tailwindcss/postcss": "4.1.11",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/react-beautiful-dnd": "^13.1.8",
|
||||
"axios": "^1.11.0",
|
||||
"clsx": "2.1.1",
|
||||
@@ -54,7 +54,8 @@
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-dom": "18.3.1",
|
||||
"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",
|
||||
"sonner": "^2.0.6",
|
||||
"tailwind-variants": "1.0.0",
|
||||
@@ -63,14 +64,14 @@
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "1.2.8",
|
||||
"@eslint/eslintrc": "3.3.1",
|
||||
"@eslint/js": "9.25.1",
|
||||
"@eslint/js": "9.39.2",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.31.1",
|
||||
"@typescript-eslint/parser": "8.31.1",
|
||||
"@vitejs/plugin-react": "4.4.1",
|
||||
"eslint": "9.25.1",
|
||||
"@vitejs/plugin-react": "^5.1.3",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
@@ -83,7 +84,7 @@
|
||||
"postcss": "8.5.6",
|
||||
"prettier": "3.5.3",
|
||||
"typescript": "5.6.3",
|
||||
"vite": "5.4.11",
|
||||
"vite-tsconfig-paths": "4.3.2"
|
||||
"vite": "npm:rolldown-vite@^7.3.1",
|
||||
"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 NodePage from "@/pages/node";
|
||||
import UserPage from "@/pages/user";
|
||||
import GroupPage from "@/pages/group";
|
||||
import ProfilePage from "@/pages/profile";
|
||||
import LimitPage from "@/pages/limit";
|
||||
import ConfigPage from "@/pages/config";
|
||||
import { SettingsPage } from "@/pages/settings";
|
||||
|
||||
import AdminLayout from "@/layouts/admin";
|
||||
import H5Layout from "@/layouts/h5";
|
||||
import H5SimpleLayout from "@/layouts/h5-simple";
|
||||
|
||||
import { isLoggedIn } from "@/utils/auth";
|
||||
import { siteConfig } from "@/config/site";
|
||||
|
||||
@@ -27,11 +26,14 @@ const useH5Mode = () => {
|
||||
// 检测移动设备或小屏幕
|
||||
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模式
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isH5Param = urlParams.get('h5') === 'true';
|
||||
|
||||
const isH5Param = urlParams.get("h5") === "true";
|
||||
|
||||
return isMobile || isMobileBrowser || isH5Param;
|
||||
};
|
||||
|
||||
@@ -42,39 +44,50 @@ const useH5Mode = () => {
|
||||
// 检测移动设备或小屏幕
|
||||
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模式
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isH5Param = urlParams.get('h5') === 'true';
|
||||
|
||||
const isH5Param = urlParams.get("h5") === "true";
|
||||
|
||||
setIsH5(isMobile || isMobileBrowser || isH5Param);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', checkH5Mode);
|
||||
|
||||
return () => window.removeEventListener('resize', checkH5Mode);
|
||||
window.addEventListener("resize", checkH5Mode);
|
||||
|
||||
return () => window.removeEventListener("resize", checkH5Mode);
|
||||
}, []);
|
||||
|
||||
return isH5;
|
||||
};
|
||||
|
||||
// 简化的路由保护组件 - 使用 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 isH5 = useH5Mode();
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!authenticated) {
|
||||
// 使用 React Router 导航,避免无限跳转
|
||||
navigate('/', { replace: true });
|
||||
navigate("/", { replace: true });
|
||||
}
|
||||
}, [authenticated, navigate]);
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -86,6 +99,7 @@ const ProtectedRoute = ({ children, useSimpleLayout = false, skipLayout = false
|
||||
|
||||
// 根据模式和页面类型选择布局
|
||||
let Layout;
|
||||
|
||||
if (isH5 && useSimpleLayout) {
|
||||
Layout = H5SimpleLayout;
|
||||
} else if (isH5) {
|
||||
@@ -93,31 +107,30 @@ const ProtectedRoute = ({ children, useSimpleLayout = false, skipLayout = false
|
||||
} else {
|
||||
Layout = AdminLayout;
|
||||
}
|
||||
|
||||
|
||||
return <Layout>{children}</Layout>;
|
||||
};
|
||||
|
||||
|
||||
// 登录页面路由组件 - 已登录则重定向到dashboard
|
||||
const LoginRoute = () => {
|
||||
const authenticated = isLoggedIn();
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (authenticated) {
|
||||
// 使用 React Router 导航,避免无限跳转
|
||||
navigate('/dashboard', { replace: true });
|
||||
navigate("/dashboard", { replace: true });
|
||||
}
|
||||
}, [authenticated, navigate]);
|
||||
|
||||
|
||||
if (authenticated) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return <IndexPage />;
|
||||
};
|
||||
|
||||
@@ -125,19 +138,18 @@ function App() {
|
||||
// 立即设置页面标题(使用已从缓存读取的配置)
|
||||
useEffect(() => {
|
||||
document.title = siteConfig.name;
|
||||
|
||||
|
||||
// 异步检查是否有配置更新
|
||||
const checkTitleUpdate = async () => {
|
||||
try {
|
||||
// 引入必要的函数
|
||||
const { getCachedConfig } = await import('@/config/site');
|
||||
const cachedAppName = await getCachedConfig('app_name');
|
||||
const { getCachedConfig } = await import("@/config/site");
|
||||
const cachedAppName = await getCachedConfig("app_name");
|
||||
|
||||
if (cachedAppName && cachedAppName !== document.title) {
|
||||
document.title = cachedAppName;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('检查标题更新失败:', error);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// 延迟检查,避免阻塞初始渲染
|
||||
@@ -148,83 +160,88 @@ function App() {
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<LoginRoute />} />
|
||||
<Route
|
||||
path="/change-password"
|
||||
<Route element={<LoginRoute />} path="/" />
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute skipLayout={true}>
|
||||
<ChangePasswordPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
}
|
||||
path="/change-password"
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DashboardPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
}
|
||||
path="/dashboard"
|
||||
/>
|
||||
<Route
|
||||
path="/forward"
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ForwardPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
}
|
||||
path="/forward"
|
||||
/>
|
||||
<Route
|
||||
path="/tunnel"
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<TunnelPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
}
|
||||
path="/tunnel"
|
||||
/>
|
||||
<Route
|
||||
path="/node"
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<NodePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
}
|
||||
path="/node"
|
||||
/>
|
||||
<Route
|
||||
path="/user"
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute useSimpleLayout={true}>
|
||||
<UserPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
}
|
||||
path="/user"
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute useSimpleLayout={true}>
|
||||
<GroupPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/group"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProfilePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
}
|
||||
path="/profile"
|
||||
/>
|
||||
<Route
|
||||
path="/limit"
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute useSimpleLayout={true}>
|
||||
<LimitPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
}
|
||||
path="/limit"
|
||||
/>
|
||||
<Route
|
||||
path="/config"
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute useSimpleLayout={true}>
|
||||
<ConfigPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<SettingsPage />}
|
||||
}
|
||||
path="/config"
|
||||
/>
|
||||
<Route element={<SettingsPage />} path="/settings" />
|
||||
</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 {
|
||||
@@ -14,11 +14,13 @@ export interface LoginResponse {
|
||||
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请求
|
||||
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 deleteUser = (id: number) => Network.post("/user/delete", { id });
|
||||
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 updateNode = (data: any) => Network.post("/node/update", data);
|
||||
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) => {
|
||||
const params = nodeId ? { nodeId } : {};
|
||||
|
||||
return Network.post("/node/check-status", params);
|
||||
};
|
||||
|
||||
// 隧道CRUD操作 - 全部使用POST请求
|
||||
export const createTunnel = (data: any) => Network.post("/tunnel/create", data);
|
||||
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 deleteTunnel = (id: number) => Network.post("/tunnel/delete", { id });
|
||||
export const diagnoseTunnel = (tunnelId: number) => Network.post("/tunnel/diagnose", { tunnelId });
|
||||
export const deleteTunnel = (id: number) =>
|
||||
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请求
|
||||
export const assignUserTunnel = (data: any) => Network.post("/tunnel/user/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 assignUserTunnel = (data: any) =>
|
||||
Network.post("/tunnel/user/assign", data);
|
||||
export const batchAssignUserTunnel = (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");
|
||||
|
||||
// 转发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 updateForward = (data: any) => Network.post("/forward/update", data);
|
||||
export const deleteForward = (id: number) => Network.post("/forward/delete", { id });
|
||||
export const forceDeleteForward = (id: number) => Network.post("/forward/force-delete", { id });
|
||||
export const updateForward = (data: any) =>
|
||||
Network.post("/forward/update", data);
|
||||
export const deleteForward = (id: number) =>
|
||||
Network.post("/forward/delete", { id });
|
||||
export const forceDeleteForward = (id: number) =>
|
||||
Network.post("/forward/force-delete", { id });
|
||||
|
||||
// 转发服务控制操作 - 通过Java后端接口
|
||||
export const pauseForwardService = (forwardId: number) => Network.post("/forward/pause", { id: forwardId });
|
||||
export const resumeForwardService = (forwardId: number) => Network.post("/forward/resume", { id: forwardId });
|
||||
export const pauseForwardService = (forwardId: number) =>
|
||||
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请求
|
||||
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 updateSpeedLimit = (data: any) => Network.post("/speed-limit/update", data);
|
||||
export const deleteSpeedLimit = (id: number) => Network.post("/speed-limit/delete", { id });
|
||||
export const updateSpeedLimit = (data: any) =>
|
||||
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 getConfigByName = (name: string) => Network.post("/config/get", { name });
|
||||
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 getConfigByName = (name: string) =>
|
||||
Network.post("/config/get", { name });
|
||||
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 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