Compare commits

...

29 Commits

Author SHA1 Message Date
sagit 71c40127d5 Merge pull request #2 from Sagit-chu/opencode/sunny-island
fix: fix some bugs
2026-01-31 13:56:12 +08:00
root aac1d63ac0 feat: enable drag-sort in node monitoring 2026-01-31 05:52:52 +00:00
root 8222b31917 feat: enable drag-sort in tunnel management 2026-01-31 05:52:39 +00:00
root 81c9c117a2 feat: add tunnel/node update-order API clients 2026-01-31 05:52:26 +00:00
root de8e487dff feat: add update-order APIs for tunnel and node 2026-01-31 05:52:14 +00:00
root f4c59f64b0 feat: add inx ordering for tunnel and node 2026-01-31 05:51:20 +00:00
root 6adf90d45c fix: make node deletion detach tunnels safely
When deleting a node, detach it from affected tunnel chains and keep tunnel config consistent instead of deleting entire tunnels.
2026-01-31 05:00:18 +00:00
root 71eab8e07e chore: resolve latest release in install scripts
Install scripts now derive the release tag dynamically (with a CN mirror fallback) instead of being pinned to a single version.
2026-01-31 05:00:11 +00:00
sagit dd58ac67de feat: allow changing tunnel when editing forward rules
- Add tunnelId field to ForwardUpdateDto
- Refactor updateForward to support tunnel change:
  - Release old tunnel resources (GOST services + ForwardPort)
  - Allocate new tunnel resources with proper rollback
  - Preserve flow statistics (inFlow/outFlow)
- Remove tunnel selector disabled state in forward.tsx
- Bump version to 2.0.9
2026-01-24 05:27:51 +00:00
sagit e6226d8069 chore: update install scripts to use Sagit-chu repo and version 2.0.8 2026-01-24 04:59:39 +00:00
sagit 6e25e1838d ci: migrate Docker images to GHCR and add tag-triggered release
- Replace Docker Hub with GitHub Container Registry (ghcr.io)
- Add tag push trigger for automatic release creation
- Dynamic version from tag name (2.0.8 format)
- Frontend GitHub links now use env variable VITE_GITHUB_REPO
- Release artifacts auto-replace repo/version in scripts
2026-01-24 04:40:12 +00:00
sagit c0c88a8466 update readme 2026-01-24 04:28:02 +00:00
sagit 4edbb66224 feat: 支持编辑隧道入口/出口/转发链配置
- 后端: TunnelUpdateDto 添加 inNodeId, chainNodes, outNodeId 字段
- 后端: TunnelServiceImpl 重写 updateTunnel() 支持节点配置更新
- 后端: 添加自动回滚机制,更新失败时恢复原配置
- 前端: tunnel.tsx 解除编辑模式下的字段禁用限制
- 前端: 保留隧道类型不可修改(端口转发/隧道转发)
2026-01-24 04:22:55 +00:00
sagit 84e01c7851 docs: add AGENTS.md knowledge base files 2026-01-24 03:52:58 +00:00
qaq 761db1844d 修复流量暂停问题 2026-01-06 11:30:39 +08:00
qaq ca76cc29de 修复流量暂停问题 2026-01-06 11:26:43 +08:00
qaq c20a78a196 修复流量问题 2026-01-06 11:23:16 +08:00
qaq 2053cbec32 优化版本管理 2025-11-25 09:56:20 +08:00
bqlpfy 687e9c78ad Fix punctuation in README warning about version 2.0 2025-11-24 22:48:51 +08:00
bqlpfy 07105309fb Add warning for version 2.0 installation
Added warning about not installing version 2.0 due to issues.
2025-11-24 22:47:52 +08:00
qaq 31290137fe 优化gost执行诊断时计入DNS解析时间的问题 2025-11-21 16:27:58 +08:00
qaq 4a23f3b45a 优化gost执行诊断时计入DNS解析时间的问题 2025-11-21 16:24:45 +08:00
qaq 0b2974726c 修修复转发链v6格式化错误的问题 2025-11-21 10:17:53 +08:00
qaq 43aebdf8f4 修复agent时携带错误的屏蔽协议数据,编辑转发时无法使用原分配端口 2025-11-20 13:51:48 +08:00
qaq 37ea338a35 修复WAL模式下丢失数据问题 2025-11-20 12:15:30 +08:00
qaq 4063d39d6a 添加赞助商 2025-11-20 11:37:21 +08:00
qaq f6dd3ad657 修复v6,重复分配端口,指定网卡失败的问题 2025-11-20 11:27:58 +08:00
qaq 252c16de29 统一agent命名规则 2025-11-19 17:30:39 +08:00
qaq 4fef8eeb32 修复出口协议无法选择,加入自定义转发端口,增加arm支持 2025-11-19 16:32:48 +08:00
48 changed files with 3079 additions and 744 deletions
+235 -99
View File
@@ -1,118 +1,167 @@
name: Build and Push Images Based on Version
name: Build and Push Images
# 在这里定义统一版本号
env:
VERSION: "2.0.1-beta"
VERSION: "2.0.7-beta" # 分支推送时使用的默认版本
REGISTRY: ghcr.io
on:
push:
branches:
- main
- beta
tags:
- '[0-9]*' # 匹配 2.0.8, 2.0.8-beta 等格式
jobs:
check-version:
name: Check Version and Decide Build
runs-on: ubuntu-latest
outputs:
version: ${{ env.VERSION }}
should_build: ${{ steps.check-tag.outputs.should_build }}
version: ${{ steps.version.outputs.version }}
should_build: ${{ steps.version.outputs.should_build }}
should_build_gost: ${{ steps.version.outputs.should_build_gost }}
is_tag: ${{ steps.version.outputs.is_tag }}
image_owner: ${{ steps.version.outputs.image_owner }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
fetch-depth: 0 # 获取所有历史和 tags
fetch-depth: 0
- name: Display version
- name: Determine version and build strategy
id: version
run: |
echo "Current version: ${{ env.VERSION }}"
# 镜像 owner 需要小写
IMAGE_OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
echo "image_owner=$IMAGE_OWNER" >> $GITHUB_OUTPUT
- name: Check if tag exists
id: check-tag
run: |
if git rev-parse "${{ env.VERSION }}" >/dev/null 2>&1; then
echo "Tag ${{ env.VERSION }} already exists, skip build"
echo "should_build=false" >> $GITHUB_OUTPUT
else
echo "Tag ${{ env.VERSION }} does not exist, will build all components"
if [[ "${{ github.ref_type }}" == "tag" ]]; then
# Tag 触发:直接使用 tag 名作为版本,全量构建
VERSION="${{ github.ref_name }}"
echo "🏷️ Tag trigger detected: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "is_tag=true" >> $GITHUB_OUTPUT
echo "should_build=true" >> $GITHUB_OUTPUT
echo "should_build_gost=true" >> $GITHUB_OUTPUT
else
# 分支触发:使用 env.VERSION,检查是否需要构建
VERSION="${{ env.VERSION }}"
echo "🌿 Branch trigger detected, using version: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "is_tag=false" >> $GITHUB_OUTPUT
# 检查 tag 是否已存在
if git rev-parse "$VERSION" >/dev/null 2>&1; then
echo "Tag $VERSION already exists"
echo "should_build=false" >> $GITHUB_OUTPUT
# 检查 go-gost 目录是否有变化
TAG_COMMIT=$(git rev-list -n 1 "$VERSION")
if git diff --quiet --ignore-all-space --ignore-blank-lines $TAG_COMMIT HEAD -- go-gost/ 2>/dev/null; then
echo "✅ GOST files unchanged since tag"
echo "should_build_gost=false" >> $GITHUB_OUTPUT
else
echo "🔄 Detected changes in go-gost directory"
git diff --stat $TAG_COMMIT HEAD -- go-gost/ || true
echo "should_build_gost=true" >> $GITHUB_OUTPUT
fi
else
echo "Tag $VERSION does not exist, will build all components"
echo "should_build=true" >> $GITHUB_OUTPUT
echo "should_build_gost=true" >> $GITHUB_OUTPUT
fi
fi
build-gost:
name: Build & Compress GOST Binary
needs: check-version
if: needs.check-version.outputs.should_build == 'true'
if: needs.check-version.outputs.should_build_gost == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
uses: actions/setup-go@v5
with:
go-version: '1.21'
go-version: '1.23'
- name: Cache Go modules
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
key: ${{ runner.os }}-go-${{ hashFiles('go-gost/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Install UPX
run: |
wget https://github.com/upx/upx/releases/download/v4.2.1/upx-4.2.1-amd64_linux.tar.xz
wget -q https://github.com/upx/upx/releases/download/v4.2.1/upx-4.2.1-amd64_linux.tar.xz
tar -xf upx-4.2.1-amd64_linux.tar.xz
sudo mv upx-4.2.1-amd64_linux/upx /usr/local/bin/
rm -rf upx-4.2.1-amd64_linux*
- name: Build GOST binary
- name: Build GOST binary (AMD64)
working-directory: ./go-gost
run: |
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o gost
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -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
- name: Compress with UPX
working-directory: ./go-gost
run: |
upx --best --lzma gost
upx --best --lzma gost-amd64
upx --best --lzma gost-arm64
- name: Upload GOST artifact
- name: Upload GOST AMD64 artifact
uses: actions/upload-artifact@v4
with:
name: gost-binary
path: ./go-gost/gost
name: gost-binary-amd64
path: ./go-gost/gost-amd64
- name: Upload GOST ARM64 artifact
uses: actions/upload-artifact@v4
with:
name: gost-binary-arm64
path: ./go-gost/gost-arm64
build-vite:
name: Build & Push Vite Frontend
needs: check-version
if: needs.check-version.outputs.should_build == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@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: |
echo "VITE_GITHUB_REPO=https://github.com/${{ github.repository }}" > ./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 \
--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:
@@ -120,17 +169,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') }}
@@ -141,92 +193,176 @@ 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 \
--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 binary
- name: Download GOST AMD64 binary
uses: actions/download-artifact@v4
with:
name: gost-binary
path: ./artifacts
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 }}"
# 创建并推送 tag
git tag "${VERSION}" ${{ github.sha }}
git push origin "${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 "Version ${VERSION}
## 📝 Commit Information
- **Message**: ${COMMIT_MSG}
--notes "## 📝 Release Information
- **Version**: ${VERSION}
- **Commit**: [\`${GITHUB_SHA:0:7}\`](https://github.com/${{ github.repository }}/commit/${{ github.sha }})
- **Author**: ${COMMIT_AUTHOR}
- **Date**: ${COMMIT_DATE}
- **Commit**: [\`${GITHUB_SHA:0:7}\`](https://github.com/${{ github.repository }}/commit/${{ github.sha }})
## 📦 Components
- **GOST Binary** (Linux AMD64, compressed with UPX)
- **Vite Frontend** Docker Image: \`bqlpfy/vite-frontend:${VERSION}\` / \`bqlpfy/vite-frontend:latest\`
- **Spring Boot Backend** Docker Image: \`bqlpfy/springboot-backend:${VERSION}\` / \`bqlpfy/springboot-backend:latest\`
## 📥 Installation Files
- \`install.sh\` - GOST 客户端安装脚本
- \`panel_install.sh\` - 面板安装脚本
- \`docker-compose-v4.yml\` - Docker Compose 配置 (IPv4)
- \`docker-compose-v6.yml\` - Docker Compose 配置 (IPv6)
## ℹ️ Build Details
- Build date: $(date -u +%Y-%m-%dT%H:%M:%SZ)
- Branch: ${{ github.ref_name }}" \
- **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 --clobber
echo "📤 上传 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
gh release upload "${VERSION}" ./artifacts/install.sh --clobber
gh release upload "${VERSION}" ./artifacts/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}"
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
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: Rename binaries
run: |
mv ./artifacts/amd64/gost-amd64 ./artifacts/gost-amd64
mv ./artifacts/arm64/gost-arm64 ./artifacts/gost-arm64
- name: Update GOST binaries in Release
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${{ needs.check-version.outputs.version }}"
echo "🔄 更新 Release ${VERSION} 中的 GOST 二进制文件..."
gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
echo "✅ GOST 二进制文件更新完成"
@@ -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 .` 成功
+43
View File
@@ -0,0 +1,43 @@
# PROJECT KNOWLEDGE BASE
**Generated:** Sat Jan 24 2026
**Context:** Monorepo for Flux Panel (Traffic Forwarding)
## OVERVIEW
Flux Panel is a traffic forwarding management system based on [go-gost](https://github.com/go-gost/gost). It manages tunnels, port forwarding, and user quotas.
**Stack:** Monorepo (Java/Spring Boot Backend + React/Vite Frontend + Go/GOST Service).
## STRUCTURE
```
/root/flux-panel/
├── springboot-backend/ # Java 21 + Spring Boot 2.7 Admin API
├── vite-frontend/ # React 18 + Vite + HeroUI/NextUI
├── go-gost/ # Go 1.23 + GOST Extensions (Core logic)
├── docker-compose*.yml # Deployment configs (v4/v6)
└── *.sh # Install scripts (panel_install.sh, install.sh)
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| **Admin API** | `springboot-backend/` | Users, quotas, billing logic |
| **UI/Dashboard** | `vite-frontend/` | Management console |
| **Core Forwarding** | `go-gost/` | GOST implementation & extensions |
| **Deploy** | `docker-compose-v4.yml` | Container orchestration |
## CONVENTIONS
- **Monorepo**: 3 distinct languages/stacks. Treat each subdir as a separate project.
- **Docker**: Primary deployment method.
- **Scripts**: `panel_install.sh` for panel, `install.sh` for nodes.
## COMMANDS
```bash
# Quick Deploy (Panel)
./panel_install.sh
# Quick Deploy (Node)
./install.sh
# Docker
docker-compose -f docker-compose-v4.yml up -d
```
+15 -20
View File
@@ -1,7 +1,5 @@
# flux-panel转发面板 哆啦A梦转发面板
本项目基于 [go-gost/gost](https://github.com/go-gost/gost) 和 [go-gost/x](https://github.com/go-gost/x) 两个开源库,实现了转发面板。
---
## 特性
@@ -18,13 +16,23 @@
---
### Docker Compose部署
#### 快速部署
面板端:
面板端(稳定版):
```bash
curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
```
节点端:
节点端(稳定版):
```bash
curl -L https://raw.githubusercontent.com/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
```
面板端(开发版):
```bash
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/beta/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
```
节点端(开发版):
```bash
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/beta/install.sh -o install.sh && chmod +x install.sh && ./install.sh
```
@@ -58,17 +66,4 @@ curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/main/inst
作者对因使用本项目所造成的任何直接或间接损失概不负责,亦不提供任何形式的担保、承诺或技术支持。
请务必在合法、合规、安全的前提下使用本项目。
---
## ⭐ 喝杯咖啡!(USDT)
| 网络 | 地址 |
|------------|----------------------------------------------------------------------|
| BNB(BEP20) | `0x755492c03728851bbf855daa28a1e089f9aca4d1` |
| TRC20 | `TYh2L3xxXpuJhAcBWnt3yiiADiCSJLgUm7` |
| Aptos | `0xf2f9fb14749457748506a8281628d556e8540d1eb586d202cd8b02b99d369ef8` |
[![Star History Chart](https://api.star-history.com/svg?repos=bqlpfy/flux-panel&type=Date)](https://www.star-history.com/#bqlpfy/flux-panel&Date)
请务必在合法、合规、安全的前提下使用本项目。
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+4 -2
View File
@@ -1,6 +1,6 @@
services:
backend:
image: bqlpfy/springboot-backend:2.0.1-beta
image: bqlpfy/springboot-backend:2.0.7-beta
container_name: springboot-backend
restart: unless-stopped
environment:
@@ -15,6 +15,8 @@ services:
- sqlite_data:/app/data
networks:
- gost-network
stop_grace_period: 30s
stop_signal: SIGTERM
healthcheck:
test: ["CMD", "sh", "-c", "wget --no-verbose --tries=1 --spider http://localhost:6365/flow/test || exit 1"]
interval: 30s
@@ -23,7 +25,7 @@ services:
start_period: 60s
frontend:
image: bqlpfy/vite-frontend:2.0.1-beta
image: bqlpfy/vite-frontend:2.0.7-beta
container_name: vite-frontend
restart: unless-stopped
ports:
+4 -2
View File
@@ -1,6 +1,6 @@
services:
backend:
image: bqlpfy/springboot-backend:2.0.1-beta
image: bqlpfy/springboot-backend:2.0.7-beta
container_name: springboot-backend
restart: unless-stopped
environment:
@@ -15,6 +15,8 @@ services:
- sqlite_data:/app/data
networks:
- gost-network
stop_grace_period: 30s
stop_signal: SIGTERM
healthcheck:
test: ["CMD", "sh", "-c", "wget --no-verbose --tries=1 --spider http://localhost:6365/flow/test || exit 1"]
interval: 30s
@@ -23,7 +25,7 @@ services:
start_period: 60s
frontend:
image: bqlpfy/vite-frontend:2.0.1-beta
image: bqlpfy/vite-frontend:2.0.7-beta
container_name: vite-frontend
restart: unless-stopped
ports:
+31
View File
@@ -0,0 +1,31 @@
# GO-GOST SERVICE KNOWLEDGE BASE
## OVERVIEW
Core forwarding service based on GOST v3.
**Stack:** Go 1.23, GOST Core v0.3.1, GOST x (Extensions).
## STRUCTURE
```
go-gost/
├── main.go # Entry point
├── x/ # Local extensions (REPLACES github.com/go-gost/x)
│ ├── api/ # Management API
│ ├── registry/ # Service registry
│ ├── handler/ # Protocol handlers (socks, tunnel, relay)
│ └── listener/ # Network listeners (tcp, udp, tun/tap)
└── go.mod # Defines local replacement
```
## CONVENTIONS
- **Local Replace**: `go.mod` uses `replace github.com/go-gost/x => ./x`.
- **Extensions**: Custom logic lives in `x/`. This is the primary place for modifications.
- **Handlers**: Implements SOCKS5, Tunnel, Relay, etc.
## COMMANDS
```bash
# Run
go run .
# Build
go build .
```
+1 -1
View File
@@ -119,7 +119,7 @@ func main() {
log := xlogger.NewLogger()
logger.SetDefault(log)
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, "2.0.0")
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, "2.0.2")
defer wsReporter.Stop()
service.SetHTTPReportURL(config.Addr, config.Secret)
+117 -66
View File
@@ -14,8 +14,8 @@ import (
"time"
"github.com/go-gost/x/config"
"github.com/go-gost/x/service"
"github.com/go-gost/x/internal/util/crypto"
"github.com/go-gost/x/service"
"github.com/gorilla/websocket"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/host"
@@ -87,6 +87,9 @@ type TcpPingResponse struct {
type WebSocketReporter struct {
url string
addr string // 保存服务器地址
secret string // 保存密钥
version string // 保存版本号
conn *websocket.Conn
reconnectTime time.Duration
pingInterval time.Duration
@@ -195,7 +198,25 @@ func (w *WebSocketReporter) connect() error {
w.connecting = false
}()
u, err := url.Parse(w.url)
// 重新读取 config.json 获取最新的协议配置
type LocalConfig struct {
Addr string `json:"addr"`
Secret string `json:"secret"`
Http int `json:"http"`
Tls int `json:"tls"`
Socks int `json:"socks"`
}
var cfg LocalConfig
if b, err := os.ReadFile("config.json"); err == nil {
json.Unmarshal(b, &cfg)
}
// 使用最新的配置重新构建 URL
currentURL := "ws://" + w.addr + "/system-info?type=1&secret=" + w.secret + "&version=" + w.version +
"&http=" + strconv.Itoa(cfg.Http) + "&tls=" + strconv.Itoa(cfg.Tls) + "&socks=" + strconv.Itoa(cfg.Socks)
u, err := url.Parse(currentURL)
if err != nil {
return fmt.Errorf("解析URL失败: %v", err)
}
@@ -225,7 +246,7 @@ func (w *WebSocketReporter) connect() error {
return nil
})
fmt.Printf("✅ WebSocket连接建立成功\n")
fmt.Printf("✅ WebSocket连接建立成功 (http=%d, tls=%d, socks=%d)\n", cfg.Http, cfg.Tls, cfg.Socks)
return nil
}
@@ -775,81 +796,81 @@ func (w *WebSocketReporter) handleDeleteLimiter(data interface{}) error {
// handleSetProtocol 处理设置屏蔽协议的命令
func (w *WebSocketReporter) handleSetProtocol(data interface{}) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("序列化协议设置失败: %v", err)
}
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("序列化协议设置失败: %v", err)
}
// 支持 {"http":0/1, "tls":0/1, "socks":0/1}
var req struct {
HTTP *int `json:"http"`
TLS *int `json:"tls"`
SOCKS *int `json:"socks"`
}
if err := json.Unmarshal(jsonData, &req); err != nil {
return fmt.Errorf("解析协议设置失败: %v", err)
}
// 支持 {"http":0/1, "tls":0/1, "socks":0/1}
var req struct {
HTTP *int `json:"http"`
TLS *int `json:"tls"`
SOCKS *int `json:"socks"`
}
if err := json.Unmarshal(jsonData, &req); err != nil {
return fmt.Errorf("解析协议设置失败: %v", err)
}
// 读取当前值作为默认
httpVal, tlsVal, socksVal := 0, 0, 0
// 读取当前值作为默认
httpVal, tlsVal, socksVal := 0, 0, 0
if req.HTTP != nil {
if *req.HTTP != 0 && *req.HTTP != 1 {
return fmt.Errorf("http 取值必须为0或1")
}
httpVal = *req.HTTP
}
if req.TLS != nil {
if *req.TLS != 0 && *req.TLS != 1 {
return fmt.Errorf("tls 取值必须为0或1")
}
tlsVal = *req.TLS
}
if req.SOCKS != nil {
if *req.SOCKS != 0 && *req.SOCKS != 1 {
return fmt.Errorf("socks 取值必须为0或1")
}
socksVal = *req.SOCKS
}
if req.HTTP != nil {
if *req.HTTP != 0 && *req.HTTP != 1 {
return fmt.Errorf("http 取值必须为0或1")
}
httpVal = *req.HTTP
}
if req.TLS != nil {
if *req.TLS != 0 && *req.TLS != 1 {
return fmt.Errorf("tls 取值必须为0或1")
}
tlsVal = *req.TLS
}
if req.SOCKS != nil {
if *req.SOCKS != 0 && *req.SOCKS != 1 {
return fmt.Errorf("socks 取值必须为0或1")
}
socksVal = *req.SOCKS
}
// 设置至 service,全量传递(未提供的值沿用0)
service.SetProtocolBlock(httpVal, tlsVal, socksVal)
// 设置至 service,全量传递(未提供的值沿用0)
service.SetProtocolBlock(httpVal, tlsVal, socksVal)
// 同步写入本地 config.json
if err := updateLocalConfigJSON(httpVal, tlsVal, socksVal); err != nil {
return fmt.Errorf("写入config.json失败: %v", err)
}
return nil
// 同步写入本地 config.json
if err := updateLocalConfigJSON(httpVal, tlsVal, socksVal); err != nil {
return fmt.Errorf("写入config.json失败: %v", err)
}
return nil
}
// updateLocalConfigJSON 将 http/tls/socks 写入工作目录下的 config.json
func updateLocalConfigJSON(httpVal int, tlsVal int, socksVal int) error {
path := "config.json"
path := "config.json"
// 读取现有配置
type LocalConfig struct {
Addr string `json:"addr"`
Secret string `json:"secret"`
Http int `json:"http"`
Tls int `json:"tls"`
Socks int `json:"socks"`
}
// 读取现有配置
type LocalConfig struct {
Addr string `json:"addr"`
Secret string `json:"secret"`
Http int `json:"http"`
Tls int `json:"tls"`
Socks int `json:"socks"`
}
var cfg LocalConfig
if b, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal(b, &cfg)
}
var cfg LocalConfig
if b, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal(b, &cfg)
}
cfg.Http = httpVal
cfg.Tls = tlsVal
cfg.Socks = socksVal
cfg.Http = httpVal
cfg.Tls = tlsVal
cfg.Socks = socksVal
// 写回
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
// 写回
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// handleCall 处理服务端的call回调消息
@@ -1021,12 +1042,16 @@ func getMemoryInfo() MemoryInfo {
// StartWebSocketReporterWithConfig 使用配置字段启动WebSocket报告器
func StartWebSocketReporterWithConfig(addr string, secret string, http int, tls int, socks int, version string) *WebSocketReporter {
// 构建包含本机IP的WebSocket URL
// 构建初始 WebSocket URL
fullURL := "ws://" + addr + "/system-info?type=1&secret=" + secret + "&version=" + version + "&http=" + strconv.Itoa(http) + "&tls=" + strconv.Itoa(tls) + "&socks=" + strconv.Itoa(socks)
fmt.Printf("🔗 WebSocket连接URL: %s\n", fullURL)
reporter := NewWebSocketReporter(fullURL, secret)
// 保存 addr, secret, version 供重连时使用
reporter.addr = addr
reporter.secret = secret
reporter.version = version
reporter.Start()
return reporter
}
@@ -1107,6 +1132,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()
+103 -7
View File
@@ -1,12 +1,108 @@
#!/bin/bash
# 下载地址
DOWNLOAD_URL="https://github.com/bqlpfy/flux-panel/releases/download/2.0.1-beta/gost"
# GitHub repo used for release downloads
REPO="Sagit-chu/flux-panel"
# 获取系统架构
get_architecture() {
ARCH=$(uname -m)
case $ARCH in
x86_64)
echo "amd64"
;;
aarch64|arm64)
echo "arm64"
;;
*)
echo "amd64" # 默认使用 amd64
;;
esac
}
# 安装目录
INSTALL_DIR="/etc/flux_agent"
# 识别国家(用于镜像加速)
COUNTRY=$(curl -s https://ipinfo.io/country)
if [ "$COUNTRY" = "CN" ]; then
# 拼接 URL
DOWNLOAD_URL="https://ghfast.top/${DOWNLOAD_URL}"
fi
maybe_proxy_url() {
local url="$1"
if [ "$COUNTRY" = "CN" ]; then
echo "https://ghfast.top/${url}"
else
echo "$url"
fi
}
resolve_latest_release_tag() {
local effective_url tag api_tag latest_url api_url
latest_url="https://github.com/${REPO}/releases/latest"
api_url="https://api.github.com/repos/${REPO}/releases/latest"
# 方式1:跟随重定向,取最终 URL 的最后一段作为 tag
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$latest_url" 2>/dev/null || true)
tag="${effective_url##*/}"
if [[ -n "$tag" && "$tag" != "latest" ]]; then
echo "$tag"
return 0
fi
# CN 环境下可尝试通过镜像访问(不影响非 CN)
if [ "$COUNTRY" = "CN" ]; then
effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' -L "$(maybe_proxy_url "$latest_url")" 2>/dev/null || true)
tag="${effective_url##*/}"
if [[ -n "$tag" && "$tag" != "latest" ]]; then
echo "$tag"
return 0
fi
fi
# 方式2:GitHub API(无需 jq)
api_tag=$(curl -fsSL "$api_url" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
if [[ -n "$api_tag" ]]; then
echo "$api_tag"
return 0
fi
if [ "$COUNTRY" = "CN" ]; then
api_tag=$(curl -fsSL "$(maybe_proxy_url "$api_url")" 2>/dev/null | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' || true)
if [[ -n "$api_tag" ]]; then
echo "$api_tag"
return 0
fi
fi
return 1
}
resolve_version() {
if [[ -n "${VERSION:-}" ]]; then
echo "$VERSION"
return 0
fi
if [[ -n "${FLUX_VERSION:-}" ]]; then
echo "$FLUX_VERSION"
return 0
fi
if resolve_latest_release_tag; then
return 0
fi
echo "❌ 无法获取最新版本号。你可以手动指定版本,例如:VERSION=<版本号> ./install.sh" >&2
return 1
}
# 构建下载地址
build_download_url() {
local ARCH=$(get_architecture)
echo "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/gost-${ARCH}"
}
# 解析版本并构建下载地址
RESOLVED_VERSION=$(resolve_version) || exit 1
DOWNLOAD_URL=$(maybe_proxy_url "$(build_download_url)")
@@ -342,4 +438,4 @@ main() {
}
# 执行主函数
main
main
+83 -9
View File
@@ -7,16 +7,82 @@ export LC_ALL=C
# 全局下载地址配置
DOCKER_COMPOSEV4_URL="https://github.com/bqlpfy/flux-panel/releases/download/2.0.1-beta/docker-compose-v4.yml"
DOCKER_COMPOSEV6_URL="https://github.com/bqlpfy/flux-panel/releases/download/2.0.1-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
}
# 全局下载地址配置(默认获取最新版本;也可用 VERSION=... 覆盖)
RESOLVED_VERSION=$(resolve_version) || exit 1
DOCKER_COMPOSEV4_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/docker-compose-v4.yml")
DOCKER_COMPOSEV6_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${RESOLVED_VERSION}/docker-compose-v6.yml")
@@ -234,7 +300,15 @@ update_panel() {
configure_docker_ipv6
fi
echo "🛑 停止当前服务..."
# 先发送 SIGTERM 信号,让应用优雅关闭
docker stop -t 30 springboot-backend 2>/dev/null || true
docker stop -t 10 vite-frontend 2>/dev/null || true
# 等待 WAL 文件同步
echo "⏳ 等待数据同步..."
sleep 5
# 然后再完全停止
$DOCKER_CMD down
echo "⬇️ 拉取最新镜像..."
+37
View File
@@ -0,0 +1,37 @@
# SPRINGBOOT BACKEND KNOWLEDGE BASE
## OVERVIEW
Admin API for Flux Panel. Manages users, licenses, and traffic rules.
**Stack:** Java 21, Spring Boot 2.7.18, SQLite, MyBatis Plus.
## STRUCTURE
```
springboot-backend/
├── src/main/java/com/admin/
│ ├── controller/ # API Endpoints
│ ├── entity/ # DB Models (MyBatis Plus)
│ ├── mapper/ # Data Access
│ ├── service/ # Business Logic
│ └── common/ # Utils, DTOs
└── src/main/resources/
├── application.yml # Config
├── mapper/ # XML Mappers
├── data.sql # Init data
└── bgimages/ # Static resources
```
## CONVENTIONS
- **DB**: SQLite used via `sqlite-jdbc`.
- **ORM**: MyBatis Plus + MyBatis Plus Join.
- **JSON**: FastJSON2 used for serialization.
- **Utils**: Hutool used extensively.
- **Auth**: Likely custom or token-based (see `controller` logic).
## COMMANDS
```bash
# Build
mvn clean package
# Run
java -jar target/admin-0.0.1-SNAPSHOT.jar
```
+1 -1
View File
@@ -16,7 +16,7 @@ ENV LC_ALL=en_US.UTF-8
RUN sed -i 's/archive.ubuntu.com/mirrors.ustc.edu.cn/g' /etc/apt/sources.list \
&& sed -i 's/security.ubuntu.com/mirrors.ustc.edu.cn/g' /etc/apt/sources.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends fontconfig fonts-dejavu \
&& apt-get install -y --no-install-recommends fontconfig fonts-dejavu sqlite3 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
@@ -21,4 +21,6 @@ public class ForwardDto {
private String remoteAddr;
private String strategy;
private Integer inPort;
}
@@ -20,9 +20,13 @@ public class ForwardUpdateDto {
@NotBlank(message = "转发名称不能为空")
private String name;
private Integer tunnelId;
@NotBlank(message = "远程地址不能为空")
private String remoteAddr;
private String strategy;
private Integer inPort;
}
@@ -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<>();
}
@@ -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,54 @@
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");
}
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
);
}
}
@@ -1,5 +1,6 @@
package com.admin.common.task;
import com.admin.common.dto.GostDto;
import com.admin.common.utils.GostUtil;
import com.admin.entity.*;
import com.admin.service.*;
@@ -1,5 +1,7 @@
package com.admin.common.utils;
import cn.hutool.core.util.StrUtil;
import com.admin.common.dto.GostDto;
import com.admin.entity.*;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
@@ -12,26 +14,34 @@ import java.util.Objects;
public class GostUtil {
public static void AddLimiters(Long node_id, Long name, String speed) {
public static GostDto AddLimiters(Long node_id, Long name, String speed) {
JSONObject data = createLimiterData(name, speed);
WebSocketServer.send_msg(node_id, data, "AddLimiters");
GostDto gostDto = WebSocketServer.send_msg(node_id, data, "AddLimiters");
if (gostDto.getMsg().contains("exists")){
gostDto.setMsg("OK");
}
return gostDto;
}
public static void UpdateLimiters(Long node_id, Long name, String speed) {
public static GostDto UpdateLimiters(Long node_id, Long name, String speed) {
JSONObject data = createLimiterData(name, speed);
JSONObject req = new JSONObject();
req.put("limiter", name + "");
req.put("data", data);
WebSocketServer.send_msg(node_id, req, "UpdateLimiters");
return WebSocketServer.send_msg(node_id, req, "UpdateLimiters");
}
public static void DeleteLimiters(Long node_id, Long name) {
public static GostDto DeleteLimiters(Long node_id, Long name) {
JSONObject req = new JSONObject();
req.put("limiter", name + "");
WebSocketServer.send_msg(node_id, req, "DeleteLimiters");
GostDto gostDto = WebSocketServer.send_msg(node_id, req, "DeleteLimiters");
if (gostDto.getMsg().contains("not found")){
gostDto.setMsg("OK");
}
return gostDto;
}
public static void AddChains(Long node_id, List<ChainTunnel> chainTunnels, Map<Long, Node> node_s) {
public static GostDto AddChains(Long node_id, List<ChainTunnel> chainTunnels, Map<Long, Node> node_s) {
JSONArray nodes = new JSONArray();
for (ChainTunnel chainTunnel : chainTunnels) {
JSONObject dialer = new JSONObject();
@@ -43,19 +53,23 @@ public class GostUtil {
Node node_info = node_s.get(chainTunnel.getNodeId());
JSONObject node = new JSONObject();
node.put("name", "node_" + chainTunnel.getInx());
node.put("addr", node_info.getServerIp() + ":" + chainTunnel.getPort());
node.put("addr", processServerAddress(node_info.getServerIp() + ":" + chainTunnel.getPort()));
node.put("connector", connector);
node.put("dialer", dialer);
if (StringUtils.isNotBlank(node_info.getInterfaceName())) {
node.put("interface", node_info.getInterfaceName());
}
nodes.add(node);
}
JSONObject hop = new JSONObject();
hop.put("name", "hop_" + chainTunnels.getFirst().getTunnelId());
// interface设置在转发链
if (StringUtils.isNotBlank(node_s.get(node_id).getInterfaceName())) {
hop.put("interface", node_s.get(node_id).getInterfaceName());
}
JSONObject selector = new JSONObject();
selector.put("strategy", chainTunnels.getFirst().getStrategy());
selector.put("maxFails", 1);
@@ -72,24 +86,34 @@ public class GostUtil {
data.put("name", "chains_" + chainTunnels.getFirst().getTunnelId());
data.put("hops", hops);
WebSocketServer.send_msg(node_id, data, "AddChains");
GostDto gostDto = WebSocketServer.send_msg(node_id, data, "AddChains");
if (gostDto.getMsg().contains("exists")){
gostDto.setMsg("OK");
}
return gostDto;
}
public static void DeleteChains(Long node_id, String name) {
public static GostDto DeleteChains(Long node_id, String name) {
JSONObject data = new JSONObject();
data.put("chain", name);
WebSocketServer.send_msg(node_id, data, "DeleteChains");
GostDto gostDto = WebSocketServer.send_msg(node_id, data, "DeleteChains");
if (gostDto.getMsg().contains("not found")){
gostDto.setMsg("OK");
}
return gostDto;
}
public static void AddChainService(Long node_id, ChainTunnel chainTunnel, Map<Long, Node> node_s) {
public static GostDto AddChainService(Long node_id, ChainTunnel chainTunnel, Map<Long, Node> node_s) {
JSONArray services = new JSONArray();
Node node_info = node_s.get(chainTunnel.getNodeId());
JSONObject service_item = new JSONObject();
service_item.put("name", chainTunnel.getTunnelId() + "_tls");
service_item.put("addr", node_info.getTcpListenAddr() + ":" + chainTunnel.getPort());
if (StringUtils.isNotBlank(node_info.getInterfaceName())) {
// 只为出口节点(chainType=3)设置 interface
if (chainTunnel.getChainType() == 3 && StringUtils.isNotBlank(node_s.get(node_id).getInterfaceName())) {
JSONObject metadata = new JSONObject();
metadata.put("interface", node_info.getInterfaceName());
metadata.put("interface", node_s.get(node_id).getInterfaceName());
service_item.put("metadata", metadata);
}
@@ -106,16 +130,14 @@ public class GostUtil {
services.add(service_item);
WebSocketServer.send_msg(node_id, services, "AddService");
GostDto gostDto = WebSocketServer.send_msg(node_id, services, "AddService");
if (gostDto.getMsg().contains("exists")){
gostDto.setMsg("OK");
}
return gostDto;
}
public static void DeleteChainService(Long node_id, JSONArray services) {
JSONObject data = new JSONObject();
data.put("services", services);
WebSocketServer.send_msg(node_id, data, "DeleteService");
}
public static void AddAndUpdateService(String name, Integer limiter, Node node, Forward forward, ForwardPort forwardPort, Tunnel tunnel, String meth) {
public static GostDto AddAndUpdateService(String name, Integer limiter, Node node, Forward forward, ForwardPort forwardPort, Tunnel tunnel, String meth) {
JSONArray services = new JSONArray();
String[] protocols = {"tcp", "udp"};
for (String protocol : protocols) {
@@ -127,7 +149,8 @@ public class GostUtil {
service.put("addr", node.getUdpListenAddr() + ":" + forwardPort.getPort());
}
if (StringUtils.isNotBlank(node.getInterfaceName())) {
// 只在端口转发时设置 interface(隧道转发时 interface 在转发链的节点上设置)
if (tunnel.getType() == 1 && StringUtils.isNotBlank(node.getInterfaceName())) {
JSONObject metadata = new JSONObject();
metadata.put("interface", node.getInterfaceName());
service.put("metadata", metadata);
@@ -155,22 +178,30 @@ public class GostUtil {
services.add(service);
}
WebSocketServer.send_msg(node.getId(), services, meth);
GostDto gostDto = WebSocketServer.send_msg(node.getId(), services, meth);
if (gostDto.getMsg().contains("exists")){
gostDto.setMsg("OK");
}
return gostDto;
}
public static void DeleteService(Long node_id, JSONArray services) {
public static GostDto DeleteService(Long node_id, JSONArray services) {
JSONObject data = new JSONObject();
data.put("services", services);
WebSocketServer.send_msg(node_id, data, "DeleteService");
GostDto gostDto = WebSocketServer.send_msg(node_id, data, "DeleteService");
if (gostDto.getMsg().contains("not found")){
gostDto.setMsg("OK");
}
return gostDto;
}
public static void PauseAndResumeService(Long node_id, String name, String meth) {
public static GostDto PauseAndResumeService(Long node_id, String name, String meth) {
JSONObject data = new JSONObject();
JSONArray services = new JSONArray();
services.add(name + "_tcp");
services.add(name + "_udp");
data.put("services", services);
WebSocketServer.send_msg(node_id, data, meth);
return WebSocketServer.send_msg(node_id, data, meth);
}
@@ -208,7 +239,7 @@ public class GostUtil {
num++;
}
if (strategy == null || strategy.equals("")) {
if (strategy == null || strategy.isEmpty()) {
strategy = "fifo";
}
@@ -222,5 +253,42 @@ public class GostUtil {
return forwarder;
}
public static String processServerAddress(String serverAddr) {
if (StrUtil.isBlank(serverAddr)) {
return serverAddr;
}
// 如果已经被方括号包裹,直接返回
if (serverAddr.startsWith("[")) {
return serverAddr;
}
// 查找最后一个冒号,分离主机和端口
int lastColonIndex = serverAddr.lastIndexOf(':');
if (lastColonIndex == -1) {
// 没有端口号,直接检查是否需要包裹
return isIPv6Address(serverAddr) ? "[" + serverAddr + "]" : serverAddr;
}
String host = serverAddr.substring(0, lastColonIndex);
String port = serverAddr.substring(lastColonIndex);
// 检查主机部分是否为IPv6地址
if (isIPv6Address(host)) {
return "[" + host + "]" + port;
}
return serverAddr;
}
private static boolean isIPv6Address(String address) {
// IPv6地址包含多个冒号,至少2个
if (!address.contains(":")) {
return false;
}
// 计算冒号数量,IPv6地址至少有2个冒号
long colonCount = address.chars().filter(ch -> ch == ':').count();
return colonCount >= 2;
}
}
@@ -229,6 +229,7 @@ public class WebSocketServer extends TextWebSocketHandler {
String socks = (String) session.getAttributes().get("socks");
log.info("节点 {} 尝试连接,开始处理连接逻辑", nodeId);
log.info("屏蔽协议数据:http->{}, tls->{}, socks->{}", http, tls, socks);
// 检查是否已有该节点的连接,如果有则记录日志但直接覆盖
WebSocketSession existingSession = nodeSessions.get(nodeId);
@@ -3,8 +3,11 @@ package com.admin.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import javax.annotation.PreDestroy;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.Statement;
@@ -12,9 +15,11 @@ import java.sql.Statement;
/**
* SQLite 数据库配置
* 启用 WAL (Write-Ahead Logging) 模式以提高并发性能
* 添加定期 checkpoint 和优雅关闭处理
*/
@Slf4j
@Component
@EnableScheduling
public class SQLiteConfig implements ApplicationRunner {
private final DataSource dataSource;
@@ -33,10 +38,46 @@ public class SQLiteConfig implements ApplicationRunner {
statement.execute("PRAGMA cache_size=-64000;"); // 64MB 缓存
statement.execute("PRAGMA temp_store=MEMORY;");
statement.execute("PRAGMA busy_timeout=5000;"); // 5秒超时
statement.execute("PRAGMA wal_autocheckpoint=1000;"); // 每1000页自动checkpoint
log.info("SQLite WAL mode configured successfully");
} catch (Exception e) {
log.error("Failed to configure SQLite database", e);
throw e;
}
}
/**
* 定期执行 checkpoint,确保 WAL 文件内容写入主数据库
* 每5分钟执行一次
*/
@Scheduled(fixedDelay = 300000, initialDelay = 300000)
public void performCheckpoint() {
try (Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement()) {
statement.execute("PRAGMA wal_checkpoint(TRUNCATE);");
log.debug("SQLite WAL checkpoint completed");
} catch (Exception e) {
log.error("Failed to perform SQLite checkpoint", e);
}
}
/**
* 应用关闭前执行最终的 checkpoint,确保所有数据都写入主数据库文件
*/
@PreDestroy
public void onShutdown() {
log.info("Performing final SQLite checkpoint before shutdown...");
try (Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement()) {
// 强制执行 checkpoint,将所有 WAL 内容写入主数据库
statement.execute("PRAGMA wal_checkpoint(TRUNCATE);");
log.info("Final SQLite checkpoint completed successfully");
} catch (Exception e) {
log.error("Failed to perform final SQLite checkpoint", e);
}
}
}
@@ -7,11 +7,13 @@ import com.admin.common.task.CheckGostConfigAsync;
import com.admin.common.utils.AESCrypto;
import com.admin.common.utils.GostUtil;
import com.admin.entity.*;
import com.admin.service.ChainTunnelService;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import org.springframework.context.annotation.Lazy;
import org.springframework.web.bind.annotation.*;
import lombok.extern.slf4j.Slf4j;
@@ -64,6 +66,10 @@ public class FlowController extends BaseController {
@Resource
CheckGostConfigAsync checkGostConfigAsync;
@Resource
@Lazy
ChainTunnelService chainTunnelService;
/**
* 加密消息包装器
*/
@@ -306,17 +312,14 @@ public class FlowController extends BaseController {
}
public void pauseService(List<Forward> forwardList, String name) {
// for (Forward forward : forwardList) {
// Tunnel tunnel = tunnelService.getById(forward.getTunnelId());
// if (tunnel != null) {
// GostUtil.PauseService(tunnel.getInNodeId(), name);
// if (tunnel.getType() == 2) {
// GostUtil.PauseRemoteService(tunnel.getOutNodeId(), name);
// }
// }
// forward.setStatus(0);
// forwardService.updateById(forward);
// }
for (Forward forward : forwardList) {
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", forward.getTunnelId()).eq("chain_type", 1));
for (ChainTunnel chainTunnel : chainTunnels) {
GostUtil.PauseAndResumeService(chainTunnel.getNodeId(), name, "PauseService");
}
forward.setStatus(0);
forwardService.updateById(forward);
}
}
private void updateForwardFlow(String forwardId, FlowDto flowStats) {
@@ -54,12 +54,24 @@ 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);
}
}
@@ -137,4 +137,16 @@ 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);
}
}
@@ -14,7 +14,7 @@ import lombok.EqualsAndHashCode;
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class Node extends BaseEntity {
public class Node extends BaseEntity {
private static final long serialVersionUID = 1L;
@@ -38,6 +38,12 @@ 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;
}
@@ -3,8 +3,10 @@ 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;
import com.admin.entity.Node;
import com.baomidou.mybatisplus.extension.service.IService;
import java.util.Map;
/**
* <p>
@@ -14,7 +16,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 +26,12 @@ 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);
}
@@ -4,8 +4,10 @@ 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 com.admin.entity.Tunnel;
import com.baomidou.mybatisplus.extension.service.IService;
import java.util.Map;
/**
* <p>
@@ -15,7 +17,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
* @author QAQ
* @since 2025-06-03
*/
public interface TunnelService extends IService<Tunnel> {
public interface TunnelService extends IService<Tunnel> {
/**
* 创建隧道
@@ -55,7 +57,11 @@ public interface TunnelService extends IService<Tunnel> {
* @param tunnelId 隧道ID
* @return 诊断结果
*/
R diagnoseTunnel(Long tunnelId);
Integer getNodePort(Long nodeId, Integer type);
}
R diagnoseTunnel(Long tunnelId);
/**
* 更新隧道排序(管理员)
* @param params 包含tunnels数组的参数,每个元素包含id和inx
*/
R updateTunnelOrder(Map<String, Object> params);
}
@@ -18,6 +18,7 @@ import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.util.*;
@@ -82,53 +83,60 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
// 判断是否使用隧道的inIp
boolean useTunnelInIp = tunnel.getInIp() != null && !tunnel.getInIp().trim().isEmpty();
// 收集所有的IP列表
List<String> ipList = new ArrayList<>();
// 收集所有的端口列表
List<Integer> portList = new ArrayList<>();
Set<String> ipPortSet = new LinkedHashSet<>();
if (useTunnelInIp) {
// 使用隧道的inIp
// 使用隧道的inIp(求笛卡尔积)
List<String> ipList = new ArrayList<>();
List<Integer> portList = new ArrayList<>();
String[] tunnelInIps = tunnel.getInIp().split(",");
for (String ip : tunnelInIps) {
if (ip != null && !ip.trim().isEmpty()) {
ipList.add(ip.trim());
}
}
} else {
// 使用节点的serverIp
// 收集所有端口
for (ForwardPort forwardPort : forwardPorts) {
Node node = nodeService.getById(forwardPort.getNodeId());
if (node != null && node.getServerIp() != null) {
ipList.add(node.getServerIp());
if (forwardPort.getPort() != null) {
portList.add(forwardPort.getPort());
}
}
}
// 收集所有端口
for (ForwardPort forwardPort : forwardPorts) {
if (forwardPort.getPort() != null) {
portList.add(forwardPort.getPort());
// 去重
List<String> uniqueIps = ipList.stream().distinct().toList();
List<Integer> uniquePorts = portList.stream().distinct().toList();
// 组合 IP:Port(笛卡尔积)
for (String ip : uniqueIps) {
for (Integer port : uniquePorts) {
ipPortSet.add(ip + ":" + port);
}
}
// inPort设置为第一个端口(用于向后兼容)
if (!uniquePorts.isEmpty()) {
forward.setInPort(uniquePorts.getFirst());
}
} else {
// 使用节点的serverIp(一对一,不求笛卡尔积)
for (ForwardPort forwardPort : forwardPorts) {
Node node = nodeService.getById(forwardPort.getNodeId());
if (node != null && node.getServerIp() != null && forwardPort.getPort() != null) {
ipPortSet.add(node.getServerIp() + ":" + forwardPort.getPort());
}
}
// inPort设置为第一个端口(用于向后兼容)
if (!forwardPorts.isEmpty() && forwardPorts.getFirst().getPort() != null) {
forward.setInPort(forwardPorts.getFirst().getPort());
}
}
// 去重
List<String> uniqueIps = ipList.stream().distinct().toList();
List<Integer> uniquePorts = portList.stream().distinct().toList();
// 组合 IP:Port(笛卡尔积)
Set<String> ipPortSet = new LinkedHashSet<>();
for (String ip : uniqueIps) {
for (Integer port : uniquePorts) {
ipPortSet.add(ip + ":" + port);
}
}
// 设置入口IP和端口
// 设置入口IP
if (!ipPortSet.isEmpty()) {
forward.setInIp(String.join(",", ipPortSet));
// inPort设置为第一个端口(用于向后兼容)
forward.setInPort(uniquePorts.getFirst());
}
}
@@ -159,89 +167,229 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
forward.setUserName(currentUser.getUserName());
forward.setCreatedTime(System.currentTimeMillis());
forward.setUpdatedTime(System.currentTimeMillis());
List<JSONObject> success = new ArrayList<>();
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()).eq("chain_type", 1));
chainTunnels = get_port(chainTunnels, forwardDto.getInPort(), 0L);
this.save(forward);
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()).eq("chain_type", 1));
for (ChainTunnel chainTunnel : chainTunnels) {
Integer nodePort = tunnelService.getNodePort(chainTunnel.getNodeId(), 2);
ForwardPort forwardPort = new ForwardPort();
forwardPort.setForwardId(forward.getId());
forwardPort.setNodeId(chainTunnel.getNodeId());
forwardPort.setPort(nodePort);
forwardPort.setPort(chainTunnel.getPort());
forwardPortService.save(forwardPort);
String serviceName = buildServiceName(forward.getId(), forward.getUserId(), permissionResult.getUserTunnel());
Integer limiter = permissionResult.getLimiter();
Node node = nodeService.getById(chainTunnel.getNodeId());
if (node == null){
if (node == null) {
return R.err("部分节点不存在");
}
GostUtil.AddAndUpdateService(serviceName, limiter, node, forward, forwardPort, tunnel, "AddService");
GostDto gostDto = GostUtil.AddAndUpdateService(serviceName, limiter, node, forward, forwardPort, tunnel, "AddService");
if (Objects.equals(gostDto.getMsg(), "OK")) {
JSONObject data = new JSONObject();
data.put("node_id", node.getId());
data.put("name", serviceName);
success.add(data);
} else {
this.removeById(forward.getId());
forwardPortService.remove(new QueryWrapper<ForwardPort>().eq("forward_id", forward.getId()));
for (JSONObject jsonObject : success) {
JSONArray se = new JSONArray();
se.add(jsonObject.getString("name") + "_tcp");
se.add(jsonObject.getString("name") + "_udp");
GostUtil.DeleteService(jsonObject.getLong("node_id"), se);
return R.err(gostDto.getMsg());
}
}
}
return R.ok();
}
@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 = null;
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", oldTunnel.getId()).eq("chain_type", 1));
chainTunnels = get_port(chainTunnels, forwardUpdateDto.getInPort(), existForward.getId());
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()).eq("chain_type", 1));
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){
if (node == null) {
return R.err("部分节点不存在");
}
ForwardPort forwardPort = forwardPortService.getOne(new QueryWrapper<ForwardPort>().eq("forward_id", existForward.getId()).eq("node_id", node.getId()));
if (forwardPort == null){
if (forwardPort == null) {
return R.err("部分节点不存在1");
}
GostUtil.AddAndUpdateService(serviceName, limiter, node, existForward, forwardPort, tunnel, "UpdateService");
forwardPort.setPort(chainTunnel.getPort());
forwardPortService.updateById(forwardPort);
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) {
@@ -282,7 +430,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
String serviceName = buildServiceName(forward.getId(), forward.getUserId(), userTunnel);
Node node = nodeService.getById(chainTunnel.getNodeId());
if (node == null){
if (node == null) {
return R.err("部分节点不存在");
}
@@ -298,7 +446,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
@Override
public R pauseForward(Long id) {
return changeForwardStatus(id, 0, "PauseService");
return changeForwardStatus(id, 0, "PauseService");
}
@Override
@@ -393,7 +541,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
// 1. 入口->第一跳(或出口)
for (ChainTunnel inNode : inNodes) {
Node fromNode = nodeService.getById(inNode.getNodeId());
if (fromNode != null) {
if (!chainNodesList.isEmpty()) {
for (ChainTunnel firstChainNode : chainNodesList.getFirst()) {
@@ -429,10 +577,10 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
// 2. 链路测试
for (int i = 0; i < chainNodesList.size(); i++) {
List<ChainTunnel> currentHop = chainNodesList.get(i);
for (ChainTunnel currentNode : currentHop) {
Node fromNode = nodeService.getById(currentNode.getNodeId());
if (fromNode != null) {
if (i + 1 < chainNodesList.size()) {
for (ChainTunnel nextNode : chainNodesList.get(i + 1)) {
@@ -500,65 +648,57 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
}
@Override
@Transactional
public R updateForwardOrder(Map<String, Object> params) {
try {
// 1. 获取当前用户信息
UserInfo currentUser = getCurrentUserInfo();
// 1. 获取当前用户信息
UserInfo currentUser = getCurrentUserInfo();
// 2. 验证参数
if (!params.containsKey("forwards")) {
return R.err("缺少forwards参数");
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> forwardsList = (List<Map<String, Object>>) params.get("forwards");
if (forwardsList == null || forwardsList.isEmpty()) {
return R.err("forwards参数不能为空");
}
// 3. 验证用户权限(只能更新自己的转发)
if (currentUser.getRoleId() != 0) {
// 普通用户只能更新自己的转发
List<Long> forwardIds = forwardsList.stream()
.map(item -> Long.valueOf(item.get("id").toString()))
.collect(Collectors.toList());
// 检查所有转发是否属于当前用户
QueryWrapper<Forward> queryWrapper = new QueryWrapper<>();
queryWrapper.in("id", forwardIds);
queryWrapper.eq("user_id", currentUser.getUserId());
long count = this.count(queryWrapper);
if (count != forwardIds.size()) {
return R.err("只能更新自己的转发排序");
}
}
// 4. 批量更新排序
List<Forward> forwardsToUpdate = new ArrayList<>();
for (Map<String, Object> forwardData : forwardsList) {
Long id = Long.valueOf(forwardData.get("id").toString());
Integer inx = Integer.valueOf(forwardData.get("inx").toString());
Forward forward = new Forward();
forward.setId(id);
forward.setInx(inx);
forwardsToUpdate.add(forward);
}
// 5. 执行批量更新
boolean success = this.updateBatchById(forwardsToUpdate);
if (success) {
log.info("用户 {} 更新了 {} 个转发的排序", currentUser.getUserName(), forwardsToUpdate.size());
return R.ok("排序更新成功");
} else {
return R.err("排序更新失败");
}
} catch (Exception e) {
log.error("更新转发排序失败", e);
return R.err("更新排序时发生错误: " + e.getMessage());
// 2. 验证参数
if (!params.containsKey("forwards")) {
return R.err("缺少forwards参数");
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> forwardsList = (List<Map<String, Object>>) params.get("forwards");
if (forwardsList == null || forwardsList.isEmpty()) {
return R.err("forwards参数不能为空");
}
// 3. 验证用户权限(只能更新自己的转发)
if (currentUser.getRoleId() != 0) {
// 普通用户只能更新自己的转发
List<Long> forwardIds = forwardsList.stream()
.map(item -> Long.valueOf(item.get("id").toString()))
.collect(Collectors.toList());
// 检查所有转发是否属于当前用户
QueryWrapper<Forward> queryWrapper = new QueryWrapper<>();
queryWrapper.in("id", forwardIds);
queryWrapper.eq("user_id", currentUser.getUserId());
long count = this.count(queryWrapper);
if (count != forwardIds.size()) {
return R.err("只能更新自己的转发排序");
}
}
// 4. 批量更新排序
List<Forward> forwardsToUpdate = new ArrayList<>();
for (Map<String, Object> forwardData : forwardsList) {
Long id = Long.valueOf(forwardData.get("id").toString());
Integer inx = Integer.valueOf(forwardData.get("inx").toString());
Forward forward = new Forward();
forward.setId(id);
forward.setInx(inx);
forwardsToUpdate.add(forward);
}
// 5. 执行批量更新
this.updateBatchById(forwardsToUpdate);
return R.ok();
}
@@ -614,10 +754,11 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
for (ChainTunnel chainTunnel : chainTunnels) {
String serviceName = buildServiceName(forward.getId(), forward.getUserId(), userTunnel);
Node node = nodeService.getById(chainTunnel.getNodeId());
if (node == null){
if (node == null) {
return R.err("部分节点不存在");
}
GostUtil.PauseAndResumeService(node.getId(), serviceName, gostMethod);
GostDto gostDto = GostUtil.PauseAndResumeService(node.getId(), serviceName, gostMethod);
if (!Objects.equals(gostDto.getMsg(), "OK")) return R.err(gostDto.getMsg());
}
forward.setStatus(targetStatus);
forward.setUpdatedTime(System.currentTimeMillis());
@@ -916,6 +1057,94 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
return forwardId + "_" + userId + "_" + userTunnelId;
}
public List<ChainTunnel> get_port(List<ChainTunnel> chainTunnelList, Integer in_port, Long forward_id) {
List<List<Integer>> list = new ArrayList<>();
// 获取每个节点的端口列表
for (ChainTunnel tunnel : chainTunnelList) {
List<Integer> nodePort = getNodePort(tunnel.getNodeId(), forward_id);
if (nodePort.isEmpty()) {
throw new RuntimeException("暂无可用端口");
}
list.add(nodePort);
}
// ========== 如果指定了 in_port,优先检查公有 ==========
if (in_port != null) {
for (List<Integer> ports : list) {
if (!ports.contains(in_port)) {
throw new RuntimeException("指定端口 " + in_port + " 不可用(并非所有节点都有此端口)");
}
}
// 所有节点都有该端口 设置回 ChainTunnel
for (ChainTunnel tunnel : chainTunnelList) {
tunnel.setPort(in_port);
}
return chainTunnelList;
}
// ========== 未指定 in_port 查找最小的共同端口 ==========
Set<Integer> intersection = new HashSet<>(list.getFirst());
for (int i = 1; i < list.size(); i++) {
intersection.retainAll(list.get(i));
}
if (!intersection.isEmpty()) {
// 找最小端口
Integer commonMin = intersection.stream().min(Integer::compareTo).orElseThrow();
// 设置到所有节点
for (ChainTunnel tunnel : chainTunnelList) {
tunnel.setPort(commonMin);
}
return chainTunnelList;
}
// ========== 没有共同端口取各自第一个可用端口 ==========
for (int i = 0; i < chainTunnelList.size(); i++) {
List<Integer> ports = list.get(i);
Integer first = ports.getFirst();
chainTunnelList.get(i).setPort(first);
}
return chainTunnelList;
}
public List<Integer> getNodePort(Long nodeId, Long forward_id) {
Node node = nodeService.getById(nodeId);
if (node == null) {
throw new RuntimeException("节点不存在");
}
// 1. 查询隧道转发链占用的端口
List<ChainTunnel> chainTunnels = chainTunnelService.list(
new QueryWrapper<ChainTunnel>().eq("node_id", nodeId)
);
Set<Integer> usedPorts = chainTunnels.stream()
.map(ChainTunnel::getPort)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
List<ForwardPort> list = forwardPortService.list(new QueryWrapper<ForwardPort>().eq("node_id", nodeId).ne("forward_id", forward_id));
Set<Integer> forwardUsedPorts = new HashSet<>();
for (ForwardPort forwardPort : list) {
forwardUsedPorts.add(forwardPort.getPort());
}
usedPorts.addAll(forwardUsedPorts);
List<Integer> parsedPorts = TunnelServiceImpl.parsePorts(node.getPort());
return parsedPorts.stream()
.filter(p -> !usedPorts.contains(p))
.toList();
}
// ========== 内部数据类 ==========
@Data
@@ -966,7 +1195,7 @@ public class ForwardServiceImpl extends ServiceImpl<ForwardMapper, Forward> impl
private double averageTime;
private double packetLoss;
private long timestamp;
// 链路类型相关字段
private Integer fromChainType; // 1: 入口, 2: 链, 3: 出口
private Integer fromInx;
@@ -6,28 +6,37 @@ 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.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.List;
import java.util.Objects;
import java.util.regex.Pattern;
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 org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.transaction.annotation.Transactional;
@Service
public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements NodeService {
@Service
@Slf4j
public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements NodeService {
@Resource
@@ -37,8 +46,11 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
@Resource
ViteConfigService viteConfigService;
@Resource
ChainTunnelService chainTunnelService;
@Resource
ChainTunnelService chainTunnelService;
@Resource
ForwardPortService forwardPortService;
@Override
@@ -58,12 +70,40 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
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) {
@@ -100,20 +140,232 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
return R.ok();
}
@Override
public R deleteNode(Long id) {
Node node = this.getById(id);
if (node == null) {
return R.err("节点不存在");
}
List<ChainTunnel> list = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("node_id", id).groupBy("tunnel_id"));
for (ChainTunnel tunnel : list) {
tunnelService.deleteTunnel(tunnel.getTunnelId());
}
this.removeById(id);
return R.ok();
}
@Override
public R deleteNode(Long id) {
Node node = this.getById(id);
if (node == null) {
return R.err("节点不存在");
}
List<ChainTunnel> affected = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("node_id", id));
Map<Long, List<ChainTunnel>> byTunnelId = affected.stream()
.filter(ct -> ct.getTunnelId() != null)
.collect(Collectors.groupingBy(ChainTunnel::getTunnelId));
for (Map.Entry<Long, List<ChainTunnel>> entry : byTunnelId.entrySet()) {
Long tunnelId = entry.getKey();
Tunnel tunnel = tunnelService.getById(tunnelId);
List<ChainTunnel> before = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnelId));
// Remove the node from the tunnel definition (do NOT delete the tunnel).
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnelId).eq("node_id", id));
if (tunnel == null) {
continue;
}
List<ChainTunnel> after = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnelId));
Integer removedChainType = entry.getValue().isEmpty() ? null : entry.getValue().get(0).getChainType();
// Keep tunnel.inIp consistent when it was auto-derived from entry nodes.
String oldDerivedInIp = buildDerivedInIp(before);
String newDerivedInIp = buildDerivedInIp(after);
if (shouldUpdateTunnelInIp(tunnel.getInIp(), oldDerivedInIp)) {
updateTunnelInIp(tunnelId, newDerivedInIp);
}
boolean valid = isTunnelConfigValid(tunnel, after);
if (!valid) {
disableTunnelAndCleanupGostIfNeeded(tunnel, after, "node-delete");
continue;
}
// For tunnel-forwarding (type=2), removing a chain/out node requires rebuilding config.
// Removing an entry node (chainType=1) does not affect remaining nodes' chain targets.
if (tunnel.getType() != null && tunnel.getType() == 2 && removedChainType != null && removedChainType != 1) {
try {
cleanupGostConfig(after, tunnelId);
rebuildGostConfig(after, tunnel);
} catch (Exception e) {
log.warn("Failed to rebuild gost config after node delete. tunnelId={}, nodeId={}, err={}", tunnelId, id, e.getMessage(), e);
disableTunnelAndCleanupGostIfNeeded(tunnel, after, "node-delete:rebuild-failed");
}
}
}
// Remove per-forward port allocations on this node (avoid orphan ForwardPort rows).
try {
forwardPortService.remove(new QueryWrapper<ForwardPort>().eq("node_id", id));
} catch (Exception e) {
log.warn("Failed to cleanup forward ports when deleting node. nodeId={}, err={}", id, e.getMessage(), e);
}
this.removeById(id);
return R.ok();
}
private boolean isTunnelConfigValid(Tunnel tunnel, List<ChainTunnel> chainTunnels) {
if (tunnel == null || chainTunnels == null) {
return false;
}
long inCount = chainTunnels.stream()
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
.count();
if (inCount <= 0) {
return false;
}
if (tunnel.getType() != null && tunnel.getType() == 2) {
long outCount = chainTunnels.stream()
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 3)
.count();
return outCount > 0;
}
return true;
}
private boolean shouldUpdateTunnelInIp(String currentInIp, String oldDerivedInIp) {
if (StrUtil.isBlank(currentInIp)) {
return true;
}
if (oldDerivedInIp == null) {
return false;
}
return Objects.equals(currentInIp, oldDerivedInIp);
}
private void updateTunnelInIp(Long tunnelId, String derivedInIp) {
Tunnel update = new Tunnel();
update.setId(tunnelId);
update.setInIp(derivedInIp == null ? "" : derivedInIp);
update.setUpdatedTime(System.currentTimeMillis());
tunnelService.updateById(update);
}
private String buildDerivedInIp(List<ChainTunnel> chainTunnels) {
if (chainTunnels == null) {
return "";
}
List<ChainTunnel> inNodes = chainTunnels.stream()
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
.collect(Collectors.toList());
if (inNodes.isEmpty()) {
return "";
}
StringBuilder inIp = new StringBuilder();
for (ChainTunnel inNode : inNodes) {
Node n = this.getById(inNode.getNodeId());
if (n == null || StrUtil.isBlank(n.getServerIp())) {
return null;
}
inIp.append(n.getServerIp()).append(",");
}
inIp.deleteCharAt(inIp.length() - 1);
return inIp.toString();
}
private void disableTunnelAndCleanupGostIfNeeded(Tunnel tunnel, List<ChainTunnel> remaining, String reason) {
try {
Tunnel update = new Tunnel();
update.setId(tunnel.getId());
update.setStatus(0);
update.setUpdatedTime(System.currentTimeMillis());
tunnelService.updateById(update);
} catch (Exception e) {
log.warn("Failed to disable tunnel. tunnelId={}, reason={}, err={}", tunnel.getId(), reason, e.getMessage(), e);
}
if (tunnel.getType() != null && tunnel.getType() == 2) {
try {
cleanupGostConfig(remaining, tunnel.getId());
} catch (Exception e) {
log.warn("Failed to cleanup gost config when disabling tunnel. tunnelId={}, reason={}, err={}", tunnel.getId(), reason, e.getMessage(), e);
}
}
}
private void cleanupGostConfig(List<ChainTunnel> chainTunnels, Long tunnelId) {
if (chainTunnels == null) {
return;
}
for (ChainTunnel chainTunnel : chainTunnels) {
if (chainTunnel.getChainType() == null) {
continue;
}
if (chainTunnel.getChainType() == 1) {
GostUtil.DeleteChains(chainTunnel.getNodeId(), "chains_" + tunnelId);
} else if (chainTunnel.getChainType() == 2) {
GostUtil.DeleteChains(chainTunnel.getNodeId(), "chains_" + tunnelId);
JSONArray services = new JSONArray();
services.add(tunnelId + "_tls");
GostUtil.DeleteService(chainTunnel.getNodeId(), services);
} else if (chainTunnel.getChainType() == 3) {
JSONArray services = new JSONArray();
services.add(tunnelId + "_tls");
GostUtil.DeleteService(chainTunnel.getNodeId(), services);
}
}
}
private void rebuildGostConfig(List<ChainTunnel> chainTunnels, Tunnel tunnel) {
if (tunnel == null || chainTunnels == null) {
return;
}
Map<Long, Node> nodes = new HashMap<>();
for (ChainTunnel ct : chainTunnels) {
Node n = this.getById(ct.getNodeId());
if (n != null) {
nodes.put(n.getId(), n);
}
}
List<ChainTunnel> inNodes = chainTunnels.stream()
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
.collect(Collectors.toList());
Map<Integer, List<ChainTunnel>> chainNodesMap = chainTunnels.stream()
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 2)
.collect(Collectors.groupingBy(ct -> ct.getInx() != null ? ct.getInx() : 0));
List<List<ChainTunnel>> chainNodesList = chainNodesMap.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(Map.Entry::getValue)
.collect(Collectors.toList());
List<ChainTunnel> outNodes = chainTunnels.stream()
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 3)
.collect(Collectors.toList());
if (tunnel.getType() != null && tunnel.getType() == 2) {
for (ChainTunnel inNode : inNodes) {
if (chainNodesList.isEmpty()) {
GostUtil.AddChains(inNode.getNodeId(), outNodes, nodes);
} else {
GostUtil.AddChains(inNode.getNodeId(), chainNodesList.get(0), nodes);
}
}
for (int i = 0; i < chainNodesList.size(); i++) {
for (ChainTunnel chainNode : chainNodesList.get(i)) {
if (i + 1 >= chainNodesList.size()) {
GostUtil.AddChains(chainNode.getNodeId(), outNodes, nodes);
} else {
GostUtil.AddChains(chainNode.getNodeId(), chainNodesList.get(i + 1), nodes);
}
GostUtil.AddChainService(chainNode.getNodeId(), chainNode, nodes);
}
}
for (ChainTunnel outNode : outNodes) {
GostUtil.AddChainService(outNode.getNodeId(), outNode, nodes);
}
}
}
@Override
@@ -125,9 +377,9 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
ViteConfig viteConfig = viteConfigService.getOne(new QueryWrapper<ViteConfig>().eq("name", "ip"));
if (viteConfig == null) return R.err("请先前往网站配置中设置ip");
StringBuilder command = new StringBuilder();
command.append("curl -L https://github.com/bqlpfy/flux-panel/releases/download/2.0.1-beta/install.sh")
.append(" -o ./install.sh && chmod +x ./install.sh && ");
String processedServerAddr = processServerAddress(viteConfig.getValue());
command.append("curl -L https://github.com/Sagit-chu/flux-panel/releases/latest/download/install.sh")
.append(" -o ./install.sh && chmod +x ./install.sh && ");
String processedServerAddr = GostUtil.processServerAddress(viteConfig.getValue());
command.append("./install.sh")
.append(" -a ").append(processedServerAddr) // 服务器地址
.append(" -s ").append(node.getSecret()); // 节点密钥
@@ -182,44 +434,6 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
}
private String processServerAddress(String serverAddr) {
if (StrUtil.isBlank(serverAddr)) {
return serverAddr;
}
// 如果已经被方括号包裹,直接返回
if (serverAddr.startsWith("[")) {
return serverAddr;
}
// 查找最后一个冒号,分离主机和端口
int lastColonIndex = serverAddr.lastIndexOf(':');
if (lastColonIndex == -1) {
// 没有端口号,直接检查是否需要包裹
return isIPv6Address(serverAddr) ? "[" + serverAddr + "]" : serverAddr;
}
String host = serverAddr.substring(0, lastColonIndex);
String port = serverAddr.substring(lastColonIndex);
// 检查主机部分是否为IPv6地址
if (isIPv6Address(host)) {
return "[" + host + "]" + port;
}
return serverAddr;
}
private boolean isIPv6Address(String address) {
// IPv6地址包含多个冒号,至少2个
if (!address.contains(":")) {
return false;
}
// 计算冒号数量,IPv6地址至少有2个冒号
long colonCount = address.chars().filter(ch -> ch == ':').count();
return colonCount >= 2;
}
}
@@ -8,6 +8,7 @@ import com.admin.common.utils.GostUtil;
import com.admin.entity.*;
import com.admin.mapper.SpeedLimitMapper;
import com.admin.service.*;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.Data;
@@ -19,6 +20,7 @@ import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
@@ -65,11 +67,23 @@ public class SpeedLimitServiceImpl extends ServiceImpl<SpeedLimitMapper, SpeedLi
String speedInMBps = convertBitsToMBps(speedLimit.getSpeed());
List<Long> limit_success = new ArrayList<>();
List<ChainTunnel> tunnelList = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", speedLimit.getTunnelId()));
for (ChainTunnel chainTunnel : tunnelList) {
Node node = nodeService.getById(chainTunnel.getNodeId());
if (node != null) {
GostUtil.AddLimiters(node.getId(),speedLimit.getId(),speedInMBps);
GostDto gostDto = GostUtil.AddLimiters(node.getId(), speedLimit.getId(), speedInMBps);
if (Objects.equals(gostDto.getMsg(), "OK")){
limit_success.add(node.getId());
}else {
this.removeById(speedLimit.getId());
for (Long node_id : limit_success) {
GostDto deleteLimiters = GostUtil.DeleteLimiters(node_id, speedLimit.getId());
System.out.println(deleteLimiters);
}
return R.err(gostDto.getMsg());
}
}
}
return R.ok();
@@ -94,7 +108,8 @@ public class SpeedLimitServiceImpl extends ServiceImpl<SpeedLimitMapper, SpeedLi
for (ChainTunnel chainTunnel : tunnelList) {
Node node = nodeService.getById(chainTunnel.getNodeId());
if (node != null) {
GostUtil.UpdateLimiters(node.getId(),speedLimit.getId(),speedInMBps);
GostDto gostDto = GostUtil.UpdateLimiters(node.getId(), speedLimit.getId(), speedInMBps);
if (!Objects.equals(gostDto.getMsg(), "OK")) return R.err(gostDto.getMsg());
}
}
this.updateById(speedLimit);
@@ -116,7 +131,8 @@ public class SpeedLimitServiceImpl extends ServiceImpl<SpeedLimitMapper, SpeedLi
for (ChainTunnel chainTunnel : tunnelList) {
Node node = nodeService.getById(chainTunnel.getNodeId());
if (node != null) {
GostUtil.DeleteLimiters(node.getId(),speedLimit.getId());
GostDto gostDto = GostUtil.DeleteLimiters(node.getId(), speedLimit.getId());
if (!Objects.equals(gostDto.getMsg(), "OK"))return R.err(gostDto.getMsg());
}
}
this.removeById(id);
@@ -18,6 +18,7 @@ import lombok.Data;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.util.*;
@@ -81,7 +82,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
Node node = nodeService.getById(chain_node.getNodeId());
if (node == null) return R.err("节点不存在");
nodes.put(node.getId(), node);
Integer nodePort = getNodePort(chain_node.getNodeId(), 1);
Integer nodePort = getNodePort(chain_node.getNodeId());
chain_node.setPort(nodePort);
chain_node.setInx(inx); // 设置转发链序号
chainTunnels.add(chain_node);
@@ -93,7 +94,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
Node node = nodeService.getById(out_node.getNodeId());
if (node == null) return R.err("节点不存在");
nodes.put(node.getId(), node);
Integer nodePort = getNodePort(out_node.getNodeId(), 1);
Integer nodePort = getNodePort(out_node.getNodeId());
out_node.setPort(nodePort);
chainTunnels.add(out_node);
}
@@ -132,14 +133,35 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
}
chainTunnelService.saveBatch(chainTunnels);
List<JSONObject> chain_success = new ArrayList<>();
List<JSONObject> service_success = new ArrayList<>();
if (tunnel.getType() == 2) {
for (ChainTunnel in_node : tunnelDto.getInNodeId()) {
// 创建Chain, 指向chainNode的第一跳。如果chainNode为空就是指向出口
if (tunnelDto.getChainNodes().isEmpty()) { // 指向出口
GostUtil.AddChains(in_node.getNodeId(), tunnelDto.getOutNodeId(), nodes);
GostDto gostDto = GostUtil.AddChains(in_node.getNodeId(), tunnelDto.getOutNodeId(), nodes);
isError(gostDto);
} else {
GostUtil.AddChains(in_node.getNodeId(), tunnelDto.getChainNodes().getFirst(), nodes);// 指向第一跳
GostDto gostDto = GostUtil.AddChains(in_node.getNodeId(), tunnelDto.getChainNodes().getFirst(), nodes);// 指向第一跳
if (Objects.equals(gostDto.getMsg(), "OK")){
JSONObject data = new JSONObject();
data.put("node_id", in_node.getNodeId());
data.put("name", "chains_" + tunnel.getId());
chain_success.add(data);
}else {
this.removeById(tunnel.getId());
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
for (JSONObject chainSuccess : chain_success) {
GostDto deleteChains = GostUtil.DeleteChains(chainSuccess.getLong("node_id"), chainSuccess.getString("name"));
System.out.println(deleteChains);
}
return R.err(gostDto.getMsg());
}
}
}
@@ -149,19 +171,79 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
for (ChainTunnel chainTunnel : chainTunnels1) {
int inx = i+1;
if (inx >= tunnelDto.getChainNodes().size()) { // 指向出口
GostUtil.AddChains(chainTunnel.getNodeId(), tunnelDto.getOutNodeId(), nodes);
GostDto gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), tunnelDto.getOutNodeId(), nodes);
if (Objects.equals(gostDto.getMsg(), "OK")){
JSONObject data = new JSONObject();
data.put("node_id", chainTunnel.getNodeId());
data.put("name", "chains_" + tunnel.getId());
chain_success.add(data);
}else {
this.removeById(tunnel.getId());
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
for (JSONObject chainSuccess : chain_success) {
GostDto deleteChains = GostUtil.DeleteChains(chainSuccess.getLong("node_id"), chainSuccess.getString("name"));
System.out.println(deleteChains);
}
return R.err(gostDto.getMsg());
}
} else {
GostUtil.AddChains(chainTunnel.getNodeId(), tunnelDto.getChainNodes().get(inx), nodes);
GostDto gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), tunnelDto.getChainNodes().get(inx), nodes);
if (Objects.equals(gostDto.getMsg(), "OK")){
JSONObject data = new JSONObject();
data.put("node_id", chainTunnel.getNodeId());
data.put("name", "chains_" + tunnel.getId());
chain_success.add(data);
}else {
this.removeById(tunnel.getId());
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
for (JSONObject chainSuccess : chain_success) {
GostDto deleteChains = GostUtil.DeleteChains(chainSuccess.getLong("node_id"), chainSuccess.getString("name"));
System.out.println(deleteChains);
}
return R.err(gostDto.getMsg());
}
}
GostUtil.AddChainService(chainTunnel.getNodeId(), chainTunnel, nodes);
GostDto gostDto = GostUtil.AddChainService(chainTunnel.getNodeId(), chainTunnel, nodes);
if (Objects.equals(gostDto.getMsg(), "OK")){
JSONObject data = new JSONObject();
data.put("node_id", chainTunnel.getNodeId());
data.put("name", tunnel.getId() + "_tls");
service_success.add(data);
}else {
this.removeById(tunnel.getId());
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
for (JSONObject serviceSuccess : service_success) {
JSONArray jsonArray = new JSONArray();
jsonArray.add(serviceSuccess.getString("name"));
GostDto deleteService = GostUtil.DeleteService(serviceSuccess.getLong("node_id"), jsonArray);
System.out.println(deleteService);
}
return R.err(gostDto.getMsg());
}
}
}
for (ChainTunnel out_node : tunnelDto.getOutNodeId()) {
GostUtil.AddChainService(out_node.getNodeId(), out_node, nodes);
GostDto gostDto = GostUtil.AddChainService(out_node.getNodeId(), out_node, nodes);
if (Objects.equals(gostDto.getMsg(), "OK")){
JSONObject data = new JSONObject();
data.put("node_id", out_node.getNodeId());
data.put("name", tunnel.getId() + "_tls");
service_success.add(data);
}else {
this.removeById(tunnel.getId());
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()));
for (JSONObject serviceSuccess : service_success) {
JSONArray jsonArray = new JSONArray();
jsonArray.add(serviceSuccess.getString("name"));
GostDto deleteService = GostUtil.DeleteService(serviceSuccess.getLong("node_id"), jsonArray);
System.out.println(deleteService);
}
return R.err(gostDto.getMsg());
}
}
}
@@ -171,7 +253,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
@Override
public R getAllTunnels() {
List<Tunnel> tunnelList = this.list();
List<Tunnel> tunnelList = this.list(new QueryWrapper<Tunnel>().orderByAsc("inx").orderByAsc("id"));
// 查询所有隧道的ChainTunnel信息
List<Long> tunnelIds = tunnelList.stream()
@@ -236,11 +318,137 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
return R.ok(detailDtoList);
}
@Override
@Transactional
public R updateTunnelOrder(Map<String, Object> params) {
if (!params.containsKey("tunnels")) {
return R.err("缺少tunnels参数");
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> tunnelsList = (List<Map<String, Object>>) params.get("tunnels");
if (tunnelsList == null || tunnelsList.isEmpty()) {
return R.err("tunnels参数不能为空");
}
List<Tunnel> tunnelsToUpdate = new ArrayList<>();
for (Map<String, Object> tunnelData : tunnelsList) {
Long id = Long.valueOf(tunnelData.get("id").toString());
Integer inx = Integer.valueOf(tunnelData.get("inx").toString());
Tunnel tunnel = new Tunnel();
tunnel.setId(id);
tunnel.setInx(inx);
tunnelsToUpdate.add(tunnel);
}
this.updateBatchById(tunnelsToUpdate);
return R.ok();
}
@Override
public R updateTunnel(TunnelUpdateDto tunnelUpdateDto) {
Tunnel existingTunnel = this.getById(tunnelUpdateDto.getId());
if (existingTunnel == null) return R.err("隧道不存在");
List<ChainTunnel> oldChainTunnels = chainTunnelService.list(
new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnelUpdateDto.getId())
);
boolean hasNodeChanges = detectNodeChanges(oldChainTunnels, tunnelUpdateDto);
if (hasNodeChanges && tunnelUpdateDto.getInNodeId() != null) {
List<ChainTunnel> backupChains = deepCopyChainTunnels(oldChainTunnels);
List<Long> nodeIds = new ArrayList<>();
Map<Long, Node> nodes = new HashMap<>();
for (ChainTunnel inNode : tunnelUpdateDto.getInNodeId()) {
nodeIds.add(inNode.getNodeId());
Node node = nodeService.getById(inNode.getNodeId());
if (node == null) return R.err("入口节点不存在: " + inNode.getNodeId());
if (node.getStatus() != 1) return R.err("入口节点不在线: " + node.getName());
nodes.put(node.getId(), node);
}
List<ChainTunnel> newChainTunnels = new ArrayList<>();
for (ChainTunnel inNode : tunnelUpdateDto.getInNodeId()) {
inNode.setTunnelId(existingTunnel.getId());
inNode.setChainType(1);
newChainTunnels.add(inNode);
}
if (existingTunnel.getType() == 2) {
if (tunnelUpdateDto.getOutNodeId() == null || tunnelUpdateDto.getOutNodeId().isEmpty()) {
return R.err("隧道转发类型必须配置出口节点");
}
List<List<ChainTunnel>> chainNodes = tunnelUpdateDto.getChainNodes() == null ?
new ArrayList<>() : tunnelUpdateDto.getChainNodes();
int inx = 1;
for (List<ChainTunnel> hop : chainNodes) {
for (ChainTunnel chainNode : hop) {
nodeIds.add(chainNode.getNodeId());
Node node = nodeService.getById(chainNode.getNodeId());
if (node == null) return R.err("转发链节点不存在: " + chainNode.getNodeId());
if (node.getStatus() != 1) return R.err("转发链节点不在线: " + node.getName());
nodes.put(node.getId(), node);
Integer port = getNodePort(chainNode.getNodeId());
chainNode.setPort(port);
chainNode.setInx(inx);
chainNode.setChainType(2);
chainNode.setTunnelId(existingTunnel.getId());
newChainTunnels.add(chainNode);
}
inx++;
}
for (ChainTunnel outNode : tunnelUpdateDto.getOutNodeId()) {
nodeIds.add(outNode.getNodeId());
Node node = nodeService.getById(outNode.getNodeId());
if (node == null) return R.err("出口节点不存在: " + outNode.getNodeId());
if (node.getStatus() != 1) return R.err("出口节点不在线: " + node.getName());
nodes.put(node.getId(), node);
Integer port = getNodePort(outNode.getNodeId());
outNode.setPort(port);
outNode.setChainType(3);
outNode.setTunnelId(existingTunnel.getId());
newChainTunnels.add(outNode);
}
}
Set<Long> nodeIdSet = new HashSet<>(nodeIds);
if (nodeIdSet.size() != nodeIds.size()) {
return R.err("节点配置重复");
}
try {
cleanupGostConfig(oldChainTunnels, existingTunnel.getId());
chainTunnelService.remove(
new QueryWrapper<ChainTunnel>().eq("tunnel_id", existingTunnel.getId())
);
R applyResult = applyNewGostConfig(tunnelUpdateDto, existingTunnel, nodes);
if (applyResult.getCode() != 0) {
chainTunnelService.saveBatch(backupChains);
rebuildGostConfig(backupChains, existingTunnel);
return R.err("更新失败,已回滚: " + applyResult.getMsg());
}
chainTunnelService.saveBatch(newChainTunnels);
} catch (Exception e) {
chainTunnelService.saveBatch(backupChains);
rebuildGostConfig(backupChains, existingTunnel);
return R.err("更新失败,已回滚: " + e.getMessage());
}
}
Tunnel tunnel = new Tunnel();
tunnel.setId(tunnelUpdateDto.getId());
tunnel.setName(tunnelUpdateDto.getName());
@@ -248,18 +456,23 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
tunnel.setTrafficRatio(tunnelUpdateDto.getTrafficRatio());
tunnel.setInIp(tunnelUpdateDto.getInIp());
if (StringUtils.isEmpty(tunnel.getInIp())){
StringBuilder in_ip = new StringBuilder();
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()).eq("chain_type", 1));
if (StringUtils.isEmpty(tunnel.getInIp())) {
StringBuilder inIp = new StringBuilder();
List<ChainTunnel> chainTunnels = chainTunnelService.list(
new QueryWrapper<ChainTunnel>().eq("tunnel_id", tunnel.getId()).eq("chain_type", 1)
);
for (ChainTunnel chainTunnel : chainTunnels) {
Node node = nodeService.getById(chainTunnel.getNodeId());
if (node == null)return R.err("隧道节点数据错误,部分节点不存在");
in_ip.append(node.getServerIp()).append(",");
if (node == null) return R.err("隧道节点数据错误,部分节点不存在");
inIp.append(node.getServerIp()).append(",");
}
in_ip.deleteCharAt(in_ip.length() - 1);
tunnel.setInIp(in_ip.toString());
if (inIp.length() > 0) {
inIp.deleteCharAt(inIp.length() - 1);
}
tunnel.setInIp(inIp.toString());
}
tunnel.setUpdatedTime(System.currentTimeMillis());
this.updateById(tunnel);
return R.ok();
}
@@ -286,12 +499,12 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
GostUtil.DeleteChains(chainTunnel.getNodeId(), "chains_" + chainTunnel.getTunnelId());
JSONArray services = new JSONArray();
services.add(chainTunnel.getTunnelId() + "_tls");
GostUtil.DeleteChainService(chainTunnel.getNodeId(), services);
GostUtil.DeleteService(chainTunnel.getNodeId(), services);
}
else { // 出口
JSONArray services = new JSONArray();
services.add(chainTunnel.getTunnelId() + "_tls");
GostUtil.DeleteChainService(chainTunnel.getNodeId(), services);
GostUtil.DeleteService(chainTunnel.getNodeId(), services);
}
}
chainTunnelService.remove(new QueryWrapper<ChainTunnel>().eq("tunnel_id", id));
@@ -471,8 +684,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
return R.ok(diagnosisReport);
}
@Override
public Integer getNodePort(Long nodeId,Integer type) {
public Integer getNodePort(Long nodeId) {
Node node = nodeService.getById(nodeId);
if (node == null){
@@ -505,11 +717,7 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
if (availablePorts.isEmpty()) {
throw new RuntimeException("节点端口已满,无可用端口");
}
if (type == 1) {
return availablePorts.getLast();
}else {
return availablePorts.getFirst();
}
return availablePorts.getFirst();
}
public static List<Integer> parsePorts(String input) {
@@ -531,6 +739,10 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
return set.stream().sorted().collect(Collectors.toList());
}
private void isError(GostDto gostDto){
}
private DiagnosisResult performTcpPingDiagnosis(Node node, String targetIp, int port, String description) {
try {
// 构建TCP ping请求数据
@@ -626,5 +838,231 @@ public class TunnelServiceImpl extends ServiceImpl<TunnelMapper, Tunnel> impleme
}
}
private boolean detectNodeChanges(List<ChainTunnel> oldChains, TunnelUpdateDto dto) {
if (dto.getInNodeId() == null) {
return false;
}
List<ChainTunnel> oldInNodes = oldChains.stream()
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 1)
.collect(Collectors.toList());
List<ChainTunnel> oldChainNodes = oldChains.stream()
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 2)
.collect(Collectors.toList());
List<ChainTunnel> oldOutNodes = oldChains.stream()
.filter(ct -> ct.getChainType() != null && ct.getChainType() == 3)
.collect(Collectors.toList());
Set<Long> oldInNodeIds = oldInNodes.stream().map(ChainTunnel::getNodeId).collect(Collectors.toSet());
Set<Long> newInNodeIds = dto.getInNodeId().stream().map(ChainTunnel::getNodeId).collect(Collectors.toSet());
if (!oldInNodeIds.equals(newInNodeIds)) {
return true;
}
List<ChainTunnel> flatNewChainNodes = (dto.getChainNodes() == null) ? new ArrayList<>() :
dto.getChainNodes().stream().flatMap(List::stream).collect(Collectors.toList());
if (oldChainNodes.size() != flatNewChainNodes.size()) {
return true;
}
for (int i = 0; i < oldChainNodes.size(); i++) {
ChainTunnel oldCt = oldChainNodes.get(i);
boolean found = flatNewChainNodes.stream().anyMatch(newCt ->
Objects.equals(oldCt.getNodeId(), newCt.getNodeId()) &&
Objects.equals(oldCt.getProtocol(), newCt.getProtocol()) &&
Objects.equals(oldCt.getStrategy(), newCt.getStrategy()) &&
Objects.equals(oldCt.getInx(), newCt.getInx())
);
if (!found) {
return true;
}
}
List<ChainTunnel> newOutNodes = (dto.getOutNodeId() == null) ? new ArrayList<>() : dto.getOutNodeId();
if (oldOutNodes.size() != newOutNodes.size()) {
return true;
}
Set<Long> oldOutNodeIds = oldOutNodes.stream().map(ChainTunnel::getNodeId).collect(Collectors.toSet());
Set<Long> newOutNodeIds = newOutNodes.stream().map(ChainTunnel::getNodeId).collect(Collectors.toSet());
if (!oldOutNodeIds.equals(newOutNodeIds)) {
return true;
}
for (ChainTunnel oldOut : oldOutNodes) {
boolean found = newOutNodes.stream().anyMatch(newOut ->
Objects.equals(oldOut.getNodeId(), newOut.getNodeId()) &&
Objects.equals(oldOut.getProtocol(), newOut.getProtocol()) &&
Objects.equals(oldOut.getStrategy(), newOut.getStrategy())
);
if (!found) {
return true;
}
}
return false;
}
private void cleanupGostConfig(List<ChainTunnel> chainTunnels, Long tunnelId) {
for (ChainTunnel chainTunnel : chainTunnels) {
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 List<ChainTunnel> deepCopyChainTunnels(List<ChainTunnel> original) {
List<ChainTunnel> copy = new ArrayList<>();
for (ChainTunnel ct : original) {
ChainTunnel newCt = new ChainTunnel();
newCt.setId(ct.getId());
newCt.setTunnelId(ct.getTunnelId());
newCt.setChainType(ct.getChainType());
newCt.setNodeId(ct.getNodeId());
newCt.setPort(ct.getPort());
newCt.setStrategy(ct.getStrategy());
newCt.setInx(ct.getInx());
newCt.setProtocol(ct.getProtocol());
copy.add(newCt);
}
return copy;
}
private R applyNewGostConfig(TunnelUpdateDto dto, Tunnel tunnel, Map<Long, Node> nodes) {
List<JSONObject> chainSuccess = new ArrayList<>();
List<JSONObject> serviceSuccess = new ArrayList<>();
if (tunnel.getType() == 2) {
List<List<ChainTunnel>> chainNodes = dto.getChainNodes() == null ? new ArrayList<>() : dto.getChainNodes();
for (ChainTunnel inNode : dto.getInNodeId()) {
GostDto gostDto;
if (chainNodes.isEmpty()) {
gostDto = GostUtil.AddChains(inNode.getNodeId(), dto.getOutNodeId(), nodes);
} else {
gostDto = GostUtil.AddChains(inNode.getNodeId(), chainNodes.get(0), nodes);
}
if (!Objects.equals(gostDto.getMsg(), "OK")) {
rollbackGostChanges(chainSuccess, serviceSuccess);
return R.err("创建入口Chain失败: " + gostDto.getMsg());
}
JSONObject data = new JSONObject();
data.put("node_id", inNode.getNodeId());
data.put("name", "chains_" + tunnel.getId());
chainSuccess.add(data);
}
for (int i = 0; i < chainNodes.size(); i++) {
List<ChainTunnel> currentHop = chainNodes.get(i);
for (ChainTunnel chainTunnel : currentHop) {
GostDto gostDto;
if (i + 1 >= chainNodes.size()) {
gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), dto.getOutNodeId(), nodes);
} else {
gostDto = GostUtil.AddChains(chainTunnel.getNodeId(), chainNodes.get(i + 1), nodes);
}
if (!Objects.equals(gostDto.getMsg(), "OK")) {
rollbackGostChanges(chainSuccess, serviceSuccess);
return R.err("创建转发链Chain失败: " + gostDto.getMsg());
}
JSONObject chainData = new JSONObject();
chainData.put("node_id", chainTunnel.getNodeId());
chainData.put("name", "chains_" + tunnel.getId());
chainSuccess.add(chainData);
GostDto serviceResult = GostUtil.AddChainService(chainTunnel.getNodeId(), chainTunnel, nodes);
if (!Objects.equals(serviceResult.getMsg(), "OK")) {
rollbackGostChanges(chainSuccess, serviceSuccess);
return R.err("创建转发链Service失败: " + serviceResult.getMsg());
}
JSONObject serviceData = new JSONObject();
serviceData.put("node_id", chainTunnel.getNodeId());
serviceData.put("name", tunnel.getId() + "_tls");
serviceSuccess.add(serviceData);
}
}
for (ChainTunnel outNode : dto.getOutNodeId()) {
GostDto gostDto = GostUtil.AddChainService(outNode.getNodeId(), outNode, nodes);
if (!Objects.equals(gostDto.getMsg(), "OK")) {
rollbackGostChanges(chainSuccess, serviceSuccess);
return R.err("创建出口Service失败: " + gostDto.getMsg());
}
JSONObject serviceData = new JSONObject();
serviceData.put("node_id", outNode.getNodeId());
serviceData.put("name", tunnel.getId() + "_tls");
serviceSuccess.add(serviceData);
}
}
return R.ok();
}
private void rollbackGostChanges(List<JSONObject> chainSuccess, List<JSONObject> serviceSuccess) {
for (JSONObject chain : chainSuccess) {
GostUtil.DeleteChains(chain.getLong("node_id"), chain.getString("name"));
}
for (JSONObject service : serviceSuccess) {
JSONArray services = new JSONArray();
services.add(service.getString("name"));
GostUtil.DeleteService(service.getLong("node_id"), services);
}
}
private void rebuildGostConfig(List<ChainTunnel> chainTunnels, Tunnel tunnel) {
Map<Long, Node> nodes = new HashMap<>();
for (ChainTunnel ct : chainTunnels) {
Node node = nodeService.getById(ct.getNodeId());
if (node != null) {
nodes.put(node.getId(), node);
}
}
List<ChainTunnel> inNodes = chainTunnels.stream()
.filter(ct -> ct.getChainType() == 1)
.collect(Collectors.toList());
Map<Integer, List<ChainTunnel>> chainNodesMap = chainTunnels.stream()
.filter(ct -> 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() == 3)
.collect(Collectors.toList());
if (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 chainTunnel : chainNodesList.get(i)) {
if (i + 1 >= chainNodesList.size()) {
GostUtil.AddChains(chainTunnel.getNodeId(), outNodes, nodes);
} else {
GostUtil.AddChains(chainTunnel.getNodeId(), chainNodesList.get(i + 1), nodes);
}
GostUtil.AddChainService(chainTunnel.getNodeId(), chainTunnel, nodes);
}
}
for (ChainTunnel outNode : outNodes) {
GostUtil.AddChainService(outNode.getNodeId(), outNode, nodes);
}
}
}
}
@@ -264,44 +264,61 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
for (UserPackageDto.UserForwardDetailDto forward : forwards) {
Tunnel tunnel = tunnelService.getById(forward.getTunnelId());
if (tunnel == null) continue;
List<ForwardPort> forwardPorts = forwardPortService.list(
new QueryWrapper<ForwardPort>().eq("forward_id", forward.getId())
);
if (forwardPorts.isEmpty()) continue;
boolean useTunnelInIp = tunnel.getInIp() != null && !tunnel.getInIp().trim().isEmpty();
List<String> ipList = new ArrayList<>();
List<Integer> portList = new ArrayList<>();
java.util.Set<String> ipPortSet = new java.util.LinkedHashSet<>();
if (useTunnelInIp) {
// 使用隧道的inIp(求笛卡尔积)
List<String> ipList = new ArrayList<>();
List<Integer> portList = new ArrayList<>();
String[] tunnelInIps = tunnel.getInIp().split(",");
for (String ip : tunnelInIps) {
if (ip != null && !ip.trim().isEmpty()) {
ipList.add(ip.trim());
}
}
} else {
for (ForwardPort forwardPort : forwardPorts) {
Node node = nodeService.getById(forwardPort.getNodeId());
if (node != null && node.getServerIp() != null) {
ipList.add(node.getServerIp());
if (forwardPort.getPort() != null) {
portList.add(forwardPort.getPort());
}
}
}
for (ForwardPort forwardPort : forwardPorts) {
if (forwardPort.getPort() != null) {
portList.add(forwardPort.getPort());
}
}
List<String> uniqueIps = ipList.stream().distinct().toList();
List<Integer> uniquePorts = portList.stream().distinct().toList();
java.util.Set<String> ipPortSet = new java.util.LinkedHashSet<>();
for (String ip : uniqueIps) {
for (Integer port : uniquePorts) {
ipPortSet.add(ip + ":" + port);
List<String> uniqueIps = ipList.stream().distinct().toList();
List<Integer> uniquePorts = portList.stream().distinct().toList();
for (String ip : uniqueIps) {
for (Integer port : uniquePorts) {
ipPortSet.add(ip + ":" + port);
}
}
if (!uniquePorts.isEmpty()) {
forward.setInPort(uniquePorts.getFirst());
}
} else {
// 使用节点的serverIp(一对一,不求笛卡尔积)
for (ForwardPort forwardPort : forwardPorts) {
Node node = nodeService.getById(forwardPort.getNodeId());
if (node != null && node.getServerIp() != null && forwardPort.getPort() != null) {
ipPortSet.add(node.getServerIp() + ":" + forwardPort.getPort());
}
}
if (!forwardPorts.isEmpty() && forwardPorts.getFirst().getPort() != null) {
forward.setInPort(forwardPorts.getFirst().getPort());
}
}
if (!ipPortSet.isEmpty()) {
forward.setInIp(String.join(",", ipPortSet));
forward.setInPort(uniquePorts.getFirst());
}
}
}
@@ -39,7 +39,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 +73,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 (
@@ -123,4 +125,3 @@ CREATE TABLE IF NOT EXISTS vite_config (
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;
}
}
+34
View File
@@ -0,0 +1,34 @@
# VITE FRONTEND KNOWLEDGE BASE
## OVERVIEW
Web management console for Flux Panel.
**Stack:** React 18, Vite 5, TypeScript, TailwindCSS 4, HeroUI (NextUI).
## STRUCTURE
```
vite-frontend/
├── src/
│ ├── pages/ # Route views
│ ├── components/ # Reusable UI parts
│ ├── layouts/ # Page wrappers
│ ├── api/ # Axios wrappers
│ ├── config/ # App settings
│ └── utils/ # Helpers
├── vite.config.ts # Vite config (Base: '/')
└── package.json
```
## CONVENTIONS
- **UI Lib**: HeroUI (formerly NextUI) + Tailwind CSS 4.
- **Routing**: React Router DOM 6.
- **State**: Check `provider.tsx` or local state.
- **Build**: Output to `dist/`.
## COMMANDS
```bash
# Dev
npm run dev
# Build
npm run build
```
+1 -1
View File
@@ -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
+3 -1
View File
@@ -29,6 +29,7 @@ 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 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);
@@ -41,6 +42,7 @@ 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 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);
@@ -88,4 +90,4 @@ export const updateConfig = (name: string, value: string) => Network.post("/conf
// 验证码相关接口
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);
+4 -1
View File
@@ -4,8 +4,9 @@ export type SiteConfig = typeof siteConfig;
// 缓存相关常量
const CACHE_PREFIX = 'vite_config_';
const VERSION = "2.0.1-beta";
const VERSION = "2.0.9";
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') {
@@ -13,6 +14,7 @@ const getInitialConfig = () => {
name: "flux",
version: VERSION,
app_version: APP_VERSION,
github_repo: GITHUB_REPO,
};
}
@@ -22,6 +24,7 @@ const getInitialConfig = () => {
name: cachedAppName,
version: VERSION,
app_version: APP_VERSION,
github_repo: GITHUB_REPO,
};
}
return {
+1 -1
View File
@@ -311,7 +311,7 @@ export default function AdminLayout({
<p className="text-xs text-gray-400 dark:text-gray-500">
Powered by{' '}
<a
href="https://github.com/bqlpfy/flux-panel"
href={siteConfig.github_repo}
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
+29 -2
View File
@@ -406,6 +406,14 @@ export default function ForwardPage() {
newErrors.tunnelId = '请选择关联隧道';
}
// 验证入口端口(可选,如果填写则验证)
if (form.inPort !== null && form.inPort !== undefined) {
const port = Number(form.inPort);
if (isNaN(port) || port < 1 || port > 65535) {
newErrors.inPort = '端口必须在 1-65535 之间';
}
}
if (!form.remoteAddr.trim()) {
newErrors.remoteAddr = '请输入远程地址';
} else {
@@ -526,6 +534,7 @@ export default function ForwardPage() {
userId: form.userId,
name: form.name,
tunnelId: form.tunnelId,
inPort: form.inPort,
remoteAddr: processedRemoteAddr,
strategy: addressCount > 1 ? form.strategy : 'fifo'
};
@@ -535,6 +544,7 @@ export default function ForwardPage() {
const createData = {
name: form.name,
tunnelId: form.tunnelId,
inPort: form.inPort,
remoteAddr: processedRemoteAddr,
strategy: addressCount > 1 ? form.strategy : 'fifo'
};
@@ -1596,8 +1606,7 @@ export default function ForwardPage() {
isInvalid={!!errors.tunnelId}
errorMessage={errors.tunnelId}
variant="bordered"
isDisabled={isEdit}
description={isEdit ? "编辑时无法修改关联隧道" : undefined}
description={isEdit ? "更改隧道将释放原端口并在新隧道分配端口" : undefined}
>
{tunnels.map((tunnel) => (
<SelectItem key={tunnel.id} >
@@ -1605,6 +1614,24 @@ export default function ForwardPage() {
</SelectItem>
))}
</Select>
<Input
label="入口端口"
placeholder="留空则自动分配可用端口"
type="number"
value={form.inPort !== null ? form.inPort.toString() : ''}
onChange={(e) => {
const value = e.target.value;
setForm(prev => ({
...prev,
inPort: value ? parseInt(value) : null
}));
}}
isInvalid={!!errors.inPort}
errorMessage={errors.inPort}
variant="bordered"
description="指定入口端口,留空则从节点可用端口中自动分配"
/>
<Textarea
label="远程地址"
+8 -8
View File
@@ -308,14 +308,14 @@ export default function IndexPage() {
<div className="fixed inset-x-0 bottom-4 text-center py-4">
<p className="text-xs text-gray-400 dark:text-gray-500">
Powered by{' '}
<a
href="https://github.com/bqlpfy/flux-panel"
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
flux-panel
</a>
<a
href={siteConfig.github_repo}
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
flux-panel
</a>
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
v{ isWebView ? siteConfig.app_version : siteConfig.version}
+329 -136
View File
@@ -9,20 +9,40 @@ import { Switch } from "@heroui/switch";
import { Spinner } from "@heroui/spinner";
import { Alert } from "@heroui/alert";
import { Progress } from "@heroui/progress";
import { Accordion, AccordionItem } from "@heroui/accordion";
import toast from 'react-hot-toast';
import axios from 'axios';
import {
DndContext,
KeyboardSensor,
PointerSensor,
type DragEndEvent,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
rectSortingStrategy,
sortableKeyboardCoordinates,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
createNode,
getNodeList,
updateNode,
deleteNode,
getNodeInstallCommand
getNodeInstallCommand,
updateNodeOrder
} from "@/api";
interface Node {
id: number;
inx?: number;
name: string;
ip: string;
serverIp: string;
@@ -62,6 +82,7 @@ interface NodeForm {
export default function NodePage() {
const [nodeList, setNodeList] = useState<Node[]>([]);
const [nodeOrder, setNodeOrder] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [dialogVisible, setDialogVisible] = useState(false);
const [dialogTitle, setDialogTitle] = useState('');
@@ -111,12 +132,49 @@ export default function NodePage() {
try {
const res = await getNodeList();
if (res.code === 0) {
setNodeList(res.data.map((node: any) => ({
const nodesData: Node[] = (res.data || []).map((node: any) => ({
...node,
inx: node.inx ?? 0,
connectionStatus: node.status === 1 ? 'online' : 'offline',
systemInfo: null,
copyLoading: false
})));
copyLoading: false,
}));
setNodeList(nodesData);
// 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序
const hasDbOrdering = nodesData.some((n) => n.inx !== undefined && n.inx !== 0);
if (hasDbOrdering) {
const dbOrder = [...nodesData]
.sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))
.map((n) => n.id);
setNodeOrder(dbOrder);
} else {
try {
const stored = localStorage.getItem('node-order');
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
const existingIds = new Set(nodesData.map((n) => n.id));
const validOrder = parsed
.map((id: any) => Number(id))
.filter((id: number) => existingIds.has(id));
if (validOrder.length > 0) {
setNodeOrder(validOrder);
} else {
setNodeOrder(nodesData.map((n) => n.id));
}
} else {
setNodeOrder(nodesData.map((n) => n.id));
}
} else {
setNodeOrder(nodesData.map((n) => n.id));
}
} catch {
setNodeOrder(nodesData.map((n) => n.id));
}
}
} else {
toast.error(res.msg || '加载节点列表失败');
}
@@ -605,6 +663,120 @@ export default function NodePage() {
setErrors({});
};
// 处理拖拽结束
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!active || !over || active.id === over.id) return;
if (!nodeOrder || nodeOrder.length === 0) return;
const activeId = Number(active.id);
const overId = Number(over.id);
if (isNaN(activeId) || isNaN(overId)) return;
const oldIndex = nodeOrder.indexOf(activeId);
const newIndex = nodeOrder.indexOf(overId);
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
const newOrder = arrayMove(nodeOrder, oldIndex, newIndex);
setNodeOrder(newOrder);
// 保存到 localStorage
try {
localStorage.setItem('node-order', JSON.stringify(newOrder));
} catch (error) {
console.warn('无法保存节点排序到localStorage:', error);
}
// 持久化到数据库
try {
const nodesToUpdate = newOrder.map((id, index) => ({ id, inx: index }));
const response = await updateNodeOrder({ nodes: nodesToUpdate });
if (response.code === 0) {
setNodeList((prev) =>
prev.map((node) => {
const updated = nodesToUpdate.find((n) => n.id === node.id);
return updated ? { ...node, inx: updated.inx } : node;
})
);
} else {
toast.error('保存排序失败:' + (response.msg || '未知错误'));
}
} catch (error) {
console.error('保存节点排序到数据库失败:', error);
toast.error('保存排序失败,请重试');
}
};
// 传感器配置
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// 根据排序顺序获取节点列表
const getSortedNodes = (): Node[] => {
if (!nodeList || nodeList.length === 0) return [];
const sortedNodes = [...nodeList].sort((a, b) => {
const aInx = a.inx ?? 0;
const bInx = b.inx ?? 0;
return aInx - bInx;
});
// 如果数据库中没有排序信息,则使用本地存储的顺序
if (nodeOrder && nodeOrder.length > 0 && sortedNodes.every((n) => n.inx === undefined || n.inx === 0)) {
const nodeMap = new Map(nodeList.map((n) => [n.id, n] as const));
const localSorted: Node[] = [];
nodeOrder.forEach((id) => {
const node = nodeMap.get(id);
if (node) localSorted.push(node);
});
nodeList.forEach((node) => {
if (!nodeOrder.includes(node.id)) {
localSorted.push(node);
}
});
return localSorted;
}
return sortedNodes;
};
const SortableItem = ({
id,
children,
}: {
id: number;
children: (listeners: any) => any;
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: transform ? CSS.Transform.toString(transform) : undefined,
transition: transition || undefined,
opacity: isDragging ? 0.5 : 1,
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
{children(listeners)}
</div>
);
};
return (
<div className="px-3 lg:px-6 py-8">
@@ -650,18 +822,35 @@ export default function NodePage() {
</CardBody>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{nodeList.map((node) => (
<Card
key={node.id}
className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200"
>
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext
items={getSortedNodes().map((n) => n.id)}
strategy={rectSortingStrategy}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{getSortedNodes().map((node) => (
<SortableItem key={node.id} id={node.id}>
{(listeners) => (
<Card
key={node.id}
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200"
>
<CardHeader className="pb-2">
<div className="flex justify-between items-start w-full">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground truncate text-sm">{node.name}</h3>
</div>
<div className="flex items-center gap-1.5 ml-2">
<div
className="cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
{...listeners}
title="拖拽排序"
style={{ touchAction: 'none' }}
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
</svg>
</div>
<Chip
color={node.connectionStatus === 'online' ? 'success' : 'danger'}
variant="flat"
@@ -824,9 +1013,13 @@ export default function NodePage() {
</div>
</CardBody>
</Card>
))}
</div>
)}
)}
</SortableItem>
))}
</div>
</SortableContext>
</DndContext>
)}
{/* 新增/编辑节点对话框 */}
<Modal
@@ -875,141 +1068,141 @@ export default function NodePage() {
}}
/>
<Input
label="出口网卡名或IP"
placeholder="请输入出口网卡名或IP"
value={form.interfaceName}
onChange={(e) => setForm(prev => ({ ...prev, interfaceName: e.target.value }))}
isInvalid={!!errors.interfaceName}
errorMessage={errors.interfaceName}
variant="bordered"
description="用于多IP服务器指定使用那个IP请求远程地址,不懂的默认为空就行"
/>
{/* 高级配置 */}
<Accordion variant="bordered">
<AccordionItem
key="advanced"
aria-label="高级配置"
title="高级配置"
>
<div className="space-y-4 pb-2">
<Input
label="出口网卡名或IP"
placeholder="请输入出口网卡名或IP"
value={form.interfaceName}
onChange={(e) => setForm(prev => ({ ...prev, interfaceName: e.target.value }))}
isInvalid={!!errors.interfaceName}
errorMessage={errors.interfaceName}
variant="bordered"
description="用于多IP服务器指定使用那个IP请求远程地址,不懂的默认为空就行"
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="TCP监听地址"
placeholder="请输入TCP监听地址"
value={form.tcpListenAddr}
onChange={(e) => setForm(prev => ({ ...prev, tcpListenAddr: e.target.value }))}
isInvalid={!!errors.tcpListenAddr}
errorMessage={errors.tcpListenAddr}
variant="bordered"
startContent={
<div className="pointer-events-none flex items-center">
<span className="text-default-400 text-small">TCP</span>
</div>
}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="TCP监听地址"
placeholder="请输入TCP监听地址"
value={form.tcpListenAddr}
onChange={(e) => setForm(prev => ({ ...prev, tcpListenAddr: e.target.value }))}
isInvalid={!!errors.tcpListenAddr}
errorMessage={errors.tcpListenAddr}
variant="bordered"
startContent={
<div className="pointer-events-none flex items-center">
<span className="text-default-400 text-small">TCP</span>
</div>
}
/>
<Input
label="UDP监听地址"
placeholder="请输入UDP监听地址"
value={form.udpListenAddr}
onChange={(e) => setForm(prev => ({ ...prev, udpListenAddr: e.target.value }))}
isInvalid={!!errors.udpListenAddr}
errorMessage={errors.udpListenAddr}
variant="bordered"
startContent={
<div className="pointer-events-none flex items-center">
<span className="text-default-400 text-small">UDP</span>
</div>
}
/>
</div>
{/* 屏蔽协议 */}
<div className="mt-1">
<div className="text-sm font-medium text-default-700">屏蔽协议</div>
<div className="text-xs text-default-500 mb-2">开启开关以屏蔽对应协议</div>
{protocolDisabled && (
<Alert
color="warning"
variant="flat"
description={protocolDisabledReason || '等待节点上线后再设置'}
className="mb-2"
/>
)}
<div className={`grid grid-cols-1 sm:grid-cols-3 gap-3 bg-default-50 dark:bg-default-100 p-3 rounded-md border border-default-200 dark:border-default-100/30 ${protocolDisabled ? 'opacity-70' : ''}`}>
{/* HTTP tile */}
<div className="px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors">
<div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-default-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M2 10h20"/></svg>
<div className="text-sm font-medium text-default-700">HTTP</div>
</div>
<div className="flex items-center justify-between">
<div className="text-xs text-default-500">禁用/启用</div>
<Switch
size="sm"
isSelected={form.http === 1}
isDisabled={protocolDisabled}
onValueChange={(v) => setForm(prev => ({ ...prev, http: v ? 1 : 0 }))}
<Input
label="UDP监听地址"
placeholder="请输入UDP监听地址"
value={form.udpListenAddr}
onChange={(e) => setForm(prev => ({ ...prev, udpListenAddr: e.target.value }))}
isInvalid={!!errors.udpListenAddr}
errorMessage={errors.udpListenAddr}
variant="bordered"
startContent={
<div className="pointer-events-none flex items-center">
<span className="text-default-400 text-small">UDP</span>
</div>
}
/>
</div>
<div className="mt-1 text-xs text-default-400">{form.http === 1 ? '已开启' : '已关闭'}</div>
</div>
{/* 屏蔽协议 */}
<div>
<div className="text-sm font-medium text-default-700 mb-2">屏蔽协议</div>
<div className="text-xs text-default-500 mb-2">开启开关以屏蔽对应协议</div>
{protocolDisabled && (
<Alert
color="warning"
variant="flat"
description={protocolDisabledReason || '等待节点上线后再设置'}
className="mb-2"
/>
)}
<div className={`grid grid-cols-1 sm:grid-cols-3 gap-3 bg-default-50 dark:bg-default-100 p-3 rounded-md border border-default-200 dark:border-default-100/30 ${protocolDisabled ? 'opacity-70' : ''}`}>
{/* HTTP tile */}
<div className="px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors">
<div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-default-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M2 10h20"/></svg>
<div className="text-sm font-medium text-default-700">HTTP</div>
</div>
<div className="flex items-center justify-between">
<div className="text-xs text-default-500">禁用/启用</div>
<Switch
size="sm"
isSelected={form.http === 1}
isDisabled={protocolDisabled}
onValueChange={(v) => setForm(prev => ({ ...prev, http: v ? 1 : 0 }))}
/>
</div>
<div className="mt-1 text-xs text-default-400">{form.http === 1 ? '已开启' : '已关闭'}</div>
</div>
{/* TLS tile */}
<div className="px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors">
<div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-default-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 10V7a6 6 0 1 1 12 0v3"/><rect x="4" y="10" width="16" height="10" rx="2"/></svg>
<div className="text-sm font-medium text-default-700">TLS</div>
{/* TLS tile */}
<div className="px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors">
<div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-default-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 10V7a6 6 0 1 1 12 0v3"/><rect x="4" y="10" width="16" height="10" rx="2"/></svg>
<div className="text-sm font-medium text-default-700">TLS</div>
</div>
<div className="flex items-center justify-between">
<div className="text-xs text-default-500">禁用/启用</div>
<Switch
size="sm"
isSelected={form.tls === 1}
isDisabled={protocolDisabled}
onValueChange={(v) => setForm(prev => ({ ...prev, tls: v ? 1 : 0 }))}
/>
</div>
<div className="mt-1 text-xs text-default-400">{form.tls === 1 ? '已开启' : '已关闭'}</div>
</div>
{/* SOCKS tile */}
<div className="px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors">
<div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-default-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<div className="text-sm font-medium text-default-700">SOCKS</div>
</div>
<div className="flex items-center justify-between">
<div className="text-xs text-default-500">禁用/启用</div>
<Switch
size="sm"
isSelected={form.socks === 1}
isDisabled={protocolDisabled}
onValueChange={(v) => setForm(prev => ({ ...prev, socks: v ? 1 : 0 }))}
/>
</div>
<div className="mt-1 text-xs text-default-400">{form.socks === 1 ? '已开启' : '已关闭'}</div>
</div>
</div>
</div>
<div className="flex items-center justify-between">
<div className="text-xs text-default-500">禁用/启用</div>
<Switch
size="sm"
isSelected={form.tls === 1}
isDisabled={protocolDisabled}
onValueChange={(v) => setForm(prev => ({ ...prev, tls: v ? 1 : 0 }))}
/>
</div>
<div className="mt-1 text-xs text-default-400">{form.tls === 1 ? '已开启' : '已关闭'}</div>
</div>
{/* SOCKS tile */}
<div className="px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors">
<div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-default-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<div className="text-sm font-medium text-default-700">SOCKS</div>
</div>
<div className="flex items-center justify-between">
<div className="text-xs text-default-500">禁用/启用</div>
<Switch
size="sm"
isSelected={form.socks === 1}
isDisabled={protocolDisabled}
onValueChange={(v) => setForm(prev => ({ ...prev, socks: v ? 1 : 0 }))}
/>
</div>
<div className="mt-1 text-xs text-default-400">{form.socks === 1 ? '已开启' : '已关闭'}</div>
</div>
</div>
</div>
<Alert
<Alert
color="danger"
variant="flat"
description="请不要在出口节点执行屏蔽协议,否则可能影响转发;屏蔽协议仅需在入口节点执行。"
className="mt-3"
/>
</div>
</AccordionItem>
</Accordion>
<Alert
color="primary"
variant="flat"
description="服务器ip是你要添加的服务器的ip地址,不是面板的ip地址。"
className="mt-4"
/>
color="primary"
variant="flat"
description="服务器ip是你要添加的服务器的ip地址,不是面板的ip地址。"
className="mt-4"
/>
<Alert
color="primary"
variant="flat"
title="TCP,UDP监听地址"
description="V6或者双栈填写[::],V4填写0.0.0.0。不懂的就去看文档网站内的说明"
className="mt-4"
/>
</div>
</ModalBody>
<ModalFooter>
@@ -1122,4 +1315,4 @@ export default function NodePage() {
</div>
);
}
}
+8 -8
View File
@@ -241,14 +241,14 @@ export default function ProfilePage() {
<div className="fixed inset-x-0 bottom-20 text-center py-4">
<p className="text-xs text-gray-400 dark:text-gray-500">
Powered by{' '}
<a
href="https://github.com/bqlpfy/flux-panel"
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
flux-panel
</a>
<a
href={siteConfig.github_repo}
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
flux-panel
</a>
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
v{ isWebViewFunc() ? siteConfig.app_version : siteConfig.version}
+274 -51
View File
@@ -10,6 +10,23 @@ import { Divider } from "@heroui/divider";
import { Alert } from "@heroui/alert";
import toast from 'react-hot-toast';
import {
DndContext,
KeyboardSensor,
PointerSensor,
type DragEndEvent,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
rectSortingStrategy,
sortableKeyboardCoordinates,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
createTunnel,
@@ -17,7 +34,8 @@ import {
updateTunnel,
deleteTunnel,
getNodeList,
diagnoseTunnel
diagnoseTunnel,
updateTunnelOrder
} from "@/api";
interface ChainTunnel {
@@ -30,6 +48,7 @@ interface ChainTunnel {
interface Tunnel {
id: number;
inx?: number;
name: string;
type: number; // 1: 端口转发, 2: 隧道转发
inNodeId: ChainTunnel[]; // 入口节点列表
@@ -87,6 +106,7 @@ interface DiagnosisResult {
export default function TunnelPage() {
const [loading, setLoading] = useState(true);
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [tunnelOrder, setTunnelOrder] = useState<number[]>([]);
const [nodes, setNodes] = useState<Node[]>([]);
// 模态框状态
@@ -131,7 +151,45 @@ export default function TunnelPage() {
]);
if (tunnelsRes.code === 0) {
setTunnels(tunnelsRes.data || []);
const tunnelsData: Tunnel[] = (tunnelsRes.data || []).map((t: any) => ({
...t,
inx: t.inx ?? 0,
}));
setTunnels(tunnelsData);
// 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序
const hasDbOrdering = tunnelsData.some((t) => t.inx !== undefined && t.inx !== 0);
if (hasDbOrdering) {
const dbOrder = [...tunnelsData]
.sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))
.map((t) => t.id);
setTunnelOrder(dbOrder);
} else {
try {
const stored = localStorage.getItem('tunnel-order');
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
const existingIds = new Set(tunnelsData.map((t) => t.id));
const validOrder = parsed
.map((id: any) => Number(id))
.filter((id: number) => existingIds.has(id));
if (validOrder.length > 0) {
setTunnelOrder(validOrder);
} else {
setTunnelOrder(tunnelsData.map((t) => t.id));
}
} else {
setTunnelOrder(tunnelsData.map((t) => t.id));
}
} else {
setTunnelOrder(tunnelsData.map((t) => t.id));
}
} catch {
setTunnelOrder(tunnelsData.map((t) => t.id));
}
}
} else {
toast.error(tunnelsRes.msg || '获取隧道列表失败');
}
@@ -357,6 +415,9 @@ export default function TunnelPage() {
.map(group => group.filter(node => node.nodeId !== -1))
.filter(group => group.length > 0); // 移除空组
// 过滤掉出口节点中的占位节点
const cleanedOutNodeId = (form.outNodeId || []).filter(node => node.nodeId !== -1);
// 将换行符分隔的IP转换为逗号分隔
const inIpString = form.inIp
.split('\n')
@@ -367,6 +428,7 @@ export default function TunnelPage() {
const data = {
...form,
inIp: inIpString,
outNodeId: cleanedOutNodeId,
chainNodes: cleanedChainNodes
};
@@ -477,6 +539,120 @@ export default function TunnelPage() {
return { text: '😵 很差', color: 'danger' };
};
// 处理拖拽结束
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!active || !over || active.id === over.id) return;
if (!tunnelOrder || tunnelOrder.length === 0) return;
const activeId = Number(active.id);
const overId = Number(over.id);
if (isNaN(activeId) || isNaN(overId)) return;
const oldIndex = tunnelOrder.indexOf(activeId);
const newIndex = tunnelOrder.indexOf(overId);
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
const newOrder = arrayMove(tunnelOrder, oldIndex, newIndex);
setTunnelOrder(newOrder);
// 保存到 localStorage
try {
localStorage.setItem('tunnel-order', JSON.stringify(newOrder));
} catch (error) {
console.warn('无法保存隧道排序到localStorage:', error);
}
// 持久化到数据库
try {
const tunnelsToUpdate = newOrder.map((id, index) => ({ id, inx: index }));
const response = await updateTunnelOrder({ tunnels: tunnelsToUpdate });
if (response.code === 0) {
setTunnels((prev) =>
prev.map((tunnel) => {
const updated = tunnelsToUpdate.find((t) => t.id === tunnel.id);
return updated ? { ...tunnel, inx: updated.inx } : tunnel;
})
);
} else {
toast.error('保存排序失败:' + (response.msg || '未知错误'));
}
} catch (error) {
console.error('保存隧道排序到数据库失败:', error);
toast.error('保存排序失败,请重试');
}
};
// 传感器配置
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// 根据排序顺序获取隧道列表
const getSortedTunnels = (): Tunnel[] => {
if (!tunnels || tunnels.length === 0) return [];
const sortedTunnels = [...tunnels].sort((a, b) => {
const aInx = a.inx ?? 0;
const bInx = b.inx ?? 0;
return aInx - bInx;
});
// 如果数据库中没有排序信息,则使用本地存储的顺序
if (tunnelOrder && tunnelOrder.length > 0 && sortedTunnels.every((t) => t.inx === undefined || t.inx === 0)) {
const tunnelMap = new Map(tunnels.map((t) => [t.id, t] as const));
const localSorted: Tunnel[] = [];
tunnelOrder.forEach((id) => {
const tunnel = tunnelMap.get(id);
if (tunnel) localSorted.push(tunnel);
});
tunnels.forEach((tunnel) => {
if (!tunnelOrder.includes(tunnel.id)) {
localSorted.push(tunnel);
}
});
return localSorted;
}
return sortedTunnels;
};
const SortableItem = ({
id,
children,
}: {
id: number;
children: (listeners: any) => any;
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: transform ? CSS.Transform.toString(transform) : undefined,
transition: transition || undefined,
opacity: isDragging ? 0.5 : 1,
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
{children(listeners)}
</div>
);
};
if (loading) {
return (
@@ -512,17 +688,24 @@ export default function TunnelPage() {
{/* 隧道卡片网格 */}
{tunnels.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{tunnels.map((tunnel) => {
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext
items={getSortedTunnels().map((t) => t.id)}
strategy={rectSortingStrategy}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{getSortedTunnels().map((tunnel) => {
const typeDisplay = getTypeDisplay(tunnel.type);
return (
<Card key={tunnel.id} className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200">
<CardHeader className="pb-2">
<div className="flex justify-between items-start w-full">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground truncate text-sm">{tunnel.name}</h3>
<div className="flex items-center gap-1.5 mt-1">
<SortableItem key={tunnel.id} id={tunnel.id}>
{(listeners) => (
<Card key={tunnel.id} className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200">
<CardHeader className="pb-2">
<div className="flex justify-between items-start w-full">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground truncate text-sm">{tunnel.name}</h3>
<div className="flex items-center gap-1.5 mt-1">
<Chip
color={typeDisplay.color as any}
variant="flat"
@@ -531,13 +714,23 @@ export default function TunnelPage() {
>
{typeDisplay.text}
</Chip>
</div>
</div>
<div
className="cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
{...listeners}
title="拖拽排序"
style={{ touchAction: 'none' }}
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
</svg>
</div>
</div>
</div>
</div>
</CardHeader>
</CardHeader>
<CardBody className="pt-0 pb-3">
<CardBody className="pt-0 pb-3">
<div className="space-y-3">
{/* 拓扑结构 */}
<div className="pt-2 border-t border-divider">
@@ -650,10 +843,14 @@ export default function TunnelPage() {
</div>
</CardBody>
</Card>
);
})}
</div>
) : (
)}
</SortableItem>
);
})}
</div>
</SortableContext>
</DndContext>
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
<CardBody className="text-center py-16">
@@ -689,7 +886,7 @@ export default function TunnelPage() {
{isEdit ? '编辑隧道' : '新增隧道'}
</h2>
<p className="text-small text-default-500">
{isEdit ? '编辑时只能修改隧道名称、流量计算和流量倍率' : '创建新的隧道配置'}
{isEdit ? '修改节点配置会中断现有连接,隧道类型不可修改' : '创建新的隧道配置'}
</p>
</ModalHeader>
<ModalBody>
@@ -799,11 +996,10 @@ export default function TunnelPage() {
});
setForm(prev => ({ ...prev, inNodeId: newInNodeId }));
}}
isInvalid={!!errors.inNodeId}
errorMessage={errors.inNodeId}
variant="bordered"
isDisabled={isEdit}
>
isInvalid={!!errors.inNodeId}
errorMessage={errors.inNodeId}
variant="bordered"
>
{nodes.map((node) => (
<SelectItem
key={node.id}
@@ -856,7 +1052,6 @@ export default function TunnelPage() {
]
}));
}}
isDisabled={isEdit}
startContent={
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
@@ -885,7 +1080,6 @@ export default function TunnelPage() {
variant="light"
isIconOnly
onPress={() => removeChainNode(groupIndex)}
isDisabled={isEdit}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -928,7 +1122,6 @@ export default function TunnelPage() {
}}
variant="bordered"
size="sm"
isDisabled={isEdit}
classNames={{
label: "text-xs",
value: "text-sm"
@@ -987,7 +1180,6 @@ export default function TunnelPage() {
}}
variant="bordered"
size="sm"
isDisabled={isEdit}
classNames={{
label: "text-xs",
value: "text-sm"
@@ -1014,7 +1206,6 @@ export default function TunnelPage() {
}}
variant="bordered"
size="sm"
isDisabled={isEdit}
classNames={{
label: "text-xs",
value: "text-sm"
@@ -1052,7 +1243,7 @@ export default function TunnelPage() {
label="节点"
placeholder="请选择出口节点(可多选)"
selectionMode="multiple"
selectedKeys={form.outNodeId ? form.outNodeId.map(ct => ct.nodeId.toString()) : []}
selectedKeys={form.outNodeId ? form.outNodeId.filter(ct => ct.nodeId !== -1).map(ct => ct.nodeId.toString()) : []}
disabledKeys={[
...nodes.filter(node => node.status !== 1).map(node => node.id.toString()),
...form.inNodeId.map(ct => ct.nodeId.toString()),
@@ -1061,12 +1252,17 @@ export default function TunnelPage() {
onSelectionChange={(keys) => {
const selectedIds = Array.from(keys).map(key => parseInt(key as string));
const currentOutNodes = form.outNodeId || [];
const protocol = currentOutNodes.length > 0 ? currentOutNodes[0].protocol || 'tls' : 'tls';
const strategy = currentOutNodes.length > 0 ? currentOutNodes[0].strategy || 'round' : 'round';
let protocol = 'tls';
let strategy = 'round';
if (currentOutNodes.length > 0) {
protocol = currentOutNodes[0].protocol || 'tls';
strategy = currentOutNodes[0].strategy || 'round';
}
const realNodes = currentOutNodes.filter(ct => ct.nodeId !== -1);
const newOutNodeId: ChainTunnel[] = selectedIds.map(nodeId => {
// 保留已有的配置或使用默认值
const existing = currentOutNodes.find(ct => ct.nodeId === nodeId);
const existing = realNodes.find(ct => ct.nodeId === nodeId);
return existing || { nodeId, chainType: 3, protocol, strategy };
});
setForm(prev => ({ ...prev, outNodeId: newOutNodeId }));
@@ -1074,7 +1270,6 @@ export default function TunnelPage() {
isInvalid={!!errors.outNodeId}
errorMessage={errors.outNodeId}
variant="bordered"
isDisabled={isEdit}
classNames={{
label: "text-xs",
value: "text-sm"
@@ -1116,20 +1311,35 @@ export default function TunnelPage() {
<Select
label="协议"
placeholder="选择协议"
selectedKeys={[form.outNodeId && form.outNodeId.length > 0 ? form.outNodeId[0].protocol || 'tls' : 'tls']}
selectedKeys={[(() => {
if (!form.outNodeId || form.outNodeId.length === 0) return 'tls';
return form.outNodeId[0].protocol || 'tls';
})()]}
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
if (selectedKey && form.outNodeId) {
setForm(prev => ({
...prev,
outNodeId: (prev.outNodeId || []).map(ct => ({ ...ct, protocol: selectedKey }))
}));
if (selectedKey) {
setForm(prev => {
const currentOutNodes = prev.outNodeId || [];
const currentStrategy = currentOutNodes.length > 0 ? currentOutNodes[0].strategy || 'round' : 'round';
if (currentOutNodes.length === 0) {
// 如果还没有出口节点,创建一个占位节点保存设置
return {
...prev,
outNodeId: [{ nodeId: -1, chainType: 3, protocol: selectedKey, strategy: currentStrategy }]
};
}
// 更新所有出口节点的协议
return {
...prev,
outNodeId: currentOutNodes.map(ct => ({ ...ct, protocol: selectedKey }))
};
});
}
}}
isInvalid={!!errors.protocol}
errorMessage={errors.protocol}
variant="bordered"
isDisabled={isEdit}
classNames={{
label: "text-xs",
value: "text-sm"
@@ -1147,18 +1357,31 @@ export default function TunnelPage() {
<Select
label="负载策略"
placeholder="选择策略"
selectedKeys={[form.outNodeId && form.outNodeId.length > 0 ? form.outNodeId[0].strategy || 'round' : 'round']}
selectedKeys={[(() => {
if (!form.outNodeId || form.outNodeId.length === 0) return 'round';
return form.outNodeId[0].strategy || 'round';
})()]}
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
if (selectedKey && form.outNodeId) {
setForm(prev => ({
...prev,
outNodeId: (prev.outNodeId || []).map(ct => ({ ...ct, strategy: selectedKey }))
}));
if (selectedKey) {
setForm(prev => {
const currentOutNodes = prev.outNodeId || [];
const currentProtocol = currentOutNodes.length > 0 ? currentOutNodes[0].protocol || 'tls' : 'tls';
if (currentOutNodes.length === 0) {
return {
...prev,
outNodeId: [{ nodeId: -1, chainType: 3, protocol: currentProtocol, strategy: selectedKey }]
};
}
return {
...prev,
outNodeId: currentOutNodes.map(ct => ({ ...ct, strategy: selectedKey }))
};
});
}
}}
variant="bordered"
isDisabled={isEdit}
classNames={{
label: "text-xs",
value: "text-sm"
@@ -1599,4 +1822,4 @@ export default function TunnelPage() {
</div>
);
}
}