mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 23:56:36 +08:00
Compare commits
104 Commits
2.0.5-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 |
@@ -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 .
|
||||
+219
-127
@@ -1,93 +1,115 @@
|
||||
name: Build and Push Images Based on Version
|
||||
name: Build and Push Images
|
||||
|
||||
# 在这里定义统一版本号
|
||||
env:
|
||||
VERSION: "2.0.5-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 }}
|
||||
tag_exists: ${{ steps.check-tag.outputs.tag_exists }}
|
||||
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, will only update files"
|
||||
echo "should_build=false" >> $GITHUB_OUTPUT
|
||||
echo "tag_exists=true" >> $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 "tag_exists=false" >> $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-amd64
|
||||
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)
|
||||
- name: Compress with UPX
|
||||
working-directory: ./go-gost
|
||||
run: |
|
||||
upx --best --lzma gost-amd64
|
||||
|
||||
- name: Compress with UPX (ARM64)
|
||||
working-directory: ./go-gost
|
||||
run: |
|
||||
upx --best --lzma gost-arm64
|
||||
|
||||
- name: Upload GOST AMD64 artifact
|
||||
@@ -107,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:
|
||||
@@ -139,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') }}
|
||||
@@ -160,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
|
||||
@@ -205,73 +355,15 @@ jobs:
|
||||
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 "## 📝 Commit Information
|
||||
- **Message**: ${COMMIT_MSG}
|
||||
- **Author**: ${COMMIT_AUTHOR}
|
||||
- **Date**: ${COMMIT_DATE}
|
||||
- **Commit**: [\`${GITHUB_SHA:0:7}\`](https://github.com/${{ github.repository }}/commit/${{ github.sha }})" \
|
||||
--repo ${{ github.repository }}
|
||||
|
||||
# 上传所有文件到 release
|
||||
echo "📤 上传 GOST 二进制文件..."
|
||||
|
||||
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}"
|
||||
|
||||
update-release-files:
|
||||
name: Update Release Files Only
|
||||
needs: check-version
|
||||
if: needs.check-version.outputs.tag_exists == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Update Release Files
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
VERSION="${{ needs.check-version.outputs.version }}"
|
||||
|
||||
echo "📤 更新 Release ${VERSION} 中的脚本和配置文件..."
|
||||
|
||||
# 上传安装脚本(覆盖)
|
||||
echo "📤 更新安装脚本..."
|
||||
gh release upload "${VERSION}" ./install.sh --clobber
|
||||
gh release upload "${VERSION}" ./panel_install.sh --clobber
|
||||
|
||||
# 上传 Docker Compose 配置文件(覆盖)
|
||||
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 "ℹ️ Docker 镜像和 GOST 二进制未重新构建"
|
||||
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,40 +1,5 @@
|
||||
|
||||
# flux-panel转发面板 哆啦A梦转发面板
|
||||
|
||||
# 赞助商
|
||||
<p align="center">
|
||||
<a href="https://vps.town" style="margin: 0 20px; text-align:center;">
|
||||
<img src="./doc/vpstown.png" width="300">
|
||||
</a>
|
||||
|
||||
<a href="https://whmcs.as211392.com" style="margin: 0 20px; text-align:center;">
|
||||
<img src="./doc/as211392.png" width="300">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# 1.x无法无损升级到2.x请备份好数据
|
||||
1.x迁移2.x流程如下
|
||||
1.进入1.x的后台在转发页面导出所有转发保存
|
||||
2.使用1.x节点脚本卸载所有节点
|
||||
```
|
||||
curl -L https://github.com/bqlpfy/flux-panel/releases/download/1.4.2/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
||||
```
|
||||
3.使用1.x面版脚本卸载面板
|
||||
|
||||
```
|
||||
curl -L https://github.com/bqlpfy/flux-panel/releases/download/1.4.2/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
|
||||
```
|
||||
4.使用2.x脚本安装面板
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
使用1.x版本的请使用上面给出的脚本,不要使用最新的,最新的默认2.x
|
||||
嫌弃麻烦或者想稳定的我不推荐你升级到2.x版本 目前还在beta阶段,很多问题。
|
||||
|
||||
|
||||
本项目基于 [go-gost/gost](https://github.com/go-gost/gost) 和 [go-gost/x](https://github.com/go-gost/x) 两个开源库,实现了转发面板。
|
||||
---
|
||||
## 特性
|
||||
@@ -51,24 +16,13 @@ curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/main/pane
|
||||
---
|
||||
### Docker Compose部署
|
||||
#### 快速部署
|
||||
面板端(1.x):
|
||||
面板端:
|
||||
```bash
|
||||
curl -L https://github.com/bqlpfy/flux-panel/releases/download/1.4.2/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
|
||||
```
|
||||
节点端(1.x):
|
||||
节点端:
|
||||
```bash
|
||||
curl -L https://github.com/bqlpfy/flux-panel/releases/download/1.4.2/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
||||
|
||||
```
|
||||
|
||||
面板端(2.x):
|
||||
```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
|
||||
```
|
||||
节点端(2.x):
|
||||
```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
|
||||
```
|
||||
|
||||
#### 默认管理员账号
|
||||
@@ -101,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` |
|
||||
|
||||
+11
-3
@@ -1,8 +1,12 @@
|
||||
services:
|
||||
backend:
|
||||
image: bqlpfy/springboot-backend:2.0.5-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}
|
||||
@@ -25,9 +29,13 @@ services:
|
||||
start_period: 60s
|
||||
|
||||
frontend:
|
||||
image: bqlpfy/vite-frontend:2.0.5-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 +60,4 @@ networks:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
- subnet: 172.20.0.0/16
|
||||
|
||||
+11
-3
@@ -1,8 +1,12 @@
|
||||
services:
|
||||
backend:
|
||||
image: bqlpfy/springboot-backend:2.0.5-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}
|
||||
@@ -25,9 +29,13 @@ services:
|
||||
start_period: 60s
|
||||
|
||||
frontend:
|
||||
image: bqlpfy/vite-frontend:2.0.5-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:
|
||||
@@ -54,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.1")
|
||||
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,9 +87,9 @@ type TcpPingResponse struct {
|
||||
|
||||
type WebSocketReporter struct {
|
||||
url string
|
||||
addr string // 保存服务器地址
|
||||
secret string // 保存密钥
|
||||
version string // 保存版本号
|
||||
addr string // 保存服务器地址
|
||||
secret string // 保存密钥
|
||||
version string // 保存版本号
|
||||
conn *websocket.Conn
|
||||
reconnectTime time.Duration
|
||||
pingInterval time.Duration
|
||||
@@ -206,14 +206,14 @@ func (w *WebSocketReporter) connect() error {
|
||||
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 +
|
||||
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)
|
||||
@@ -466,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 {
|
||||
// 处理普通消息
|
||||
@@ -477,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,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
|
||||
@@ -506,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"
|
||||
}
|
||||
@@ -796,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回调消息
|
||||
@@ -1132,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()
|
||||
|
||||
|
||||
+87
-12
@@ -1,5 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
# GitHub repo used for release downloads
|
||||
REPO="Sagit-chu/flux-panel"
|
||||
|
||||
# 获取系统架构
|
||||
get_architecture() {
|
||||
ARCH=$(uname -m)
|
||||
@@ -16,20 +19,90 @@ get_architecture() {
|
||||
esac
|
||||
}
|
||||
|
||||
# 安装目录
|
||||
INSTALL_DIR="/etc/flux_agent"
|
||||
|
||||
# 识别国家(用于镜像加速)
|
||||
COUNTRY=$(curl -s https://ipinfo.io/country)
|
||||
|
||||
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/bqlpfy/flux-panel/releases/download/2.0.5-beta/gost-${ARCH}"
|
||||
echo "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/gost-${ARCH}"
|
||||
}
|
||||
|
||||
# 下载地址
|
||||
DOWNLOAD_URL=$(build_download_url)
|
||||
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
|
||||
# 解析版本并构建下载地址
|
||||
RESOLVED_VERSION=$(resolve_version) || exit 1
|
||||
DOWNLOAD_URL=$(maybe_proxy_url "$(build_download_url)")
|
||||
|
||||
|
||||
|
||||
@@ -222,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
|
||||
@@ -239,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
|
||||
}
|
||||
|
||||
@@ -365,4 +440,4 @@ main() {
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main
|
||||
main
|
||||
|
||||
+110
-8
@@ -7,16 +7,87 @@ export LC_ALL=C
|
||||
|
||||
|
||||
|
||||
# 全局下载地址配置
|
||||
DOCKER_COMPOSEV4_URL="https://github.com/bqlpfy/flux-panel/releases/download/2.0.5-beta/docker-compose-v4.yml"
|
||||
DOCKER_COMPOSEV6_URL="https://github.com/bqlpfy/flux-panel/releases/download/2.0.5-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")"
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -43,6 +44,7 @@ public class GostUtil {
|
||||
|
||||
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());
|
||||
@@ -53,7 +55,11 @@ public class GostUtil {
|
||||
Node node_info = node_s.get(chainTunnel.getNodeId());
|
||||
JSONObject node = new JSONObject();
|
||||
node.put("name", "node_" + chainTunnel.getInx());
|
||||
node.put("addr", processServerAddress(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);
|
||||
|
||||
@@ -291,4 +297,115 @@ public class GostUtil {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,12 @@ 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 数据库配置
|
||||
@@ -39,13 +44,98 @@ public class SQLiteConfig implements ApplicationRunner {
|
||||
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 文件内容写入主数据库
|
||||
@@ -80,4 +170,3 @@ public class SQLiteConfig implements ApplicationRunner {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,5 +59,25 @@ public interface TunnelService extends IService<Tunnel> {
|
||||
* @param tunnelId 隧道ID
|
||||
* @return 诊断结果
|
||||
*/
|
||||
R diagnoseTunnel(Long tunnelId);
|
||||
}
|
||||
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);
|
||||
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -209,56 +210,93 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public R updateForward(ForwardUpdateDto forwardUpdateDto) {
|
||||
// 1. 获取当前用户信息
|
||||
UserInfo currentUser = getCurrentUserInfo();
|
||||
|
||||
|
||||
// 2. 检查转发是否存在
|
||||
Forward existForward = validateForwardExists(forwardUpdateDto.getId(), currentUser);
|
||||
if (existForward == null) {
|
||||
return R.err("转发不存在");
|
||||
}
|
||||
|
||||
Integer oldTunnelId = existForward.getTunnelId();
|
||||
Integer newTunnelId = forwardUpdateDto.getTunnelId() != null ? forwardUpdateDto.getTunnelId() : oldTunnelId;
|
||||
boolean tunnelChanged = !Objects.equals(oldTunnelId, newTunnelId);
|
||||
|
||||
Tunnel tunnel = validateTunnel(existForward.getTunnelId());
|
||||
if (tunnel == null) {
|
||||
return R.err("隧道不存在");
|
||||
Tunnel oldTunnel = validateTunnel(oldTunnelId);
|
||||
if (oldTunnel == null) {
|
||||
return R.err("原隧道不存在");
|
||||
}
|
||||
|
||||
UserPermissionResult permissionResult = checkUserPermissions(currentUser, tunnel, null);
|
||||
if (permissionResult.isHasError()) {
|
||||
return R.err(permissionResult.getErrorMessage());
|
||||
}
|
||||
|
||||
UserTunnel userTunnel;
|
||||
UserTunnel oldUserTunnel;
|
||||
if (currentUser.getRoleId() != 0) {
|
||||
userTunnel = getUserTunnel(currentUser.getUserId(), tunnel.getId().intValue());
|
||||
if (userTunnel == null) {
|
||||
return R.err("你没有该隧道权限");
|
||||
oldUserTunnel = getUserTunnel(currentUser.getUserId(), oldTunnelId);
|
||||
if (oldUserTunnel == null) {
|
||||
return R.err("你没有原隧道权限");
|
||||
}
|
||||
} else {
|
||||
// 管理员用户也需要获取UserTunnel(如果存在的话),用于构建正确的服务名称
|
||||
// 通过forward记录获取原始的用户ID
|
||||
userTunnel = getUserTunnel(existForward.getUserId(), tunnel.getId().intValue());
|
||||
oldUserTunnel = getUserTunnel(existForward.getUserId(), oldTunnelId);
|
||||
}
|
||||
|
||||
if (tunnelChanged) {
|
||||
Tunnel newTunnel = validateTunnel(newTunnelId);
|
||||
if (newTunnel == null) {
|
||||
return R.err("新隧道不存在");
|
||||
}
|
||||
if (newTunnel.getStatus() != 1) {
|
||||
return R.err("新隧道已禁用,无法切换");
|
||||
}
|
||||
|
||||
UserPermissionResult newPermResult = checkUserPermissions(currentUser, newTunnel, null);
|
||||
if (newPermResult.isHasError()) {
|
||||
return R.err(newPermResult.getErrorMessage());
|
||||
}
|
||||
|
||||
UserTunnel newUserTunnel;
|
||||
if (currentUser.getRoleId() != 0) {
|
||||
newUserTunnel = getUserTunnel(currentUser.getUserId(), newTunnelId);
|
||||
if (newUserTunnel == null) {
|
||||
return R.err("你没有新隧道权限");
|
||||
}
|
||||
} else {
|
||||
newUserTunnel = getUserTunnel(existForward.getUserId(), newTunnelId);
|
||||
}
|
||||
|
||||
releaseOldTunnelResources(existForward, oldTunnel, oldUserTunnel);
|
||||
|
||||
existForward.setTunnelId(newTunnelId);
|
||||
existForward.setRemoteAddr(forwardUpdateDto.getRemoteAddr());
|
||||
existForward.setName(forwardUpdateDto.getName());
|
||||
existForward.setStrategy(forwardUpdateDto.getStrategy());
|
||||
existForward.setStatus(1);
|
||||
existForward.setUpdatedTime(System.currentTimeMillis());
|
||||
this.updateById(existForward);
|
||||
|
||||
R allocResult = allocateNewTunnelResources(existForward, newTunnel, forwardUpdateDto.getInPort(), newPermResult, newUserTunnel);
|
||||
if (allocResult.getCode() != 0) {
|
||||
throw new RuntimeException(allocResult.getMsg());
|
||||
}
|
||||
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
UserPermissionResult permissionResult = checkUserPermissions(currentUser, oldTunnel, null);
|
||||
if (permissionResult.isHasError()) {
|
||||
return R.err(permissionResult.getErrorMessage());
|
||||
}
|
||||
|
||||
existForward.setRemoteAddr(forwardUpdateDto.getRemoteAddr());
|
||||
existForward.setName(forwardUpdateDto.getName());
|
||||
existForward.setStrategy(forwardUpdateDto.getStrategy());
|
||||
existForward.setStatus(1);
|
||||
existForward.setUpdatedTime(System.currentTimeMillis());
|
||||
this.updateById(existForward);
|
||||
|
||||
|
||||
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()).eq("chain_type", 1));
|
||||
|
||||
// 自己占用的应该不算
|
||||
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", oldTunnel.getId()).eq("chain_type", 1));
|
||||
chainTunnels = get_port(chainTunnels, forwardUpdateDto.getInPort(), existForward.getId());
|
||||
|
||||
|
||||
|
||||
for (ChainTunnel chainTunnel : chainTunnels) {
|
||||
String serviceName = buildServiceName(existForward.getId(), existForward.getUserId(), userTunnel);
|
||||
String serviceName = buildServiceName(existForward.getId(), existForward.getUserId(), oldUserTunnel);
|
||||
Integer limiter = permissionResult.getLimiter();
|
||||
Node node = nodeService.getById(chainTunnel.getNodeId());
|
||||
if (node == null) {
|
||||
@@ -270,13 +308,89 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
||||
}
|
||||
forwardPort.setPort(chainTunnel.getPort());
|
||||
forwardPortService.updateById(forwardPort);
|
||||
GostDto gostDto = GostUtil.AddAndUpdateService(serviceName, limiter, node, existForward, forwardPort, tunnel, "UpdateService");
|
||||
GostDto gostDto = GostUtil.AddAndUpdateService(serviceName, limiter, node, existForward, forwardPort, oldTunnel, "UpdateService");
|
||||
if (!Objects.equals(gostDto.getMsg(), "OK")) return R.err(gostDto.getMsg());
|
||||
}
|
||||
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
private void releaseOldTunnelResources(Forward forward, Tunnel oldTunnel, UserTunnel oldUserTunnel) {
|
||||
List<ChainTunnel> oldInNodes = chainTunnelService.list(
|
||||
new QueryWrapper<ChainTunnel>()
|
||||
.eq("tunnel_id", oldTunnel.getId())
|
||||
.eq("chain_type", 1)
|
||||
);
|
||||
|
||||
for (ChainTunnel chainTunnel : oldInNodes) {
|
||||
String serviceName = buildServiceName(forward.getId(), forward.getUserId(), oldUserTunnel);
|
||||
Node node = nodeService.getById(chainTunnel.getNodeId());
|
||||
if (node != null) {
|
||||
JSONArray services = new JSONArray();
|
||||
services.add(serviceName + "_tcp");
|
||||
services.add(serviceName + "_udp");
|
||||
GostUtil.DeleteService(node.getId(), services);
|
||||
}
|
||||
}
|
||||
|
||||
forwardPortService.remove(new QueryWrapper<ForwardPort>().eq("forward_id", forward.getId()));
|
||||
}
|
||||
|
||||
private R allocateNewTunnelResources(Forward forward, Tunnel newTunnel, Integer requestedPort,
|
||||
UserPermissionResult permResult, UserTunnel newUserTunnel) {
|
||||
List<ChainTunnel> newInNodes = chainTunnelService.list(
|
||||
new QueryWrapper<ChainTunnel>()
|
||||
.eq("tunnel_id", newTunnel.getId())
|
||||
.eq("chain_type", 1)
|
||||
);
|
||||
|
||||
newInNodes = get_port(newInNodes, requestedPort, forward.getId());
|
||||
|
||||
List<JSONObject> successServices = new ArrayList<>();
|
||||
|
||||
for (ChainTunnel chainTunnel : newInNodes) {
|
||||
ForwardPort forwardPort = new ForwardPort();
|
||||
forwardPort.setForwardId(forward.getId());
|
||||
forwardPort.setNodeId(chainTunnel.getNodeId());
|
||||
forwardPort.setPort(chainTunnel.getPort());
|
||||
forwardPortService.save(forwardPort);
|
||||
|
||||
String serviceName = buildServiceName(forward.getId(), forward.getUserId(), newUserTunnel);
|
||||
Integer limiter = permResult.getLimiter();
|
||||
Node node = nodeService.getById(chainTunnel.getNodeId());
|
||||
|
||||
if (node == null) {
|
||||
rollbackCreatedServices(successServices);
|
||||
forwardPortService.remove(new QueryWrapper<ForwardPort>().eq("forward_id", forward.getId()));
|
||||
return R.err("新隧道部分节点不存在");
|
||||
}
|
||||
|
||||
GostDto gostDto = GostUtil.AddAndUpdateService(serviceName, limiter, node, forward, forwardPort, newTunnel, "AddService");
|
||||
|
||||
if (!Objects.equals(gostDto.getMsg(), "OK")) {
|
||||
rollbackCreatedServices(successServices);
|
||||
forwardPortService.remove(new QueryWrapper<ForwardPort>().eq("forward_id", forward.getId()));
|
||||
return R.err("在新隧道创建服务失败: " + gostDto.getMsg());
|
||||
}
|
||||
|
||||
JSONObject data = new JSONObject();
|
||||
data.put("node_id", node.getId());
|
||||
data.put("name", serviceName);
|
||||
successServices.add(data);
|
||||
}
|
||||
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
private void rollbackCreatedServices(List<JSONObject> created) {
|
||||
for (JSONObject jsonObject : created) {
|
||||
JSONArray se = new JSONArray();
|
||||
se.add(jsonObject.getString("name") + "_tcp");
|
||||
se.add(jsonObject.getString("name") + "_udp");
|
||||
GostUtil.DeleteService(jsonObject.getLong("node_id"), se);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public R deleteForward(Long id) {
|
||||
|
||||
@@ -400,10 +514,10 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
||||
.filter(ct -> ct.getChainType() == 3)
|
||||
.toList();
|
||||
|
||||
List<DiagnosisResult> results = new ArrayList<>();
|
||||
List<CompletableFuture<DiagnosisResult>> futures = new ArrayList<>();
|
||||
String[] remoteAddresses = forward.getRemoteAddr().split(",");
|
||||
|
||||
// 根据隧道类型执行不同的诊断策略
|
||||
// 根据隧道类型执行不同的诊断策略(并行执行所有诊断任务)
|
||||
if (tunnel.getType() == 1) {
|
||||
// 端口转发:入口节点直接TCP ping目标地址
|
||||
for (ChainTunnel inNode : inNodes) {
|
||||
@@ -413,12 +527,18 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
||||
String targetIp = extractIpFromAddress(remoteAddress);
|
||||
int targetPort = extractPortFromAddress(remoteAddress);
|
||||
if (targetIp != null && targetPort != -1) {
|
||||
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
||||
node, targetIp, targetPort,
|
||||
"入口(" + node.getName() + ")->目标(" + remoteAddress + ")"
|
||||
);
|
||||
result.setFromChainType(1);
|
||||
results.add(result);
|
||||
final Node finalNode = node;
|
||||
final String finalTargetIp = targetIp;
|
||||
final int finalTargetPort = targetPort;
|
||||
final String finalRemoteAddress = remoteAddress;
|
||||
futures.add(CompletableFuture.supplyAsync(() -> {
|
||||
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
||||
finalNode, finalTargetIp, finalTargetPort,
|
||||
"入口(" + finalNode.getName() + ")->目标(" + finalRemoteAddress + ")"
|
||||
);
|
||||
result.setFromChainType(1);
|
||||
return result;
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -434,27 +554,37 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
||||
for (ChainTunnel firstChainNode : chainNodesList.getFirst()) {
|
||||
Node toNode = nodeService.getById(firstChainNode.getNodeId());
|
||||
if (toNode != null) {
|
||||
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
||||
fromNode, toNode.getServerIp(), firstChainNode.getPort(),
|
||||
"入口(" + fromNode.getName() + ")->第1跳(" + toNode.getName() + ")"
|
||||
);
|
||||
result.setFromChainType(1);
|
||||
result.setToChainType(2);
|
||||
result.setToInx(firstChainNode.getInx());
|
||||
results.add(result);
|
||||
final Node finalFromNode = fromNode;
|
||||
final Node finalToNode = toNode;
|
||||
final ChainTunnel finalFirstChainNode = firstChainNode;
|
||||
futures.add(CompletableFuture.supplyAsync(() -> {
|
||||
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
||||
finalFromNode, GostUtil.selectDialHost(finalFromNode, finalToNode), finalFirstChainNode.getPort(),
|
||||
"入口(" + finalFromNode.getName() + ")->第1跳(" + finalToNode.getName() + ")"
|
||||
);
|
||||
result.setFromChainType(1);
|
||||
result.setToChainType(2);
|
||||
result.setToInx(finalFirstChainNode.getInx());
|
||||
return result;
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else if (!outNodes.isEmpty()) {
|
||||
for (ChainTunnel outNode : outNodes) {
|
||||
Node toNode = nodeService.getById(outNode.getNodeId());
|
||||
if (toNode != null) {
|
||||
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
||||
fromNode, toNode.getServerIp(), outNode.getPort(),
|
||||
"入口(" + fromNode.getName() + ")->出口(" + toNode.getName() + ")"
|
||||
);
|
||||
result.setFromChainType(1);
|
||||
result.setToChainType(3);
|
||||
results.add(result);
|
||||
final Node finalFromNode = fromNode;
|
||||
final Node finalToNode = toNode;
|
||||
final ChainTunnel finalOutNode = outNode;
|
||||
futures.add(CompletableFuture.supplyAsync(() -> {
|
||||
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
||||
finalFromNode, GostUtil.selectDialHost(finalFromNode, finalToNode), finalOutNode.getPort(),
|
||||
"入口(" + finalFromNode.getName() + ")->出口(" + finalToNode.getName() + ")"
|
||||
);
|
||||
result.setFromChainType(1);
|
||||
result.setToChainType(3);
|
||||
return result;
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -464,6 +594,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
||||
// 2. 链路测试
|
||||
for (int i = 0; i < chainNodesList.size(); i++) {
|
||||
List<ChainTunnel> currentHop = chainNodesList.get(i);
|
||||
final int hopIndex = i;
|
||||
|
||||
for (ChainTunnel currentNode : currentHop) {
|
||||
Node fromNode = nodeService.getById(currentNode.getNodeId());
|
||||
@@ -473,29 +604,41 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
||||
for (ChainTunnel nextNode : chainNodesList.get(i + 1)) {
|
||||
Node toNode = nodeService.getById(nextNode.getNodeId());
|
||||
if (toNode != null) {
|
||||
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
||||
fromNode, toNode.getServerIp(), nextNode.getPort(),
|
||||
"第" + (i + 1) + "跳(" + fromNode.getName() + ")->第" + (i + 2) + "跳(" + toNode.getName() + ")"
|
||||
);
|
||||
result.setFromChainType(2);
|
||||
result.setFromInx(currentNode.getInx());
|
||||
result.setToChainType(2);
|
||||
result.setToInx(nextNode.getInx());
|
||||
results.add(result);
|
||||
final Node finalFromNode = fromNode;
|
||||
final Node finalToNode = toNode;
|
||||
final ChainTunnel finalCurrentNode = currentNode;
|
||||
final ChainTunnel finalNextNode = nextNode;
|
||||
futures.add(CompletableFuture.supplyAsync(() -> {
|
||||
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
||||
finalFromNode, GostUtil.selectDialHost(finalFromNode, finalToNode), finalNextNode.getPort(),
|
||||
"第" + (hopIndex + 1) + "跳(" + finalFromNode.getName() + ")->第" + (hopIndex + 2) + "跳(" + finalToNode.getName() + ")"
|
||||
);
|
||||
result.setFromChainType(2);
|
||||
result.setFromInx(finalCurrentNode.getInx());
|
||||
result.setToChainType(2);
|
||||
result.setToInx(finalNextNode.getInx());
|
||||
return result;
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else if (!outNodes.isEmpty()) {
|
||||
for (ChainTunnel outNode : outNodes) {
|
||||
Node toNode = nodeService.getById(outNode.getNodeId());
|
||||
if (toNode != null) {
|
||||
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
||||
fromNode, toNode.getServerIp(), outNode.getPort(),
|
||||
"第" + (i + 1) + "跳(" + fromNode.getName() + ")->出口(" + toNode.getName() + ")"
|
||||
);
|
||||
result.setFromChainType(2);
|
||||
result.setFromInx(currentNode.getInx());
|
||||
result.setToChainType(3);
|
||||
results.add(result);
|
||||
final Node finalFromNode = fromNode;
|
||||
final Node finalToNode = toNode;
|
||||
final ChainTunnel finalCurrentNode = currentNode;
|
||||
final ChainTunnel finalOutNode = outNode;
|
||||
futures.add(CompletableFuture.supplyAsync(() -> {
|
||||
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
||||
finalFromNode, GostUtil.selectDialHost(finalFromNode, finalToNode), finalOutNode.getPort(),
|
||||
"第" + (hopIndex + 1) + "跳(" + finalFromNode.getName() + ")->出口(" + finalToNode.getName() + ")"
|
||||
);
|
||||
result.setFromChainType(2);
|
||||
result.setFromInx(finalCurrentNode.getInx());
|
||||
result.setToChainType(3);
|
||||
return result;
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -511,18 +654,29 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
||||
String targetIp = extractIpFromAddress(remoteAddress);
|
||||
int targetPort = extractPortFromAddress(remoteAddress);
|
||||
if (targetIp != null && targetPort != -1) {
|
||||
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
||||
node, targetIp, targetPort,
|
||||
"出口(" + node.getName() + ")->目标(" + remoteAddress + ")"
|
||||
);
|
||||
result.setFromChainType(3);
|
||||
results.add(result);
|
||||
final Node finalNode = node;
|
||||
final String finalTargetIp = targetIp;
|
||||
final int finalTargetPort = targetPort;
|
||||
final String finalRemoteAddress = remoteAddress;
|
||||
futures.add(CompletableFuture.supplyAsync(() -> {
|
||||
DiagnosisResult result = performTcpPingDiagnosisWithConnectionCheck(
|
||||
finalNode, finalTargetIp, finalTargetPort,
|
||||
"出口(" + finalNode.getName() + ")->目标(" + finalRemoteAddress + ")"
|
||||
);
|
||||
result.setFromChainType(3);
|
||||
return result;
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 等待所有诊断任务完成并收集结果
|
||||
List<DiagnosisResult> results = futures.stream()
|
||||
.map(CompletableFuture::join)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 构建诊断报告
|
||||
Map<String, Object> diagnosisReport = new HashMap<>();
|
||||
diagnosisReport.put("forwardId", id);
|
||||
@@ -1090,4 +1244,269 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
|
||||
private Integer toInx;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public R batchDeleteForwards(BatchDeleteDto batchDeleteDto) {
|
||||
UserInfo currentUser = getCurrentUserInfo();
|
||||
BatchOperationResultDto result = new BatchOperationResultDto();
|
||||
|
||||
for (Long id : batchDeleteDto.getIds()) {
|
||||
try {
|
||||
Forward forward = validateForwardExists(id, currentUser);
|
||||
if (forward == null) {
|
||||
result.addFailedItem(id, "转发不存在或无权限");
|
||||
continue;
|
||||
}
|
||||
|
||||
Tunnel tunnel = validateTunnel(forward.getTunnelId());
|
||||
if (tunnel == null) {
|
||||
result.addFailedItem(id, "隧道不存在");
|
||||
continue;
|
||||
}
|
||||
|
||||
UserTunnel userTunnel = null;
|
||||
if (currentUser.getRoleId() != 0) {
|
||||
userTunnel = getUserTunnel(currentUser.getUserId(), tunnel.getId().intValue());
|
||||
if (userTunnel == null) {
|
||||
result.addFailedItem(id, "没有该隧道权限");
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
userTunnel = getUserTunnel(forward.getUserId(), tunnel.getId().intValue());
|
||||
}
|
||||
|
||||
List<ChainTunnel> chainTunnels = chainTunnelService.list(
|
||||
new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()).eq("chain_type", 1)
|
||||
);
|
||||
|
||||
boolean deleteSuccess = true;
|
||||
for (ChainTunnel chainTunnel : chainTunnels) {
|
||||
String serviceName = buildServiceName(forward.getId(), forward.getUserId(), userTunnel);
|
||||
Node node = nodeService.getById(chainTunnel.getNodeId());
|
||||
if (node != null) {
|
||||
JSONArray services = new JSONArray();
|
||||
services.add(serviceName + "_tcp");
|
||||
services.add(serviceName + "_udp");
|
||||
GostUtil.DeleteService(node.getId(), services);
|
||||
}
|
||||
}
|
||||
|
||||
if (deleteSuccess) {
|
||||
forwardPortService.remove(new QueryWrapper<ForwardPort>().eq("forward_id", id));
|
||||
this.removeById(id);
|
||||
result.incrementSuccess();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
result.addFailedItem(id, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return R.ok(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public R batchPauseForwards(BatchDeleteDto batchDeleteDto) {
|
||||
return batchChangeForwardStatus(batchDeleteDto.getIds(), 0, "PauseService");
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public R batchResumeForwards(BatchDeleteDto batchDeleteDto) {
|
||||
return batchChangeForwardStatus(batchDeleteDto.getIds(), 1, "ResumeService");
|
||||
}
|
||||
|
||||
private R batchChangeForwardStatus(List<Long> ids, int targetStatus, String gostMethod) {
|
||||
BatchOperationResultDto result = new BatchOperationResultDto();
|
||||
|
||||
for (Long id : ids) {
|
||||
try {
|
||||
R changeResult = changeForwardStatus(id, targetStatus, gostMethod);
|
||||
if (changeResult.getCode() == 0) {
|
||||
result.incrementSuccess();
|
||||
} else {
|
||||
result.addFailedItem(id, changeResult.getMsg());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
result.addFailedItem(id, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return R.ok(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public R batchRedeployForwards(BatchRedeployDto batchRedeployDto) {
|
||||
UserInfo currentUser = getCurrentUserInfo();
|
||||
BatchOperationResultDto result = new BatchOperationResultDto();
|
||||
|
||||
for (Long id : batchRedeployDto.getIds()) {
|
||||
try {
|
||||
Forward forward = validateForwardExists(id, currentUser);
|
||||
if (forward == null) {
|
||||
result.addFailedItem(id, "转发不存在或无权限");
|
||||
continue;
|
||||
}
|
||||
|
||||
Tunnel tunnel = validateTunnel(forward.getTunnelId());
|
||||
if (tunnel == null) {
|
||||
result.addFailedItem(id, "隧道不存在");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tunnel.getStatus() != 1) {
|
||||
result.addFailedItem(id, "隧道已禁用");
|
||||
continue;
|
||||
}
|
||||
|
||||
UserPermissionResult permissionResult = checkUserPermissions(currentUser, tunnel, id);
|
||||
if (permissionResult.isHasError()) {
|
||||
result.addFailedItem(id, permissionResult.getErrorMessage());
|
||||
continue;
|
||||
}
|
||||
|
||||
List<ForwardPort> forwardPorts = forwardPortService.list(
|
||||
new QueryWrapper<ForwardPort>().eq("forward_id", id)
|
||||
);
|
||||
|
||||
for (ForwardPort forwardPort : forwardPorts) {
|
||||
String serviceName = buildServiceName(forward.getId(), forward.getUserId(), permissionResult.getUserTunnel());
|
||||
Node node = nodeService.getById(forwardPort.getNodeId());
|
||||
if (node != null) {
|
||||
GostUtil.AddAndUpdateService(serviceName, permissionResult.getLimiter(),
|
||||
node, forward, forwardPort, tunnel, "UpdateService");
|
||||
}
|
||||
}
|
||||
|
||||
result.incrementSuccess();
|
||||
} catch (Exception e) {
|
||||
result.addFailedItem(id, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return R.ok(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public R batchChangeTunnel(BatchChangeTunnelDto batchChangeTunnelDto) {
|
||||
UserInfo currentUser = getCurrentUserInfo();
|
||||
BatchOperationResultDto result = new BatchOperationResultDto();
|
||||
|
||||
Long targetTunnelId = batchChangeTunnelDto.getTargetTunnelId();
|
||||
Tunnel targetTunnel = tunnelService.getById(targetTunnelId);
|
||||
if (targetTunnel == null) {
|
||||
return R.err("目标隧道不存在");
|
||||
}
|
||||
if (targetTunnel.getStatus() != 1) {
|
||||
return R.err("目标隧道已禁用");
|
||||
}
|
||||
|
||||
for (Long forwardId : batchChangeTunnelDto.getForwardIds()) {
|
||||
try {
|
||||
Forward forward = validateForwardExists(forwardId, currentUser);
|
||||
if (forward == null) {
|
||||
result.addFailedItem(forwardId, "转发不存在或无权限");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (forward.getTunnelId().equals(targetTunnelId.intValue())) {
|
||||
result.addFailedItem(forwardId, "已是目标隧道");
|
||||
continue;
|
||||
}
|
||||
|
||||
List<ForwardPort> existingForwardPorts = forwardPortService.list(
|
||||
new QueryWrapper<ForwardPort>().eq("forward_id", forwardId).orderByAsc("id")
|
||||
);
|
||||
Integer originalInPort = existingForwardPorts.stream()
|
||||
.map(ForwardPort::getPort)
|
||||
.filter(Objects::nonNull)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
Tunnel oldTunnel = validateTunnel(forward.getTunnelId());
|
||||
if (oldTunnel != null) {
|
||||
UserTunnel oldUserTunnel = null;
|
||||
if (currentUser.getRoleId() != 0) {
|
||||
oldUserTunnel = getUserTunnel(currentUser.getUserId(), oldTunnel.getId().intValue());
|
||||
} else {
|
||||
oldUserTunnel = getUserTunnel(forward.getUserId(), oldTunnel.getId().intValue());
|
||||
}
|
||||
|
||||
List<ChainTunnel> oldChainTunnels = chainTunnelService.list(
|
||||
new QueryWrapper<ChainTunnel>().eq("tunnel_id", oldTunnel.getId()).eq("chain_type", 1)
|
||||
);
|
||||
for (ChainTunnel chainTunnel : oldChainTunnels) {
|
||||
String serviceName = buildServiceName(forward.getId(), forward.getUserId(), oldUserTunnel);
|
||||
Node node = nodeService.getById(chainTunnel.getNodeId());
|
||||
if (node != null) {
|
||||
JSONArray services = new JSONArray();
|
||||
services.add(serviceName + "_tcp");
|
||||
services.add(serviceName + "_udp");
|
||||
GostUtil.DeleteService(node.getId(), services);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
forwardPortService.remove(new QueryWrapper<ForwardPort>().eq("forward_id", forwardId));
|
||||
|
||||
forward.setTunnelId(targetTunnelId.intValue());
|
||||
forward.setUpdatedTime(System.currentTimeMillis());
|
||||
this.updateById(forward);
|
||||
|
||||
UserPermissionResult permissionResult = checkUserPermissions(currentUser, targetTunnel, forwardId);
|
||||
if (permissionResult.isHasError()) {
|
||||
result.addFailedItem(forwardId, "切换成功但无法下发: " + permissionResult.getErrorMessage());
|
||||
continue;
|
||||
}
|
||||
|
||||
List<ChainTunnel> newChainTunnels = chainTunnelService.list(
|
||||
new QueryWrapper<ChainTunnel>().eq("tunnel_id", targetTunnel.getId()).eq("chain_type", 1)
|
||||
);
|
||||
List<ChainTunnel> chainTunnelsWithPort = allocatePortsForBatchTunnelChange(newChainTunnels, originalInPort, forwardId);
|
||||
|
||||
for (ChainTunnel chainTunnel : chainTunnelsWithPort) {
|
||||
ForwardPort forwardPort = new ForwardPort();
|
||||
forwardPort.setForwardId(forwardId);
|
||||
forwardPort.setNodeId(chainTunnel.getNodeId());
|
||||
forwardPort.setPort(chainTunnel.getPort());
|
||||
forwardPortService.save(forwardPort);
|
||||
|
||||
String serviceName = buildServiceName(forward.getId(), forward.getUserId(), permissionResult.getUserTunnel());
|
||||
Node node = nodeService.getById(chainTunnel.getNodeId());
|
||||
if (node != null) {
|
||||
GostUtil.AddAndUpdateService(serviceName, permissionResult.getLimiter(),
|
||||
node, forward, forwardPort, targetTunnel, "AddService");
|
||||
}
|
||||
}
|
||||
|
||||
result.incrementSuccess();
|
||||
} catch (Exception e) {
|
||||
result.addFailedItem(forwardId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return R.ok(result);
|
||||
}
|
||||
|
||||
private List<ChainTunnel> allocatePortsForBatchTunnelChange(List<ChainTunnel> newChainTunnels, Integer originalInPort, Long forwardId) {
|
||||
if (originalInPort == null) {
|
||||
return get_port(newChainTunnels, null, forwardId);
|
||||
}
|
||||
|
||||
try {
|
||||
return get_port(newChainTunnels, originalInPort, forwardId);
|
||||
} catch (RuntimeException originalPortError) {
|
||||
try {
|
||||
return get_port(newChainTunnels, null, forwardId);
|
||||
} catch (RuntimeException autoAllocateError) {
|
||||
throw new RuntimeException(
|
||||
"原入口端口 " + originalInPort + " 在目标隧道不可用,自动分配新端口也失败: " + autoAllocateError.getMessage(),
|
||||
autoAllocateError
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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,33 +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
|
||||
@@ -38,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) {
|
||||
@@ -96,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
|
||||
@@ -126,8 +403,8 @@ 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.5-beta/install.sh")
|
||||
.append(" -o ./install.sh && chmod +x ./install.sh && ");
|
||||
command.append("curl -L https://github.com/Sagit-chu/flux-panel/releases/latest/download/install.sh")
|
||||
.append(" -o ./install.sh && chmod +x ./install.sh && ");
|
||||
String processedServerAddr = GostUtil.processServerAddress(viteConfig.getValue());
|
||||
command.append("./install.sh")
|
||||
.append(" -a ").append(processedServerAddr) // 服务器地址
|
||||
@@ -137,52 +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 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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
+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 });
|
||||
|
||||
@@ -1,41 +1,42 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { getPanelAddresses, isWebViewFunc} from '@/utils/panel';
|
||||
import axios, { AxiosResponse } from "axios";
|
||||
|
||||
import { getPanelAddresses, isWebViewFunc } from "@/utils/panel";
|
||||
|
||||
interface PanelAddress {
|
||||
name: string;
|
||||
address: string;
|
||||
address: string;
|
||||
inx: boolean;
|
||||
}
|
||||
|
||||
const setPanelAddressesFunc = (newAddress: PanelAddress[]) => {
|
||||
newAddress.forEach(item => {
|
||||
newAddress.forEach((item) => {
|
||||
if (item.inx) {
|
||||
baseURL = `${item.address}/api/v1/`;
|
||||
axios.defaults.baseURL = baseURL;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getWebViewPanelAddress() {
|
||||
(window as any).setAddresses = setPanelAddressesFunc
|
||||
getPanelAddresses("setAddresses");
|
||||
};
|
||||
|
||||
let baseURL: string = '';
|
||||
function getWebViewPanelAddress() {
|
||||
(window as any).setAddresses = setPanelAddressesFunc;
|
||||
getPanelAddresses("setAddresses");
|
||||
}
|
||||
|
||||
let baseURL: string = "";
|
||||
|
||||
export const reinitializeBaseURL = () => {
|
||||
if (isWebViewFunc()) {
|
||||
getWebViewPanelAddress();
|
||||
} else {
|
||||
baseURL = import.meta.env.VITE_API_BASE ? `${import.meta.env.VITE_API_BASE}/api/v1/` : '/api/v1/';
|
||||
baseURL = import.meta.env.VITE_API_BASE
|
||||
? `${import.meta.env.VITE_API_BASE}/api/v1/`
|
||||
: "/api/v1/";
|
||||
axios.defaults.baseURL = baseURL;
|
||||
}
|
||||
};
|
||||
|
||||
reinitializeBaseURL();
|
||||
|
||||
|
||||
interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
msg: string;
|
||||
@@ -45,98 +46,119 @@ interface ApiResponse<T = any> {
|
||||
// 处理token失效的逻辑
|
||||
function handleTokenExpired() {
|
||||
// 清除localStorage中的token
|
||||
window.localStorage.removeItem('token');
|
||||
window.localStorage.removeItem('role_id');
|
||||
window.localStorage.removeItem('name');
|
||||
|
||||
window.localStorage.removeItem("token");
|
||||
window.localStorage.removeItem("role_id");
|
||||
window.localStorage.removeItem("name");
|
||||
|
||||
// 跳转到登录页面
|
||||
if (window.location.pathname !== '/') {
|
||||
window.location.href = '/';
|
||||
if (window.location.pathname !== "/") {
|
||||
window.location.href = "/";
|
||||
}
|
||||
}
|
||||
|
||||
// 检查响应是否为token失效
|
||||
function isTokenExpired(response: ApiResponse) {
|
||||
return response && response.code === 401 &&
|
||||
(response.msg === '未登录或token已过期' ||
|
||||
response.msg === '无效的token或token已过期' ||
|
||||
response.msg === '无法获取用户权限信息');
|
||||
return (
|
||||
response &&
|
||||
response.code === 401 &&
|
||||
(response.msg === "未登录或token已过期" ||
|
||||
response.msg === "无效的token或token已过期" ||
|
||||
response.msg === "无法获取用户权限信息")
|
||||
);
|
||||
}
|
||||
|
||||
const Network = {
|
||||
get: function<T = any>(path: string = '', data: any = {}): Promise<ApiResponse<T>> {
|
||||
return new Promise(function(resolve) {
|
||||
get: function <T = any>(
|
||||
path: string = "",
|
||||
data: any = {},
|
||||
): Promise<ApiResponse<T>> {
|
||||
return new Promise(function (resolve) {
|
||||
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
|
||||
if (baseURL === '') {
|
||||
resolve({"code": -1, "msg": " - 请先设置面板地址", "data": null as T});
|
||||
if (baseURL === "") {
|
||||
resolve({ code: -1, msg: " - 请先设置面板地址", data: null as T });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
axios.get(path, {
|
||||
params: data,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
"Authorization": window.localStorage.getItem('token')
|
||||
}
|
||||
})
|
||||
.then(function(response: AxiosResponse<ApiResponse<T>>) {
|
||||
axios
|
||||
.get(path, {
|
||||
params: data,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
Authorization: window.localStorage.getItem("token"),
|
||||
},
|
||||
})
|
||||
.then(function (response: AxiosResponse<ApiResponse<T>>) {
|
||||
// 检查是否token失效
|
||||
if (isTokenExpired(response.data)) {
|
||||
handleTokenExpired();
|
||||
|
||||
return;
|
||||
}
|
||||
resolve(response.data);
|
||||
})
|
||||
.catch(function(error: any) {
|
||||
console.error('GET请求错误:', error);
|
||||
|
||||
// 检查是否是401错误(token失效)
|
||||
if (error.response && error.response.status === 401) {
|
||||
handleTokenExpired();
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({"code": -1, "msg": error.message || "网络请求失败", "data": null as T});
|
||||
});
|
||||
.catch(function (error: any) {
|
||||
// 检查是否是401错误(token失效)
|
||||
if (error.response && error.response.status === 401) {
|
||||
handleTokenExpired();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
code: -1,
|
||||
msg: error.message || "网络请求失败",
|
||||
data: null as T,
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
post: function<T = any>(path: string = '', data: any = {}): Promise<ApiResponse<T>> {
|
||||
return new Promise(function(resolve) {
|
||||
post: function <T = any>(
|
||||
path: string = "",
|
||||
data: any = {},
|
||||
): Promise<ApiResponse<T>> {
|
||||
return new Promise(function (resolve) {
|
||||
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
|
||||
if (baseURL === '') {
|
||||
resolve({"code": -1, "msg": " - 请先设置面板地址", "data": null as T});
|
||||
if (baseURL === "") {
|
||||
resolve({ code: -1, msg: " - 请先设置面板地址", data: null as T });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
axios.post(path, data, {
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
"Authorization": window.localStorage.getItem('token'),
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
})
|
||||
.then(function(response: AxiosResponse<ApiResponse<T>>) {
|
||||
axios
|
||||
.post(path, data, {
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
Authorization: window.localStorage.getItem("token"),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then(function (response: AxiosResponse<ApiResponse<T>>) {
|
||||
// 检查是否token失效
|
||||
if (isTokenExpired(response.data)) {
|
||||
handleTokenExpired();
|
||||
|
||||
return;
|
||||
}
|
||||
resolve(response.data);
|
||||
})
|
||||
.catch(function(error: any) {
|
||||
console.error('POST请求错误:', error);
|
||||
|
||||
// 检查是否是401错误(token失效)
|
||||
if (error.response && error.response.status === 401) {
|
||||
handleTokenExpired();
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({"code": -1, "msg": error.message || "网络请求失败", "data": null as T});
|
||||
});
|
||||
.catch(function (error: any) {
|
||||
// 检查是否是401错误(token失效)
|
||||
if (error.response && error.response.status === 401) {
|
||||
handleTokenExpired();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
code: -1,
|
||||
msg: error.message || "网络请求失败",
|
||||
data: null as T,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default Network;
|
||||
export default Network;
|
||||
|
||||
@@ -84,11 +84,11 @@ export const SearchIcon = ({
|
||||
>
|
||||
<path
|
||||
d="M11.5 21C16.7467 21 21 16.7467 21 11.5C21 6.25329 16.7467 2 11.5 2C6.25329 2 2 6.25329 2 11.5C2 16.7467 6.25329 21 11.5 21Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M22 22L20.5 20.5"
|
||||
@@ -142,19 +142,19 @@ export const EditIcon = ({
|
||||
>
|
||||
<path
|
||||
d="M11 4H4C3.46957 4 2.96086 4.21071 2.58579 4.58579C2.21071 4.96086 2 5.46957 2 6V20C2 20.5304 2.21071 21.0391 2.58579 21.4142C2.96086 21.7893 3.46957 22 4 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V13"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M18.5 2.49998C18.8978 2.10216 19.4374 1.87866 20 1.87866C20.5626 1.87866 21.1022 2.10216 21.5 2.49998C21.8978 2.89781 22.1213 3.43737 22.1213 3.99998C22.1213 4.56259 21.8978 5.10216 21.5 5.49998L12 15L8 16L9 12L18.5 2.49998Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -183,11 +183,11 @@ export const DeleteIcon = ({
|
||||
/>
|
||||
<path
|
||||
d="M8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M19 6V20C19 20.5304 18.7893 21.0391 18.4142 21.4142C18.0391 21.7893 17.5304 22 17 22H7C6.46957 22 5.96086 21.7893 5.58579 21.4142C5.21071 21.0391 5 20.5304 5 20V6H19Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -209,19 +209,19 @@ export const UserIcon = ({
|
||||
>
|
||||
<path
|
||||
d="M20 21V19C20 17.9391 19.5786 16.9217 18.8284 16.1716C18.0783 15.4214 17.0609 15 16 15H8C6.93913 15 5.92172 15.4214 5.17157 16.1716C4.42143 16.9217 4 17.9391 4 19V21"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M12 11C14.2091 11 16 9.20914 16 7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7C8 9.20914 9.79086 11 12 11Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -243,19 +243,19 @@ export const SettingsIcon = ({
|
||||
>
|
||||
<path
|
||||
d="M12.22 2H11.78C11.2496 2 10.7409 2.21071 10.3658 2.58579C9.99072 2.96086 9.78 3.46957 9.78 4C9.78 4.53043 9.99072 5.03914 10.3658 5.41421C10.7409 5.78929 11.2496 6 11.78 6H12.22C12.7504 6 13.2591 5.78929 13.6342 5.41421C14.0093 5.03914 14.22 4.53043 14.22 4C14.22 3.46957 14.0093 2.96086 13.6342 2.58579C13.2591 2.21071 12.7504 2 12.22 2V2Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M19.5 8.5C19.8978 8.10218 20.2374 7.63877 20.5 7.12132C20.7626 6.60387 20.8478 6.03677 20.8478 5.464C20.8478 4.89123 20.7626 4.32413 20.5 3.80668C20.2374 3.28923 19.8978 2.82582 19.5 2.428C19.1022 2.03018 18.6388 1.69063 18.1213 1.42801C17.6039 1.16539 17.0368 1.08023 16.464 1.08023C15.8912 1.08023 15.3241 1.16539 14.8067 1.42801C14.2892 1.69063 13.8258 2.03018 13.428 2.428L12 3.856L10.572 2.428C9.78889 1.64486 8.73968 1.21408 7.64800 1.21408C6.55632 1.21408 5.50711 1.64486 4.72400 2.428C3.94086 3.21111 3.51008 4.26032 3.51008 5.352C3.51008 6.44368 3.94086 7.49289 4.72400 8.276L6.15200 9.704L12 15.552L17.848 9.704L19.276 8.276C19.5 8.052 19.5 8.276 19.5 8.5Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
NavbarBrand,
|
||||
NavbarContent,
|
||||
} from "@heroui/navbar";
|
||||
import { isWebViewFunc } from '@/utils/panel';
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { isWebViewFunc } from "@/utils/panel";
|
||||
import { Logo } from "@/components/icons";
|
||||
import { siteConfig, getCachedConfig } from "@/config/site";
|
||||
|
||||
@@ -20,22 +20,20 @@ export const Navbar = () => {
|
||||
// 检测是否在WebView中运行
|
||||
useEffect(() => {
|
||||
setIsWebView(isWebViewFunc());
|
||||
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 异步检查是否有更新的配置
|
||||
const checkForUpdates = async () => {
|
||||
try {
|
||||
const cachedAppName = await getCachedConfig('app_name');
|
||||
const cachedAppName = await getCachedConfig("app_name");
|
||||
|
||||
if (cachedAppName && cachedAppName !== appName) {
|
||||
setAppName(cachedAppName);
|
||||
// 同步更新siteConfig
|
||||
siteConfig.name = cachedAppName;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('检查配置更新失败:', error);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// 延迟执行,避免阻塞初始渲染
|
||||
@@ -44,27 +42,31 @@ export const Navbar = () => {
|
||||
// 监听配置更新事件
|
||||
const handleConfigUpdate = async () => {
|
||||
try {
|
||||
const cachedAppName = await getCachedConfig('app_name');
|
||||
const cachedAppName = await getCachedConfig("app_name");
|
||||
|
||||
if (cachedAppName) {
|
||||
setAppName(cachedAppName);
|
||||
siteConfig.name = cachedAppName;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('更新配置失败:', error);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
window.addEventListener('configUpdated', handleConfigUpdate);
|
||||
|
||||
window.addEventListener("configUpdated", handleConfigUpdate);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener('configUpdated', handleConfigUpdate);
|
||||
window.removeEventListener("configUpdated", handleConfigUpdate);
|
||||
};
|
||||
}, [appName]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeroUINavbar maxWidth="xl" position="sticky" height="60px" className="shrink-0">
|
||||
<HeroUINavbar
|
||||
className="shrink-0"
|
||||
height="60px"
|
||||
maxWidth="xl"
|
||||
position="sticky"
|
||||
>
|
||||
<NavbarContent className="basis-1/5 sm:basis-full" justify="start">
|
||||
<NavbarBrand className="gap-2 max-w-fit">
|
||||
<Link
|
||||
@@ -83,12 +85,27 @@ export const Navbar = () => {
|
||||
{isWebView && (
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||||
onClick={() => navigate('/settings')}
|
||||
title="面板设置"
|
||||
onClick={() => navigate("/settings")}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<path
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import AdminLayout from '@/layouts/admin';
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
import AdminLayout from "@/layouts/admin";
|
||||
|
||||
interface PageWrapperProps {
|
||||
children: React.ReactNode;
|
||||
@@ -8,11 +9,11 @@ interface PageWrapperProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PageWrapper({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
className = "container mx-auto max-w-7xl px-3 lg:px-6 py-8"
|
||||
export default function PageWrapper({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
className = "container mx-auto max-w-7xl px-3 lg:px-6 py-8",
|
||||
}: PageWrapperProps) {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
@@ -31,8 +32,8 @@ export default function PageWrapper({
|
||||
<div className="container mx-auto max-w-7xl px-3 lg:px-6 py-8">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin h-5 w-5 border-2 border-gray-200 dark:border-gray-700 border-t-gray-600 dark:border-t-gray-300 rounded-full"></div>
|
||||
<span className="text-default-600"></span>
|
||||
<div className="animate-spin h-5 w-5 border-2 border-gray-200 dark:border-gray-700 border-t-gray-600 dark:border-t-gray-300 rounded-full" />
|
||||
<span className="text-default-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,12 +46,10 @@ export default function PageWrapper({
|
||||
<div className={className}>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold mb-2 text-foreground">{title}</h1>
|
||||
{description && (
|
||||
<p className="text-default-600">{description}</p>
|
||||
)}
|
||||
{description && <p className="text-default-600">{description}</p>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useTheme } from '@heroui/use-theme';
|
||||
import React, { useEffect } from "react";
|
||||
import { useTheme } from "@heroui/use-theme";
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
@@ -11,17 +11,21 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
useEffect(() => {
|
||||
// 确保主题与HTML class同步
|
||||
const updateThemeClass = (currentTheme: string) => {
|
||||
if (currentTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.style.colorScheme = 'dark';
|
||||
if (currentTheme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
document.documentElement.style.colorScheme = "dark";
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.documentElement.style.colorScheme = 'light';
|
||||
document.documentElement.classList.remove("dark");
|
||||
document.documentElement.style.colorScheme = "light";
|
||||
}
|
||||
};
|
||||
|
||||
// 始终跟随系统主题
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
if (systemTheme !== theme) {
|
||||
setTheme(systemTheme);
|
||||
}
|
||||
@@ -30,15 +34,17 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
updateThemeClass(theme);
|
||||
|
||||
// 监听系统主题变化
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleThemeChange = (e: MediaQueryListEvent) => {
|
||||
const newTheme = e.matches ? 'dark' : 'light';
|
||||
const newTheme = e.matches ? "dark" : "light";
|
||||
|
||||
setTheme(newTheme);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleThemeChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleThemeChange);
|
||||
mediaQuery.addEventListener("change", handleThemeChange);
|
||||
|
||||
return () => mediaQuery.removeEventListener("change", handleThemeChange);
|
||||
}, [theme, setTheme]);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,29 +1,35 @@
|
||||
import { getConfigByName, getConfigs } from '@/api';
|
||||
import { getConfigByName, getConfigs } from "@/api";
|
||||
|
||||
export type SiteConfig = typeof siteConfig;
|
||||
|
||||
// 缓存相关常量
|
||||
const CACHE_PREFIX = 'vite_config_';
|
||||
const VERSION = "2.0.5-beta";
|
||||
const CACHE_PREFIX = "vite_config_";
|
||||
const VERSION = import.meta.env.VITE_APP_VERSION || "dev";
|
||||
const APP_VERSION = "1.0.3";
|
||||
const GITHUB_REPO =
|
||||
import.meta.env.VITE_GITHUB_REPO || "https://github.com/Sagit-chu/flux-panel";
|
||||
|
||||
const getInitialConfig = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
if (typeof window === "undefined") {
|
||||
return {
|
||||
name: "flux",
|
||||
version: VERSION,
|
||||
app_version: APP_VERSION,
|
||||
github_repo: GITHUB_REPO,
|
||||
};
|
||||
}
|
||||
|
||||
const cachedAppName = localStorage.getItem(CACHE_PREFIX + "app_name");
|
||||
|
||||
if (cachedAppName) {
|
||||
return {
|
||||
name: cachedAppName,
|
||||
version: VERSION,
|
||||
app_version: APP_VERSION,
|
||||
github_repo: GITHUB_REPO,
|
||||
};
|
||||
}
|
||||
|
||||
const cachedAppName = localStorage.getItem(CACHE_PREFIX + 'app_name');
|
||||
if (cachedAppName) {
|
||||
return {
|
||||
name: cachedAppName,
|
||||
version: VERSION,
|
||||
app_version: APP_VERSION,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: "flux",
|
||||
version: VERSION,
|
||||
@@ -38,44 +44,52 @@ export const configCache = {
|
||||
// 获取缓存的配置
|
||||
get: (key: string): string | null => {
|
||||
const cacheKey = CACHE_PREFIX + key;
|
||||
return localStorage.getItem(cacheKey);
|
||||
|
||||
return localStorage.getItem(cacheKey);
|
||||
},
|
||||
|
||||
// 设置缓存的配置
|
||||
set: (key: string, value: string): void => {
|
||||
const cacheKey = CACHE_PREFIX + key;
|
||||
localStorage.setItem(cacheKey, value);
|
||||
|
||||
localStorage.setItem(cacheKey, value);
|
||||
},
|
||||
|
||||
// 删除指定配置的缓存
|
||||
remove: (key: string): void => {
|
||||
const cacheKey = CACHE_PREFIX + key;
|
||||
|
||||
localStorage.removeItem(cacheKey);
|
||||
},
|
||||
|
||||
// 清空所有配置缓存
|
||||
clear: (): void => {
|
||||
// 获取所有localStorage的key
|
||||
const keys = Object.keys(localStorage);
|
||||
keys.forEach(key => {
|
||||
if (key.startsWith(CACHE_PREFIX)) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
// 获取所有localStorage的key
|
||||
const keys = Object.keys(localStorage);
|
||||
|
||||
keys.forEach((key) => {
|
||||
if (key.startsWith(CACHE_PREFIX)) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// 获取单个配置(优先从缓存)
|
||||
export const getCachedConfig = async (key: string): Promise<string | null> => {
|
||||
const cachedValue = configCache.get(key);
|
||||
|
||||
if (cachedValue !== null) {
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
const response = await getConfigByName(key);
|
||||
|
||||
if (response.code === 0 && response.data?.value) {
|
||||
const value = response.data.value;
|
||||
|
||||
configCache.set(key, value);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -85,32 +99,34 @@ export const getCachedConfig = async (key: string): Promise<string | null> => {
|
||||
// 获取所有配置(优先从缓存)
|
||||
export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
||||
// 尝试从缓存获取所有配置
|
||||
const configKeys = ['app_name'];
|
||||
const configKeys = ["app_name"];
|
||||
const cachedConfigs: Record<string, string> = {};
|
||||
let hasCachedData = false;
|
||||
|
||||
configKeys.forEach(key => {
|
||||
configKeys.forEach((key) => {
|
||||
const cachedValue = configCache.get(key);
|
||||
|
||||
if (cachedValue !== null) {
|
||||
cachedConfigs[key] = cachedValue;
|
||||
hasCachedData = true;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// 从API获取最新配置
|
||||
try {
|
||||
const response = await getConfigs();
|
||||
|
||||
if (response.code === 0 && response.data) {
|
||||
const configs = response.data;
|
||||
|
||||
// 将所有配置存入缓存
|
||||
Object.entries(configs).forEach(([key, value]) => {
|
||||
configCache.set(key, value as string);
|
||||
});
|
||||
|
||||
return configs;
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// API失败时返回缓存的数据
|
||||
if (hasCachedData) {
|
||||
return cachedConfigs;
|
||||
@@ -122,12 +138,13 @@ export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
||||
|
||||
// 动态更新网站配置
|
||||
export const updateSiteConfig = async () => {
|
||||
const appName = await getCachedConfig('app_name');
|
||||
if (appName && appName !== siteConfig.name) {
|
||||
siteConfig.name = appName;
|
||||
// 更新页面标题
|
||||
document.title = appName;
|
||||
}
|
||||
const appName = await getCachedConfig("app_name");
|
||||
|
||||
if (appName && appName !== siteConfig.name) {
|
||||
siteConfig.name = appName;
|
||||
// 更新页面标题
|
||||
document.title = appName;
|
||||
}
|
||||
};
|
||||
|
||||
// 清除配置缓存的工具函数
|
||||
@@ -137,7 +154,7 @@ export const updateSiteConfig = async () => {
|
||||
export const clearConfigCache = (keys?: string[]) => {
|
||||
if (keys && keys.length > 0) {
|
||||
// 删除指定的配置缓存
|
||||
keys.forEach(key => configCache.remove(key));
|
||||
keys.forEach((key) => configCache.remove(key));
|
||||
} else {
|
||||
// 清空所有配置缓存
|
||||
configCache.clear();
|
||||
@@ -145,7 +162,7 @@ export const clearConfigCache = (keys?: string[]) => {
|
||||
};
|
||||
|
||||
// 在页面加载时异步更新配置(如果有更新的话)
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
// 延迟执行,避免阻塞初始渲染
|
||||
setTimeout(() => {
|
||||
updateSiteConfig();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user