Compare commits

...

79 Commits

Author SHA1 Message Date
sagit 583905b7ed Merge pull request #21 from Sagit-chu/opencode/neon-nebula
fix(gost): use chain.NewNode() to properly initialize marker for fail…
2026-02-05 07:29:15 +08:00
sagit 6e3f045b9b Merge branch 'main' into opencode/neon-nebula 2026-02-05 07:26:49 +08:00
root ec41202b3c fix(gost): use chain.NewNode() to properly initialize marker for failover
When creating temporary Node instances with struct literals like
&chain.Node{Addr: host}, the marker field was not initialized.
Only chain.NewNode() properly initializes marker = selector.NewFailMarker().

Without a valid marker:
- Failed nodes cannot be marked (marker.Mark() is no-op on nil)
- Subsequent selections cannot filter out failed nodes
- Failover mechanism completely fails

Fixed locations:
- sniffer.go dial(): &chain.Node{Addr: host} -> chain.NewNode("", host)
- sniffer.go dialTLS(): &chain.Node{Addr: host} -> chain.NewNode("", host)
- local/handler.go: target := &chain.Node{} -> var target *chain.Node
- remote/handler.go: &chain.Node{Addr: host} -> chain.NewNode("", host)
2026-02-04 23:10:56 +00:00
sagit 1ee7dea8b4 Merge pull request #20 from Sagit-chu/Sagit-chu-patch-1
change beta to main
2026-02-04 16:55:44 +08:00
sagit 2d69350bab change beta to main 2026-02-04 16:54:15 +08:00
sagit c984e5b62a docs: remove stable installation instructions
docs: remove stable installation instructions
2026-02-04 16:53:06 +08:00
sagit 5b79b11101 Merge branch 'beta' into opencode/calm-sailor 2026-02-04 16:50:28 +08:00
root 2c22e600f7 docs: remove stable installation instructions 2026-02-04 08:46:41 +00:00
sagit aef284c474 Merge pull request #18 from Sagit-chu/opencode/sunny-wizard
fix(gost): sync agent version with release tag
2026-02-04 16:29:24 +08:00
root 0443cd9ceb fix(gost): sync agent version with release tag
- Change version.go default to 'dev' for local development
- Use version variable in WebSocket reporter instead of hardcoded '2.0.2'
- Inject version via -ldflags in CI build from tag name
2026-02-04 08:22:37 +00:00
sagit 3337422775 Merge pull request #17 from Sagit-chu/opencode/curious-nebula
fix(gost): add fallback when FailFilter excludes all nodes
2026-02-04 15:52:19 +08:00
root 3e046fc80e fix(gost): restore single-node bypass and preserve FailFilter backoff
Address reviewer feedback from PR #14 fix:

1. Single-node case: Bypass selector/FailFilter to ensure availability.
   This matches upstream go-gost/x behavior - single nodes should always
   be attempted regardless of recent failures.

2. Multi-node case: Preserve FailFilter's backoff contract. When all nodes
   are marked as failed, return nil to signal 'no healthy nodes' rather
   than falling back to a known-bad node. This prevents hammering unhealthy
   nodes and respects the failTimeout window.

The handler's retry loop with ExcludeNodes context handles the multi-node
failover properly - this change ensures hop.Select() provides correct
information about node health status.

Fixes intermittent forwarding failures introduced by #14.
2026-02-04 07:46:40 +00:00
root 0273bc6921 docs: add AGENTS.md for go-gost/x/registry 2026-02-04 06:36:03 +00:00
sagit be095057bd Merge pull request #14 from Sagit-chu/opencode/cosmic-pixel
fix(gost): implement failover for multi-node forwarding rules
2026-02-04 12:30:49 +08:00
root a98057d06a fix(gost): implement failover for multi-node forwarding rules (#12)
When a forwarding rule has multiple backend nodes configured, the first
node failure would cause the entire forward to fail instead of trying
the next available node.

Root causes fixed:
- FailFilter skipped filtering when only 1 node remained
- hop.Select() bypassed selector for single-node hops
- Handlers only attempted one node before giving up

Changes:
- selector/filter.go: Remove len<=1 early return, always filter failed nodes
- hop/hop.go: Remove single-node bypass, add ExcludeNodes context support
- ctx/value.go: Add ContextWithExcludeNodes/ExcludeNodesFromContext helpers
- handler/forward/local: Add maxRetries config, implement retry loop
- handler/forward/remote: Add maxRetries config, implement retry loop
- forwarder/sniffer.go: Add retry logic to dial() and dialTLS()

Closes #12
2026-02-04 04:12:38 +00:00
root 7d47903541 fix(ci): use legacy-peer-deps and add react-is dependency
- Use --legacy-peer-deps to resolve heroui peer dependency conflicts
- Add react-is required by recharts
2026-02-04 02:42:46 +00:00
root a7aabdd1dd fix(ci): remove npm cache to fix missing package-lock.json error 2026-02-04 02:34:08 +00:00
root 0357a92960 fix: 修复limit.tsx和forward.tsx的JSX语法错误
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-04 02:31:26 +00:00
root 8ff41c962e ci: add build check workflow and restrict release to tags only
- Add ci-build.yml for frontend/backend/agent compilation checks on push/PR
- Modify docker-build.yml to trigger only on version tags (not branches)
2026-02-04 02:27:29 +00:00
root 6f6fececa8 style: 美化用户隧道权限分配表单UI并统一代码格式
- 使用HeroUI Checkbox组件替换原生checkbox

- 重构隧道列表为card-based tile风格

- 添加选中/未选中/已分配状态视觉区分

- 应用eslint --fix代码格式统一

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-04 02:22:28 +00:00
root 72c2e28667 fix: 移除未使用的变量修复TypeScript编译错误 2026-02-03 15:55:30 +00:00
sagit 74c78851ca Merge pull request #11 from Sagit-chu/opencode/neon-rocket
feat: 简化用户隧道权限分配,支持多选隧道批量分配 (#8)
2026-02-03 23:28:55 +08:00
root 42732f844a fix: 为嵌套的TunnelAssignItem添加@Valid注解确保校验生效 2026-02-03 15:16:43 +00:00
root 99b8ac206a fix: 批量分配时对请求中的重复tunnelId进行去重
防止同一请求中包含重复tunnelId导致创建多条权限记录
2026-02-03 14:43:34 +00:00
root 0e5cd86ed1 feat: 简化用户隧道权限分配,支持多选隧道批量分配 (#8)
- 新增批量分配接口 POST /tunnel/user/batch-assign
- 支持一次选择多个隧道进行分配
- 每个隧道可单独设置限速规则
- flow/num/flowResetTime/expTime 自动从用户设置继承
- 前端表单简化为隧道多选列表+限速选择
- 已分配的隧道显示'已分配'标记且不可重复选择
2026-02-03 13:59:05 +00:00
root 60fc80b6ac fix: 节点更新时serverIpV4/V6/域名字段无法正确清空的问题
使用LambdaUpdateWrapper替代updateById以确保null值能正确更新到数据库

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-03 12:57:39 +00:00
root c8f0c55fda fix: 隧道新增入口节点后转发管理的入口IP不自动更新 2026-02-03 11:30:31 +00:00
root bfdaa47ea5 修复隧道编辑新增入口节点后转发规则不生效的问题 (#9)
当编辑隧道添加新入口节点时,现有转发规则的ForwardPort和GOST服务未同步更新,
导致新入口节点的端口实际不可用。

修复方案:在updateTunnel成功更新节点后,同步更新所有使用该隧道的转发规则:
- 对移除的入口节点:删除ForwardPort记录和GOST服务
- 对新增的入口节点:分配端口、创建ForwardPort记录和GOST服务
2026-02-03 09:28:38 +00:00
sagit d7b76b4590 Update README.md 2026-02-02 15:51:24 +08:00
root 2c2262b55d 完善分层 AGENTS.md,便于快速定位代码 2026-02-02 07:41:27 +00:00
root 7ca01aba5d 修复转发管理隧道下拉顺序不同步 2026-02-02 06:28:45 +00:00
sagit 0f57ec58b3 Merge pull request #10 from Sagit-chu/opencode/silent-falcon
优化拖拽排序顺滑度
2026-02-02 12:55:09 +08:00
root ac30f0172f 修复平铺模式用户切换与图表TS构建 2026-02-02 04:50:22 +00:00
root 531ba0bfed 新建隧道默认追加到末尾 2026-02-02 03:29:47 +00:00
root d787e4b07a 优化拖拽排序顺滑度 2026-02-02 03:14:18 +00:00
root 68e5d0ac0b 修复节点监控进度条频繁重置 2026-02-01 15:39:23 +00:00
root 38b70821c5 节点监控离线延迟去抖 2026-02-01 14:41:23 +00:00
root 936158dd32 节点地址输入支持域名 2026-02-01 12:34:31 +00:00
root e0d0553fd3 修复v6-only入口优先选择出口IPv6 2026-02-01 12:34:31 +00:00
root 78aa86b23c 前端版本号从tag注入并提交本地配置 2026-02-01 12:10:17 +00:00
root 1f850593dd 节点管理页支持填写IPv4/IPv6 2026-02-01 11:14:19 +00:00
root c4519c243a 组隧道链路按地址族自动匹配节点IP(v4优先) 2026-02-01 11:14:19 +00:00
root 31ccc48436 启动时迁移并回填节点双栈IP列 2026-02-01 11:14:19 +00:00
root 1c4914ec77 支持节点双栈IP字段 2026-02-01 11:14:19 +00:00
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
qaq 9146eb0375 修复节点安装脚本 2025-11-19 14:56:25 +08:00
qaq ad974875e4 2.0.0-beta测试版本 2025-11-19 14:36:42 +08:00
qaq acd00ae413 2.0版本测试 2025-11-19 14:31:38 +08:00
qaq 65ca18d649 2.0版本测试 2025-11-19 14:28:45 +08:00
qaq a27b4d781e chore: bump version to 1.4.2 2025-11-14 11:19:54 +08:00
qaq ba50076468 chore: bump version to 1.4.2 2025-11-14 10:30:37 +08:00
141 changed files with 15209 additions and 11491 deletions
+68
View File
@@ -0,0 +1,68 @@
name: CI Build Check
on:
push:
branches: ['**']
pull_request:
branches: ['**']
jobs:
frontend:
name: Build Frontend
runs-on: ubuntu-latest
defaults:
run:
working-directory: vite-frontend
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm install --legacy-peer-deps
- name: Build
run: npm run build
backend:
name: Build Backend
runs-on: ubuntu-latest
defaults:
run:
working-directory: springboot-backend
steps:
- uses: actions/checkout@v4
- name: Setup Java 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
- name: Build with Maven
run: mvn clean package -DskipTests
agent:
name: Build Agent
runs-on: ubuntu-latest
defaults:
run:
working-directory: go-gost
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
cache-dependency-path: go-gost/go.sum
- name: Download dependencies
run: go mod download
- name: Build
run: go build -v .
+246 -99
View File
@@ -1,117 +1,168 @@
name: Build and Push Images Based on Version
name: Build and Push Images
# 在这里定义统一版本号
env:
VERSION: "1.4.2"
VERSION: "2.0.7-beta" # 分支推送时使用的默认版本
REGISTRY: ghcr.io
on:
push:
branches:
- main
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 -X main.version=${{ needs.check-version.outputs.version }}" -o gost-amd64
- name: Build GOST binary (ARM64)
working-directory: ./go-gost
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=${{ needs.check-version.outputs.version }}" -o gost-arm64
- name: Compress with UPX
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: |
VERSION="${{ needs.check-version.outputs.version }}"
{
echo "VITE_GITHUB_REPO=https://github.com/${{ github.repository }}"
echo "VITE_APP_VERSION=$VERSION"
} > ./vite-frontend/.env.production
- name: Build and push Vite Docker images
run: |
VERSION="${{ needs.check-version.outputs.version }}"
OWNER="${{ needs.check-version.outputs.image_owner }}"
docker buildx build \
--platform linux/amd64 \
--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:
@@ -119,17 +170,20 @@ jobs:
needs: check-version
if: needs.check-version.outputs.should_build == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up JDK and Maven
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
java-version: 21
distribution: 'temurin'
- name: Cache Maven dependencies
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.m2
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
@@ -140,83 +194,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}
## 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\` - 面板安装脚本
- \`gost.sql\` - 数据库初始化文件
- \`docker-compose-v4.yml\` - Docker Compose 配置 (IPv4)
- \`docker-compose-v6.yml\` - Docker Compose 配置 (IPv6)
## Details
- Build date: $(date -u +%Y-%m-%dT%H:%M:%SZ)
- Commit: ${{ github.sha }}" \
--notes "## 📝 Release Information
- **Version**: ${VERSION}
- **Commit**: [\`${GITHUB_SHA:0:7}\`](https://github.com/${{ github.repository }}/commit/${{ github.sha }})
- **Author**: ${COMMIT_AUTHOR}
- **Date**: ${COMMIT_DATE}
- **Message**: ${COMMIT_MSG}
## 📦 Docker Images
\`\`\`bash
# Backend
docker pull ${{ env.REGISTRY }}/${OWNER}/springboot-backend:${VERSION}
# Frontend
docker pull ${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION}
\`\`\`
## 🚀 Quick Install
**Panel:**
\`\`\`bash
curl -L https://github.com/${{ github.repository }}/releases/download/${VERSION}/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
\`\`\`
**Node:**
\`\`\`bash
curl -L https://github.com/${{ github.repository }}/releases/download/${VERSION}/install.sh -o install.sh && chmod +x install.sh && ./install.sh
\`\`\`" \
--repo ${{ github.repository }}
# 上传所有文件到 release
echo "📤 上传 GOST 二进制..."
gh release upload "${VERSION}" ./artifacts/gost --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
echo "📤 上传配置文件..."
gh release upload "${VERSION}" ./gost.sql --clobber
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/install.sh --clobber
gh release upload "${VERSION}" ./artifacts/panel_install.sh --clobber
echo "📤 上传 Docker Compose 配置文件..."
gh release upload "${VERSION}" ./artifacts/docker-compose-v4.yml --clobber
gh release upload "${VERSION}" ./artifacts/docker-compose-v6.yml --clobber
echo "✅ Release ${VERSION} 创建完成"
update-release-gost:
name: Update GOST Binaries in Release
needs: [check-version, build-gost]
if: needs.check-version.outputs.is_tag == 'false' && needs.check-version.outputs.should_build == 'false' && needs.check-version.outputs.should_build_gost == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Download GOST AMD64 binary
uses: actions/download-artifact@v4
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 二进制文件更新完成"
+2 -1
View File
@@ -256,4 +256,5 @@ gitee/
*.cert
doraemon.jks
device.id
commit.sh
commit.sh
sql/
@@ -0,0 +1,71 @@
# Plan: 搭建开发环境
## 目标
为 Flux Panel 项目安装所有缺失的开发依赖,使 3 个子项目都能本地开发和构建。
## 当前状态
### ✅ 已安装
| 工具 | 版本 | 用途 |
|------|------|------|
| Node.js | v20.19.2 | vite-frontend |
| npm | 9.2.0 | vite-frontend |
| Go | 1.24.4 | go-gost |
| Docker | 29.1.4 | 容器化部署 |
### ❌ 缺失
| 工具 | 需求版本 | 用途 |
|------|----------|------|
| Java | 21 | springboot-backend |
| Maven | 3.x | 构建后端 |
| Docker Compose | v2 | 容器编排 |
---
## 执行任务
### Task 1: 安装 Java 21
```bash
apt-get update && apt-get install -y openjdk-21-jdk
```
**验证**: `java -version` 应显示 openjdk 21
### Task 2: 安装 Maven
```bash
apt-get install -y maven
```
**验证**: `mvn -v` 应显示 Maven 3.x
### Task 3: 安装 Docker Compose Plugin
```bash
apt-get install -y docker-compose-plugin
```
**验证**: `docker compose version` 应显示版本号
### Task 4: 安装前端依赖
```bash
cd /root/flux-panel/vite-frontend && npm install
```
**验证**: `node_modules/` 目录存在
### Task 5: 验证后端可构建
```bash
cd /root/flux-panel/springboot-backend && mvn clean compile -q
```
**验证**: 编译成功无错误
### Task 6: 验证 Go 模块
```bash
cd /root/flux-panel/go-gost && go mod download
```
**验证**: 依赖下载成功
---
## 完成标准
- [ ] `java -version` → openjdk 21
- [ ] `mvn -v` → Maven 3.x
- [ ] `docker compose version` → v2.x
- [ ] 前端: `npm run dev` 可启动
- [ ] 后端: `mvn compile` 成功
- [ ] Go: `go build .` 成功
+9
View File
@@ -0,0 +1,9 @@
---
active: true
iteration: 1
max_iterations: 100
completion_promise: "DONE"
started_at: "2026-01-24T05:20:50.887Z"
session_id: "ses_41192b7d7ffewFHxf1dDS7ESHO"
---
Complete the task as instructed
+66
View File
@@ -0,0 +1,66 @@
# PROJECT KNOWLEDGE BASE
**Generated:** Mon Feb 02 2026
**Commit:** 7ca01ab
**Branch:** beta
## OVERVIEW
Flux Panel is a traffic forwarding management system built on a forked GOST v3 stack. It ships as Dockerized Spring Boot (admin API) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
## STRUCTURE
```
./
├── go-gost/ # Go forwarding agent (forked gost + local x/)
│ └── x/ # Local fork of github.com/go-gost/x (replace => ./x)
├── springboot-backend/ # Java/Spring Boot admin API (SQLite/MyBatis)
├── vite-frontend/ # React/Vite dashboard (HeroUI + Tailwind)
├── android-app/ # Android WebView wrapper (optional)
├── ios-app/ # iOS WebView wrapper (optional)
├── docker-compose-v4.yml # Panel deploy (IPv4-only bridge)
├── docker-compose-v6.yml # Panel deploy (IPv6-enabled bridge)
├── panel_install.sh # Panel installer/upgrader (downloads compose)
├── install.sh # Node installer/upgrader (downloads gost binary)
└── .github/workflows/ # CI: build/push images + release artifacts
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| **Deploy (Docker)** | `docker-compose-v4.yml` | Env: `JWT_SECRET`, `BACKEND_PORT`, `FRONTEND_PORT` |
| **Deploy (IPv6)** | `docker-compose-v6.yml` | Same as v4 + IPv6-enabled bridge |
| **Panel install** | `panel_install.sh` | Picks v4/v6, generates `JWT_SECRET`, downloads compose |
| **Node install** | `install.sh` | Installs `/etc/flux_agent/flux_agent` + writes `config.json`/`gost.json` + systemd `flux_agent.service` |
| **Admin API entry** | `springboot-backend/src/main/java/com/admin/AdminApplication.java` | Spring Boot app |
| **Admin API routes** | `springboot-backend/src/main/java/com/admin/controller/` | Mostly `/api/v1/*` controllers |
| **Admin auth** | `springboot-backend/src/main/java/com/admin/common/interceptor/JwtInterceptor.java` | Checks `Authorization` header |
| **Web UI routing** | `vite-frontend/src/App.tsx` | React Router v6 + ProtectedRoute/H5 layouts |
| **Web UI API client** | `vite-frontend/src/api/network.ts` | Axios `baseURL` + `Authorization` header |
| **Go agent entry** | `go-gost/main.go` | Reads panel `config.json` + starts gost services |
| **Go x fork** | `go-gost/x/` | Handlers/listeners/dialers + management API |
## CONVENTIONS
- `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `springboot-backend/`.
- `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
## ANTI-PATTERNS (THIS PROJECT)
- Do not edit generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
## COMMANDS
```bash
# Panel (Docker)
docker compose -f docker-compose-v4.yml up -d
docker compose -f docker-compose-v6.yml up -d
# Release-based install scripts
./panel_install.sh
./install.sh
# Local dev (per subproject)
(cd springboot-backend && mvn clean package)
(cd vite-frontend && npm run dev)
(cd go-gost && go run .)
```
## NOTES
- LSP servers are not installed in this environment (gopls/jdtls/typescript-language-server); rely on grep-based navigation.
- `vite-frontend/vite.config.ts` sets `minify: false` and disables treeshake; expect larger bundles.
+13 -13
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) 两个开源库,实现了转发面板。
---
## 特性
@@ -17,10 +15,15 @@
## 部署流程
---
### Docker Compose部署
#### 快速部署
面板端:
```bash
curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
```
节点端:
```bash
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
```
#### 默认管理员账号
@@ -52,17 +55,14 @@ curl -L https://raw.githubusercontent.com/bqlpfy/flux-panel/refs/heads/main/pane
作者对因使用本项目所造成的任何直接或间接损失概不负责,亦不提供任何形式的担保、承诺或技术支持。
请务必在合法、合规、安全的前提下使用本项目。
请务必在合法、合规、安全的前提下使用本项目。
---
## ⭐ 喝杯咖啡!
## ⭐ 喝杯咖啡!(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)
| BNB(BEP20) | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
| TRC20 | `TM8VYdU3s3gSX5PC8swjAJrAzZFCHKqG2k` |
| Aptos | `0x49427bfcba1006a346447430689b2307ac156316bb34850d1d3029ff9d118da5` |
| polygon | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
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

+9 -38
View File
@@ -1,39 +1,10 @@
services:
mysql:
image: mysql:5.7
container_name: gost-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
TZ: Asia/Shanghai
volumes:
- mysql_data:/var/lib/mysql
- ./gost.sql:/docker-entrypoint-initdb.d/init.sql:ro
command: >
--default-authentication-plugin=mysql_native_password
--character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci
--max_connections=1000
--innodb_buffer_pool_size=256M
networks:
- gost-network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 10s
retries: 10
backend:
image: bqlpfy/springboot-backend:1.4.2
image: bqlpfy/springboot-backend:2.0.7-beta
container_name: springboot-backend
restart: unless-stopped
environment:
DB_HOST: mysql
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_PATH: /app/data/gost.db
JWT_SECRET: ${JWT_SECRET}
LOG_DIR: /app/logs
JAVA_OPTS: "-Xms256m -Xmx512m -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"
@@ -41,20 +12,20 @@ services:
- "${BACKEND_PORT}:6365"
volumes:
- backend_logs:/app/logs
depends_on:
mysql:
condition: service_healthy
- sqlite_data:/app/data
networks:
- gost-network
stop_grace_period: 30s
stop_signal: SIGTERM
healthcheck:
test: ["CMD", "sh", "-c", "wget --no-verbose --tries=1 --spider http://localhost:6365/flow/test || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 90s
start_period: 60s
frontend:
image: bqlpfy/vite-frontend:1.4.2
image: bqlpfy/vite-frontend:2.0.7-beta
container_name: vite-frontend
restart: unless-stopped
ports:
@@ -67,8 +38,8 @@ services:
volumes:
mysql_data:
name: mysql_data
sqlite_data:
name: sqlite_data
driver: local
backend_logs:
name: backend_logs
+9 -38
View File
@@ -1,39 +1,10 @@
services:
mysql:
image: mysql:5.7
container_name: gost-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
TZ: Asia/Shanghai
volumes:
- mysql_data:/var/lib/mysql
- ./gost.sql:/docker-entrypoint-initdb.d/init.sql:ro
command: >
--default-authentication-plugin=mysql_native_password
--character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci
--max_connections=1000
--innodb_buffer_pool_size=256M
networks:
- gost-network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 10s
retries: 10
backend:
image: bqlpfy/springboot-backend:1.4.2
image: bqlpfy/springboot-backend:2.0.7-beta
container_name: springboot-backend
restart: unless-stopped
environment:
DB_HOST: mysql
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_PATH: /app/data/gost.db
JWT_SECRET: ${JWT_SECRET}
LOG_DIR: /app/logs
JAVA_OPTS: "-Xms256m -Xmx512m -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"
@@ -41,20 +12,20 @@ services:
- "${BACKEND_PORT}:6365"
volumes:
- backend_logs:/app/logs
depends_on:
mysql:
condition: service_healthy
- sqlite_data:/app/data
networks:
- gost-network
stop_grace_period: 30s
stop_signal: SIGTERM
healthcheck:
test: ["CMD", "sh", "-c", "wget --no-verbose --tries=1 --spider http://localhost:6365/flow/test || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 90s
start_period: 60s
frontend:
image: bqlpfy/vite-frontend:1.4.2
image: bqlpfy/vite-frontend:2.0.7-beta
container_name: vite-frontend
restart: unless-stopped
ports:
@@ -67,8 +38,8 @@ services:
volumes:
mysql_data:
name: mysql_data
sqlite_data:
name: sqlite_data
driver: local
backend_logs:
name: backend_logs
+37
View File
@@ -0,0 +1,37 @@
# GO-GOST SERVICE KNOWLEDGE BASE
**Generated:** Mon Feb 02 2026
## OVERVIEW
Forwarding agent built on GOST v3 with a local fork of `github.com/go-gost/x` under `x/`.
**Stack:** Go 1.23, github.com/go-gost/core v0.3.1, local `go-gost/x` module.
## STRUCTURE
```
go-gost/
├── main.go # Entry; reads panel config.json; starts svc.Run(program)
├── config.go # Panel config.json loader (addr/secret + ports)
├── program.go # GOST runtime: parse config, run/reload services
├── x/ # Local fork of github.com/go-gost/x (has its own go.mod)
└── go.mod # replace github.com/go-gost/x => ./x
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Panel integration config | `go-gost/config.go` | Expects `config.json` in cwd by default |
| Service lifecycle/reload | `go-gost/program.go` | Parses config; handles SIGHUP reload |
| WebSocket reporting | `go-gost/main.go` | Starts reporter + sets HTTP report URL |
| Protocol behaviors | `go-gost/x/` | Handlers/listeners/dialers live here |
## CONVENTIONS
- Two configs exist: panel integration uses `config.json`; forwarding services use GOST config (defaults to `gost.{json,yaml}` via viper search paths).
- `go-gost/x/` is the primary extension surface; avoid editing vendored deps.
## COMMANDS
```bash
cd go-gost
go run .
go test ./...
go build .
```
+1 -1
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, "1.2.3")
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, version)
defer wsReporter.Stop()
service.SetHTTPReportURL(config.Addr, config.Secret)
+1 -1
View File
@@ -1,5 +1,5 @@
package main
var (
version = "3.1.0"
version = "dev"
)
+42
View File
@@ -0,0 +1,42 @@
# GO-GOST/X KNOWLEDGE BASE
## OVERVIEW
Local fork of `github.com/go-gost/x` used by `go-gost/` via `replace github.com/go-gost/x => ./x`. Most protocol/runtime behavior changes happen here.
## STRUCTURE
```
go-gost/x/
├── api/ # Gin management API + embedded swagger docs
├── config/ # Config model + parsing/load/reload
├── connector/ # Outbound connect implementations
├── dialer/ # Outbound dialers (tcp/tls/ws/quic/...)
├── handler/ # Protocol handlers (socks/http/tunnel/relay/...)
├── listener/ # Inbound listeners (tcp/udp/tun/tap/redirect/...)
├── limiter/ # Traffic/rate/conn limiters
├── registry/ # Registries for services/handlers/listeners/etc
├── service/ # Service wrappers + reporting hooks
├── socket/ # WebSocket reporter / panel integration
└── internal/ # Shared internals (grpc proto, net utils, sniffing, tls, ...)
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Management API routes/auth | `go-gost/x/api/api.go` | `/docs`, `/config/*`; BasicAuth + interceptor |
| Service config parsing | `go-gost/x/config/parsing/` | Converts config to running services |
| Add a handler | `go-gost/x/handler/` | Per-protocol subdirs |
| Add a listener/dialer | `go-gost/x/listener/`, `go-gost/x/dialer/` | Transport variants |
| Panel reporting | `go-gost/x/socket/` | WebSocket + HTTP report URL hooks |
## CONVENTIONS
- `go-gost/x/` is a standalone Go module (`go-gost/x/go.mod`); run go tooling from this dir when debugging module resolution.
- Generated gRPC/proto code lives under `go-gost/x/internal/util/grpc/proto/`.
## ANTI-PATTERNS
- Do not edit generated files in `go-gost/x/internal/util/grpc/proto/` (`*.pb.go`, `*_grpc.pb.go`).
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+23
View File
@@ -0,0 +1,23 @@
# GO-GOST/X API KNOWLEDGE BASE
## OVERVIEW
Gin-based management API for reading/writing config and controlling services at runtime.
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Route registration | `go-gost/x/api/api.go` | `Register(*gin.Engine, *Options)` |
| Auth gating | `go-gost/x/api/middleware.go` | Drops non-BasicAuth requests; optional auther check |
| Service CRUD + pause/resume | `go-gost/x/api/config_service.go` | Uses registry + `config.OnUpdate(...)` |
| Swagger spec | `go-gost/x/api/swagger.yaml` | Served at `/docs` via embedded FS |
## CONVENTIONS
- CORS is `AllowAllOrigins: true` (see `go-gost/x/api/api.go`).
- Requests without a valid Basic `Authorization` header are silently dropped (connection hijack + close) by `GlobalInterceptor()`.
- Many operations mutate the in-memory config via `config.OnUpdate(...)` after starting/stopping services.
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+23
View File
@@ -0,0 +1,23 @@
# GO-GOST/X CONFIG KNOWLEDGE BASE
## OVERVIEW
Config model + parsing/loading pipeline for the `go-gost/x` runtime. This is the bridge between `gost.json`/`gost.yaml` and in-memory registries/services.
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Config structs + global state | `go-gost/x/config/config.go` | `Global()`, `Set()`, `OnUpdate()` |
| Default config file search | `go-gost/x/config/config.go` | Viper `SetConfigName("gost")` + paths `/etc/gost/`, `$HOME/.gost/`, `.` |
| Registry wiring | `go-gost/x/config/loader/loader.go` | Parses config sections and registers into registries |
| Metadata keys | `go-gost/x/config/parsing/parse.go` | `MDKey*` constants used by parsers |
| Config parser behavior | `go-gost/x/config/parsing/parser/parser.go` | CLI/env overrides; loads `gost.*` when empty |
## CONVENTIONS
- Default config file is named `gost` (e.g. `gost.json`) and is discovered via viper search paths.
- Runtime config mutations should go through `config.OnUpdate(...)` so changes are applied under the global mutex.
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+20
View File
@@ -109,3 +109,23 @@ func LoggerFromContext(ctx context.Context) logger.Logger {
v, _ := ctx.Value(keyLogger).(logger.Logger)
return v
}
// excludeNodesKey saves the list of node addresses to exclude during selection.
// This is used for failover retry logic - when a node fails, it gets added to
// the exclude list so the next Select() call will skip it.
type excludeNodesKey struct{}
var (
keyExcludeNodes = &excludeNodesKey{}
)
// ContextWithExcludeNodes returns a context with the list of node addresses to exclude.
func ContextWithExcludeNodes(ctx context.Context, nodes []string) context.Context {
return context.WithValue(ctx, keyExcludeNodes, nodes)
}
// ExcludeNodesFromContext returns the list of node addresses to exclude from selection.
func ExcludeNodesFromContext(ctx context.Context) []string {
v, _ := ctx.Value(keyExcludeNodes).([]string)
return v
}
+35
View File
@@ -0,0 +1,35 @@
# GO-GOST/X DIALERS KNOWLEDGE BASE
## OVERVIEW
Outbound dialers (client-side connection establishment) used by connectors/handlers.
## STRUCTURE
```
go-gost/x/dialer/
├── direct/ # Baseline dialer
├── tcp/
├── udp/
├── tls/
├── ws/
├── quic/
├── http2/
├── http3/
├── ssh/
├── wg/ # WireGuard dialer
└── ...
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Pick a dialer | `go-gost/x/dialer/` | One subdir per transport |
| TCP baseline | `go-gost/x/dialer/tcp/dialer.go` | Reference implementation |
## CONVENTIONS
- Dialer implementations typically live in `dialer.go` with a paired `metadata.go` (e.g. `go-gost/x/dialer/tcp/`).
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+32
View File
@@ -0,0 +1,32 @@
# GO-GOST/X HANDLERS KNOWLEDGE BASE
## OVERVIEW
Protocol handlers (server-side request handling) used by services defined in the GOST config.
## STRUCTURE
```
go-gost/x/handler/
├── http/ # handler.go + metadata.go (+ udp.go)
├── socks/ # SOCKS variants
├── tunnel/ # Tunnel forwarding
├── relay/ # Relay forwarding
├── redirect/ # TCP/UDP redirect handlers
├── router/ # Routing/association entrypoints
└── ...
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Find a protocol handler | `go-gost/x/handler/` | Subdir per protocol (`http`, `socks`, `tunnel`, ...) |
| HTTP specifics | `go-gost/x/handler/http/handler.go` | Implements HTTP proxy behavior |
| SOCKS specifics | `go-gost/x/handler/socks/` | v4/v5 implementations |
## CONVENTIONS
- Handler implementations typically live in `handler.go` with a paired `metadata.go` (e.g. `go-gost/x/handler/http/`).
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+69 -36
View File
@@ -176,51 +176,84 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand
}
}
target := &chain.Node{}
if h.hop != nil {
target = h.hop.Select(ctx,
hop.ProtocolSelectOption(proto),
)
}
if target == nil {
err := errors.New("node not available")
return err
// Determine max retry attempts
maxRetries := h.md.maxRetries
if maxRetries <= 0 {
// Default: try all available nodes
if nl, ok := h.hop.(hop.NodeList); ok {
maxRetries = len(nl.Nodes())
}
if maxRetries <= 0 {
maxRetries = 1
}
}
addr := target.Addr
if opts := target.Options(); opts != nil {
switch opts.Network {
case "unix":
network = opts.Network
default:
if _, _, err := net.SplitHostPort(addr); err != nil {
addr += ":0"
var triedNodes []string
var lastErr error
var cc net.Conn
for attempt := 0; attempt < maxRetries; attempt++ {
// Select a target node, excluding previously tried nodes
selectCtx := ctxvalue.ContextWithExcludeNodes(ctx, triedNodes)
var target *chain.Node
if h.hop != nil {
target = h.hop.Select(selectCtx,
hop.ProtocolSelectOption(proto),
)
}
if target == nil {
if lastErr != nil {
return lastErr
}
return errors.New("node not available")
}
// Track this node as tried
triedNodes = append(triedNodes, target.Addr)
addr := target.Addr
if opts := target.Options(); opts != nil {
switch opts.Network {
case "unix":
network = opts.Network
default:
if _, _, err := net.SplitHostPort(addr); err != nil {
addr += ":0"
}
}
}
}
ro.Network = network
ro.Host = addr
ro.Network = network
ro.Host = addr
var buf bytes.Buffer
cc, err := h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, addr)
ro.Route = buf.String()
if err != nil {
// TODO: the router itself may be failed due to the failed node in the router,
// the dead marker may be a wrong operation.
if marker := target.Marker(); marker != nil {
marker.Mark()
var buf bytes.Buffer
cc, err = h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, addr)
ro.Route = buf.String()
if err != nil {
// Mark node as failed for future selections
if marker := target.Marker(); marker != nil {
marker.Mark()
}
lastErr = err
// Try next node
continue
}
return err
}
if marker := target.Marker(); marker != nil {
marker.Reset()
}
defer cc.Close()
xnet.Transport(conn, cc)
// Success - reset marker and proceed
if marker := target.Marker(); marker != nil {
marker.Reset()
}
defer cc.Close()
return nil
xnet.Transport(conn, cc)
return nil
}
// All retries exhausted
if lastErr != nil {
return lastErr
}
return errors.New("all nodes failed")
}
func (h *forwardHandler) checkRateLimit(addr net.Addr) bool {
@@ -25,6 +25,12 @@ type metadata struct {
privateKey crypto.PrivateKey
alpn string
mitmBypass bypass.Bypass
// maxRetries specifies the maximum number of failover retry attempts.
// When a target node fails, the handler will try the next available node.
// 0 means use the total number of available nodes (try all nodes once).
// Default: 0 (try all available nodes)
maxRetries int
}
func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) {
@@ -56,5 +62,8 @@ func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) {
h.md.alpn = mdutil.GetString(md, "mitm.alpn")
h.md.mitmBypass = registry.BypassRegistry().Get(mdutil.GetString(md, "mitm.bypass"))
// maxRetries: 0 means try all available nodes (default behavior)
h.md.maxRetries = mdutil.GetInt(md, "maxRetries", "retry.max")
return
}
+84 -51
View File
@@ -204,68 +204,101 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand
}
}
var target *chain.Node
if host != "" {
target = &chain.Node{
Addr: host,
// Determine max retry attempts
maxRetries := h.md.maxRetries
if maxRetries <= 0 {
// Default: try all available nodes
if nl, ok := h.hop.(hop.NodeList); ok {
maxRetries = len(nl.Nodes())
}
}
if h.hop != nil {
target = h.hop.Select(ctx,
hop.ProtocolSelectOption(proto),
)
}
if target == nil {
err := errors.New("node not available")
log.Error(err)
return err
}
if opts := target.Options(); opts != nil {
switch opts.Network {
case "unix":
network = opts.Network
default:
if maxRetries <= 0 {
maxRetries = 1
}
}
ro.Network = network
ro.Host = target.Addr
var triedNodes []string
var lastErr error
var cc net.Conn
log = log.WithFields(map[string]any{
"node": target.Name,
"dst": fmt.Sprintf("%s/%s", target.Addr, network),
})
for attempt := 0; attempt < maxRetries; attempt++ {
// Select a target node, excluding previously tried nodes
selectCtx := ctxvalue.ContextWithExcludeNodes(ctx, triedNodes)
var target *chain.Node
if host != "" {
target = chain.NewNode("", host)
}
if h.hop != nil {
target = h.hop.Select(selectCtx,
hop.ProtocolSelectOption(proto),
)
}
if target == nil {
if lastErr != nil {
return lastErr
}
err := errors.New("node not available")
log.Error(err)
return err
}
log.Debugf("%s >> %s", conn.RemoteAddr(), target.Addr)
// Track this node as tried
triedNodes = append(triedNodes, target.Addr)
var buf bytes.Buffer
cc, err := h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, target.Addr)
ro.Route = buf.String()
if err != nil {
log.Error(err)
// TODO: the router itself may be failed due to the failed node in the router,
// the dead marker may be a wrong operation.
if opts := target.Options(); opts != nil {
switch opts.Network {
case "unix":
network = opts.Network
default:
}
}
ro.Network = network
ro.Host = target.Addr
targetLog := log.WithFields(map[string]any{
"node": target.Name,
"dst": fmt.Sprintf("%s/%s", target.Addr, network),
})
targetLog.Debugf("%s >> %s", conn.RemoteAddr(), target.Addr)
var buf bytes.Buffer
cc, err = h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, target.Addr)
ro.Route = buf.String()
if err != nil {
targetLog.Error(err)
// Mark node as failed for future selections
if marker := target.Marker(); marker != nil {
marker.Mark()
}
lastErr = err
// Try next node
continue
}
// Success - reset marker and proceed
if marker := target.Marker(); marker != nil {
marker.Mark()
marker.Reset()
}
return err
}
defer cc.Close()
if marker := target.Marker(); marker != nil {
marker.Reset()
defer cc.Close()
cc = proxyproto.WrapClientConn(h.md.proxyProtocol, conn.RemoteAddr(), convertAddr(conn.LocalAddr()), cc)
t := time.Now()
targetLog.Infof("%s <-> %s", conn.RemoteAddr(), target.Addr)
xnet.Transport(conn, cc)
targetLog.WithFields(map[string]any{
"duration": time.Since(t),
}).Infof("%s >-< %s", conn.RemoteAddr(), target.Addr)
return nil
}
cc = proxyproto.WrapClientConn(h.md.proxyProtocol, conn.RemoteAddr(), convertAddr(conn.LocalAddr()), cc)
t := time.Now()
log.Infof("%s <-> %s", conn.RemoteAddr(), target.Addr)
xnet.Transport(conn, cc)
log.WithFields(map[string]any{
"duration": time.Since(t),
}).Infof("%s >-< %s", conn.RemoteAddr(), target.Addr)
return nil
// All retries exhausted
if lastErr != nil {
return lastErr
}
return errors.New("all nodes failed")
}
func (h *forwardHandler) checkRateLimit(addr net.Addr) bool {
@@ -26,6 +26,12 @@ type metadata struct {
privateKey crypto.PrivateKey
alpn string
mitmBypass bypass.Bypass
// maxRetries specifies the maximum number of failover retry attempts.
// When a target node fails, the handler will try the next available node.
// 0 means use the total number of available nodes (try all nodes once).
// Default: 0 (try all available nodes)
maxRetries int
}
func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) {
@@ -57,5 +63,9 @@ func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) {
}
h.md.alpn = mdutil.GetString(md, "mitm.alpn")
h.md.mitmBypass = registry.BypassRegistry().Get(mdutil.GetString(md, "mitm.bypass"))
// maxRetries: 0 means try all available nodes (default behavior)
h.md.maxRetries = mdutil.GetInt(md, "maxRetries", "retry.max")
return
}
+37 -5
View File
@@ -18,6 +18,7 @@ import (
"github.com/go-gost/core/selector"
"github.com/go-gost/x/config"
node_parser "github.com/go-gost/x/config/parsing/node"
ctxvalue "github.com/go-gost/x/ctx"
"github.com/go-gost/x/internal/loader"
)
@@ -141,11 +142,25 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
return nil
}
// Get list of nodes to exclude (for failover retry)
excludeNodes := ctxvalue.ExcludeNodesFromContext(ctx)
excludeSet := make(map[string]bool)
for _, addr := range excludeNodes {
excludeSet[addr] = true
}
var nodes []*chain.Node
for _, node := range p.Nodes() {
if node == nil {
continue
}
// Skip nodes in the exclude list (failover retry)
if excludeSet[node.Addr] || excludeSet[node.Name] {
log.Debugf("node %s(%s) excluded for failover retry", node.Name, node.Addr)
continue
}
// node level bypass
if node.Options().Bypass != nil &&
node.Options().Bypass.Contains(ctx, options.Network, options.Addr, bypass.WithHostOpton(options.Host)) {
@@ -177,9 +192,6 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
if len(nodes) == 0 {
return nil
}
if len(nodes) == 1 {
return nodes[0]
}
sort.Slice(nodes, func(i, j int) bool {
return nodes[i].Options().Priority > nodes[j].Options().Priority
@@ -189,9 +201,29 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
return nodes[0]
}
if s := p.options.selector; s != nil {
return s.Select(ctx, nodes...)
// For single-node case: bypass selector/FailFilter to ensure availability.
// The marker system still works for metrics, but we don't block requests
// based on recent failures - the connection will be attempted regardless.
// This matches upstream go-gost/x behavior.
if len(nodes) == 1 {
return nodes[0]
}
// Multi-node case: use selector with FailFilter for proper failover.
// FailFilter will exclude recently-failed nodes, allowing traffic to
// be routed to healthy alternatives.
if s := p.options.selector; s != nil {
if node := s.Select(ctx, nodes...); node != nil {
return node
}
// All nodes filtered out by FailFilter - all are marked as failed.
// Return nil to signal "no healthy nodes available" to the caller.
// The handler's retry loop will handle this appropriately.
log.Debugf("all %d nodes filtered out by FailFilter, no healthy nodes available", len(nodes))
return nil
}
// Fallback: return first node if no selector configured
return nodes[0]
}
+174 -109
View File
@@ -247,64 +247,98 @@ func (h *Sniffer) dial(ctx context.Context, conn net.Conn, req *http.Request, ho
}
}
node = &chain.Node{
Addr: host,
// Determine max retry attempts
maxRetries := 1
if nl, ok := ho.Hop.(hop.NodeList); ok {
maxRetries = len(nl.Nodes())
}
if ho.Hop != nil {
node = ho.Hop.Select(ctx,
hop.ClientIPSelectOption(net.ParseIP(ro.ClientIP)),
hop.ProtocolSelectOption(sniffing.ProtoHTTP),
hop.HostSelectOption(host),
hop.MethodSelectOption(req.Method),
hop.PathSelectOption(req.URL.Path),
hop.QuerySelectOption(req.URL.Query()),
hop.HeaderSelectOption(req.Header),
)
}
if node == nil {
ho.Log.Warnf("node for %s not found", host)
res.StatusCode = http.StatusBadGateway
ro.HTTP.StatusCode = res.StatusCode
res.Write(conn)
return nil, nil, errors.New("node not available")
if maxRetries <= 0 {
maxRetries = 1
}
ro.Host = node.Addr
ho.Log = ho.Log.WithFields(map[string]any{
"node": node.Name,
"dst": node.Addr,
})
ho.Log.Debugf("find node for host %s -> %s(%s)", host, node.Name, node.Addr)
var triedNodes []string
var lastErr error
cc, err = dial(ctx, "tcp", node.Addr)
if err != nil {
// TODO: the router itself may be failed due to the failed node in the router,
// the dead marker may be a wrong operation.
if marker := node.Marker(); marker != nil {
marker.Mark()
for attempt := 0; attempt < maxRetries; attempt++ {
// Select a node, excluding previously tried nodes
selectCtx := ctxvalue.ContextWithExcludeNodes(ctx, triedNodes)
node = chain.NewNode("", host)
if ho.Hop != nil {
node = ho.Hop.Select(selectCtx,
hop.ClientIPSelectOption(net.ParseIP(ro.ClientIP)),
hop.ProtocolSelectOption(sniffing.ProtoHTTP),
hop.HostSelectOption(host),
hop.MethodSelectOption(req.Method),
hop.PathSelectOption(req.URL.Path),
hop.QuerySelectOption(req.URL.Query()),
hop.HeaderSelectOption(req.Header),
)
}
ho.Log.Warnf("connect to node %s(%s) failed: %v", node.Name, node.Addr, err)
res.Write(conn)
return
}
if marker := node.Marker(); marker != nil {
marker.Reset()
}
if tlsSettings := node.Options().TLS; tlsSettings != nil {
cfg := &tls.Config{
ServerName: tlsSettings.ServerName,
InsecureSkipVerify: !tlsSettings.Secure,
if node == nil {
if lastErr != nil {
ho.Log.Warnf("node for %s not found after retries", host)
res.StatusCode = http.StatusBadGateway
ro.HTTP.StatusCode = res.StatusCode
res.Write(conn)
return nil, nil, lastErr
}
ho.Log.Warnf("node for %s not found", host)
res.StatusCode = http.StatusBadGateway
ro.HTTP.StatusCode = res.StatusCode
res.Write(conn)
return nil, nil, errors.New("node not available")
}
tls_util.SetTLSOptions(cfg, &config.TLSOptions{
MinVersion: tlsSettings.Options.MinVersion,
MaxVersion: tlsSettings.Options.MaxVersion,
CipherSuites: tlsSettings.Options.CipherSuites,
ALPN: tlsSettings.Options.ALPN,
// Track this node as tried
triedNodes = append(triedNodes, node.Addr)
ro.Host = node.Addr
ho.Log = ho.Log.WithFields(map[string]any{
"node": node.Name,
"dst": node.Addr,
})
cc = tls.Client(cc, cfg)
ho.Log.Debugf("find node for host %s -> %s(%s)", host, node.Name, node.Addr)
cc, err = dial(ctx, "tcp", node.Addr)
if err != nil {
// Mark node as failed for future selections
if marker := node.Marker(); marker != nil {
marker.Mark()
}
ho.Log.Warnf("connect to node %s(%s) failed: %v, trying next node", node.Name, node.Addr, err)
lastErr = err
continue
}
// Success - reset marker
if marker := node.Marker(); marker != nil {
marker.Reset()
}
if tlsSettings := node.Options().TLS; tlsSettings != nil {
cfg := &tls.Config{
ServerName: tlsSettings.ServerName,
InsecureSkipVerify: !tlsSettings.Secure,
}
tls_util.SetTLSOptions(cfg, &config.TLSOptions{
MinVersion: tlsSettings.Options.MinVersion,
MaxVersion: tlsSettings.Options.MaxVersion,
CipherSuites: tlsSettings.Options.CipherSuites,
ALPN: tlsSettings.Options.ALPN,
})
cc = tls.Client(cc, cfg)
}
return node, cc, nil
}
return
// All retries exhausted
ho.Log.Warnf("all nodes failed for host %s", host)
res.Write(conn)
if lastErr != nil {
return nil, nil, lastErr
}
return nil, nil, errors.New("all nodes failed")
}
func (h *Sniffer) serveH2(ctx context.Context, conn net.Conn, ho *HandleOptions) error {
@@ -847,74 +881,105 @@ func (h *Sniffer) dialTLS(ctx context.Context, host string, ho *HandleOptions) (
return
}
if host != "" {
node = &chain.Node{
Addr: host,
}
}
ro := ho.RecorderObject
if ho.Hop != nil {
node = ho.Hop.Select(ctx,
hop.ClientIPSelectOption(net.ParseIP(ro.ClientIP)),
hop.HostSelectOption(host),
hop.ProtocolSelectOption(sniffing.ProtoTLS),
)
// Determine max retry attempts
maxRetries := 1
if nl, ok := ho.Hop.(hop.NodeList); ok {
maxRetries = len(nl.Nodes())
}
if node == nil {
err = errors.New("node not available")
return
if maxRetries <= 0 {
maxRetries = 1
}
addr := node.Addr
if opts := node.Options(); opts != nil {
switch opts.Network {
case "unix":
ro.Network = opts.Network
default:
if _, _, err := net.SplitHostPort(addr); err != nil {
addr += ":443"
var triedNodes []string
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
// Select a node, excluding previously tried nodes
selectCtx := ctxvalue.ContextWithExcludeNodes(ctx, triedNodes)
node = nil
if host != "" {
node = chain.NewNode("", host)
}
if ho.Hop != nil {
node = ho.Hop.Select(selectCtx,
hop.ClientIPSelectOption(net.ParseIP(ro.ClientIP)),
hop.HostSelectOption(host),
hop.ProtocolSelectOption(sniffing.ProtoTLS),
)
}
if node == nil {
if lastErr != nil {
ho.Log.Warnf("node for %s not found after retries", host)
return nil, nil, lastErr
}
ho.Log.Warnf("node for %s not found", host)
return nil, nil, errors.New("node not available")
}
// Track this node as tried
triedNodes = append(triedNodes, node.Addr)
addr := node.Addr
if opts := node.Options(); opts != nil {
switch opts.Network {
case "unix":
ro.Network = opts.Network
default:
if _, _, err := net.SplitHostPort(addr); err != nil {
addr += ":443"
}
}
}
}
ro.Host = addr
ro.Host = addr
ho.Log = ho.Log.WithFields(map[string]any{
"host": host,
"node": node.Name,
"dst": fmt.Sprintf("%s/%s", addr, ro.Network),
})
ho.Log.Debugf("find node for host %s -> %s(%s)", host, node.Name, addr)
cc, err = dial(ctx, ro.Network, addr)
if err != nil {
// TODO: the router itself may be failed due to the failed node in the router,
// the dead marker may be a wrong operation.
if marker := node.Marker(); marker != nil {
marker.Mark()
}
ho.Log.Warnf("connect to node %s(%s) failed: %v", node.Name, node.Addr, err)
return
}
if marker := node.Marker(); marker != nil {
marker.Reset()
}
if tlsSettings := node.Options().TLS; tlsSettings != nil {
cfg := &tls.Config{
ServerName: tlsSettings.ServerName,
InsecureSkipVerify: !tlsSettings.Secure,
}
tls_util.SetTLSOptions(cfg, &config.TLSOptions{
MinVersion: tlsSettings.Options.MinVersion,
MaxVersion: tlsSettings.Options.MaxVersion,
CipherSuites: tlsSettings.Options.CipherSuites,
ALPN: tlsSettings.Options.ALPN,
ho.Log = ho.Log.WithFields(map[string]any{
"host": host,
"node": node.Name,
"dst": fmt.Sprintf("%s/%s", addr, ro.Network),
})
cc = tls.Client(cc, cfg)
ho.Log.Debugf("find node for host %s -> %s(%s)", host, node.Name, addr)
cc, err = dial(ctx, ro.Network, addr)
if err != nil {
// Mark node as failed for future selections
if marker := node.Marker(); marker != nil {
marker.Mark()
}
ho.Log.Warnf("connect to node %s(%s) failed: %v, trying next node", node.Name, node.Addr, err)
lastErr = err
continue
}
// Success - reset marker
if marker := node.Marker(); marker != nil {
marker.Reset()
}
if tlsSettings := node.Options().TLS; tlsSettings != nil {
cfg := &tls.Config{
ServerName: tlsSettings.ServerName,
InsecureSkipVerify: !tlsSettings.Secure,
}
tls_util.SetTLSOptions(cfg, &config.TLSOptions{
MinVersion: tlsSettings.Options.MinVersion,
MaxVersion: tlsSettings.Options.MaxVersion,
CipherSuites: tlsSettings.Options.CipherSuites,
ALPN: tlsSettings.Options.ALPN,
})
cc = tls.Client(cc, cfg)
}
return node, cc, nil
}
return
// All retries exhausted
ho.Log.Warnf("all nodes failed for host %s", host)
if lastErr != nil {
return nil, nil, lastErr
}
return nil, nil, errors.New("all nodes failed")
}
func (h *Sniffer) terminateTLS(ctx context.Context, conn, cc net.Conn, clientHello *dissector.ClientHelloInfo, ho *HandleOptions) error {
+35
View File
@@ -0,0 +1,35 @@
# GO-GOST/X LISTENERS KNOWLEDGE BASE
## OVERVIEW
Inbound listeners (transport-level accept loops) used by services defined in the GOST config.
## STRUCTURE
```
go-gost/x/listener/
├── tcp/ # listener.go + metadata.go
├── udp/
├── tls/
├── ws/
├── quic/
├── redirect/ # tcp/ + udp/
├── tun/ # TUN device listener
├── tap/ # TAP device listener
└── ...
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Listener registry | `go-gost/x/listener/` | One subdir per transport |
| TCP baseline | `go-gost/x/listener/tcp/listener.go` | Reference for other transports |
| Redirect listeners | `go-gost/x/listener/redirect/` | Per-protocol accept + redirect |
| TUN/TAP | `go-gost/x/listener/tun/`, `go-gost/x/listener/tap/` | Virtual interface listeners |
## CONVENTIONS
- Listener implementations typically live in `listener.go` with a paired `metadata.go` (e.g. `go-gost/x/listener/tcp/`).
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+29
View File
@@ -0,0 +1,29 @@
# GO-GOST REGISTRY KNOWLEDGE BASE
**Generated:** Wed Feb 04 2026
## OVERVIEW
Central registration point for all pluggable GOST components (handlers, listeners, dialers, etc.).
Allows the configuration system to resolve string types (e.g., "socks5") to actual Go implementations.
## STRUCTURE
One file per component type, exporting a standard Registry interface.
```
go-gost/x/registry/
├── handler.go # RegisterHandler(name, newFunc)
├── listener.go # RegisterListener(name, newFunc)
├── dialer.go # RegisterDialer(name, newFunc)
└── ... # Same pattern for auth, bypass, admission
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Register a new component | `go-gost/x/registry/{type}.go` | Use `Register{Type}(name, creator)` |
| Component lookup | `go-gost/x/registry/{type}.go` | `Get{Type}(name)` returns the creator function |
| Default registrations | `go-gost/x/` (init functions) | Most components register themselves in their package `init()` |
## CONVENTIONS
- Thread-safe maps used for storage.
- Names are case-sensitive (usually lowercase).
- Components must be registered *before* the configuration parser runs (usually done via `import _ "..."` in `main.go`).
+3 -1
View File
@@ -5,8 +5,8 @@ import (
"time"
"github.com/go-gost/core/metadata"
mdutil "github.com/go-gost/x/metadata/util"
"github.com/go-gost/core/selector"
mdutil "github.com/go-gost/x/metadata/util"
)
type failFilter[T any] struct {
@@ -24,6 +24,8 @@ func FailFilter[T any](maxFails int, timeout time.Duration) selector.Filter[T] {
}
// Filter filters dead objects.
// For single-node case, skip filtering to ensure availability (matches upstream).
// For multi-node case, filter out failed nodes to enable failover.
func (f *failFilter[T]) Filter(ctx context.Context, vs ...T) []T {
if len(vs) <= 1 {
return vs
+206
View File
@@ -0,0 +1,206 @@
package service
import (
"context"
"fmt"
"sync"
"time"
)
// GlobalTrafficManager 全局流量管理器(所有服务共享)
type GlobalTrafficManager struct {
mu sync.RWMutex
serviceTraffic map[string]*ServiceTraffic // key: 服务名, value: 流量数据
ctx context.Context
cancel context.CancelFunc
reportTicker *time.Ticker
}
// ServiceTraffic 单个服务的流量累积
type ServiceTraffic struct {
mu sync.Mutex
ServiceName string
UpBytes int64 // 上行流量(累积)
DownBytes int64 // 下行流量(累积)
}
var (
globalManager *GlobalTrafficManager
globalManagerOnce sync.Once
)
// GetGlobalTrafficManager 获取全局流量管理器单例
func GetGlobalTrafficManager() *GlobalTrafficManager {
globalManagerOnce.Do(func() {
ctx, cancel := context.WithCancel(context.Background())
globalManager = &GlobalTrafficManager{
serviceTraffic: make(map[string]*ServiceTraffic),
ctx: ctx,
cancel: cancel,
reportTicker: time.NewTicker(5 * time.Second),
}
// 启动定时上报协程
go globalManager.startReporting()
})
return globalManager
}
// AddTraffic 添加流量到指定服务(由各服务调用)
func (m *GlobalTrafficManager) AddTraffic(serviceName string, upBytes, downBytes int64) {
if upBytes == 0 && downBytes == 0 {
return
}
m.mu.Lock()
defer m.mu.Unlock()
// 获取或创建服务流量记录
traffic, exists := m.serviceTraffic[serviceName]
if !exists {
traffic = &ServiceTraffic{
ServiceName: serviceName,
}
m.serviceTraffic[serviceName] = traffic
}
// 累加流量
traffic.mu.Lock()
traffic.UpBytes += upBytes
traffic.DownBytes += downBytes
traffic.mu.Unlock()
}
// startReporting 启动定时上报协程(每5秒执行一次)
func (m *GlobalTrafficManager) startReporting() {
for {
select {
case <-m.reportTicker.C:
m.collectAndReport()
case <-m.ctx.Done():
fmt.Printf("⏹️ 全局流量上报器已停止\n")
return
}
}
}
// collectAndReport 收集所有服务流量并合并上报
func (m *GlobalTrafficManager) collectAndReport() {
m.mu.Lock()
// 如果没有流量,直接返回
if len(m.serviceTraffic) == 0 {
m.mu.Unlock()
return
}
// 复制当前所有流量数据(避免长时间持锁)
trafficSnapshot := make(map[string]*ServiceTraffic)
reportData := make(map[string]struct {
up int64
down int64
})
for name, traffic := range m.serviceTraffic {
traffic.mu.Lock()
if traffic.UpBytes > 0 || traffic.DownBytes > 0 {
trafficSnapshot[name] = traffic
reportData[name] = struct {
up int64
down int64
}{
up: traffic.UpBytes,
down: traffic.DownBytes,
}
}
traffic.mu.Unlock()
}
m.mu.Unlock()
// 如果没有需要上报的流量,返回
if len(reportData) == 0 {
return
}
// 构建上报数据数组(保持每个服务独立)
reportItems := make([]TrafficReportItem, 0, len(reportData))
var totalUp, totalDown int64
for serviceName, data := range reportData {
reportItems = append(reportItems, TrafficReportItem{
N: serviceName, // 保持服务名不变
U: data.up,
D: data.down,
})
totalUp += data.up
totalDown += data.down
}
// 批量发送上报请求(一次HTTP请求包含所有服务)
success, err := sendBatchTrafficReport(m.ctx, reportItems)
if err != nil {
fmt.Printf("❌ 全局流量上报失败: %v (总流量: ↑%d ↓%d, %d个服务)\n", err, totalUp, totalDown, len(reportItems))
return
}
if !success {
fmt.Printf("⚠️ 全局流量上报未成功 (总流量: ↑%d ↓%d, %d个服务)\n", totalUp, totalDown, len(reportItems))
return
}
// 上报成功,清空已上报的流量
m.clearReportedTraffic(reportData)
}
// clearReportedTraffic 清空已成功上报的流量
func (m *GlobalTrafficManager) clearReportedTraffic(reportedData map[string]struct {
up int64
down int64
}) {
m.mu.Lock()
defer m.mu.Unlock()
for serviceName, reported := range reportedData {
if traffic, exists := m.serviceTraffic[serviceName]; exists {
traffic.mu.Lock()
// 减去已上报的流量
traffic.UpBytes -= reported.up
traffic.DownBytes -= reported.down
// 如果流量归零,从map中删除该服务记录(避免内存泄漏)
if traffic.UpBytes <= 0 && traffic.DownBytes <= 0 {
traffic.mu.Unlock()
delete(m.serviceTraffic, serviceName)
} else {
traffic.mu.Unlock()
}
}
}
}
// Stop 停止全局流量管理器
func (m *GlobalTrafficManager) Stop() {
if m.reportTicker != nil {
m.reportTicker.Stop()
}
if m.cancel != nil {
m.cancel()
}
fmt.Printf("🛑 全局流量管理器已停止\n")
}
// GetServiceTraffic 获取指定服务的当前流量(用于调试)
func (m *GlobalTrafficManager) GetServiceTraffic(serviceName string) (upBytes, downBytes int64) {
m.mu.RLock()
defer m.mu.RUnlock()
if traffic, exists := m.serviceTraffic[serviceName]; exists {
traffic.mu.Lock()
upBytes = traffic.UpBytes
downBytes = traffic.DownBytes
traffic.mu.Unlock()
}
return
}
+8 -12
View File
@@ -403,19 +403,15 @@ func (s *defaultService) observeStats(ctx context.Context) {
TotalErrs: st.Get(stats.KindTotalErrs),
},
}
// 将流量累积到全局管理器,而不是立即上报
if outputBytes > 0 || inputBytes > 0 {
reportItems := TrafficReportItem{
N: s.name,
U: int64(outputBytes),
D: int64(inputBytes),
}
success, err := sendTrafficReport(ctx, reportItems)
if err != nil {
fmt.Printf("发送流量报告失败: %v", err)
} else if success {
if xstats, ok := st.(*xstats.Stats); ok {
xstats.ResetTraffic(st.Get(stats.KindInputBytes)-inputBytes, st.Get(stats.KindOutputBytes)-outputBytes)
}
globalManager := GetGlobalTrafficManager()
globalManager.AddTraffic(s.name, int64(outputBytes), int64(inputBytes))
// 立即重置流量计数(因为已经记录到全局管理器中)
if xstats, ok := st.(*xstats.Stats); ok {
xstats.ResetTraffic(st.Get(stats.KindInputBytes)-inputBytes, st.Get(stats.KindOutputBytes)-outputBytes)
}
}
+3 -2
View File
@@ -41,8 +41,8 @@ func SetHTTPReportURL(addr string, secret string) {
}
}
// sendTrafficReport 发送流量报告到HTTP接口
func sendTrafficReport(ctx context.Context, reportItems TrafficReportItem) (bool, error) {
// sendBatchTrafficReport 批量发送多个服务的流量报告到HTTP接口
func sendBatchTrafficReport(ctx context.Context, reportItems []TrafficReportItem) (bool, error) {
jsonData, err := json.Marshal(reportItems)
if err != nil {
return false, fmt.Errorf("序列化报告数据失败: %v", err)
@@ -112,6 +112,7 @@ func sendTrafficReport(ctx context.Context, reportItems TrafficReportItem) (bool
}
}
// sendConfigReport 发送配置报告到HTTP接口
func sendConfigReport(ctx context.Context) (bool, error) {
if configReportURL == "" {
+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()
-306
View File
@@ -1,306 +0,0 @@
-- phpMyAdmin SQL Dump
-- version 5.2.0
-- https://www.phpmyadmin.net/
--
-- 主机: localhost
-- 生成日期: 2025-08-14 21:52:52
-- 服务器版本: 5.7.40-log
-- PHP 版本: 7.4.33
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
START TRANSACTION;
SET time_zone = "+00:00";
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8mb4 */;
--
-- 数据库: `gost`
--
-- --------------------------------------------------------
--
-- 表的结构 `forward`
--
CREATE TABLE `forward` (
`id` int(10) NOT NULL,
`user_id` int(10) NOT NULL,
`user_name` varchar(100) NOT NULL,
`name` varchar(100) NOT NULL,
`tunnel_id` int(10) NOT NULL,
`in_port` int(10) NOT NULL,
`out_port` int(10) DEFAULT NULL,
`remote_addr` longtext NOT NULL,
`strategy` varchar(100) NOT NULL DEFAULT 'fifo',
`interface_name` varchar(200) DEFAULT NULL,
`in_flow` bigint(20) NOT NULL DEFAULT '0',
`out_flow` bigint(20) NOT NULL DEFAULT '0',
`created_time` bigint(20) NOT NULL,
`updated_time` bigint(20) NOT NULL,
`status` int(10) NOT NULL,
`inx` int(10) NOT NULL DEFAULT '0'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- --------------------------------------------------------
--
-- 表的结构 `node`
--
CREATE TABLE `node` (
`id` int(10) NOT NULL,
`name` varchar(100) NOT NULL,
`secret` varchar(100) NOT NULL,
`ip` longtext,
`server_ip` varchar(100) NOT NULL,
`port_sta` int(10) NOT NULL,
`port_end` int(10) NOT NULL,
`version` varchar(100) DEFAULT NULL,
`http` int(10) NOT NULL DEFAULT '0',
`tls` int(10) NOT NULL DEFAULT '0',
`socks` int(10) NOT NULL DEFAULT '0',
`created_time` bigint(20) NOT NULL,
`updated_time` bigint(20) DEFAULT NULL,
`status` int(10) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- --------------------------------------------------------
--
-- 表的结构 `speed_limit`
--
CREATE TABLE `speed_limit` (
`id` int(10) NOT NULL,
`name` varchar(100) NOT NULL,
`speed` int(10) NOT NULL,
`tunnel_id` int(10) NOT NULL,
`tunnel_name` varchar(100) NOT NULL,
`created_time` bigint(20) NOT NULL,
`updated_time` bigint(20) DEFAULT NULL,
`status` int(10) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- --------------------------------------------------------
--
-- 表的结构 `statistics_flow`
--
CREATE TABLE `statistics_flow` (
`id` int(10) NOT NULL,
`user_id` int(10) NOT NULL,
`flow` bigint(20) NOT NULL,
`total_flow` bigint(20) NOT NULL,
`time` varchar(100) NOT NULL,
`created_time` bigint(20) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- --------------------------------------------------------
--
-- 表的结构 `tunnel`
--
CREATE TABLE `tunnel` (
`id` int(10) NOT NULL,
`name` varchar(100) NOT NULL,
`traffic_ratio` decimal(10,1) NOT NULL DEFAULT '1.0',
`in_node_id` int(10) NOT NULL,
`in_ip` varchar(100) NOT NULL,
`out_node_id` int(10) NOT NULL,
`out_ip` varchar(100) NOT NULL,
`type` int(10) NOT NULL,
`protocol` varchar(10) NOT NULL DEFAULT 'tls',
`flow` int(10) NOT NULL,
`tcp_listen_addr` varchar(100) NOT NULL DEFAULT '[::]',
`udp_listen_addr` varchar(100) NOT NULL DEFAULT '[::]',
`interface_name` varchar(200) DEFAULT NULL,
`created_time` bigint(20) NOT NULL,
`updated_time` bigint(20) NOT NULL,
`status` int(10) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- --------------------------------------------------------
--
-- 表的结构 `user`
--
CREATE TABLE `user` (
`id` int(10) NOT NULL,
`user` varchar(100) NOT NULL,
`pwd` varchar(100) NOT NULL,
`role_id` int(10) NOT NULL,
`exp_time` bigint(20) NOT NULL,
`flow` bigint(20) NOT NULL,
`in_flow` bigint(20) NOT NULL DEFAULT '0',
`out_flow` bigint(20) NOT NULL DEFAULT '0',
`flow_reset_time` bigint(20) NOT NULL,
`num` int(10) NOT NULL,
`created_time` bigint(20) NOT NULL,
`updated_time` bigint(20) DEFAULT NULL,
`status` int(10) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- 转存表中的数据 `user`
--
INSERT INTO `user` (`id`, `user`, `pwd`, `role_id`, `exp_time`, `flow`, `in_flow`, `out_flow`, `flow_reset_time`, `num`, `created_time`, `updated_time`, `status`) VALUES
(1, 'admin_user', '3c85cdebade1c51cf64ca9f3c09d182d', 0, 2727251700000, 99999, 0, 0, 1, 99999, 1748914865000, 1754011744252, 1);
-- --------------------------------------------------------
--
-- 表的结构 `user_tunnel`
--
CREATE TABLE `user_tunnel` (
`id` int(10) NOT NULL,
`user_id` int(10) NOT NULL,
`tunnel_id` int(10) NOT NULL,
`speed_id` int(10) DEFAULT NULL,
`num` int(10) NOT NULL,
`flow` bigint(20) NOT NULL,
`in_flow` bigint(20) NOT NULL DEFAULT '0',
`out_flow` bigint(20) NOT NULL DEFAULT '0',
`flow_reset_time` bigint(20) NOT NULL,
`exp_time` bigint(20) NOT NULL,
`status` int(10) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- --------------------------------------------------------
--
-- 表的结构 `vite_config`
--
CREATE TABLE `vite_config` (
`id` int(10) NOT NULL,
`name` varchar(200) NOT NULL,
`value` varchar(200) NOT NULL,
`time` bigint(20) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
--
-- 转存表中的数据 `vite_config`
--
INSERT INTO `vite_config` (`id`, `name`, `value`, `time`) VALUES
(1, 'app_name', 'flux', 1755147963000);
--
-- 转储表的索引
--
--
-- 表的索引 `forward`
--
ALTER TABLE `forward`
ADD PRIMARY KEY (`id`);
--
-- 表的索引 `node`
--
ALTER TABLE `node`
ADD PRIMARY KEY (`id`);
--
-- 表的索引 `speed_limit`
--
ALTER TABLE `speed_limit`
ADD PRIMARY KEY (`id`);
--
-- 表的索引 `statistics_flow`
--
ALTER TABLE `statistics_flow`
ADD PRIMARY KEY (`id`);
--
-- 表的索引 `tunnel`
--
ALTER TABLE `tunnel`
ADD PRIMARY KEY (`id`);
--
-- 表的索引 `user`
--
ALTER TABLE `user`
ADD PRIMARY KEY (`id`);
--
-- 表的索引 `user_tunnel`
--
ALTER TABLE `user_tunnel`
ADD PRIMARY KEY (`id`);
--
-- 表的索引 `vite_config`
--
ALTER TABLE `vite_config`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `name` (`name`);
--
-- 在导出的表使用AUTO_INCREMENT
--
--
-- 使用表AUTO_INCREMENT `forward`
--
ALTER TABLE `forward`
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
--
-- 使用表AUTO_INCREMENT `node`
--
ALTER TABLE `node`
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
--
-- 使用表AUTO_INCREMENT `speed_limit`
--
ALTER TABLE `speed_limit`
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
--
-- 使用表AUTO_INCREMENT `statistics_flow`
--
ALTER TABLE `statistics_flow`
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
--
-- 使用表AUTO_INCREMENT `tunnel`
--
ALTER TABLE `tunnel`
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
--
-- 使用表AUTO_INCREMENT `user`
--
ALTER TABLE `user`
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
--
-- 使用表AUTO_INCREMENT `user_tunnel`
--
ALTER TABLE `user_tunnel`
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
--
-- 使用表AUTO_INCREMENT `vite_config`
--
ALTER TABLE `vite_config`
MODIFY `id` int(10) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1;
COMMIT;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
+153 -62
View File
@@ -1,12 +1,108 @@
#!/bin/bash
# 下载地址
DOWNLOAD_URL="https://github.com/bqlpfy/flux-panel/releases/download/1.4.2/gost"
INSTALL_DIR="/etc/gost"
# GitHub repo used for release downloads
REPO="Sagit-chu/flux-panel"
# 获取系统架构
get_architecture() {
ARCH=$(uname -m)
case $ARCH in
x86_64)
echo "amd64"
;;
aarch64|arm64)
echo "arm64"
;;
*)
echo "amd64" # 默认使用 amd64
;;
esac
}
# 安装目录
INSTALL_DIR="/etc/flux_agent"
# 识别国家(用于镜像加速)
COUNTRY=$(curl -s https://ipinfo.io/country)
if [ "$COUNTRY" = "CN" ]; then
# 拼接 URL
DOWNLOAD_URL="https://ghfast.top/${DOWNLOAD_URL}"
fi
maybe_proxy_url() {
local url="$1"
if [ "$COUNTRY" = "CN" ]; then
echo "https://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)")
@@ -131,8 +227,8 @@ while getopts "a:s:" opt; do
done
# 安装功能
install_gost() {
echo "🚀 开始安装 GOST..."
install_flux_agent() {
echo "🚀 开始安装 flux_agent..."
get_config_params
# 检查并安装 tcpkill
@@ -142,27 +238,27 @@ install_gost() {
mkdir -p "$INSTALL_DIR"
# 停止并禁用已有服务
if systemctl list-units --full -all | grep -Fq "gost.service"; then
echo "🔍 检测到已存在的gost服务"
systemctl stop gost 2>/dev/null && echo "🛑 停止服务"
systemctl disable gost 2>/dev/null && echo "🚫 禁用自启"
if systemctl list-units --full -all | grep -Fq "flux_agent.service"; then
echo "🔍 检测到已存在的flux_agent服务"
systemctl stop flux_agent 2>/dev/null && echo "🛑 停止服务"
systemctl disable flux_agent 2>/dev/null && echo "🚫 禁用自启"
fi
# 删除旧文件
[[ -f "$INSTALL_DIR/gost" ]] && echo "🧹 删除旧文件 gost" && rm -f "$INSTALL_DIR/gost"
[[ -f "$INSTALL_DIR/flux_agent" ]] && echo "🧹 删除旧文件 flux_agent" && rm -f "$INSTALL_DIR/flux_agent"
# 下载 gost
echo "⬇️ 下载 gost 中..."
curl -L "$DOWNLOAD_URL" -o "$INSTALL_DIR/gost"
if [[ ! -f "$INSTALL_DIR/gost" || ! -s "$INSTALL_DIR/gost" ]]; then
# 下载 flux_agent
echo "⬇️ 下载 flux_agent 中..."
curl -L "$DOWNLOAD_URL" -o "$INSTALL_DIR/flux_agent"
if [[ ! -f "$INSTALL_DIR/flux_agent" || ! -s "$INSTALL_DIR/flux_agent" ]]; then
echo "❌ 下载失败,请检查网络或下载链接。"
exit 1
fi
chmod +x "$INSTALL_DIR/gost"
chmod +x "$INSTALL_DIR/flux_agent"
echo "✅ 下载完成"
# 打印版本
echo "🔎 gost 版本:$($INSTALL_DIR/gost -V)"
echo "🔎 flux_agent 版本:$($INSTALL_DIR/flux_agent -V)"
# 写入 config.json (安装时总是创建新的)
CONFIG_FILE="$INSTALL_DIR/config.json"
@@ -189,15 +285,15 @@ EOF
chmod 600 "$INSTALL_DIR"/*.json
# 创建 systemd 服务
SERVICE_FILE="/etc/systemd/system/gost.service"
SERVICE_FILE="/etc/systemd/system/flux_agent.service"
cat > "$SERVICE_FILE" <<EOF
[Unit]
Description=Gost Proxy Service
Description=Flux_agent Proxy Service
After=network.target
[Service]
WorkingDirectory=$INSTALL_DIR
ExecStart=$INSTALL_DIR/gost
ExecStart=$INSTALL_DIR/flux_agent
Restart=on-failure
[Install]
@@ -206,27 +302,27 @@ EOF
# 启动服务
systemctl daemon-reload
systemctl enable gost
systemctl start gost
systemctl enable flux_agent
systemctl start flux_agent
# 检查状态
echo "🔄 检查服务状态..."
if systemctl is-active --quiet gost; then
echo "✅ 安装完成,gost服务已启动并设置为开机启动。"
if systemctl is-active --quiet flux_agent; then
echo "✅ 安装完成,flux_agent服务已启动并设置为开机启动。"
echo "📁 配置目录: $INSTALL_DIR"
echo "🔧 服务状态: $(systemctl is-active gost)"
echo "🔧 服务状态: $(systemctl is-active flux_agent)"
else
echo "❌ gost服务启动失败,请执行以下命令查看日志:"
echo "journalctl -u gost -f"
echo "❌ flux_agent服务启动失败,请执行以下命令查看日志:"
echo "journalctl -u flux_agent -f"
fi
}
# 更新功能
update_gost() {
echo "🔄 开始更新 GOST..."
update_flux_agent() {
echo "🔄 开始更新 flux_agent..."
if [[ ! -d "$INSTALL_DIR" ]]; then
echo "❌ GOST 未安装,请先选择安装。"
echo "❌ flux_agent 未安装,请先选择安装。"
return 1
fi
@@ -237,52 +333,52 @@ update_gost() {
# 先下载新版本
echo "⬇️ 下载最新版本..."
curl -L "$DOWNLOAD_URL" -o "$INSTALL_DIR/gost.new"
if [[ ! -f "$INSTALL_DIR/gost.new" || ! -s "$INSTALL_DIR/gost.new" ]]; then
curl -L "$DOWNLOAD_URL" -o "$INSTALL_DIR/flux_agent.new"
if [[ ! -f "$INSTALL_DIR/flux_agent.new" || ! -s "$INSTALL_DIR/flux_agent.new" ]]; then
echo "❌ 下载失败。"
return 1
fi
# 停止服务
if systemctl list-units --full -all | grep -Fq "gost.service"; then
echo "🛑 停止 gost 服务..."
systemctl stop gost
if systemctl list-units --full -all | grep -Fq "flux_agent.service"; then
echo "🛑 停止 flux_agent 服务..."
systemctl stop flux_agent
fi
# 替换文件
mv "$INSTALL_DIR/gost.new" "$INSTALL_DIR/gost"
chmod +x "$INSTALL_DIR/gost"
mv "$INSTALL_DIR/flux_agent.new" "$INSTALL_DIR/flux_agent"
chmod +x "$INSTALL_DIR/flux_agent"
# 打印版本
echo "🔎 新版本:$($INSTALL_DIR/gost -V)"
echo "🔎 新版本:$($INSTALL_DIR/flux_agent -V)"
# 重启服务
echo "🔄 重启服务..."
systemctl start gost
systemctl start flux_agent
echo "✅ 更新完成,服务已重新启动。"
}
# 卸载功能
uninstall_gost() {
echo "🗑️ 开始卸载 GOST..."
uninstall_flux_agent() {
echo "🗑️ 开始卸载 flux_agent..."
read -p "确认卸载 GOST 吗?此操作将删除所有相关文件 (y/N): " confirm
read -p "确认卸载 flux_agent 吗?此操作将删除所有相关文件 (y/N): " confirm
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
echo "❌ 取消卸载"
return 0
fi
# 停止并禁用服务
if systemctl list-units --full -all | grep -Fq "gost.service"; then
if systemctl list-units --full -all | grep -Fq "flux_agent.service"; then
echo "🛑 停止并禁用服务..."
systemctl stop gost 2>/dev/null
systemctl disable gost 2>/dev/null
systemctl stop flux_agent 2>/dev/null
systemctl disable flux_agent 2>/dev/null
fi
# 删除服务文件
if [[ -f "/etc/systemd/system/gost.service" ]]; then
rm -f "/etc/systemd/system/gost.service"
if [[ -f "/etc/systemd/system/flux_agent.service" ]]; then
rm -f "/etc/systemd/system/flux_agent.service"
echo "🧹 删除服务文件"
fi
@@ -302,7 +398,7 @@ uninstall_gost() {
main() {
# 如果提供了命令行参数,直接执行安装
if [[ -n "$SERVER_ADDR" && -n "$SECRET" ]]; then
install_gost
install_flux_agent
delete_self
exit 0
fi
@@ -310,36 +406,31 @@ main() {
# 显示交互式菜单
while true; do
show_menu
read -p "请输入选项 (1-5): " choice
read -p "请输入选项 (1-4): " choice
case $choice in
1)
install_gost
install_flux_agent
delete_self
exit 0
;;
2)
update_gost
update_flux_agent
delete_self
exit 0
;;
3)
uninstall_gost
uninstall_flux_agent
delete_self
exit 0
;;
4)
block_protocol
delete_self
exit 0
;;
5)
echo "👋 退出脚本"
delete_self
exit 0
;;
*)
echo "❌ 无效选项,请输入 1-5"
echo "❌ 无效选项,请输入 1-4"
echo ""
;;
esac
@@ -347,4 +438,4 @@ main() {
}
# 执行主函数
main
main
+86 -781
View File
@@ -7,18 +7,82 @@ export LC_ALL=C
# 全局下载地址配置
DOCKER_COMPOSEV4_URL="https://github.com/bqlpfy/flux-panel/releases/download/1.4.2/docker-compose-v4.yml"
DOCKER_COMPOSEV6_URL="https://github.com/bqlpfy/flux-panel/releases/download/1.4.2/docker-compose-v6.yml"
GOST_SQL_URL="https://github.com/bqlpfy/flux-panel/releases/download/1.4.2/gost.sql"
# 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}"
GOST_SQL_URL="https://ghfast.top/${GOST_SQL_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")
@@ -149,8 +213,7 @@ show_menu() {
echo "1. 安装面板"
echo "2. 更新面板"
echo "3. 卸载面板"
echo "4. 导出备份"
echo "5. 退出"
echo "4. 退出"
echo "==============================================="
}
@@ -173,17 +236,13 @@ delete_self() {
get_config_params() {
echo "🔧 请输入配置参数:"
read -p "前端端口(默认 6366): " FRONTEND_PORT
FRONTEND_PORT=${FRONTEND_PORT:-6366}
read -p "后端端口(默认 6365): " BACKEND_PORT
BACKEND_PORT=${BACKEND_PORT:-6365}
DB_NAME=$(generate_random)
DB_USER=$(generate_random)
DB_PASSWORD=$(generate_random)
# 生成JWT密钥
JWT_SECRET=$(generate_random)
}
@@ -197,14 +256,6 @@ install_panel() {
DOCKER_COMPOSE_URL=$(get_docker_compose_url)
echo "📡 选择配置文件:$(basename "$DOCKER_COMPOSE_URL")"
curl -L -o docker-compose.yml "$DOCKER_COMPOSE_URL"
# 检查 gost.sql 是否已存在
if [[ -f "gost.sql" ]]; then
echo "⏭️ 跳过下载: gost.sql (使用当前位置的文件)"
else
echo "📡 下载数据库初始化文件..."
curl -L -o gost.sql "$GOST_SQL_URL"
fi
echo "✅ 文件准备完成"
# 自动检测并配置 IPv6 支持
@@ -214,9 +265,6 @@ install_panel() {
fi
cat > .env <<EOF
DB_NAME=$DB_NAME
DB_USER=$DB_USER
DB_PASSWORD=$DB_PASSWORD
JWT_SECRET=$JWT_SECRET
FRONTEND_PORT=$FRONTEND_PORT
BACKEND_PORT=$BACKEND_PORT
@@ -252,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 "⬇️ 拉取最新镜像..."
@@ -295,755 +351,9 @@ update_panel() {
sleep 1
done
# 检查数据库容器健康状态
echo "🔍 检查数据库服务状态..."
for i in {1..60}; do
if docker ps --format "{{.Names}}" | grep -q "^gost-mysql$"; then
DB_HEALTH=$(docker inspect -f '{{.State.Health.Status}}' gost-mysql 2>/dev/null || echo "unknown")
if [[ "$DB_HEALTH" == "healthy" ]]; then
echo "✅ 数据库服务健康检查通过"
break
elif [[ "$DB_HEALTH" == "starting" ]]; then
# 继续等待
:
elif [[ "$DB_HEALTH" == "unhealthy" ]]; then
echo "⚠️ 数据库健康状态:$DB_HEALTH"
fi
else
echo "⚠️ 数据库容器未找到或未运行"
DB_HEALTH="not_running"
fi
if [ $i -eq 60 ]; then
echo "❌ 数据库服务启动超时(60秒)"
echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' gost-mysql 2>/dev/null || echo '容器不存在')"
echo "🛑 更新终止"
return 1
fi
# 每10秒显示一次进度
if [ $((i % 10)) -eq 1 ]; then
echo "⏳ 等待数据库服务启动... ($i/60) 状态:${DB_HEALTH:-unknown}"
fi
sleep 1
done
# 从容器环境变量获取数据库信息
echo "🔍 获取数据库配置信息..."
# 等待一下让服务完全就绪
echo "⏳ 等待服务完全就绪..."
sleep 5
# 先检查后端容器是否在运行
if ! docker ps --format "{{.Names}}" | grep -q "^springboot-backend$"; then
echo "❌ 后端容器未运行,无法获取数据库配置"
echo "🔍 当前运行的容器:"
docker ps --format "table {{.Names}}\t{{.Status}}"
echo "🛑 更新终止"
return 1
fi
DB_INFO=$(docker exec springboot-backend env | grep "^DB_" 2>/dev/null || echo "")
if [[ -n "$DB_INFO" ]]; then
DB_NAME=$(echo "$DB_INFO" | grep "^DB_NAME=" | cut -d'=' -f2)
DB_PASSWORD=$(echo "$DB_INFO" | grep "^DB_PASSWORD=" | cut -d'=' -f2)
DB_USER=$(echo "$DB_INFO" | grep "^DB_USER=" | cut -d'=' -f2)
DB_HOST=$(echo "$DB_INFO" | grep "^DB_HOST=" | cut -d'=' -f2)
echo "📋 数据库配置:"
echo " 数据库名: $DB_NAME"
echo " 用户名: $DB_USER"
echo " 主机: $DB_HOST"
else
echo "❌ 无法获取数据库配置信息"
echo "🔍 尝试诊断问题:"
echo " 容器状态: $(docker inspect -f '{{.State.Status}}' springboot-backend 2>/dev/null || echo '容器不存在')"
echo " 健康状态: $(docker inspect -f '{{.State.Health.Status}}' springboot-backend 2>/dev/null || echo '无健康检查')"
# 尝试从 .env 文件读取配置
if [[ -f ".env" ]]; then
echo "🔄 尝试从 .env 文件读取配置..."
DB_NAME=$(grep "^DB_NAME=" .env | cut -d'=' -f2 2>/dev/null)
DB_PASSWORD=$(grep "^DB_PASSWORD=" .env | cut -d'=' -f2 2>/dev/null)
DB_USER=$(grep "^DB_USER=" .env | cut -d'=' -f2 2>/dev/null)
if [[ -n "$DB_NAME" && -n "$DB_PASSWORD" && -n "$DB_USER" ]]; then
echo "✅ 从 .env 文件成功读取数据库配置"
echo "📋 数据库配置:"
echo " 数据库名: $DB_NAME"
echo " 用户名: $DB_USER"
else
echo "❌ .env 文件中的数据库配置不完整"
echo "🛑 更新终止"
return 1
fi
else
echo "❌ 未找到 .env 文件"
echo "🛑 更新终止"
return 1
fi
fi
# 检查必要的数据库配置
if [[ -z "$DB_PASSWORD" || -z "$DB_USER" || -z "$DB_NAME" ]]; then
echo "❌ 数据库配置不完整(缺少必要参数)"
echo "🛑 更新终止"
return 1
fi
# 执行数据库字段变更
echo "🔄 执行数据库结构更新..."
# 创建临时迁移文件(现在有了数据库信息)
cat > temp_migration.sql <<EOF
-- 数据库结构更新
USE \`$DB_NAME\`;
-- user 表:删除 name 字段(如果存在)
SET @sql = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'user'
AND column_name = 'name'
),
'ALTER TABLE \`user\` DROP COLUMN \`name\`;',
'SELECT "Column \`name\` not exists in \`user\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- node 表:删除 port 字段、添加 server_ip 字段(如果不存在)
SET @sql = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'node'
AND column_name = 'port'
),
'ALTER TABLE \`node\` DROP COLUMN \`port\`;',
'SELECT "Column \`port\` not exists in \`node\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(
NOT EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'node'
AND column_name = 'server_ip'
),
'ALTER TABLE \`node\` ADD COLUMN \`server_ip\` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;',
'SELECT "Column \`server_ip\` already exists in \`node\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 将 ip 赋值给 server_ip(如果字段都存在)
UPDATE \`node\`
SET \`server_ip\` = \`ip\`
WHERE \`server_ip\` IS NULL;
-- node 表:修改 ip 字段类型为 longtext
SET @sql = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'node'
AND column_name = 'ip'
AND data_type = 'varchar'
),
'ALTER TABLE \`node\` MODIFY COLUMN \`ip\` LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;',
'SELECT "Column \`ip\` not exists or already modified in \`node\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- node 表:添加 version 字段(如果不存在)
SET @sql = (
SELECT IF(
NOT EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'node'
AND column_name = 'version'
),
'ALTER TABLE \`node\` ADD COLUMN \`version\` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL;',
'SELECT "Column \`version\` already exists in \`node\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- node 表:添加 port_sta 字段(如果不存在)
SET @sql = (
SELECT IF(
NOT EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'node'
AND column_name = 'port_sta'
),
'ALTER TABLE \`node\` ADD COLUMN \`port_sta\` INT(10) DEFAULT 1000 COMMENT "端口起始范围";',
'SELECT "Column \`port_sta\` already exists in \`node\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- node 表:添加 port_end 字段(如果不存在)
SET @sql = (
SELECT IF(
NOT EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'node'
AND column_name = 'port_end'
),
'ALTER TABLE \`node\` ADD COLUMN \`port_end\` INT(10) DEFAULT 65535 COMMENT "端口结束范围";',
'SELECT "Column \`port_end\` already exists in \`node\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 为现有节点设置默认端口范围
UPDATE \`node\`
SET \`port_sta\` = 1000, \`port_end\` = 65535
WHERE \`port_sta\` IS NULL OR \`port_end\` IS NULL;
-- node 表:添加 http、tls、socks 字段(如果不存在)
SET @sql = (
SELECT IF(
NOT EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'node'
AND column_name = 'http'
),
'ALTER TABLE \`node\` ADD COLUMN \`http\` INT(10) DEFAULT 0 COMMENT "HTTP 服务端口";',
'SELECT "Column \`http\` already exists in \`node\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(
NOT EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'node'
AND column_name = 'tls'
),
'ALTER TABLE \`node\` ADD COLUMN \`tls\` INT(10) DEFAULT 0 COMMENT "TLS 服务端口";',
'SELECT "Column \`tls\` already exists in \`node\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(
NOT EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'node'
AND column_name = 'socks'
),
'ALTER TABLE \`node\` ADD COLUMN \`socks\` INT(10) DEFAULT 0 COMMENT "SOCKS 服务端口";',
'SELECT "Column \`socks\` already exists in \`node\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 为现有节点设置 http、tls、socks 默认值
UPDATE \`node\`
SET \`http\` = IFNULL(\`http\`, 0),
\`tls\` = IFNULL(\`tls\`, 0),
\`socks\` = IFNULL(\`socks\`, 0);
-- tunnel 表:删除废弃字段(如果存在)
SET @sql = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'tunnel'
AND column_name = 'in_port_sta'
),
'ALTER TABLE \`tunnel\` DROP COLUMN \`in_port_sta\`;',
'SELECT "Column \`in_port_sta\` not exists in \`tunnel\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'tunnel'
AND column_name = 'in_port_end'
),
'ALTER TABLE \`tunnel\` DROP COLUMN \`in_port_end\`;',
'SELECT "Column \`in_port_end\` not exists in \`tunnel\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'tunnel'
AND column_name = 'out_ip_sta'
),
'ALTER TABLE \`tunnel\` DROP COLUMN \`out_ip_sta\`;',
'SELECT "Column \`out_ip_sta\` not exists in \`tunnel\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'tunnel'
AND column_name = 'out_ip_end'
),
'ALTER TABLE \`tunnel\` DROP COLUMN \`out_ip_end\`;',
'SELECT "Column \`out_ip_end\` not exists in \`tunnel\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- tunnel 表:添加 tcp_listen_addr、udp_listen_addr、protocol(如果不存在)
-- tcp_listen_addr
SET @sql = (
SELECT IF(
NOT EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'tunnel'
AND column_name = 'tcp_listen_addr'
),
'ALTER TABLE \`tunnel\` ADD COLUMN \`tcp_listen_addr\` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT "0.0.0.0";',
'SELECT "Column \`tcp_listen_addr\` already exists in \`tunnel\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- udp_listen_addr
SET @sql = (
SELECT IF(
NOT EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'tunnel'
AND column_name = 'udp_listen_addr'
),
'ALTER TABLE \`tunnel\` ADD COLUMN \`udp_listen_addr\` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT "0.0.0.0";',
'SELECT "Column \`udp_listen_addr\` already exists in \`tunnel\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- protocol
SET @sql = (
SELECT IF(
NOT EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'tunnel'
AND column_name = 'protocol'
),
'ALTER TABLE \`tunnel\` ADD COLUMN \`protocol\` VARCHAR(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT "tls";',
'SELECT "Column \`protocol\` already exists in \`tunnel\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- traffic_ratio (流量倍率)
SET @sql = (
SELECT IF(
NOT EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'tunnel'
AND column_name = 'traffic_ratio'
),
'ALTER TABLE \`tunnel\` ADD COLUMN \`traffic_ratio\` DECIMAL(5,1) DEFAULT 1.0 COMMENT "流量倍率";',
'SELECT "Column \`traffic_ratio\` already exists in \`tunnel\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 为现有数据设置默认流量倍率
UPDATE \`tunnel\`
SET \`traffic_ratio\` = 1.0
WHERE \`traffic_ratio\` IS NULL;
-- forward 表:删除 proxy_protocol 字段(如果存在)
SET @sql = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'forward'
AND column_name = 'proxy_protocol'
),
'ALTER TABLE \`forward\` DROP COLUMN \`proxy_protocol\`;',
'SELECT "Column \`proxy_protocol\` not exists in \`forward\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- forward 表:修改 remote_addr 字段类型为 longtext
SET @sql = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'forward'
AND column_name = 'remote_addr'
AND data_type = 'varchar'
),
'ALTER TABLE \`forward\` MODIFY COLUMN \`remote_addr\` LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL;',
'SELECT "Column \`remote_addr\` not exists or already modified in \`forward\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- forward 表:添加 strategy 字段(负载均衡策略)
SET @sql = (
SELECT IF(
NOT EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'forward'
AND column_name = 'strategy'
),
'ALTER TABLE \`forward\` ADD COLUMN \`strategy\` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT "fifo" COMMENT "负载均衡策略";',
'SELECT "Column \`strategy\` already exists in \`forward\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 为现有数据设置默认负载均衡策略
UPDATE \`forward\`
SET \`strategy\` = 'fifo'
WHERE \`strategy\` IS NULL;
-- forward 表:添加 inx 字段(排序索引)
SET @sql = (
SELECT IF(
NOT EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'forward'
AND column_name = 'inx'
),
'ALTER TABLE \`forward\` ADD COLUMN \`inx\` INT(10) DEFAULT 0 COMMENT "排序索引";',
'SELECT "Column \`inx\` already exists in \`forward\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 为现有数据设置默认排序索引
UPDATE \`forward\`
SET \`inx\` = 0
WHERE \`inx\` IS NULL;
-- tunnel 表:添加 interface_name 字段(如果不存在)
SET @sql = (
SELECT IF(
NOT EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'tunnel'
AND column_name = 'interface_name'
),
'ALTER TABLE \`tunnel\` ADD COLUMN \`interface_name\` VARCHAR(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL;',
'SELECT "Column \`interface_name\` already exists in \`tunnel\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- forward 表:添加 interface_name 字段(如果不存在)
SET @sql = (
SELECT IF(
NOT EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'forward'
AND column_name = 'interface_name'
),
'ALTER TABLE \`forward\` ADD COLUMN \`interface_name\` VARCHAR(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL;',
'SELECT "Column \`interface_name\` already exists in \`forward\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 创建 vite_config 表(如果不存在)
CREATE TABLE IF NOT EXISTS \`vite_config\` (
\`id\` int(10) NOT NULL AUTO_INCREMENT,
\`name\` varchar(200) NOT NULL,
\`value\` varchar(200) NOT NULL,
\`time\` bigint(20) NOT NULL,
PRIMARY KEY (\`id\`),
UNIQUE KEY \`unique_name\` (\`name\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 创建 statistics_flow 表(如果不存在)
CREATE TABLE IF NOT EXISTS \`statistics_flow\` (
\`id\` bigint(20) NOT NULL AUTO_INCREMENT,
\`user_id\` int(10) NOT NULL,
\`flow\` bigint(20) NOT NULL,
\`total_flow\` bigint(20) NOT NULL,
\`time\` varchar(100) NOT NULL,
\`created_time\` bigint(20) NOT NULL,
PRIMARY KEY (\`id\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- statistics_flow 表:添加 created_time 字段(如果不存在)
SET @sql = (
SELECT IF(
NOT EXISTS (
SELECT 1
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE()
AND table_name = 'statistics_flow'
AND column_name = 'created_time'
),
'ALTER TABLE \`statistics_flow\` ADD COLUMN \`created_time\` BIGINT(20) NOT NULL DEFAULT 0 COMMENT "创建时间毫秒时间戳";',
'SELECT "Column \`created_time\` already exists in \`statistics_flow\`";'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 为现有记录设置当前毫秒时间戳(仅当 created_time 为 0 或 NULL 时)
UPDATE \`statistics_flow\`
SET \`created_time\` = UNIX_TIMESTAMP() * 1000
WHERE \`created_time\` = 0 OR \`created_time\` IS NULL;
EOF
# 检查数据库容器
if ! docker ps --format "{{.Names}}" | grep -q "^gost-mysql$"; then
echo "❌ 数据库容器 gost-mysql 未运行"
echo "🔍 当前运行的容器:"
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
echo "❌ 数据库结构更新失败,请手动执行 temp_migration.sql"
echo "📁 迁移文件已保存为 temp_migration.sql"
return 1
fi
# 执行数据库迁移
if docker exec -i gost-mysql mysql -u "$DB_USER" -p"$DB_PASSWORD" < temp_migration.sql 2>/dev/null; then
echo "✅ 数据库结构更新完成"
else
echo "⚠️ 使用用户密码失败,尝试root密码..."
if docker exec -i gost-mysql mysql -u root -p"$DB_PASSWORD" < temp_migration.sql 2>/dev/null; then
echo "✅ 数据库结构更新完成"
else
echo "❌ 数据库结构更新失败,请手动执行 temp_migration.sql"
echo "📁 迁移文件已保存为 temp_migration.sql"
echo "🔍 数据库容器状态: $(docker inspect -f '{{.State.Status}}' gost-mysql 2>/dev/null || echo '容器不存在')"
echo "🛑 更新终止"
return 1
fi
fi
# 清理临时文件
rm -f temp_migration.sql
echo "✅ 更新完成"
}
# 导出数据库备份
export_migration_sql() {
echo "📄 开始导出数据库备份..."
# 获取数据库配置信息
echo "🔍 获取数据库配置信息..."
# 先检查后端容器是否在运行
if ! docker ps --format "{{.Names}}" | grep -q "^springboot-backend$"; then
echo "❌ 后端容器未运行,尝试从 .env 文件读取配置..."
# 从 .env 文件读取配置
if [[ -f ".env" ]]; then
DB_NAME=$(grep "^DB_NAME=" .env | cut -d'=' -f2 2>/dev/null)
DB_PASSWORD=$(grep "^DB_PASSWORD=" .env | cut -d'=' -f2 2>/dev/null)
DB_USER=$(grep "^DB_USER=" .env | cut -d'=' -f2 2>/dev/null)
if [[ -n "$DB_NAME" && -n "$DB_PASSWORD" && -n "$DB_USER" ]]; then
echo "✅ 从 .env 文件读取数据库配置成功"
else
echo "❌ .env 文件中的数据库配置不完整"
return 1
fi
else
echo "❌ 未找到 .env 文件"
return 1
fi
else
# 从容器环境变量获取数据库信息
DB_INFO=$(docker exec springboot-backend env | grep "^DB_" 2>/dev/null || echo "")
if [[ -n "$DB_INFO" ]]; then
DB_NAME=$(echo "$DB_INFO" | grep "^DB_NAME=" | cut -d'=' -f2)
DB_PASSWORD=$(echo "$DB_INFO" | grep "^DB_PASSWORD=" | cut -d'=' -f2)
DB_USER=$(echo "$DB_INFO" | grep "^DB_USER=" | cut -d'=' -f2)
echo "✅ 从容器环境变量读取数据库配置成功"
else
echo "❌ 无法从容器获取数据库配置,尝试从 .env 文件读取..."
if [[ -f ".env" ]]; then
DB_NAME=$(grep "^DB_NAME=" .env | cut -d'=' -f2 2>/dev/null)
DB_PASSWORD=$(grep "^DB_PASSWORD=" .env | cut -d'=' -f2 2>/dev/null)
DB_USER=$(grep "^DB_USER=" .env | cut -d'=' -f2 2>/dev/null)
if [[ -n "$DB_NAME" && -n "$DB_PASSWORD" && -n "$DB_USER" ]]; then
echo "✅ 从 .env 文件读取数据库配置成功"
else
echo "❌ .env 文件中的数据库配置不完整"
return 1
fi
else
echo "❌ 未找到 .env 文件"
return 1
fi
fi
fi
# 检查必要的数据库配置
if [[ -z "$DB_PASSWORD" || -z "$DB_USER" || -z "$DB_NAME" ]]; then
echo "❌ 数据库配置不完整(缺少必要参数)"
return 1
fi
echo "📋 数据库配置:"
echo " 数据库名: $DB_NAME"
echo " 用户名: $DB_USER"
# 检查数据库容器是否运行
if ! docker ps --format "{{.Names}}" | grep -q "^gost-mysql$"; then
echo "❌ 数据库容器未运行,无法导出数据"
echo "🔍 当前运行的容器:"
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
return 1
fi
# 生成数据库备份文件
SQL_FILE="database_backup_$(date +%Y%m%d_%H%M%S).sql"
echo "📝 导出数据库备份: $SQL_FILE"
# 使用 mysqldump 导出数据库
echo "⏳ 正在导出数据库..."
if docker exec gost-mysql mysqldump -u "$DB_USER" -p"$DB_PASSWORD" --single-transaction --routines --triggers "$DB_NAME" > "$SQL_FILE" 2>/dev/null; then
echo "✅ 数据库导出成功"
else
echo "⚠️ 使用用户密码失败,尝试root密码..."
if docker exec gost-mysql mysqldump -u root -p"$DB_PASSWORD" --single-transaction --routines --triggers "$DB_NAME" > "$SQL_FILE" 2>/dev/null; then
echo "✅ 数据库导出成功"
else
echo "❌ 数据库导出失败"
rm -f "$SQL_FILE"
return 1
fi
fi
# 检查文件大小
if [[ -f "$SQL_FILE" ]] && [[ -s "$SQL_FILE" ]]; then
FILE_SIZE=$(du -h "$SQL_FILE" | cut -f1)
echo "📁 文件位置: $(pwd)/$SQL_FILE"
echo "📊 文件大小: $FILE_SIZE"
else
echo "❌ 导出的文件为空或不存在"
rm -f "$SQL_FILE"
return 1
fi
}
# 卸载功能
@@ -1068,7 +378,7 @@ uninstall_panel() {
echo "🛑 停止并删除容器、镜像、卷..."
$DOCKER_CMD down --rmi all --volumes --remove-orphans
echo "🧹 删除配置文件..."
rm -f docker-compose.yml gost.sql .env
rm -f docker-compose.yml .env
echo "✅ 卸载完成"
}
@@ -1097,11 +407,6 @@ main() {
exit 0
;;
4)
export_migration_sql
delete_self
exit 0
;;
5)
echo "👋 退出脚本"
delete_self
exit 0
+3
View File
@@ -0,0 +1,3 @@
<factorypath>
<factorypathentry kind="VARJAR" id="M2_REPO/org/projectlombok/lombok/1.18.30/lombok-1.18.30.jar" enabled="true" runInBatchMode="false"/>
</factorypath>
+39
View File
@@ -0,0 +1,39 @@
# SPRINGBOOT BACKEND KNOWLEDGE BASE
**Generated:** Mon Feb 02 2026
## OVERVIEW
Admin API for Flux Panel. Manages users, tunnels, nodes, forwards, quotas, and speed limits.
**Stack:** Java 21, Spring Boot 2.7.18, SQLite, MyBatis Plus (+ join), FastJSON2.
## STRUCTURE
```
springboot-backend/
├── src/main/java/com/admin/
│ ├── controller/ # /api/v1/* endpoints
│ ├── entity/ # DB models
│ ├── mapper/ # MyBatis Plus mappers
│ ├── service/ # Business logic
│ ├── config/ # WebMvc/JWT/CORS/WebSocket config
│ └── common/ # DTOs, auth, exception handling, utilities
└── src/main/resources/
├── application.yml # Config (DB_PATH/JWT_SECRET/LOG_DIR)
├── mapper/ # XML mappers
├── schema.sql # Schema
└── data.sql # Seed data
```
## CONVENTIONS
- **DB**: SQLite URL is `jdbc:sqlite:${DB_PATH:/app/data/gost.db}` (`springboot-backend/src/main/resources/application.yml`).
- **Auth**: JWT in `Authorization` header; enforced by `com.admin.common.interceptor.JwtInterceptor` for `/api/**` (with explicit excludes in `com.admin.config.WebMvcConfig`).
- **Roles**: `@RequireRole` means admin-only (`role_id == 0`) via `com.admin.common.aop.RoleAspect`.
- **Responses**: Controllers return `com.admin.common.lang.R` (`code == 0` success).
- **CORS**: Allow-all origins; `Authorization` is exposed (`com.admin.config.WebMvcConfig`).
## COMMANDS
```bash
cd springboot-backend
mvn clean package
mvn test
java -jar target/admin-0.0.1-SNAPSHOT.jar
```
+2 -1
View File
@@ -16,11 +16,12 @@ 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/*
COPY --from=build /app/target/*.jar app.jar
EXPOSE 6365
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
+3 -4
View File
@@ -69,10 +69,9 @@
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.2.0</version>
<scope>runtime</scope>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.45.0.0</version>
</dependency>
<dependency>
@@ -0,0 +1,32 @@
# SPRINGBOOT BACKEND (com.admin) KNOWLEDGE BASE
## OVERVIEW
Primary Java code for the admin API. Controllers expose `/api/v1/*` endpoints and return `R` response envelopes.
## STRUCTURE
```
springboot-backend/src/main/java/com/admin/
├── controller/ # REST controllers (e.g., /api/v1/user)
├── service/ # Business logic interfaces + impl/
├── mapper/ # MyBatis Plus mappers
├── entity/ # DB entities
├── config/ # WebMvc/JWT/CORS/WebSocket config
└── common/ # DTOs, auth, exception handling, utilities
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| User/login endpoints | `springboot-backend/src/main/java/com/admin/controller/UserController.java` | `/api/v1/user/*` |
| Auth enforcement | `springboot-backend/src/main/java/com/admin/config/WebMvcConfig.java` | Intercepts `/api/**`, excludes login/config/captcha |
| JWT validation | `springboot-backend/src/main/java/com/admin/common/interceptor/JwtInterceptor.java` | Requires `Authorization` header |
| Admin-only ops | `springboot-backend/src/main/java/com/admin/common/annotation/RequireRole.java` | Enforced by `RoleAspect` |
| Response envelope | `springboot-backend/src/main/java/com/admin/common/lang/R.java` | `code == 0` success |
| Global error handling | `springboot-backend/src/main/java/com/admin/common/exception/GlobalExceptionHandler.java` | Maps exceptions -> `R.err(...)` |
## CONVENTIONS
- Controllers are mostly `@PostMapping` (even for list/get/delete) and use `/api/v1/*` prefixes.
- JWT is custom (no 3p lib) and includes `role_id` in payload (`springboot-backend/src/main/java/com/admin/common/utils/JwtUtil.java`).
## ANTI-PATTERNS
- Do not change auth header format lightly: frontend expects `Authorization: <token>` (no `Bearer`).
@@ -1,128 +0,0 @@
package com.admin;
import com.baomidou.mybatisplus.core.exceptions.MybatisPlusException;
import com.baomidou.mybatisplus.core.toolkit.StringPool;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.generator.AutoGenerator;
import com.baomidou.mybatisplus.generator.InjectionConfig;
import com.baomidou.mybatisplus.generator.config.*;
import com.baomidou.mybatisplus.generator.config.po.TableInfo;
import com.baomidou.mybatisplus.generator.config.rules.NamingStrategy;
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
// 演示例子,执行 main 方法控制台输入模块表名回车自动生成对应项目目录中
public class CodeGenerator {
/**
* <p>
* 读取控制台内容
* </p>
*/
public static String scanner(String tip) {
Scanner scanner = new Scanner(System.in);
StringBuilder help = new StringBuilder();
help.append("请输入" + tip + ":");
System.out.println(help.toString());
if (scanner.hasNext()) {
String ipt = scanner.next();
if (StringUtils.isNotBlank(ipt)) {
return ipt;
}
}
throw new MybatisPlusException("请输入正确的" + tip + "!");
}
public static void main(String[] args) {
// 代码生成器
AutoGenerator mpg = new AutoGenerator();
// 全局配置
GlobalConfig gc = new GlobalConfig();
String projectPath = System.getProperty("user.dir");
gc.setOutputDir(projectPath + "/src/main/java");
gc.setAuthor("QAQ");
gc.setOpen(false);
// gc.setSwagger2(true); 实体属性 Swagger2 注解
gc.setServiceName("%sService");
mpg.setGlobalConfig(gc);
// 数据源配置 - 使用环境变量
DataSourceConfig dsc = new DataSourceConfig();
String dbHost = System.getenv("DB_HOST");
String dbName = System.getenv("DB_NAME");
String dbUser = System.getenv("DB_USER");
String dbPassword = System.getenv("DB_PASSWORD");
if (dbHost == null || dbName == null || dbUser == null || dbPassword == null) {
throw new MybatisPlusException("请设置数据库环境变量: DB_HOST, DB_NAME, DB_USER, DB_PASSWORD");
}
dsc.setUrl("jdbc:mysql://" + dbHost + "/" + dbName + "?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai");
dsc.setDriverName("com.mysql.cj.jdbc.Driver");
dsc.setUsername(dbUser);
dsc.setPassword(dbPassword);
mpg.setDataSource(dsc);
// 包配置
PackageConfig pc = new PackageConfig();
// pc.setModuleName(scanner("模块名"));
pc.setParent("com.admin");
mpg.setPackageInfo(pc);
// 自定义配置
InjectionConfig cfg = new InjectionConfig() {
@Override
public void initMap() {
// to do nothing
}
};
// 如果模板引擎是 freemarker
String templatePath = "/templates/mapper.xml.ftl";
// 如果模板引擎是 velocity
// String templatePath = "/templates/mapper.xml.vm";
// 自定义输出配置
List<FileOutConfig> focList = new ArrayList<>();
// 自定义配置会被优先输出
focList.add(new FileOutConfig(templatePath) {
@Override
public String outputFile(TableInfo tableInfo) {
return projectPath + "/src/main/resources/mapper/" + pc.getModuleName()
+ "/" + tableInfo.getEntityName() + "Mapper" + StringPool.DOT_XML;
}
});
cfg.setFileOutConfigList(focList);
mpg.setCfg(cfg);
// 配置模板
TemplateConfig templateConfig = new TemplateConfig();
templateConfig.setXml(null);
mpg.setTemplate(templateConfig);
// 策略配置
StrategyConfig strategy = new StrategyConfig();
strategy.setNaming(NamingStrategy.underline_to_camel);
strategy.setColumnNaming(NamingStrategy.underline_to_camel);
strategy.setSuperEntityClass("com.admin.entity.BaseEntity");
strategy.setEntityLombokModel(true);
strategy.setRestControllerStyle(true);
// 公共父类
strategy.setSuperControllerClass("com.admin.controller.BaseController");
strategy.setSuperEntityColumns("id", "created_time", "updated_time", "status");
strategy.setInclude(scanner("表名,多个英文逗号分割").split(","));
strategy.setControllerMappingHyphenStyle(true);
// strategy.setTablePrefix("sys_");//动态调整
mpg.setStrategy(strategy);
mpg.setTemplateEngine(new FreemarkerTemplateEngine());
mpg.execute();
}
}
@@ -0,0 +1,15 @@
package com.admin.common.dto;
import lombok.Data;
import java.util.List;
@Data
public class ChainNodesItems {
private String mode;
private List<Integer> nodeIds;
}
@@ -0,0 +1,21 @@
package com.admin.common.dto;
import lombok.Data;
@Data
public class DiagnosisResult {
private Long nodeId;
private String nodeName;
private String targetIp;
private Integer targetPort;
private String description;
private boolean success;
private String message;
private double averageTime;
private double packetLoss;
private long timestamp;
private Integer fromChainType;
private Integer fromInx;
private Integer toChainType;
private Integer toInx;
}
@@ -21,14 +21,6 @@ public class ForwardDto {
private String remoteAddr;
private String strategy;
/**
* 入口端口(可选,为空时自动分配)
*/
@Min(value = 1, message = "端口号不能小于1")
@Max(value = 65535, message = "端口号不能大于65535")
private Integer inPort;
private String interfaceName;
}
@@ -0,0 +1,10 @@
package com.admin.common.dto;
import lombok.Data;
@Data
public class ForwardPortDto {
private Integer node_id;
private Integer port;
}
@@ -19,22 +19,14 @@ public class ForwardUpdateDto {
@NotBlank(message = "转发名称不能为空")
private String name;
@NotNull(message = "隧道ID不能为空")
private Integer tunnelId;
@NotBlank(message = "远程地址不能为空")
private String remoteAddr;
private String strategy;
/**
* 入口端口(可选,为空时自动分配)
*/
@Min(value = 1, message = "端口号不能小于1")
@Max(value = 65535, message = "端口号不能大于65535")
private Integer inPort;
@TableField(updateStrategy = FieldStrategy.IGNORED)
private String interfaceName;
}
@@ -25,6 +25,11 @@ public class ForwardWithTunnelDto {
private String name;
/**
* 入口IP
*/
private String inIp;
/**
* 入口端口
*/
@@ -56,15 +61,10 @@ public class ForwardWithTunnelDto {
* 隧道名称
*/
private String tunnelName;
/**
* 入口IP
*/
private String inIp;
private String userName;
/**
* 用户ID
*/
@@ -87,6 +87,4 @@ public class ForwardWithTunnelDto {
private String strategy;
private Integer inx;
private String interfaceName;
}
@@ -13,19 +13,20 @@ public class NodeDto {
@NotBlank(message = "节点名称不能为空")
private String name;
@NotBlank(message = "入口IP不能为空")
private String ip;
@NotBlank(message = "服务器ip不能为空")
private String serverIp;
@NotNull(message = "起始端口不能为空")
@Min(value = 1, message = "起始端口必须大于0")
@Max(value = 65535, message = "起始端口不能超过65535")
private Integer portSta;
private String serverIpV4;
@NotNull(message = "结束端口不能为空")
@Min(value = 1, message = "结束端口必须大于0")
@Max(value = 65535, message = "结束端口不能超过65535")
private Integer portEnd;
}
private String serverIpV6;
@NotBlank(message = "可用端口不能为空")
private String port;
private String interfaceName;
private String tcpListenAddr = "0.0.0.0";
private String udpListenAddr = "0.0.0.0";
}
@@ -4,8 +4,6 @@ import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
@Data
public class NodeUpdateDto {
@@ -16,23 +14,22 @@ public class NodeUpdateDto {
@NotBlank(message = "节点名称不能为空")
private String name;
@NotBlank(message = "入口IP不能为空")
private String ip;
@NotBlank(message = "服务器ip不能为空")
private String serverIp;
@NotNull(message = "起始端口不能为空")
@Min(value = 1, message = "起始端口必须大于0")
@Max(value = 65535, message = "起始端口不能超过65535")
private Integer portSta;
private String serverIpV4;
@NotNull(message = "结束端口不能为空")
@Min(value = 1, message = "结束端口必须大于0")
@Max(value = 65535, message = "结束端口不能超过65535")
private Integer portEnd;
private String serverIpV6;
@NotBlank(message = "可用port不能为空")
private String port;
private String interfaceName;
private Integer http;
private Integer tls;
private Integer socks;
}
private String tcpListenAddr = "0.0.0.0";
private String udpListenAddr = "0.0.0.0";
}
@@ -17,10 +17,4 @@ public class SpeedLimitUpdateDto {
@NotNull(message = "速度限制不能为空")
@Min(value = 1, message = "速度限制必须大于0")
private Integer speed;
@NotNull(message = "隧道ID不能为空")
private Long tunnelId;
@NotBlank(message = "隧道名称不能为空")
private String tunnelName;
}
@@ -0,0 +1,48 @@
package com.admin.common.dto;
import com.admin.entity.ChainTunnel;
import lombok.Data;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/**
* 隧道详情DTO - 包含节点配置信息
*/
@Data
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;
private Integer type; // 1: 端口转发, 2: 隧道转发
private Integer flow; // 1: 单向, 2: 双向
private BigDecimal trafficRatio;
private Integer status;
private Long createdTime;
private Long updatedTime;
private String inIp;
// 入口节点列表
private List<ChainTunnel> inNodeId = new ArrayList<>();
// 转发链节点列表(二维数组结构)
private List<List<ChainTunnel>> chainNodes = new ArrayList<>();
// 出口节点列表
private List<ChainTunnel> outNodeId = new ArrayList<>();
}
@@ -1,45 +1,36 @@
package com.admin.common.dto;
import com.admin.entity.ChainTunnel;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Min;
import javax.validation.constraints.Max;
import javax.validation.constraints.DecimalMin;
import javax.validation.constraints.DecimalMax;
import java.math.BigDecimal;
import java.util.List;
@Data
public class TunnelDto {
@NotBlank(message = "隧道名称不能为空")
private String name;
@NotNull(message = "入口节点不能为空")
private Long inNodeId;
// 出口节点ID,当type=1时可以为空,会自动设置为入口节点ID
private Long outNodeId;
@NotNull(message = "入口节点不能为空")
private List<ChainTunnel> inNodeId;
private List<List<ChainTunnel>> chainNodes;
private List<ChainTunnel> outNodeId;
private String inIp;
@NotNull(message = "隧道类型不能为空")
private Integer type;
@NotNull(message = "流量计算类型不能为空")
private Integer flow;
// 流量倍率,默认为1.0
@DecimalMin(value = "0.0", inclusive = false, message = "流量倍率必须大于0.0")
@DecimalMax(value = "100.0", message = "流量倍率不能大于100.0")
private BigDecimal trafficRatio;
private String interfaceName;
// 协议类型,默认为tls
private String protocol;
// TCP监听地址,默认为0.0.0.0
private String tcpListenAddr = "0.0.0.0";
// UDP监听地址,默认为0.0.0.0
private String udpListenAddr = "0.0.0.0";
}
@@ -1,36 +0,0 @@
package com.admin.common.dto;
import lombok.Data;
@Data
public class TunnelListDto {
private Integer id;
private String name;
/**
* 入口IP
*/
private String ip;
/**
* 入口节点端口起始范围
*/
private Integer inNodePortSta;
/**
* 入口节点端口结束范围
*/
private Integer inNodePortEnd;
/**
* 隧道类型(1-端口转发,2-隧道转发)
*/
private Integer type;
/**
* 协议类型
*/
private String protocol;
}
@@ -1,5 +1,6 @@
package com.admin.common.dto;
import com.admin.entity.ChainTunnel;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.Data;
@@ -10,35 +11,32 @@ import javax.validation.constraints.Max;
import javax.validation.constraints.DecimalMin;
import javax.validation.constraints.DecimalMax;
import java.math.BigDecimal;
import java.util.List;
@Data
public class TunnelUpdateDto {
@NotNull(message = "隧道ID不能为空")
private Long id;
@NotBlank(message = "隧道名称不能为空")
private String name;
@NotNull(message = "流量计算类型不能为空")
private Integer flow;
// 流量倍率
private String inIp;
@DecimalMin(value = "0.0", inclusive = false, message = "流量倍率必须大于0.0")
@DecimalMax(value = "100.0", message = "流量倍率不能大于100.0")
private BigDecimal trafficRatio;
@NotBlank
private String protocol;
// 入口节点配置(可选,为空时不更新节点配置)
private List<ChainTunnel> inNodeId;
// TCP监听地址
@NotBlank
private String tcpListenAddr;
// UDP监听地址
@NotBlank
private String udpListenAddr;
// 转发链节点配置(二维数组,每一跳可有多个节点)
private List<List<ChainTunnel>> chainNodes;
@TableField(updateStrategy = FieldStrategy.IGNORED)
private String interfaceName;
}
// 出口节点配置
private List<ChainTunnel> outNodeId;
}
@@ -0,0 +1,10 @@
package com.admin.common.dto;
import lombok.Data;
@Data
public class UserInfo {
private final Integer userId;
private final Integer roleId;
private final String userName;
}
@@ -0,0 +1,26 @@
package com.admin.common.dto;
import com.admin.entity.UserTunnel;
import com.admin.service.impl.ForwardServiceImpl;
import lombok.Data;
@Data
public class UserPermissionResult {
public boolean hasError;
private String errorMessage;
private Integer limiter;
private UserTunnel userTunnel;
public static UserPermissionResult success(Integer limiter, UserTunnel userTunnel) {
UserPermissionResult result = new UserPermissionResult();
result.setLimiter(limiter);
result.setUserTunnel(userTunnel);
return result;
}
public static UserPermissionResult error(String errorMessage) {
UserPermissionResult result = new UserPermissionResult();
result.setErrorMessage(errorMessage);
return result;
}
}
@@ -0,0 +1,26 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.NotEmpty;
import java.util.List;
@Data
public class UserTunnelBatchAssignDto {
@NotNull(message = "用户ID不能为空")
private Integer userId;
@Valid
@NotEmpty(message = "隧道列表不能为空")
private List<TunnelAssignItem> tunnels;
@Data
public static class TunnelAssignItem {
@NotNull(message = "隧道ID不能为空")
private Integer tunnelId;
private Integer speedId;
}
}
@@ -13,28 +13,15 @@ public class UserTunnelDto {
@NotNull(message = "隧道ID不能为空")
private Integer tunnelId;
@NotNull(message = "流量限制不能为空")
@Min(value = 0, message = "流量限制不能小于0")
private Long flow;
@NotNull(message = "转发数量不能为空")
@Min(value = 0, message = "转发数量不能小于0")
private Integer num;
/**
* 流量重置时间(时间戳)
*/
@NotNull(message = "流量重置时间不能为空")
private Long flowResetTime;
/**
* 到期时间(时间戳)
*/
@NotNull(message = "到期时间不能为空")
private Long expTime;
/**
* 限速规则ID(可选,null表示不限速)
*/
private Integer speedId;
}
}
@@ -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
);
}
}
@@ -5,8 +5,7 @@ import com.admin.common.lang.R;
import com.admin.common.utils.GostUtil;
import com.admin.entity.*;
import com.admin.service.*;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.alibaba.fastjson.JSONArray;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.scheduling.annotation.Async;
@@ -14,6 +13,7 @@ import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
@@ -33,8 +33,7 @@ public class CheckGostConfigAsync {
private SpeedLimitService speedLimitService;
@Resource
@Lazy
private TunnelService tunnelService;
TunnelService tunnelService;
@@ -43,56 +42,51 @@ public class CheckGostConfigAsync {
*/
@Async
public void cleanNodeConfigs(String node_id, GostConfigDto gostConfig) {
System.out.println(JSONObject.toJSONString(gostConfig));
Node node = nodeService.getById(node_id);
if (node != null) {
cleanOrphanedServices(gostConfig, node);
cleanOrphanedChains(gostConfig, node);
cleanOrphanedLimiters(gostConfig, node);
cleanOrphanedServices(gostConfig.getServices(), node);
cleanOrphanedChains(gostConfig.getChains(), node);
cleanOrphanedLimiters(gostConfig.getLimiters(), node);
}
}
/**
* 清理孤立的服务
*/
private void cleanOrphanedServices(GostConfigDto gostConfig, Node node) {
if (gostConfig.getServices() == null) {
return;
}
for (ConfigItem service : gostConfig.getServices()) {
private void cleanOrphanedServices(List<ConfigItem> configItems, Node node) {
if (configItems == null) return;
for (ConfigItem service : configItems) {
safeExecute(() -> {
if (!Objects.equals(service.getName(), "web_api")){
String[] serviceIds = parseServiceName(service.getName());
if (serviceIds.length == 4) {
String forwardId = serviceIds[0];
String userId = serviceIds[1];
String userTunnelId = serviceIds[2];
String type = serviceIds[3];
List<String> serviceIds = parseServiceName(service.getName());
if (Objects.equals(type, "tcp")) { // 只处理TCP,避免重复处理
Forward forward = forwardService.getById(forwardId);
if (forward == null) {
log.info("删除孤立的服务: {} (节点: {})", service.getName(), node.getId());
GostDto gostDto = GostUtil.DeleteService(node.getId(), forwardId + "_" + userId + "_" + userTunnelId);
System.out.println(gostDto);
}
}
JSONArray services = new JSONArray();
if (Objects.equals(serviceIds.getLast(), "tls")){
String forward_id = serviceIds.getFirst();
services.add(forward_id + "_tls");
if (Objects.equals(type, "tls")) {
Forward forward = forwardService.getById(forwardId);
if (forward == null) {
log.info("删除孤立的服务: {} (节点: {})", service.getName(), node.getId());
GostUtil.DeleteRemoteService(node.getId(), forwardId+"_"+userId+"_"+userTunnelId);
}
Tunnel tunnel = tunnelService.getById(forward_id);
if (tunnel == null) {
GostUtil.DeleteService(node.getId(), services);
log.info("删除孤立的服务: {} (节点: {})", service.getName(), node.getId());
}
}
if (Objects.equals(serviceIds.getLast(), "tcp")){
String forward_id = serviceIds.getFirst();
services.add(forward_id + "_" + serviceIds.get(1) + "_" + serviceIds.get(2) + "_tcp");
services.add(forward_id + "_" + serviceIds.get(1) + "_" + serviceIds.get(2) + "_udp");
Forward forward = forwardService.getById(forward_id);
if (forward == null) {
GostUtil.DeleteService(node.getId(), services);
log.info("删除孤立的服务: {} (节点: {})", service.getName(), node.getId());
}
}
}
}, "清理服务 " + service.getName());
}
@@ -101,28 +95,15 @@ public class CheckGostConfigAsync {
/**
* 清理孤立的链
*/
private void cleanOrphanedChains(GostConfigDto gostConfig, Node node) {
if (gostConfig.getChains() == null) {
return;
}
for (ConfigItem chain : gostConfig.getChains()) {
private void cleanOrphanedChains(List<ConfigItem> configItems, Node node) {
if (configItems == null) return;
for (ConfigItem chain : configItems) {
safeExecute(() -> {
String[] serviceIds = parseServiceName(chain.getName());
if (serviceIds.length == 4) {
String forwardId = serviceIds[0];
String userId = serviceIds[1];
String userTunnelId = serviceIds[2];
String type = serviceIds[3];
if (Objects.equals(type, "chains")) {
Forward forward = forwardService.getById(forwardId);
if (forward == null) {
log.info("删除孤立的链: {} (节点: {})", chain.getName(), node.getId());
GostUtil.DeleteChains(node.getId(), forwardId+"_"+userId+"_"+userTunnelId);
}
}
List<String> serviceIds = parseServiceName(chain.getName());
Tunnel tunnel = tunnelService.getById(serviceIds.getLast());
if (tunnel == null) {
GostUtil.DeleteChains(node.getId(), chain.getName());
log.info("删除孤立的链: {} (节点: {})", chain.getName(), node.getId());
}
}, "清理链 " + chain.getName());
}
@@ -131,69 +112,21 @@ public class CheckGostConfigAsync {
/**
* 清理孤立的限流器
*/
private void cleanOrphanedLimiters(GostConfigDto gostConfig, Node node) {
if (gostConfig.getLimiters() == null) {
return;
}
private void cleanOrphanedLimiters(List<ConfigItem> configItems, Node node) {
if (configItems == null) return;
for (ConfigItem limiter : gostConfig.getLimiters()) {
for (ConfigItem limiter : configItems) {
safeExecute(() -> {
SpeedLimit speedLimit = speedLimitService.getById(limiter.getName());
if (speedLimit == null) {
log.info("删除孤立的限流器: {} (节点: {})", limiter.getName(), node.getId());
GostUtil.DeleteLimiters(node.getId(), Long.parseLong(limiter.getName()));
log.info("删除孤立的限流器: {} (节点: {})", limiter.getName(), node.getId());
}
}, "清理限流器 " + limiter.getName());
}
}
/**
* 同步限流器
*/
private void syncLimiters(GostConfigDto gostConfig, Node node) {
List<Tunnel> tunnelList = tunnelService.list(new QueryWrapper<Tunnel>().eq("in_node_id", node.getId()));
if (tunnelList == null || tunnelList.isEmpty()) return;
safeExecute(() -> {
StringBuilder tunnelIds = new StringBuilder();
for (Tunnel tunnel : tunnelList) {
tunnelIds.append(tunnel.getId()).append(",");
}
String ids = tunnelIds.deleteCharAt(tunnelIds.length() - 1).toString();
List<SpeedLimit> speedLimits = speedLimitService.list(new QueryWrapper<SpeedLimit>().in("tunnel_id", ids));
if (speedLimits != null && !speedLimits.isEmpty()) {
List<ConfigItem> limiters = gostConfig.getLimiters();
List<Long> limiters_ids = new ArrayList<>();
List<Long> speedLimits_ids = new ArrayList<>();
if (limiters != null){
for (ConfigItem limiter : limiters) {
limiters_ids.add(Long.valueOf(limiter.getName()));
}
}
for (SpeedLimit speedLimit : speedLimits) {
speedLimits_ids.add(speedLimit.getId());
}
List<Long> diff = new ArrayList<>(speedLimits_ids);
diff.removeAll(limiters_ids);
System.out.println(diff);
if (!diff.isEmpty()) {
for (Long speed_id : diff) {
SpeedLimit speedLimit = speedLimitService.getById(speed_id);
if (speedLimit != null) {
SpeedLimitUpdateDto speedLimitUpdateDto = new SpeedLimitUpdateDto();
speedLimitUpdateDto.setId(speed_id);
speedLimitUpdateDto.setName(speedLimit.getName());
speedLimitUpdateDto.setSpeed(speedLimit.getSpeed());
speedLimitUpdateDto.setTunnelId(speedLimit.getTunnelId());
speedLimitUpdateDto.setTunnelName(speedLimit.getTunnelName());
speedLimitService.updateSpeedLimit(speedLimitUpdateDto);
}
}
}
}
}, "同步限流器 ");
}
/**
* 安全执行操作,捕获异常
@@ -210,7 +143,8 @@ public class CheckGostConfigAsync {
/**
* 解析服务名称
*/
private String[] parseServiceName(String serviceName) {
return serviceName.split("_");
private List<String> parseServiceName(String serviceName) {
String[] split = serviceName.split("_");
return new ArrayList<>(Arrays.asList(split));
}
}
@@ -1,14 +1,9 @@
package com.admin.common.task;
import com.admin.common.dto.GostDto;
import com.admin.common.utils.GostUtil;
import com.admin.entity.Forward;
import com.admin.entity.Tunnel;
import com.admin.entity.User;
import com.admin.entity.UserTunnel;
import com.admin.service.ForwardService;
import com.admin.service.TunnelService;
import com.admin.service.UserService;
import com.admin.service.UserTunnelService;
import com.admin.entity.*;
import com.admin.service.*;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import lombok.extern.slf4j.Slf4j;
@@ -38,6 +33,9 @@ public class ResetFlowAsync {
@Resource
TunnelService tunnelService;
@Resource
ChainTunnelService chainTunnelService;
/**
* 每天0点执行流量重置任务
* 查询出用户和隧道的重置流量日期是今天的数据,将上下流量重置为0
@@ -197,12 +195,18 @@ public class ResetFlowAsync {
// 查询对应转发
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>().eq("user_id", user.getId()).eq("status", 1));
for (Forward forward : forwardList) {
UserTunnel userTunnel = userTunnelService.getOne(new QueryWrapper<UserTunnel>().eq("user_id", forward.getUserId()).eq("tunnel_id", forward.getTunnelId()));
if (userTunnel != null) {
pauseForwardService(forward, userTunnel.getId());
forward.setStatus(0);
forwardService.updateById(forward);
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", forward.getTunnelId()).eq("chain_type", 1));
for (ChainTunnel chainTunnel : chainTunnels) {
UserTunnel userTunnel = userTunnelService.getOne(new QueryWrapper<UserTunnel>().eq("user_id", forward.getUserId()).eq("tunnel_id", forward.getTunnelId()));
if (userTunnel != null) {
String name = buildServiceName(forward.getId(), forward.getUserId(), userTunnel.getId());
GostUtil.PauseAndResumeService(chainTunnel.getNodeId(), name, "PauseService");
}
}
forward.setStatus(0);
forwardService.updateById(forward);
}
user.setStatus(0);
userService.updateById(user);
@@ -217,25 +221,23 @@ public class ResetFlowAsync {
for (UserTunnel userTunnel : user_tunnel_list) {
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>().eq("tunnel_id", userTunnel.getTunnelId()).eq("user_id", userTunnel.getUserId()).eq("status", 1));
for (Forward forward : forwardList) {
pauseForwardService(forward, userTunnel.getId());
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", forward.getTunnelId()).eq("chain_type", 1));
for (ChainTunnel chainTunnel : chainTunnels) {
String name = buildServiceName(forward.getId(), forward.getUserId(), userTunnel.getId());
GostUtil.PauseAndResumeService(chainTunnel.getNodeId(), name, "PauseService");
}
forward.setStatus(0);
forwardService.updateById(forward);
}
userTunnel.setStatus(0);
userTunnelService.updateById(userTunnel);
}
}
private void pauseForwardService(Forward forward, Integer userTunnelId) {
Tunnel tunnel = tunnelService.getById(forward.getTunnelId());
if (tunnel == null) return;
GostUtil.PauseService(tunnel.getInNodeId(), buildServiceName(forward.getId(), forward.getUserId(), userTunnelId));
if (tunnel.getType() == 2){
GostUtil.PauseRemoteService(tunnel.getOutNodeId(), buildServiceName(forward.getId(), forward.getUserId(), userTunnelId));
}
}
private String buildServiceName(Long forwardId, Integer userId, Integer userTunnelId) {
@@ -42,7 +42,6 @@ public class AESCrypto {
this.secretKey = new SecretKeySpec(keyBytes, ALGORITHM);
this.secureRandom = new SecureRandom();
log.info("AES加密器初始化成功");
} catch (Exception e) {
log.info("AES加密器初始化失败", e);
throw new RuntimeException("AES加密器初始化失败: " + e.getMessage(), e);
@@ -1,13 +1,15 @@
package com.admin.common.utils;
import com.admin.common.dto.GostConfigDto;
import cn.hutool.core.util.StrUtil;
import com.admin.common.dto.GostDto;
import com.admin.entity.Tunnel;
import com.admin.entity.*;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.apache.bcel.generic.RET;
import java.util.regex.Pattern;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public class GostUtil {
@@ -15,7 +17,11 @@ public class GostUtil {
public static GostDto AddLimiters(Long node_id, Long name, String speed) {
JSONObject data = createLimiterData(name, speed);
return 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 GostDto UpdateLimiters(Long node_id, Long name, String speed) {
@@ -29,266 +35,182 @@ public class GostUtil {
public static GostDto DeleteLimiters(Long node_id, Long name) {
JSONObject req = new JSONObject();
req.put("limiter", name + "");
return WebSocketServer.send_msg(node_id, req, "DeleteLimiters");
}
public static GostDto AddService(Long node_id, String name, Integer in_port, Integer limiter, String remoteAddr, Integer fow_type, Tunnel tunnel, String strategy, String interfaceName) {
JSONArray services = new JSONArray();
String[] protocols = {"tcp", "udp"};
for (String protocol : protocols) {
JSONObject service = createServiceConfig(name, in_port, limiter, remoteAddr, protocol, fow_type, tunnel, strategy, interfaceName);
services.add(service);
GostDto gostDto = WebSocketServer.send_msg(node_id, req, "DeleteLimiters");
if (gostDto.getMsg().contains("not found")){
gostDto.setMsg("OK");
}
return WebSocketServer.send_msg(node_id, services, "AddService");
return gostDto;
}
public static GostDto UpdateService(Long node_id, String name, Integer in_port, Integer limiter, String remoteAddr, Integer fow_type, Tunnel tunnel, String strategy, String interfaceName) {
JSONArray services = new JSONArray();
String[] protocols = {"tcp", "udp"};
for (String protocol : protocols) {
JSONObject service = createServiceConfig(name, in_port, limiter, remoteAddr, protocol, fow_type, tunnel, strategy, interfaceName);
services.add(service);
}
return WebSocketServer.send_msg(node_id, services, "UpdateService");
}
public static GostDto DeleteService(Long node_id, String name) {
JSONObject data = new JSONObject();
JSONArray services = new JSONArray();
services.add(name + "_tcp");
services.add(name + "_udp");
data.put("services", services);
return WebSocketServer.send_msg(node_id, data, "DeleteService");
}
public static GostDto AddRemoteService(Long node_id, String name, Integer out_port, String remoteAddr, String protocol, String strategy, String interfaceName) {
JSONObject data = new JSONObject();
data.put("name", name + "_tls");
data.put("addr", ":" + out_port);
if (StringUtils.isNotBlank(interfaceName)) {
JSONObject metadata = new JSONObject();
metadata.put("interface", interfaceName);
data.put("metadata", metadata);
}
JSONObject handler = new JSONObject();
handler.put("type", "relay");
data.put("handler", handler);
JSONObject listener = new JSONObject();
listener.put("type", protocol);
data.put("listener", listener);
JSONObject forwarder = new JSONObject();
public static GostDto AddChains(Long node_id, List<ChainTunnel> chainTunnels, Map<Long, Node> node_s) {
JSONArray nodes = new JSONArray();
Node fromNode = node_s.get(node_id);
for (ChainTunnel chainTunnel : chainTunnels) {
JSONObject dialer = new JSONObject();
dialer.put("type", chainTunnel.getProtocol());
String[] split = remoteAddr.split(",");
int num = 1;
for (String addr : split) {
JSONObject connector = new JSONObject();
connector.put("type", "relay");
Node node_info = node_s.get(chainTunnel.getNodeId());
JSONObject node = new JSONObject();
node.put("name", "node_" + num );
node.put("addr", addr);
node.put("name", "node_" + chainTunnel.getInx());
String dialHost = (fromNode != null && node_info != null)
? selectDialHost(fromNode, node_info)
: (node_info != null ? node_info.getServerIp() : null);
node.put("addr", processServerAddress(dialHost + ":" + chainTunnel.getPort()));
node.put("connector", connector);
node.put("dialer", dialer);
nodes.add(node);
num ++;
}
if (strategy == null || strategy.equals("")){
strategy = "fifo";
}
forwarder.put("nodes", nodes);
JSONObject selector = new JSONObject();
selector.put("strategy", strategy);
selector.put("maxFails", 1);
selector.put("failTimeout", "600s");
forwarder.put("selector", selector);
data.put("forwarder", forwarder);
JSONArray services = new JSONArray();
services.add(data);
return WebSocketServer.send_msg(node_id, services, "AddService");
}
public static GostDto UpdateRemoteService(Long node_id, String name, Integer out_port, String remoteAddr,String protocol, String strategy, String interfaceName) {
JSONObject data = new JSONObject();
data.put("name", name + "_tls");
data.put("addr", ":" + out_port);
if (StringUtils.isNotBlank(interfaceName)) {
JSONObject metadata = new JSONObject();
metadata.put("interface", interfaceName);
data.put("metadata", metadata);
}
JSONObject handler = new JSONObject();
handler.put("type", "relay");
data.put("handler", handler);
JSONObject listener = new JSONObject();
listener.put("type", protocol);
data.put("listener", listener);
JSONObject forwarder = new JSONObject();
JSONArray nodes = new JSONArray();
String[] split = remoteAddr.split(",");
int num = 1;
for (String addr : split) {
JSONObject node = new JSONObject();
node.put("name", "node_" + num );
node.put("addr", addr);
nodes.add(node);
num ++;
}
if (strategy == null || strategy.equals("")){
strategy = "fifo";
}
forwarder.put("nodes", nodes);
JSONObject selector = new JSONObject();
selector.put("strategy", strategy);
selector.put("maxFails", 1);
selector.put("failTimeout", "600s");
forwarder.put("selector", selector);
data.put("forwarder", forwarder);
JSONArray services = new JSONArray();
services.add(data);
return WebSocketServer.send_msg(node_id, services, "UpdateService");
}
public static GostDto DeleteRemoteService(Long node_id, String name) {
JSONArray data = new JSONArray();
data.add(name + "_tls");
JSONObject req = new JSONObject();
req.put("services", data);
return WebSocketServer.send_msg(node_id, req, "DeleteService");
}
public static GostDto PauseService(Long node_id, String name) {
JSONObject data = new JSONObject();
JSONArray services = new JSONArray();
services.add(name + "_tcp");
services.add(name + "_udp");
data.put("services", services);
return WebSocketServer.send_msg(node_id, data, "PauseService");
}
public static GostDto ResumeService(Long node_id, String name) {
JSONObject data = new JSONObject();
JSONArray services = new JSONArray();
services.add(name + "_tcp");
services.add(name + "_udp");
data.put("services", services);
return WebSocketServer.send_msg(node_id, data, "ResumeService");
}
public static GostDto PauseRemoteService(Long node_id, String name) {
JSONObject data = new JSONObject();
JSONArray services = new JSONArray();
services.add(name + "_tls");
data.put("services", services);
return WebSocketServer.send_msg(node_id, data, "PauseService");
}
public static GostDto ResumeRemoteService(Long node_id, String name) {
JSONObject data = new JSONObject();
JSONArray services = new JSONArray();
services.add(name + "_tls");
data.put("services", services);
return WebSocketServer.send_msg(node_id, data, "ResumeService");
}
public static GostDto AddChains(Long node_id, String name, String remoteAddr, String protocol, String interfaceName) {
JSONObject dialer = new JSONObject();
dialer.put("type", protocol);
if (Objects.equals(protocol, "quic")){
JSONObject metadata = new JSONObject();
metadata.put("keepAlive", true);
metadata.put("ttl", "10s");
dialer.put("metadata", metadata);
}
JSONObject connector = new JSONObject();
connector.put("type", "relay");
JSONObject node = new JSONObject();
node.put("name", "node-" + name);
node.put("addr", remoteAddr);
node.put("connector", connector);
node.put("dialer", dialer);
if (StringUtils.isNotBlank(interfaceName)) {
node.put("interface", interfaceName);
}
JSONArray nodes = new JSONArray();
nodes.add(node);
JSONObject hop = new JSONObject();
hop.put("name", "hop-" + name);
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);
selector.put("failTimeout", 600000000000L); // 600 秒(纳秒单位)
hop.put("selector", selector);
hop.put("nodes", nodes);
JSONArray hops = new JSONArray();
hops.add(hop);
JSONObject data = new JSONObject();
data.put("name", name + "_chains");
data.put("name", "chains_" + chainTunnels.getFirst().getTunnelId());
data.put("hops", hops);
return WebSocketServer.send_msg(node_id, data, "AddChains");
}
public static GostDto UpdateChains(Long node_id, String name, String remoteAddr, String protocol, String interfaceName) {
JSONObject dialer = new JSONObject();
dialer.put("type", protocol);
if (Objects.equals(protocol, "quic")){
JSONObject metadata = new JSONObject();
metadata.put("keepAlive", true);
metadata.put("ttl", "10s");
dialer.put("metadata", metadata);
GostDto gostDto = WebSocketServer.send_msg(node_id, data, "AddChains");
if (gostDto.getMsg().contains("exists")){
gostDto.setMsg("OK");
}
JSONObject connector = new JSONObject();
connector.put("type", "relay");
JSONObject node = new JSONObject();
node.put("name", "node-" + name);
node.put("addr", remoteAddr);
node.put("connector", connector);
node.put("dialer", dialer);
if (StringUtils.isNotBlank(interfaceName)) {
node.put("interface", interfaceName);
}
JSONArray nodes = new JSONArray();
nodes.add(node);
JSONObject hop = new JSONObject();
hop.put("name", "hop-" + name);
hop.put("nodes", nodes);
JSONArray hops = new JSONArray();
hops.add(hop);
JSONObject data = new JSONObject();
data.put("name", name + "_chains");
data.put("hops", hops);
JSONObject req = new JSONObject();
req.put("chain", name + "_chains");
req.put("data", data);
return WebSocketServer.send_msg(node_id, req, "UpdateChains");
return gostDto;
}
public static GostDto DeleteChains(Long node_id, String name) {
JSONObject data = new JSONObject();
data.put("chain", name + "_chains");
return WebSocketServer.send_msg(node_id, data, "DeleteChains");
data.put("chain", name);
GostDto gostDto = WebSocketServer.send_msg(node_id, data, "DeleteChains");
if (gostDto.getMsg().contains("not found")){
gostDto.setMsg("OK");
}
return gostDto;
}
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());
// 只为出口节点(chainType=3)设置 interface
if (chainTunnel.getChainType() == 3 && StringUtils.isNotBlank(node_s.get(node_id).getInterfaceName())) {
JSONObject metadata = new JSONObject();
metadata.put("interface", node_s.get(node_id).getInterfaceName());
service_item.put("metadata", metadata);
}
JSONObject handler = new JSONObject();
handler.put("type", "relay");
if (chainTunnel.getChainType() == 2){
handler.put("chain","chains_" + chainTunnel.getTunnelId());
}
service_item.put("handler", handler);
JSONObject listener = new JSONObject();
listener.put("type", chainTunnel.getProtocol());
service_item.put("listener", listener);
services.add(service_item);
GostDto gostDto = WebSocketServer.send_msg(node_id, services, "AddService");
if (gostDto.getMsg().contains("exists")){
gostDto.setMsg("OK");
}
return gostDto;
}
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) {
JSONObject service = new JSONObject();
service.put("name", name + "_" + protocol);
if (Objects.equals(protocol, "tcp")){
service.put("addr", node.getTcpListenAddr() + ":" + forwardPort.getPort());
}else {
service.put("addr", node.getUdpListenAddr() + ":" + forwardPort.getPort());
}
// 只在端口转发时设置 interface(隧道转发时 interface 在转发链的节点上设置)
if (tunnel.getType() == 1 && StringUtils.isNotBlank(node.getInterfaceName())) {
JSONObject metadata = new JSONObject();
metadata.put("interface", node.getInterfaceName());
service.put("metadata", metadata);
}
// 添加限流器配置
if (limiter != null) {
service.put("limiter", limiter.toString());
}
// 配置处理器
JSONObject handler = new JSONObject();
handler.put("type", protocol);
if (tunnel.getType() == 2){
handler.put("chain", "chains_" + forward.getTunnelId());
}
service.put("handler", handler);
// 配置监听器
JSONObject listener = createListener(protocol);
service.put("listener", listener);
JSONObject forwarder = createForwarder(forward.getRemoteAddr(), forward.getStrategy());
service.put("forwarder", forwarder);
services.add(service);
}
GostDto gostDto = WebSocketServer.send_msg(node.getId(), services, meth);
if (gostDto.getMsg().contains("exists")){
gostDto.setMsg("OK");
}
return gostDto;
}
public static GostDto DeleteService(Long node_id, JSONArray services) {
JSONObject data = new JSONObject();
data.put("services", services);
GostDto gostDto = WebSocketServer.send_msg(node_id, data, "DeleteService");
if (gostDto.getMsg().contains("not found")){
gostDto.setMsg("OK");
}
return gostDto;
}
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);
return WebSocketServer.send_msg(node_id, data, meth);
}
private static JSONObject createLimiterData(Long name, String speed) {
JSONObject data = new JSONObject();
data.put("name", name.toString());
@@ -298,59 +220,10 @@ public class GostUtil {
return data;
}
private static JSONObject createServiceConfig(String name, Integer in_port, Integer limiter, String remoteAddr, String protocol, Integer fow_type, Tunnel tunnel, String strategy, String interfaceName) {
JSONObject service = new JSONObject();
service.put("name", name + "_" + protocol);
if (Objects.equals(protocol, "tcp")){
service.put("addr", tunnel.getTcpListenAddr() + ":" + in_port);
}else {
service.put("addr", tunnel.getUdpListenAddr() + ":" + in_port);
}
if (StringUtils.isNotBlank(interfaceName)) {
JSONObject metadata = new JSONObject();
metadata.put("interface", interfaceName);
service.put("metadata", metadata);
}
// 添加限流器配置
if (limiter != null) {
service.put("limiter", limiter.toString());
}
// 配置处理器
JSONObject handler = createHandler(protocol, name, fow_type);
service.put("handler", handler);
// 配置监听器
JSONObject listener = createListener(protocol);
service.put("listener", listener);
// 端口转发需要配置转发器
if (isPortForwarding(fow_type)) {
JSONObject forwarder = createForwarder(remoteAddr, strategy);
service.put("forwarder", forwarder);
}
return service;
}
private static JSONObject createHandler(String protocol, String name, Integer fow_type) {
JSONObject handler = new JSONObject();
handler.put("type", protocol);
// 隧道转发需要添加链配置
if (isTunnelForwarding(fow_type)) {
handler.put("chain", name + "_chains");
}
return handler;
}
private static JSONObject createListener(String protocol) {
JSONObject listener = new JSONObject();
listener.put("type", protocol);
if (Objects.equals(protocol, "udp")){
if (Objects.equals(protocol, "udp")) {
JSONObject metadata = new JSONObject();
metadata.put("keepAlive", true);
listener.put("metadata", metadata);
@@ -366,13 +239,13 @@ public class GostUtil {
int num = 1;
for (String addr : split) {
JSONObject node = new JSONObject();
node.put("name", "node_" + num );
node.put("name", "node_" + num);
node.put("addr", addr);
nodes.add(node);
num ++;
num++;
}
if (strategy == null || strategy.equals("")){
if (strategy == null || strategy.isEmpty()) {
strategy = "fifo";
}
@@ -386,12 +259,153 @@ public class GostUtil {
return forwarder;
}
private static boolean isPortForwarding(Integer fow_type) {
return fow_type != null && fow_type == 1;
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 isTunnelForwarding(Integer fow_type) {
return fow_type != null && fow_type != 1;
private static boolean isIPv6Address(String address) {
// IPv6地址包含多个冒号,至少2个
if (!address.contains(":")) {
return false;
}
// 计算冒号数量,IPv6地址至少有2个冒号
long colonCount = address.chars().filter(ch -> ch == ':').count();
return colonCount >= 2;
}
/**
* v4 优先:当两端都有 v4 时选择 v4,否则尝试 v6。
* 用于节点之间建立链路(A -> B 需要选择 B 的地址族,且 A 需要支持该地址族)。
*/
public static String selectDialHost(Node fromNode, Node toNode) {
if (fromNode == null || toNode == null) {
throw new IllegalArgumentException("node is null");
}
boolean fromV4 = supportsV4(fromNode);
boolean fromV6 = supportsV6(fromNode);
boolean toV4 = supportsV4(toNode);
boolean toV6 = supportsV6(toNode);
if (fromV4 && toV4) {
return pickToAddressV4(toNode);
}
if (fromV6 && toV6) {
return pickToAddressV6(toNode);
}
throw new RuntimeException(
"节点链路不兼容:" + safeName(fromNode) + "(v4=" + fromV4 + ",v6=" + fromV6 + ") -> "
+ safeName(toNode) + "(v4=" + toV4 + ",v6=" + toV6 + ")"
);
}
private static String safeName(Node node) {
if (node.getName() == null || node.getName().isBlank()) {
return "node_" + node.getId();
}
return node.getName();
}
private static boolean supportsV4(Node node) {
// New dual-stack fields take precedence over legacy serverIp.
// If user explicitly provided only v6, treat as v6-only.
if (StrUtil.isNotBlank(node.getServerIpV4())) {
return true;
}
if (StrUtil.isNotBlank(node.getServerIpV6())) {
return false;
}
String legacy = node.getServerIp();
if (StrUtil.isBlank(legacy)) {
return false;
}
legacy = legacy.trim();
if (looksLikeIpv4(legacy)) {
return true;
}
if (isIPv6Address(legacy)) {
return false;
}
// 域名/其它:无法判断,按双栈处理以保持兼容
return true;
}
private static boolean supportsV6(Node node) {
// New dual-stack fields take precedence over legacy serverIp.
// If user explicitly provided only v4, treat as v4-only.
if (StrUtil.isNotBlank(node.getServerIpV6())) {
return true;
}
if (StrUtil.isNotBlank(node.getServerIpV4())) {
return false;
}
String legacy = node.getServerIp();
if (StrUtil.isBlank(legacy)) {
return false;
}
legacy = legacy.trim();
if (isIPv6Address(legacy)) {
return true;
}
if (looksLikeIpv4(legacy)) {
return false;
}
// 域名/其它:无法判断,按双栈处理以保持兼容
return true;
}
private static String pickToAddressV4(Node toNode) {
if (StrUtil.isNotBlank(toNode.getServerIpV4())) {
return toNode.getServerIpV4().trim();
}
String legacy = toNode.getServerIp();
return legacy != null ? legacy.trim() : null;
}
private static String pickToAddressV6(Node toNode) {
if (StrUtil.isNotBlank(toNode.getServerIpV6())) {
return toNode.getServerIpV6().trim();
}
String legacy = toNode.getServerIp();
return legacy != null ? legacy.trim() : null;
}
private static boolean looksLikeIpv4(String value) {
Pattern ipv4 = Pattern.compile("^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
return ipv4.matcher(value).matches();
}
}
@@ -229,6 +229,7 @@ public class WebSocketServer extends TextWebSocketHandler {
String socks = (String) session.getAttributes().get("socks");
log.info("节点 {} 尝试连接,开始处理连接逻辑", nodeId);
log.info("屏蔽协议数据:http->{}, tls->{}, socks->{}", http, tls, socks);
// 检查是否已有该节点的连接,如果有则记录日志但直接覆盖
WebSocketSession existingSession = nodeSessions.get(nodeId);
@@ -1,5 +1,6 @@
package com.admin.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.autoconfigure.ConfigurationCustomizer;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.BlockAttackInnerInterceptor;
@@ -15,7 +16,9 @@ public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); // 分页插件
// 配置分页插件,指定数据库类型为 SQLite
PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.SQLITE);
interceptor.addInnerInterceptor(paginationInterceptor); // 分页插件
interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); // 防止全表更新插件
return interceptor;
}
@@ -0,0 +1,172 @@
package com.admin.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import javax.annotation.PreDestroy;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Pattern;
/**
* SQLite 数据库配置
* 启用 WAL (Write-Ahead Logging) 模式以提高并发性能
* 添加定期 checkpoint 和优雅关闭处理
*/
@Slf4j
@Component
@EnableScheduling
public class SQLiteConfig implements ApplicationRunner {
private final DataSource dataSource;
public SQLiteConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public void run(ApplicationArguments args) throws Exception {
try (Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement()) {
statement.execute("PRAGMA journal_mode=WAL;");
statement.execute("PRAGMA synchronous=NORMAL;");
statement.execute("PRAGMA cache_size=-64000;"); // 64MB 缓存
statement.execute("PRAGMA temp_store=MEMORY;");
statement.execute("PRAGMA busy_timeout=5000;"); // 5秒超时
statement.execute("PRAGMA wal_autocheckpoint=1000;"); // 每1000页自动checkpoint
ensureNodeDualStackColumns(connection);
log.info("SQLite WAL mode configured successfully");
} catch (Exception e) {
log.error("Failed to configure SQLite database", e);
throw e;
}
}
private void ensureNodeDualStackColumns(Connection connection) throws Exception {
Set<String> cols = getTableColumns(connection, "node");
if (cols.isEmpty()) {
return;
}
ensureColumnIfMissing(connection, cols, "node", "server_ip_v4", "VARCHAR(100)");
ensureColumnIfMissing(connection, cols, "node", "server_ip_v6", "VARCHAR(100)");
backfillNodeDualStackColumns(connection);
}
private void backfillNodeDualStackColumns(Connection connection) throws Exception {
Pattern ipv4 = Pattern.compile("^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
try (Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery("SELECT id, server_ip, server_ip_v4, server_ip_v6 FROM node;");
PreparedStatement updV4 = connection.prepareStatement("UPDATE node SET server_ip_v4 = ? WHERE id = ?;");
PreparedStatement updV6 = connection.prepareStatement("UPDATE node SET server_ip_v6 = ? WHERE id = ?;")
) {
while (rs.next()) {
long id = rs.getLong("id");
String serverIp = rs.getString("server_ip");
String v4 = rs.getString("server_ip_v4");
String v6 = rs.getString("server_ip_v6");
if (serverIp == null || serverIp.isBlank()) {
continue;
}
if ((v4 != null && !v4.isBlank()) || (v6 != null && !v6.isBlank())) {
continue;
}
String trimmed = serverIp.trim();
if (ipv4.matcher(trimmed).matches()) {
updV4.setString(1, trimmed);
updV4.setLong(2, id);
updV4.executeUpdate();
} else {
long colonCount = trimmed.chars().filter(ch -> ch == ':').count();
if (colonCount >= 2) {
updV6.setString(1, trimmed);
updV6.setLong(2, id);
updV6.executeUpdate();
}
}
}
}
}
private Set<String> getTableColumns(Connection connection, String table) throws Exception {
Set<String> cols = new HashSet<>();
try (Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery("PRAGMA table_info(" + table + ");")) {
while (rs.next()) {
String name = rs.getString("name");
if (name != null && !name.isBlank()) {
cols.add(name);
}
}
}
return cols;
}
private void ensureColumnIfMissing(
Connection connection,
Set<String> existingColumns,
String table,
String column,
String type
) throws Exception {
if (existingColumns.contains(column)) {
return;
}
try (Statement statement = connection.createStatement()) {
statement.execute("ALTER TABLE " + table + " ADD COLUMN " + column + " " + type + ";");
}
log.info("SQLite schema updated: added {}.{}", table, column);
}
/**
* 定期执行 checkpoint,确保 WAL 文件内容写入主数据库
* 每5分钟执行一次
*/
@Scheduled(fixedDelay = 300000, initialDelay = 300000)
public void performCheckpoint() {
try (Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement()) {
statement.execute("PRAGMA wal_checkpoint(TRUNCATE);");
log.debug("SQLite WAL checkpoint completed");
} catch (Exception e) {
log.error("Failed to perform SQLite checkpoint", e);
}
}
/**
* 应用关闭前执行最终的 checkpoint,确保所有数据都写入主数据库文件
*/
@PreDestroy
public void onShutdown() {
log.info("Performing final SQLite checkpoint before shutdown...");
try (Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement()) {
// 强制执行 checkpoint,将所有 WAL 内容写入主数据库
statement.execute("PRAGMA wal_checkpoint(TRUNCATE);");
log.info("Final SQLite checkpoint completed successfully");
} catch (Exception e) {
log.error("Failed to perform final SQLite checkpoint", e);
}
}
}
@@ -3,15 +3,17 @@ package com.admin.controller;
import com.admin.common.aop.LogAnnotation;
import com.admin.common.dto.FlowDto;
import com.admin.common.dto.GostConfigDto;
import com.admin.common.lang.R;
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;
@@ -21,7 +23,6 @@ import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* 流量上报控制器
@@ -65,6 +66,10 @@ public class FlowController extends BaseController {
@Resource
CheckGostConfigAsync checkGostConfigAsync;
@Resource
@Lazy
ChainTunnelService chainTunnelService;
/**
* 加密消息包装器
*/
@@ -147,15 +152,17 @@ public class FlowController extends BaseController {
String decryptedData = decryptIfNeeded(rawData, secret);
// 3. 解析为FlowDto列表
FlowDto flowDataList = JSONObject.parseObject(decryptedData, FlowDto.class);
if (Objects.equals(flowDataList.getN(), "web_api")) {
return SUCCESS_RESPONSE;
}
// 记录日志
JSONArray flowDataList = JSONObject.parseArray(decryptedData);
log.info("节点上报流量数据{}", flowDataList);
// 4. 处理流量数据
return processFlowData(flowDataList);
for (int i = 0; i < flowDataList.size(); i++) {
String jsonObject = flowDataList.getJSONObject(i).toJSONString();
FlowDto flowDto = JSONObject.parseObject(jsonObject, FlowDto.class);
if (!Objects.equals(flowDto.getN(), "web_api")) {
processFlowData(flowDto);
}
}
return SUCCESS_RESPONSE;
}
/**
@@ -212,24 +219,30 @@ public class FlowController extends BaseController {
/**
* 处理流量数据的核心逻辑
*/
private String processFlowData(FlowDto flowDataList) {
private void processFlowData(FlowDto flowDataList) {
String[] serviceIds = parseServiceName(flowDataList.getN());
String forwardId = serviceIds[0];
String userId = serviceIds[1];
String userTunnelId = serviceIds[2];
Forward forward = forwardService.getById(forwardId);
if (forward != null){
Tunnel tunnel = tunnelService.getById(forward.getTunnelId());
// 获取流量计费类型
int flowType = getFlowType(forward);
// 处理流量倍率及单双向计算
FlowDto flowStats = filterFlowData(flowDataList, forward, flowType);
// 处理流量倍率及单双向计算
BigDecimal trafficRatio = tunnel.getTrafficRatio();
BigDecimal originalD = BigDecimal.valueOf(flowDataList.getD());
BigDecimal originalU = BigDecimal.valueOf(flowDataList.getU());
BigDecimal newD = originalD.multiply(trafficRatio);
BigDecimal newU = originalU.multiply(trafficRatio);
flowDataList.setD(newD.longValue() * tunnel.getFlow());
flowDataList.setU(newU.longValue() * tunnel.getFlow());
}
// 先更新所有流量统计 - 确保流量数据的一致性
updateForwardFlow(forwardId, flowStats);
updateUserFlow(userId, flowStats);
updateUserTunnelFlow(userTunnelId, flowStats);
updateForwardFlow(forwardId, flowDataList);
updateUserFlow(userId, flowDataList);
updateUserTunnelFlow(userTunnelId, flowDataList);
// 7. 检查和服务暂停操作
String name = buildServiceName(forwardId, userId, userTunnelId);
@@ -238,7 +251,6 @@ public class FlowController extends BaseController {
checkUserTunnelRelatedLimits(userTunnelId, name, userId);
}
return SUCCESS_RESPONSE;
}
private void checkUserRelatedLimits(String userId, String name) {
@@ -277,7 +289,7 @@ public class FlowController extends BaseController {
UserTunnel userTunnel = userTunnelService.getById(userTunnelId);
if (userTunnel == null) return;
long flow = userTunnel.getInFlow() + userTunnel.getOutFlow();
if (flow >= userTunnel.getFlow() * BYTES_TO_GB) {
if (flow >= userTunnel.getFlow() * BYTES_TO_GB) {
pauseSpecificForward(userTunnel.getTunnelId(), name, userId);
return;
}
@@ -301,45 +313,15 @@ 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);
}
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 FlowDto filterFlowData(FlowDto flowDto, Forward forward, int flowType) {
if (forward != null) {
Tunnel tunnel = tunnelService.getById(forward.getTunnelId());
if (tunnel != null) {
BigDecimal trafficRatio = tunnel.getTrafficRatio();
BigDecimal originalD = BigDecimal.valueOf(flowDto.getD());
BigDecimal originalU = BigDecimal.valueOf(flowDto.getU());
BigDecimal newD = originalD.multiply(trafficRatio);
BigDecimal newU = originalU.multiply(trafficRatio);
flowDto.setD(newD.longValue() * flowType);
flowDto.setU(newU.longValue() * flowType);
}
}
return flowDto;
}
private int getFlowType(Forward forward) {
int defaultFlowType = 2;
if (forward == null) return defaultFlowType;
Tunnel tunnel = tunnelService.getById(forward.getTunnelId());
if (tunnel == null) return defaultFlowType;
return tunnel.getFlow();
}
private void updateForwardFlow(String forwardId, FlowDto flowStats) {
// 对相同转发的流量更新进行同步,避免并发覆盖
synchronized (getForwardLock(forwardId)) {
@@ -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);
}
}
@@ -5,6 +5,7 @@ import com.admin.common.annotation.RequireRole;
import com.admin.common.dto.TunnelDto;
import com.admin.common.dto.TunnelUpdateDto;
import com.admin.common.dto.UserTunnelBatchAssignDto;
import com.admin.common.dto.UserTunnelDto;
import com.admin.common.dto.UserTunnelQueryDto;
import com.admin.common.dto.UserTunnelUpdateDto;
@@ -78,6 +79,13 @@ public class TunnelController extends BaseController {
public R assignUserTunnel(@Validated @RequestBody UserTunnelDto userTunnelDto) {
return userTunnelService.assignUserTunnel(userTunnelDto);
}
@LogAnnotation
@RequireRole
@PostMapping("/user/batch-assign")
public R batchAssignUserTunnel(@Validated @RequestBody UserTunnelBatchAssignDto batchAssignDto) {
return userTunnelService.batchAssignUserTunnel(batchAssignDto);
}
/**
* 查询用户隧道权限列表
@@ -137,4 +145,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);
}
}
@@ -0,0 +1,42 @@
package com.admin.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
@Data
@EqualsAndHashCode(callSuper = false)
public class ChainTunnel implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
// 关联的隧道ID
private Long tunnelId;
// 记录所选节点的类型: 1是入口节点 2是转发链 3是出口节点
private Integer chainType;
// 记录所选节点的ID
private Long nodeId;
// 记录节点使用的端口 入口没有次配置为null
private Integer port;
// 负载模式 入口没有次配置为null
private String strategy;
// 只有转发链有次参数 表示是第几条
private Integer inx;
// 使用的协议 入口没有次配置为null
private String protocol;
}
@@ -1,6 +1,12 @@
package com.admin.entity;
import java.io.Serializable;
import java.util.List;
import com.admin.common.dto.ForwardPortDto;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -14,6 +20,7 @@ import lombok.EqualsAndHashCode;
*/
@Data
@EqualsAndHashCode(callSuper = false)
@TableName(autoResultMap = true)
public class Forward extends BaseEntity{
private static final long serialVersionUID = 1L;
@@ -26,14 +33,8 @@ public class Forward extends BaseEntity{
private Integer tunnelId;
private Integer inPort;
private Integer outPort;
private String remoteAddr;
private String interfaceName;
private String strategy;
private Long inFlow;
@@ -41,5 +42,4 @@ public class Forward extends BaseEntity{
private Long outFlow;
private Integer inx;
}
@@ -0,0 +1,28 @@
package com.admin.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
@Data
@EqualsAndHashCode(callSuper = false)
@TableName(autoResultMap = true)
public class ForwardPort{
/**
* 主键ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long forwardId;
private Long nodeId;
private Integer port;
}
@@ -14,23 +14,23 @@ import lombok.EqualsAndHashCode;
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class Node extends BaseEntity {
public class Node extends BaseEntity {
private static final long serialVersionUID = 1L;
private String name;
private String secret;
private String ip;
private String serverIp;
private String secret;
private String serverIp;
private String serverIpV4;
private String serverIpV6;
private String version;
private Integer portSta;
private Integer portEnd;
private String port;
private Integer http;
@@ -38,4 +38,15 @@ public class Node extends BaseEntity {
private Integer socks;
}
private String interfaceName;
private String tcpListenAddr;
private String udpListenAddr;
/**
* Display/order index for drag-sorting in admin UI.
* Lower value appears first.
*/
private Integer inx;
}
@@ -2,6 +2,12 @@ package com.admin.entity;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.List;
import com.admin.common.dto.ChainNodesItems;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -15,59 +21,24 @@ import lombok.EqualsAndHashCode;
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class Tunnel extends BaseEntity {
@TableName(autoResultMap = true)
public class Tunnel extends BaseEntity {
private static final long serialVersionUID = 1L;
/**
* 隧道名称
*/
private String name;
/**
* 入口节点ID
*/
private Long inNodeId;
/**
* 入口IP (兼容字段)
*/
private String inIp;
/**
* 出口节点ID
*/
private Long outNodeId;
/**
* 出口IP (兼容字段)
*/
private String outIp;
/**
* 隧道类型(1-端口转发,2-隧道转发)
*/
private Integer type;
/**
* 流量计算类型(1 单向计算上传。2 双向)
*/
private int flow;
/**
* 协议类型
*/
private String protocol;
/**
* 流量倍率
*/
private BigDecimal trafficRatio;
private String tcpListenAddr;
private String udpListenAddr;
private String interfaceName;
}
private BigDecimal trafficRatio;
private String inIp;
/**
* Display/order index for drag-sorting in admin UI.
* Lower value appears first.
*/
private Integer inx;
}
@@ -0,0 +1,7 @@
package com.admin.mapper;
import com.admin.entity.ChainTunnel;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
public interface ChainTunnelMapper extends BaseMapper<ChainTunnel> {
}
@@ -4,6 +4,7 @@ import com.admin.entity.Forward;
import com.admin.common.dto.ForwardWithTunnelDto;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@@ -30,4 +31,5 @@ public interface ForwardMapper extends BaseMapper<Forward> {
*/
List<ForwardWithTunnelDto> selectForwardsWithTunnelByUserId(@Param("userId") Integer userId);
}
@@ -0,0 +1,8 @@
package com.admin.mapper;
import com.admin.entity.Forward;
import com.admin.entity.ForwardPort;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
public interface ForwardPortMapper extends BaseMapper<ForwardPort> {
}
@@ -30,10 +30,5 @@ public interface UserMapper extends BaseMapper<User> {
* @return 转发列表
*/
List<UserPackageDto.UserForwardDetailDto> getUserForwardDetails(@Param("userId") Integer userId);
/**
* 管理员查询所有隧道(流量和转发设置为99999)
* @return 隧道列表
*/
List<UserPackageDto.UserTunnelDetailDto> getAllTunnelsForAdmin();
}
@@ -0,0 +1,8 @@
package com.admin.service;
import com.admin.entity.ChainTunnel;
import com.admin.entity.Forward;
import com.baomidou.mybatisplus.extension.service.IService;
public interface ChainTunnelService extends IService<ChainTunnel> {
}
@@ -0,0 +1,7 @@
package com.admin.service;
import com.admin.entity.ForwardPort;
import com.baomidou.mybatisplus.extension.service.IService;
public interface ForwardPortService extends IService<ForwardPort> {
}
@@ -6,6 +6,7 @@ import com.admin.common.lang.R;
import com.admin.entity.Forward;
import com.baomidou.mybatisplus.extension.service.IService;
import java.util.Map;
import java.util.Set;
/**
* <p>
@@ -79,7 +80,4 @@ public interface ForwardService extends IService<Forward> {
* @return 更新结果
*/
R updateForwardOrder(Map<String, Object> params);
void updateForwardA(Forward forward);
}
@@ -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,8 +26,12 @@ public interface NodeService extends IService<Node> {
R deleteNode(Long id);
Node getNodeById(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,5 +57,11 @@ public interface TunnelService extends IService<Tunnel> {
* @param tunnelId 隧道ID
* @return 诊断结果
*/
R diagnoseTunnel(Long tunnelId);
}
R diagnoseTunnel(Long tunnelId);
/**
* 更新隧道排序(管理员)
* @param params 包含tunnels数组的参数,每个元素包含id和inx
*/
R updateTunnelOrder(Map<String, Object> params);
}
@@ -1,49 +1,23 @@
package com.admin.service;
import com.admin.common.dto.UserTunnelDto;
import com.admin.common.dto.UserTunnelQueryDto;
import com.admin.common.dto.UserTunnelUpdateDto;
import com.admin.common.lang.R;
import com.admin.entity.UserTunnel;
import com.baomidou.mybatisplus.extension.service.IService;
/**
* <p>
* 用户隧道权限服务类
* </p>
*
* @author QAQ
* @since 2025-06-03
*/
public interface UserTunnelService extends IService<UserTunnel> {
/**
* 分配用户隧道权限
* @param userTunnelDto 用户隧道权限数据
* @return 结果
*/
R assignUserTunnel(UserTunnelDto userTunnelDto);
/**
* 查询用户隧道权限列表
* @param queryDto 查询条件
* @return 结果
*/
R getUserTunnelList(UserTunnelQueryDto queryDto);
/**
* 删除用户隧道权限
* @param id ID
* @return 结果
*/
R removeUserTunnel(Integer id);
/**
* 更新用户隧道权限(包含流量、流量重置时间、到期时间)
* @param updateDto 更新数据
* @return 结果
*/
R updateUserTunnel(UserTunnelUpdateDto updateDto);
}
package com.admin.service;
import com.admin.common.dto.UserTunnelBatchAssignDto;
import com.admin.common.dto.UserTunnelDto;
import com.admin.common.dto.UserTunnelQueryDto;
import com.admin.common.dto.UserTunnelUpdateDto;
import com.admin.common.lang.R;
import com.admin.entity.UserTunnel;
import com.baomidou.mybatisplus.extension.service.IService;
public interface UserTunnelService extends IService<UserTunnel> {
R assignUserTunnel(UserTunnelDto userTunnelDto);
R batchAssignUserTunnel(UserTunnelBatchAssignDto batchAssignDto);
R getUserTunnelList(UserTunnelQueryDto queryDto);
R removeUserTunnel(Integer id);
R updateUserTunnel(UserTunnelUpdateDto updateDto);
}
@@ -0,0 +1,41 @@
package com.admin.service.impl;
import com.admin.common.dto.ForwardDto;
import com.admin.common.dto.ForwardUpdateDto;
import com.admin.common.dto.ForwardWithTunnelDto;
import com.admin.common.dto.GostDto;
import com.admin.common.lang.R;
import com.admin.common.utils.GostUtil;
import com.admin.common.utils.JwtUtil;
import com.admin.common.utils.WebSocketServer;
import com.admin.entity.*;
import com.admin.mapper.ChainTunnelMapper;
import com.admin.mapper.ForwardMapper;
import com.admin.service.*;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.alibaba.fastjson.JSONObject;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.*;
import java.util.stream.Collectors;
/**
* <p>
* 端口转发服务实现类
* </p>
*
* @author QAQ
* @since 2025-06-03
*/
@Slf4j
@Service
public class ChainTunnelServiceImpl extends ServiceImpl<ChainTunnelMapper, ChainTunnel> implements ChainTunnelService {
}
@@ -0,0 +1,15 @@
package com.admin.service.impl;
import com.admin.entity.ChainTunnel;
import com.admin.entity.ForwardPort;
import com.admin.mapper.ChainTunnelMapper;
import com.admin.mapper.ForwardPortMapper;
import com.admin.service.ChainTunnelService;
import com.admin.service.ForwardPortService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
@Service
public class ForwardPortServiceImpl extends ServiceImpl<ForwardPortMapper, ForwardPort> implements ForwardPortService {
}
File diff suppressed because it is too large Load Diff
@@ -6,71 +6,39 @@ 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.Node;
import com.admin.entity.Tunnel;
import com.admin.entity.ViteConfig;
import com.admin.mapper.NodeMapper;
import com.admin.mapper.TunnelMapper;
import com.admin.service.NodeService;
import com.admin.service.TunnelService;
import com.admin.service.ViteConfigService;
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.entity.*;
import com.admin.mapper.NodeMapper;
import com.admin.mapper.TunnelMapper;
import com.admin.service.*;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.HashMap;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.regex.Pattern;
import javax.annotation.Resource;
import java.util.List;
import java.util.Objects;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.beans.factory.annotation.Value;
@Service
@Slf4j
public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements NodeService {
/**
* <p>
* 节点服务实现类
* 提供节点的增删改查功能,包括节点创建、更新、删除和查询操作
* </p>
*
* @author QAQ
* @since 2025-06-03
*/
@Service
public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements NodeService {
// ========== 常量定义 ==========
/** 节点默认状态:启用 */
private static final int NODE_STATUS_ACTIVE = 0;
/** 成功响应消息 */
private static final String SUCCESS_CREATE_MSG = "节点创建成功";
private static final String SUCCESS_UPDATE_MSG = "节点更新成功";
private static final String SUCCESS_DELETE_MSG = "节点删除成功";
/** 错误响应消息 */
private static final String ERROR_CREATE_MSG = "节点创建失败";
private static final String ERROR_UPDATE_MSG = "节点更新失败";
private static final String ERROR_DELETE_MSG = "节点删除失败";
private static final String ERROR_NODE_NOT_FOUND = "节点不存在";
/** 隧道使用检查相关消息 */
private static final String ERROR_IN_NODE_IN_USE = "该节点还有 %d 个隧道作为入口节点在使用,请先删除相关隧道";
private static final String ERROR_OUT_NODE_IN_USE = "该节点还有 %d 个隧道作为出口节点在使用,请先删除相关隧道";
/** 端口范围验证相关消息 */
private static final String ERROR_PORT_STA_REQUIRED = "起始端口不能为空";
private static final String ERROR_PORT_END_REQUIRED = "结束端口不能为空";
private static final String ERROR_PORT_RANGE_INVALID = "端口必须在1-65535范围内";
private static final String ERROR_PORT_ORDER_INVALID = "结束端口不能小于起始端口";
// ========== 依赖注入 ==========
@Resource
private TunnelMapper tunnelMapper;
@Resource
@Lazy
@@ -79,52 +47,79 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
@Resource
ViteConfigService viteConfigService;
@Resource
ChainTunnelService chainTunnelService;
@Resource
ForwardPortService forwardPortService;
// ========== 公共接口实现 ==========
/**
* 创建新节点
*
* @param nodeDto 节点创建数据传输对象
* @return 创建结果响应
*/
@Override
public R createNode(NodeDto nodeDto) {
Node node = buildNewNode(nodeDto);
boolean result = this.save(node);
return result ? R.ok(SUCCESS_CREATE_MSG) : R.err(ERROR_CREATE_MSG);
}
public R createNode(NodeDto nodeDto) {
validatePortRange(nodeDto.getPort());
String normalizedV4 = normalizeV4(nodeDto.getServerIpV4(), nodeDto.getServerIp());
String normalizedV6 = normalizeV6(nodeDto.getServerIpV6(), nodeDto.getServerIp());
String primaryServerIp = pickPrimaryServerIp(normalizedV4, normalizedV6, nodeDto.getServerIp());
Node node = new Node();
node.setSecret(IdUtil.simpleUUID());
node.setStatus(0);
node.setPort(nodeDto.getPort());
node.setName(nodeDto.getName());
node.setServerIp(primaryServerIp);
node.setServerIpV4(normalizedV4);
node.setServerIpV6(normalizedV6);
long currentTime = System.currentTimeMillis();
node.setCreatedTime(currentTime);
node.setUpdatedTime(currentTime);
node.setInterfaceName(nodeDto.getInterfaceName());
this.save(node);
return R.ok();
}
@Override
public R getAllNodes() {
List<Node> nodeList = this.list(new QueryWrapper<Node>().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();
}
/**
* 获取所有节点列表
* 注意:返回结果中会隐藏节点密钥信息
*
* @return 包含所有节点的响应对象
*/
@Override
public R getAllNodes() {
List<Node> nodeList = this.list();
hideNodeSecrets(nodeList);
return R.ok(nodeList);
}
/**
* 更新节点信息
*
* @param nodeUpdateDto 节点更新数据传输对象
* @return 更新结果响应
*/
@Override
public R updateNode(NodeUpdateDto nodeUpdateDto) {
// 1. 验证节点是否存在
Node node = this.getById(nodeUpdateDto.getId());
if (node == null) {
return R.err(ERROR_NODE_NOT_FOUND);
return R.err("节点不存在");
}
//1.1 如果节点在线 且传入更新的 http/tls/socks 任意一项与数据库不一致,则通过 WS 通知节点更新设置
boolean online = node.getStatus() != null && node.getStatus() == 1;
Integer newHttp = nodeUpdateDto.getHttp();
Integer newTls = nodeUpdateDto.getTls();
@@ -147,314 +142,371 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
}
// 2. 构建更新对象并执行更新
Node updateNode = buildUpdateNode(nodeUpdateDto);
boolean result = this.updateById(updateNode);
// 更新隧道入口ip
List<Tunnel> inNodeId = tunnelService.list(new QueryWrapper<Tunnel>().eq("in_node_id", updateNode.getId()));
if (!inNodeId.isEmpty()) {
for (Tunnel tunnel : inNodeId) {
tunnel.setInIp(updateNode.getIp());
}
tunnelService.updateBatchById(inNodeId);
}
Node updateNode = buildUpdateNode(nodeUpdateDto);
// Use LambdaUpdateWrapper to explicitly set nullable fields (serverIpV4/V6)
// because updateById() skips null fields by default
LambdaUpdateWrapper<Node> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(Node::getId, updateNode.getId())
.set(Node::getName, updateNode.getName())
.set(Node::getServerIp, updateNode.getServerIp())
.set(Node::getServerIpV4, updateNode.getServerIpV4())
.set(Node::getServerIpV6, updateNode.getServerIpV6())
.set(Node::getPort, updateNode.getPort())
.set(Node::getHttp, updateNode.getHttp())
.set(Node::getTls, updateNode.getTls())
.set(Node::getSocks, updateNode.getSocks())
.set(Node::getInterfaceName, updateNode.getInterfaceName())
.set(Node::getTcpListenAddr, updateNode.getTcpListenAddr())
.set(Node::getUdpListenAddr, updateNode.getUdpListenAddr())
.set(Node::getUpdatedTime, updateNode.getUpdatedTime());
this.update(wrapper);
return R.ok();
}
// 更新服务器出口ip
List<Tunnel> outNodeId = tunnelService.list(new QueryWrapper<Tunnel>().eq("out_node_id", updateNode.getId()));
if (!outNodeId.isEmpty()) {
for (Tunnel tunnel : outNodeId) {
tunnel.setOutIp(updateNode.getServerIp());
}
tunnelService.updateBatchById(outNodeId);
}
return result ? R.ok(SUCCESS_UPDATE_MSG) : R.err(ERROR_UPDATE_MSG);
}
/**
* 删除节点
* 删除前会检查是否有隧道正在使用该节点
*
* @param id 节点ID
* @return 删除结果响应
*/
@Override
public R deleteNode(Long id) {
// 1. 验证节点是否存在
Node node = this.getById(id);
if (node == null) {
return R.err(ERROR_NODE_NOT_FOUND);
}
// 2. 检查节点使用情况
R usageCheckResult = checkNodeUsage(id);
if (usageCheckResult.getCode() != 0) {
return usageCheckResult;
}
// 3. 执行删除操作
boolean result = this.removeById(id);
return result ? R.ok(SUCCESS_DELETE_MSG) : R.err(ERROR_DELETE_MSG);
}
/**
* 根据ID获取节点信息
*
* @param id 节点ID
* @return 节点对象
* @throws RuntimeException 当节点不存在时抛出异常
*/
@Override
public Node getNodeById(Long id) {
Node node = this.getById(id);
if (node == null) {
throw new RuntimeException(ERROR_NODE_NOT_FOUND);
}
return node;
}
// ========== 私有辅助方法 ==========
/**
* 构建新节点对象
*
* @param nodeDto 节点创建DTO
* @return 构建完成的节点对象
*/
private Node buildNewNode(NodeDto nodeDto) {
Node node = new Node();
BeanUtils.copyProperties(nodeDto, node);
// 验证端口范围
validatePortRange(node.getPortSta(), node.getPortEnd());
// 设置默认属性
node.setSecret(IdUtil.simpleUUID());
node.setStatus(NODE_STATUS_ACTIVE);
// 设置时间戳
long currentTime = System.currentTimeMillis();
node.setCreatedTime(currentTime);
node.setUpdatedTime(currentTime);
return node;
}
/**
* 构建节点更新对象
*
* @param nodeUpdateDto 节点更新DTO
* @return 构建完成的更新对象
*/
private Node buildUpdateNode(NodeUpdateDto nodeUpdateDto) {
Node node = new Node();
node.setId(nodeUpdateDto.getId());
node.setName(nodeUpdateDto.getName());
node.setIp(nodeUpdateDto.getIp());
node.setServerIp(nodeUpdateDto.getServerIp());
node.setPortSta(nodeUpdateDto.getPortSta());
node.setPortEnd(nodeUpdateDto.getPortEnd());
node.setHttp(nodeUpdateDto.getHttp());
node.setTls(nodeUpdateDto.getTls());
node.setSocks(nodeUpdateDto.getSocks());
// 验证端口范围
validatePortRange(node.getPortSta(), node.getPortEnd());
node.setUpdatedTime(System.currentTimeMillis());
return node;
}
/**
* 隐藏节点列表中的密钥信息
*
* @param nodeList 节点列表
*/
private void hideNodeSecrets(List<Node> nodeList) {
nodeList.forEach(node -> node.setSecret(null));
}
@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);
}
}
}
/**
* 检查节点使用情况
* 验证是否有隧道正在使用该节点作为入口或出口节点
*
* @param nodeId 节点ID
* @return 检查结果响应
*/
private R checkNodeUsage(Long nodeId) {
// 检查入口节点使用情况
R inNodeCheckResult = checkInNodeUsage(nodeId);
if (inNodeCheckResult.getCode() != 0) {
return inNodeCheckResult;
}
// 检查出口节点使用情况
return checkOutNodeUsage(nodeId);
}
/**
* 检查节点作为入口节点的使用情况
*
* @param nodeId 节点ID
* @return 检查结果响应
*/
private R checkInNodeUsage(Long nodeId) {
QueryWrapper<Tunnel> query = new QueryWrapper<>();
query.eq("in_node_id", nodeId);
long tunnelCount = tunnelMapper.selectCount(query);
if (tunnelCount > 0) {
String errorMsg = String.format(ERROR_IN_NODE_IN_USE, tunnelCount);
return R.err(errorMsg);
}
return R.ok();
}
/**
* 检查节点作为出口节点的使用情况
*
* @param nodeId 节点ID
* @return 检查结果响应
*/
private R checkOutNodeUsage(Long nodeId) {
QueryWrapper<Tunnel> query = new QueryWrapper<>();
query.eq("out_node_id", nodeId);
long tunnelCount = tunnelMapper.selectCount(query);
if (tunnelCount > 0) {
String errorMsg = String.format(ERROR_OUT_NODE_IN_USE, tunnelCount);
return R.err(errorMsg);
}
return R.ok();
}
/**
* 获取节点安装命令
* 根据节点信息生成对应的安装命令
*
* @param id 节点ID
* @return 包含安装命令的响应对象
*/
@Override
public R getInstallCommand(Long id) {
// 1. 验证节点是否存在
Node node = this.getById(id);
if (node == null) {
return R.err(ERROR_NODE_NOT_FOUND);
return R.err("节点不存在");
}
// 2. 构建安装命令
return buildInstallCommand(node);
}
/**
* 构建节点安装命令
*
* @param node 节点对象
* @return 格式化的安装命令
*/
private R buildInstallCommand(Node node) {
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/1.4.2/install.sh")
.append(" -o ./install.sh && chmod +x ./install.sh && ");
// 处理服务器地址,如果是IPv6需要添加方括号
String processedServerAddr = processServerAddress(viteConfig.getValue());
// 第二部分:执行安装脚本(去掉-u参数)
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()); // 节点密钥
return R.ok(command.toString());
.append(" -a ").append(processedServerAddr) // 服务器地址
.append(" -s ").append(node.getSecret()); // 节点密钥
return R.ok(command);
}
/**
* 处理服务器地址,确保IPv6地址被方括号包裹
*
* @param serverAddr 原始服务器地址,格式可能为 host:port
* @return 处理后的服务器地址
*/
private String processServerAddress(String serverAddr) {
if (StrUtil.isBlank(serverAddr)) {
return serverAddr;
private Node buildUpdateNode(NodeUpdateDto nodeUpdateDto) {
validatePortRange(nodeUpdateDto.getPort());
String normalizedV4 = normalizeV4(nodeUpdateDto.getServerIpV4(), nodeUpdateDto.getServerIp());
String normalizedV6 = normalizeV6(nodeUpdateDto.getServerIpV6(), nodeUpdateDto.getServerIp());
String primaryServerIp = pickPrimaryServerIp(normalizedV4, normalizedV6, nodeUpdateDto.getServerIp());
Node node = new Node();
node.setId(nodeUpdateDto.getId());
node.setName(nodeUpdateDto.getName());
node.setServerIp(primaryServerIp);
node.setServerIpV4(normalizedV4);
node.setServerIpV6(normalizedV6);
node.setPort(nodeUpdateDto.getPort());
node.setHttp(nodeUpdateDto.getHttp());
node.setTls(nodeUpdateDto.getTls());
node.setSocks(nodeUpdateDto.getSocks());
node.setUpdatedTime(System.currentTimeMillis());
node.setInterfaceName(nodeUpdateDto.getInterfaceName());
node.setTcpListenAddr(nodeUpdateDto.getTcpListenAddr());
node.setUdpListenAddr(nodeUpdateDto.getUdpListenAddr());
return node;
}
private String pickPrimaryServerIp(String serverIpV4, String serverIpV6, String fallback) {
if (StrUtil.isNotBlank(serverIpV4)) {
return serverIpV4.trim();
}
if (StrUtil.isNotBlank(serverIpV6)) {
return serverIpV6.trim();
}
return fallback != null ? fallback.trim() : null;
}
private String normalizeV4(String serverIpV4, String legacyServerIp) {
if (StrUtil.isNotBlank(serverIpV4)) {
return serverIpV4.trim();
}
if (StrUtil.isNotBlank(legacyServerIp) && looksLikeIpv4(legacyServerIp.trim())) {
return legacyServerIp.trim();
}
return null;
}
private String normalizeV6(String serverIpV6, String legacyServerIp) {
if (StrUtil.isNotBlank(serverIpV6)) {
return serverIpV6.trim();
}
if (StrUtil.isNotBlank(legacyServerIp) && looksLikeIpv6(legacyServerIp.trim())) {
return legacyServerIp.trim();
}
return null;
}
private boolean looksLikeIpv4(String value) {
// 仅用于判定地址族(不解析域名)
Pattern ipv4 = Pattern.compile("^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
return ipv4.matcher(value).matches();
}
private boolean looksLikeIpv6(String value) {
// 粗略判定 IPv6(与 GostUtil.processServerAddress 一致思路)
long colonCount = value.chars().filter(ch -> ch == ':').count();
return colonCount >= 2;
}
private void validatePortRange(String port) {
Pattern PORT_PATTERN = Pattern.compile( "([0-9]{1,5})(-([0-9]{1,5}))?");
if (port == null || port.isEmpty()) {
throw new RuntimeException("可用端口不合法");
}
// 如果已经被方括号包裹,直接返回
if (serverAddr.startsWith("[")) {
return serverAddr;
String[] parts = port.split(",");
for (String part : parts) {
part = part.trim();
if (!PORT_PATTERN.matcher(part).matches()) {
throw new RuntimeException("可用端口不合法");
}
if (part.contains("-")) {
String[] range = part.split("-");
int start = Integer.parseInt(range[0]);
int end = Integer.parseInt(range[1]);
if (start < 0 || end < 0 || end > 65535 || start > end) {
throw new RuntimeException("可用端口不合法");
}
} else {
int ports = Integer.parseInt(part);
if (ports < 0 || ports > 65535) {
throw new RuntimeException("可用端口不合法");
}
}
}
// 查找最后一个冒号,分离主机和端口
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;
}
/**
* 判断是否为IPv6地址
*
* @param address 地址字符串(不包含端口号)
* @return 是否为IPv6地址
*/
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;
}
/**
* 验证端口范围的有效性
*
* @param portSta 起始端口
* @param portEnd 结束端口
* @throws RuntimeException 当端口范围无效时抛出异常
*/
private void validatePortRange(Integer portSta, Integer portEnd) {
// 检查起始端口是否为空
if (portSta == null) {
throw new RuntimeException(ERROR_PORT_STA_REQUIRED);
}
// 检查结束端口是否为空
if (portEnd == null) {
throw new RuntimeException(ERROR_PORT_END_REQUIRED);
}
// 检查端口范围是否在有效区间内
if (portSta < 1 || portSta > 65535 || portEnd < 1 || portEnd > 65535) {
throw new RuntimeException(ERROR_PORT_RANGE_INVALID);
}
// 检查端口顺序是否正确
if (portEnd < portSta) {
throw new RuntimeException(ERROR_PORT_ORDER_INVALID);
}
}
}
@@ -5,15 +5,10 @@ import com.admin.common.dto.SpeedLimitDto;
import com.admin.common.dto.SpeedLimitUpdateDto;
import com.admin.common.lang.R;
import com.admin.common.utils.GostUtil;
import com.admin.entity.Node;
import com.admin.entity.SpeedLimit;
import com.admin.entity.Tunnel;
import com.admin.entity.UserTunnel;
import com.admin.entity.*;
import com.admin.mapper.SpeedLimitMapper;
import com.admin.service.NodeService;
import com.admin.service.SpeedLimitService;
import com.admin.service.TunnelService;
import com.admin.service.UserTunnelService;
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;
@@ -22,8 +17,10 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
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;
@@ -41,354 +38,110 @@ import java.util.UUID;
@Service
public class SpeedLimitServiceImpl extends ServiceImpl<SpeedLimitMapper, SpeedLimit> implements SpeedLimitService {
// ========== 常量定义 ==========
/** Gost操作成功响应消息 */
private static final String GOST_SUCCESS_MSG = "OK";
/** Gost未找到资源响应消息 */
private static final String GOST_NOT_FOUND_MSG = "not found";
/** 限速规则状态 */
private static final int SPEED_LIMIT_ACTIVE_STATUS = 1;
private static final int SPEED_LIMIT_INACTIVE_STATUS = 0;
/** 速度转换比率:比特到字节 */
private static final double BITS_TO_BYTES_RATIO = 8.0;
/** 成功响应消息 */
private static final String SUCCESS_UPDATE_MSG = "限速规则更新成功";
private static final String SUCCESS_DELETE_MSG = "限速规则删除成功";
/** 错误响应消息 */
private static final String ERROR_CREATE_MSG = "限速规则创建失败";
private static final String ERROR_UPDATE_MSG = "限速规则更新失败";
private static final String ERROR_DELETE_MSG = "限速规则删除失败";
private static final String ERROR_SPEED_LIMIT_NOT_FOUND = "限速规则不存在";
private static final String ERROR_TUNNEL_NOT_FOUND = "指定的隧道不存在";
private static final String ERROR_TUNNEL_NOT_EXISTS = "隧道不存在";
private static final String ERROR_TUNNEL_NAME_MISMATCH = "隧道名称与隧道ID不匹配";
private static final String ERROR_SPEED_LIMIT_IN_USE = "该限速规则还有用户在使用 请先取消分配";
// ========== 依赖注入 ==========
@Autowired
@Resource
@Lazy
private TunnelService tunnelService;
TunnelService tunnelService;
@Autowired
private NodeService nodeService;
@Resource
NodeService nodeService;
@Autowired
private UserTunnelService userTunnelService;
@Resource
UserTunnelService userTunnelService;
@Autowired
@Lazy
private SpeedLimitService speedLimitService;
@Resource
ChainTunnelService chainTunnelService;
// ========== 公共接口实现 ==========
/**
* 创建限速规则
*
* @param speedLimitDto 限速规则创建数据传输对象
* @return 创建结果响应
*/
@Override
public R createSpeedLimit(SpeedLimitDto speedLimitDto) {
// 1. 验证隧道
TunnelValidationResult tunnelValidation = validateTunnelWithResult(speedLimitDto.getTunnelId(), speedLimitDto.getTunnelName());
if (tunnelValidation.isHasError()) {
return R.err(tunnelValidation.getErrorMessage());
}
Tunnel tunnel = tunnelService.getById(speedLimitDto.getTunnelId());
if (tunnel == null) return R.err("隧道不存在");
// 2. 创建限速规则实体
SpeedLimit speedLimit = createSpeedLimitEntity(speedLimitDto);
if (!this.save(speedLimit)) {
return R.err(ERROR_CREATE_MSG);
}
SpeedLimit speedLimit = new SpeedLimit();
BeanUtils.copyProperties(speedLimitDto, speedLimit);
long currentTime = System.currentTimeMillis();
speedLimit.setCreatedTime(currentTime);
speedLimit.setUpdatedTime(currentTime);
speedLimit.setStatus(1);
this.save(speedLimit);
// 3. 调用Gost API添加限速器
R gostResult = addGostLimiter(speedLimit, tunnelValidation.getTunnel());
if (gostResult.getCode() != 0) {
handleGostOperationFailure(speedLimit);
this.removeById(speedLimit.getId());
return gostResult;
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) {
GostDto gostDto = GostUtil.AddLimiters(node.getId(), speedLimit.getId(), speedInMBps);
if (Objects.equals(gostDto.getMsg(), "OK")){
limit_success.add(node.getId());
}else {
this.removeById(speedLimit.getId());
for (Long node_id : limit_success) {
GostDto deleteLimiters = GostUtil.DeleteLimiters(node_id, speedLimit.getId());
System.out.println(deleteLimiters);
}
return R.err(gostDto.getMsg());
}
}
}
return R.ok();
}
/**
* 获取所有限速规则
*
* @return 包含所有限速规则的响应对象
*/
@Override
public R getAllSpeedLimits() {
List<SpeedLimit> speedLimits = this.list();
return R.ok(speedLimits);
}
/**
* 更新限速规则
*
* @param speedLimitUpdateDto 限速规则更新数据传输对象
* @return 更新结果响应
*/
@Override
public R updateSpeedLimit(SpeedLimitUpdateDto speedLimitUpdateDto) {
// 1. 验证限速规则是否存在
SpeedLimit speedLimit = this.getById(speedLimitUpdateDto.getId());
if (speedLimit == null) {
return R.err(ERROR_SPEED_LIMIT_NOT_FOUND);
if (speedLimit == null) return R.err("限速不存在");
BeanUtils.copyProperties(speedLimitUpdateDto, speedLimit);
speedLimit.setUpdatedTime(System.currentTimeMillis());
String speedInMBps = convertBitsToMBps(speedLimit.getSpeed());
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) {
GostDto gostDto = GostUtil.UpdateLimiters(node.getId(), speedLimit.getId(), speedInMBps);
if (!Objects.equals(gostDto.getMsg(), "OK")) return R.err(gostDto.getMsg());
}
}
// 2. 验证隧道
TunnelValidationResult tunnelValidation = validateTunnelWithResult(speedLimitUpdateDto.getTunnelId(), speedLimitUpdateDto.getTunnelName());
if (tunnelValidation.isHasError()) {
return R.err(tunnelValidation.getErrorMessage());
}
// 3. 更新限速规则数据
updateSpeedLimitEntity(speedLimitUpdateDto, speedLimit);
// 4. 调用Gost API更新限速器
R gostResult = updateGostLimiter(speedLimit, tunnelValidation.getTunnel());
if (gostResult.getCode() != 0) {
return gostResult;
}
// 5. 保存更新
boolean result = this.updateById(speedLimit);
return result ? R.ok(SUCCESS_UPDATE_MSG) : R.err(ERROR_UPDATE_MSG);
this.updateById(speedLimit);
return R.ok();
}
/**
* 删除限速规则
* 删除前会检查是否有用户正在使用该限速规则
*
* @param id 限速规则ID
* @return 删除结果响应
*/
@Override
public R deleteSpeedLimit(Long id) {
// 1. 验证限速规则是否存在
SpeedLimit speedLimit = this.getById(id);
if (speedLimit == null) {
return R.err(ERROR_SPEED_LIMIT_NOT_FOUND);
}
// 2. 检查使用情况
R usageCheckResult = checkSpeedLimitUsage(id);
if (usageCheckResult.getCode() != 0) {
return usageCheckResult;
}
// 3. 获取隧道信息
Tunnel tunnel = tunnelService.getById(speedLimit.getTunnelId());
if (tunnel == null) {
this.removeById(id);
return R.ok();
}
// 4. 调用Gost API删除限速器
deleteGostLimiter(id, tunnel);
// 5. 删除限速规则
boolean result = this.removeById(id);
return result ? R.ok(SUCCESS_DELETE_MSG) : R.err(ERROR_DELETE_MSG);
}
// ========== 私有辅助方法 ==========
/**
* 验证隧道是否存在且名称匹配(返回详细结果)
*
* @param tunnelId 隧道ID
* @param tunnelName 隧道名称
* @return 隧道验证结果
*/
private TunnelValidationResult validateTunnelWithResult(Long tunnelId, String tunnelName) {
Tunnel tunnel = tunnelService.getById(tunnelId);
if (tunnel == null) {
return TunnelValidationResult.error(ERROR_TUNNEL_NOT_FOUND);
}
if (!tunnel.getName().equals(tunnelName)) {
return TunnelValidationResult.error(ERROR_TUNNEL_NAME_MISMATCH);
}
return TunnelValidationResult.success(tunnel);
}
/**
* 验证隧道是否存在且名称匹配(兼容原有方法)
*
* @param tunnelId 隧道ID
* @param tunnelName 隧道名称
* @return 验证结果响应
*/
private R validateTunnel(Long tunnelId, String tunnelName) {
TunnelValidationResult result = validateTunnelWithResult(tunnelId, tunnelName);
return result.isHasError() ? R.err(result.getErrorMessage()) : R.ok(result.getTunnel());
}
/**
* 创建限速规则实体对象
*
* @param speedLimitDto 限速规则创建DTO
* @return 构建完成的限速规则对象
*/
private SpeedLimit createSpeedLimitEntity(SpeedLimitDto speedLimitDto) {
SpeedLimit speedLimit = new SpeedLimit();
BeanUtils.copyProperties(speedLimitDto, speedLimit);
// 设置默认属性
long currentTime = System.currentTimeMillis();
speedLimit.setCreatedTime(currentTime);
speedLimit.setUpdatedTime(currentTime);
speedLimit.setStatus(SPEED_LIMIT_ACTIVE_STATUS);
return speedLimit;
}
/**
* 更新限速规则实体对象
*
* @param speedLimitUpdateDto 限速规则更新DTO
* @param speedLimit 待更新的限速规则对象
*/
private void updateSpeedLimitEntity(SpeedLimitUpdateDto speedLimitUpdateDto, SpeedLimit speedLimit) {
BeanUtils.copyProperties(speedLimitUpdateDto, speedLimit);
speedLimit.setUpdatedTime(System.currentTimeMillis());
}
/**
* 检查限速规则使用情况
*
* @param speedLimitId 限速规则ID
* @return 检查结果响应
*/
private R checkSpeedLimitUsage(Long speedLimitId) {
int userCount = userTunnelService.count(new QueryWrapper<UserTunnel>().eq("speed_id", speedLimitId));
if (userCount != 0) {
return R.err(ERROR_SPEED_LIMIT_IN_USE);
if (speedLimit == null) return R.err("限速规则不存在");
int userCount = userTunnelService.count(new QueryWrapper<UserTunnel>().eq("speed_id", speedLimit.getId()));
if (userCount != 0) return R.err("该限速规则还有用户在使用 请先取消分配");
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) {
GostDto gostDto = GostUtil.DeleteLimiters(node.getId(), speedLimit.getId());
if (!Objects.equals(gostDto.getMsg(), "OK"))return R.err(gostDto.getMsg());
}
}
this.removeById(id);
return R.ok();
}
/**
* 添加Gost限速器
*
* @param speedLimit 限速规则对象
* @param tunnel 隧道对象
* @return 操作结果响应
*/
private R addGostLimiter(SpeedLimit speedLimit, Tunnel tunnel) {
String speedInMBps = convertBitsToMBps(speedLimit.getSpeed());
Node node = nodeService.getNodeById(tunnel.getInNodeId());
GostDto gostResult = GostUtil.AddLimiters(
node.getId(),
speedLimit.getId(),
speedInMBps
);
return isGostOperationSuccess(gostResult) ? R.ok() : R.err(gostResult.getMsg());
}
/**
* 更新Gost限速器
*
* @param speedLimit 限速规则对象
* @param tunnel 隧道对象
* @return 操作结果响应
*/
private R updateGostLimiter(SpeedLimit speedLimit, Tunnel tunnel) {
String speedInMBps = convertBitsToMBps(speedLimit.getSpeed());
Node node = nodeService.getNodeById(tunnel.getInNodeId());
// 尝试更新限速器
GostDto gostResult = GostUtil.UpdateLimiters(node.getId(), speedLimit.getId(), speedInMBps);
// 如果限速器不存在,则创建新的
if (gostResult.getMsg().contains(GOST_NOT_FOUND_MSG)) {
gostResult = GostUtil.AddLimiters(node.getId(), speedLimit.getId(), speedInMBps);
}
return isGostOperationSuccess(gostResult) ? R.ok() : R.err(gostResult.getMsg());
}
/**
* 删除Gost限速器
*
* @param speedLimitId 限速规则ID
* @param tunnel 隧道对象
* @return 操作结果响应
*/
private R deleteGostLimiter(Long speedLimitId, Tunnel tunnel) {
Node node = nodeService.getNodeById(tunnel.getInNodeId());
GostDto gostResult = GostUtil.DeleteLimiters(node.getId(), speedLimitId);
return isGostOperationSuccess(gostResult) ? R.ok() : R.err(gostResult.getMsg());
}
/**
* 处理Gost操作失败的情况
*
* @param speedLimit 限速规则对象
*/
private void handleGostOperationFailure(SpeedLimit speedLimit) {
speedLimit.setStatus(SPEED_LIMIT_INACTIVE_STATUS);
speedLimitService.updateById(speedLimit);
}
/**
* 将比特率转换为兆字节每秒
*
* @param speedInBits 比特率速度
* @return 兆字节每秒字符串
*/
private String convertBitsToMBps(Integer speedInBits) {
double mbs = speedInBits / BITS_TO_BYTES_RATIO;
double mbs = speedInBits / 8.0;
BigDecimal bd = new BigDecimal(mbs).setScale(1, RoundingMode.HALF_UP);
return bd.doubleValue() + "";
}
/**
* 检查Gost操作是否成功
*
* @param gostResult Gost操作结果
* @return 是否成功
*/
private boolean isGostOperationSuccess(GostDto gostResult) {
return Objects.equals(gostResult.getMsg(), GOST_SUCCESS_MSG);
}
// ========== 内部数据类 ==========
/**
* 隧道验证结果封装类
*/
@Data
private static class TunnelValidationResult {
private final boolean hasError;
private final String errorMessage;
private final Tunnel tunnel;
private TunnelValidationResult(boolean hasError, String errorMessage, Tunnel tunnel) {
this.hasError = hasError;
this.errorMessage = errorMessage;
this.tunnel = tunnel;
}
public static TunnelValidationResult success(Tunnel tunnel) {
return new TunnelValidationResult(false, null, tunnel);
}
public static TunnelValidationResult error(String errorMessage) {
return new TunnelValidationResult(true, errorMessage, null);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -10,9 +10,7 @@ import com.admin.common.utils.GostUtil;
import com.admin.common.utils.JwtUtil;
import com.admin.common.utils.Md5Util;
import com.admin.entity.*;
import com.admin.mapper.ForwardMapper;
import com.admin.mapper.UserMapper;
import com.admin.mapper.UserTunnelMapper;
import com.admin.service.*;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
@@ -20,6 +18,7 @@ import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
@@ -28,83 +27,25 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* <p>
* 用户服务实现类
* 提供用户的增删改查功能,包括用户登录、创建、更新、删除和套餐信息查询
* 支持用户关联数据的级联删除,包括转发和Gost服务的清理
* </p>
*
* @author QAQ
* @since 2025-06-03
*/
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
// ========== 常量定义 ==========
/** 用户角色常量 */
private static final int ADMIN_ROLE_ID = 0; // 管理员角色ID
private static final int USER_ROLE_ID = 1; // 普通用户角色ID
/** 用户状态常量 */
private static final int USER_STATUS_ACTIVE = 1; // 用户启用状态
private static final int USER_STATUS_DISABLED = 0; // 用户停用状态
/** 隧道类型常量 */
private static final int TUNNEL_TYPE_TUNNEL_FORWARD = 2; // 隧道转发类型
/** 成功响应消息 */
private static final String SUCCESS_CREATE_MSG = "用户创建成功";
private static final String SUCCESS_UPDATE_MSG = "用户更新成功";
private static final String SUCCESS_DELETE_MSG = "用户及关联数据删除成功";
/** 错误响应消息 */
private static final String ERROR_LOGIN_CREDENTIALS = "账号或密码错误";
private static final String ERROR_ACCOUNT_DISABLED = "账户停用";
private static final String ERROR_CREATE_FAILED = "用户创建失败";
private static final String ERROR_UPDATE_FAILED = "用户更新失败";
private static final String ERROR_DELETE_FAILED = "用户删除失败";
private static final String ERROR_USER_NOT_FOUND = "用户不存在";
private static final String ERROR_USERNAME_EXISTS = "用户名已存在";
private static final String ERROR_USERNAME_TAKEN = "用户名已被其他用户使用";
private static final String ERROR_CANNOT_DELETE_ADMIN = "不能删除管理员用户";
private static final String ERROR_CANNOT_UPDATE_ADMIN = "不能修改管理员用户信息";
private static final String ERROR_USER_NOT_LOGGED_IN = "用户未登录或token无效";
private static final String ERROR_GET_PACKAGE_INFO_FAILED = "获取套餐信息失败";
private static final String ERROR_CURRENT_PASSWORD_WRONG = "当前密码错误";
private static final String ERROR_PASSWORD_NOT_MATCH = "新密码和确认密码不匹配";
@Resource
@Lazy
ForwardService forwardService;
/** 默认账号密码 */
private static final String DEFAULT_USERNAME = "admin_user";
private static final String DEFAULT_PASSWORD = "admin_user";
/** 登录响应字段名 */
private static final String LOGIN_TOKEN_FIELD = "token";
private static final String LOGIN_NAME_FIELD = "name";
private static final String LOGIN_ROLE_ID_FIELD = "role_id";
private static final String LOGIN_REQUIRE_PASSWORD_CHANGE_FIELD = "requirePasswordChange";
@Resource
UserMapper userMapper;
// ========== 依赖注入 ==========
@Resource
private UserMapper userMapper;
@Lazy
TunnelService tunnelService;
@Resource
@Lazy
private ForwardMapper forwardMapper;
@Resource
private UserTunnelMapper userTunnelMapper;
@Resource
@Lazy
private TunnelService tunnelService;
@Resource
@Lazy
private NodeService nodeService;
NodeService nodeService;
@Resource
UserTunnelService userTunnelService;
@@ -116,21 +57,15 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
StatisticsFlowService statisticsFlowService;
@Resource
private ImageCaptchaApplication application;
@Lazy
ForwardPortService forwardPortService;
@Resource
ImageCaptchaApplication application;
// ========== 公共接口实现 ==========
/**
* 用户登录
* 验证验证码、用户名密码,检查账户状态,生成JWT令牌
*
* @param loginDto 登录数据传输对象
* @return 登录结果响应,包含令牌和用户信息
*/
@Override
public R login(LoginDto loginDto) {
// 1. 验证验证码
ViteConfig viteConfig = viteConfigService.getOne(new QueryWrapper<ViteConfig>().eq("name", "captcha_enabled"));
if (viteConfig != null && Objects.equals(viteConfig.getValue(), "true")) {
if (StringUtils.isBlank(loginDto.getCaptchaId())) return R.err("验证码校验失败");
@@ -138,216 +73,129 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
if (!valid) return R.err("验证码校验失败");
}
// 2. 验证用户凭据
LoginValidationResult validationResult = validateUserCredentials(loginDto);
if (validationResult.isHasError()) {
return R.err(validationResult.getErrorMessage());
}
// 3. 生成令牌并返回用户信息
User user = validationResult.getUser();
User user = this.getOne(new QueryWrapper<User>().eq("user", loginDto.getUsername()));
if (user == null) return R.err("账号或密码错误");
if (!user.getPwd().equals(Md5Util.md5(loginDto.getPassword()))) return R.err("账号或密码错误");
if (user.getStatus() == 0) return R.err("账号被停用");
String token = JwtUtil.generateToken(user);
// 4. 检查是否使用默认账号密码
boolean requirePasswordChange = isDefaultCredentials(loginDto.getUsername(), loginDto.getPassword());
boolean requirePasswordChange = Objects.equals(loginDto.getUsername(), "admin_user") || Objects.equals(loginDto.getPassword(), "admin_user");
return R.ok(MapUtil.builder()
.put(LOGIN_TOKEN_FIELD, token)
.put(LOGIN_NAME_FIELD, user.getUser())
.put(LOGIN_ROLE_ID_FIELD, user.getRoleId())
.put(LOGIN_REQUIRE_PASSWORD_CHANGE_FIELD, requirePasswordChange)
.put("token", token)
.put("name", user.getUser())
.put("role_id", user.getRoleId())
.put("requirePasswordChange", requirePasswordChange)
.build());
}
/**
* 创建用户
* 检查用户名唯一性,设置默认属性,加密密码
*
* @param userDto 用户创建数据传输对象
* @return 创建结果响应
*/
@Override
public R createUser(UserDto userDto) {
// 1. 验证用户名唯一性
R usernameValidationResult = validateUsernameUniqueness(userDto.getUser(), null);
if (usernameValidationResult.getCode() != 0) {
return usernameValidationResult;
}
// 2. 构建用户实体并保存
User user = buildNewUserEntity(userDto);
boolean result = this.save(user);
if (result) {
// 3. 添加到期时间延时任务
return R.ok(SUCCESS_CREATE_MSG);
} else {
return R.err(ERROR_CREATE_FAILED);
}
int count = this.count(new QueryWrapper<User>().eq("user", userDto.getUser()));
if (count > 0) return R.err("用户名已存在");
User user = new User();
BeanUtils.copyProperties(userDto, user);
user.setPwd(Md5Util.md5(userDto.getPwd()));
user.setStatus(1);
user.setRoleId(1);
long currentTime = System.currentTimeMillis();
user.setCreatedTime(currentTime);
user.setUpdatedTime(currentTime);
this.save(user);
return R.ok();
}
/**
* 获取所有用户(分页)
* 支持关键字搜索,排除管理员用户,清除密码信息
*
* @return 分页用户列表响应
*/
@Override
public R getAllUsers() {
return R.ok(this.list(new QueryWrapper<User>().ne("role_id", ADMIN_ROLE_ID)));
List<User> list = this.list(new QueryWrapper<User>().ne("role_id", 0));
return R.ok(list);
}
/**
* 更新用户信息
* 验证用户存在性和用户名唯一性,处理密码加密
*
* @param userUpdateDto 用户更新数据传输对象
* @return 更新结果响应
*/
@Override
public R updateUser(UserUpdateDto userUpdateDto) {
// 1. 验证用户是否存在
if (!isUserExists(userUpdateDto.getId())) {
return R.err(ERROR_USER_NOT_FOUND);
}
User user = this.getById(userUpdateDto.getId());
if (user == null) return R.err("用户不存在");
if (user.getRoleId() == 0) return R.err("请不要作死");
// 2. 验证用户名唯一性
R usernameValidationResult = validateUsernameUniqueness(userUpdateDto.getUser(), userUpdateDto.getId());
if (usernameValidationResult.getCode() != 0) {
return usernameValidationResult;
}
int count = this.count(new QueryWrapper<User>().eq("user", userUpdateDto.getUser()).ne("id", userUpdateDto.getId()));
if (count > 0) return R.err("用户名已存在");
// 3. 验证更新条件
R updateValidationResult = validateUserUpdate(userUpdateDto.getId());
if (updateValidationResult.getCode() != 0) {
return updateValidationResult;
}
// 4. 构建更新实体并保存
User updateUser = buildUpdateUserEntity(userUpdateDto);
boolean result = this.updateById(updateUser);
if (result) {
// 5. 处理到期时间延时任务
return R.ok(SUCCESS_UPDATE_MSG);
User updateUser = new User();
BeanUtils.copyProperties(userUpdateDto, updateUser);
if (StrUtil.isNotBlank(userUpdateDto.getPwd())) {
updateUser.setPwd(Md5Util.md5(userUpdateDto.getPwd()));
} else {
return R.err(ERROR_UPDATE_FAILED);
updateUser.setPwd(null); // 不更新密码字段
}
updateUser.setUpdatedTime(System.currentTimeMillis());
this.updateById(updateUser);
return R.ok();
}
/**
* 删除用户
* 级联删除用户相关的所有数据,包括转发、Gost服务和隧道权限
*
* @param id 用户ID
* @return 删除结果响应
*/
@Override
public R deleteUser(Long id) {
// 1. 验证删除条件
R deleteValidationResult = validateUserDeletion(id);
if (deleteValidationResult.getCode() != 0) {
return deleteValidationResult;
}
try {
// 2. 级联删除用户相关数据
deleteUserRelatedData(id);
statisticsFlowService.remove(new QueryWrapper<StatisticsFlow>().eq("user_id", id));
// 3. 删除用户
boolean result = this.removeById(id);
return result ? R.ok(SUCCESS_DELETE_MSG) : R.err(ERROR_DELETE_FAILED);
} catch (Exception e) {
e.printStackTrace();
return R.err("删除用户时发生错误:" + e.getMessage());
User user = this.getById(id);
if (user == null) return R.err("用户不存在");
if (user.getRoleId() == 0) return R.err("请不要作死");
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>().eq("user_id", id));
for (Forward forward : forwardList) {
forwardService.deleteForward(forward.getId());
}
forwardService.remove(new QueryWrapper<Forward>().eq("user_id", id));
userTunnelService.remove(new QueryWrapper<UserTunnel>().eq("user_id", id));
statisticsFlowService.remove(new QueryWrapper<StatisticsFlow>().eq("user_id", id));
this.removeById(id);
return R.ok();
}
/**
* 获取用户套餐信息
* 包括用户基本信息、隧道权限详情和转发详情
*
* @return 用户套餐信息响应
*/
@Override
public R getUserPackageInfo() {
try {
// 1. 获取当前用户信息
CurrentUserInfo currentUser = getCurrentUserInfo();
if (currentUser.isHasError()) {
return R.err(currentUser.getErrorMessage());
}
// 2. 构建套餐信息
UserPackageDto packageDto = buildUserPackageDto(currentUser);
return R.ok(packageDto);
} catch (Exception e) {
e.printStackTrace();
return R.err(ERROR_GET_PACKAGE_INFO_FAILED);
}
Integer userId = JwtUtil.getUserIdFromToken();
User user = this.getById(userId);
if (user == null) return R.err("用户不存在");
UserPackageDto.UserInfoDto userInfo = buildUserInfoDto(user);
List<UserPackageDto.UserTunnelDetailDto> tunnelPermissions = userMapper.getUserTunnelDetails(userId);
List<UserPackageDto.UserForwardDetailDto> forwards = userMapper.getUserForwardDetails(user.getId().intValue());
fillForwardInIpAndPort(forwards);
List<StatisticsFlow> statisticsFlows = getLast24HoursFlowStatistics(user.getId());
UserPackageDto packageDto = new UserPackageDto();
packageDto.setUserInfo(userInfo);
packageDto.setTunnelPermissions(tunnelPermissions);
packageDto.setForwards(forwards);
packageDto.setStatisticsFlows(statisticsFlows);
return R.ok(packageDto);
}
/**
* 修改账号密码
* 验证当前密码、新密码确认、用户名唯一性、更新用户账号密码
*
* @param changePasswordDto 修改账号密码数据传输对象
* @return 修改结果响应
*/
@Override
public R updatePassword(ChangePasswordDto changePasswordDto) {
try {
// 1. 获取当前用户信息
CurrentUserInfo currentUser = getCurrentUserInfo();
if (currentUser.isHasError()) {
return R.err(currentUser.getErrorMessage());
}
// 2. 验证新密码和确认密码是否匹配
if (!changePasswordDto.getNewPassword().equals(changePasswordDto.getConfirmPassword())) {
return R.err(ERROR_PASSWORD_NOT_MATCH);
}
// 3. 验证当前密码是否正确
User user = currentUser.getUser();
String currentPasswordMd5 = Md5Util.md5(changePasswordDto.getCurrentPassword());
if (!user.getPwd().equals(currentPasswordMd5)) {
return R.err(ERROR_CURRENT_PASSWORD_WRONG);
}
// 4. 验证新用户名唯一性(如果与当前用户名不同)
if (!user.getUser().equals(changePasswordDto.getNewUsername())) {
R usernameValidationResult = validateUsernameUniqueness(changePasswordDto.getNewUsername(), user.getId());
if (usernameValidationResult.getCode() != 0) {
return usernameValidationResult;
}
}
// 5. 更新用户名和密码
User updateUser = new User();
updateUser.setId(user.getId());
updateUser.setUser(changePasswordDto.getNewUsername());
updateUser.setPwd(Md5Util.md5(changePasswordDto.getNewPassword()));
updateUser.setUpdatedTime(System.currentTimeMillis());
boolean result = this.updateById(updateUser);
return result ? R.ok("账号密码修改成功") : R.err(ERROR_UPDATE_FAILED);
} catch (Exception e) {
e.printStackTrace();
return R.err("修改账号密码时发生错误:" + e.getMessage());
Integer userId = JwtUtil.getUserIdFromToken();
User user = this.getById(userId);
if (user == null) return R.err("用户不存在");
if (!changePasswordDto.getNewPassword().equals(changePasswordDto.getConfirmPassword())) {
return R.err("新密码和确认密码不匹配");
}
String currentPasswordMd5 = Md5Util.md5(changePasswordDto.getCurrentPassword());
if (!user.getPwd().equals(currentPasswordMd5)) {
return R.err("当前密码错误");
}
if (!user.getUser().equals(changePasswordDto.getNewUsername())) {
user.setPwd(Md5Util.md5(changePasswordDto.getNewPassword()));
int count = this.count(new QueryWrapper<User>().eq("user", changePasswordDto.getNewUsername()).ne("id", user.getId()));
if (count > 0) return R.err("用户名已存在");
}
User updateUser = new User();
updateUser.setId(user.getId());
updateUser.setUser(changePasswordDto.getNewUsername());
updateUser.setPwd(Md5Util.md5(changePasswordDto.getNewPassword()));
updateUser.setUpdatedTime(System.currentTimeMillis());
this.updateById(updateUser);
return R.ok();
}
@Override
public R reset(ResetFlowDto resetFlowDto) {
if (resetFlowDto.getType() == 1){ // 清零账号流量
User user = this.getById(resetFlowDto.getId());
if (user == null) return R.err(ERROR_USER_NOT_FOUND);
if (user == null) return R.err("用户不存在");
user.setInFlow(0L);
user.setOutFlow(0L);
this.updateById(user);
@@ -361,339 +209,6 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
return R.ok();
}
// ========== 私有辅助方法 ==========
/**
* 验证用户登录凭据
*
* @param loginDto 登录数据传输对象
* @return 登录验证结果
*/
private LoginValidationResult validateUserCredentials(LoginDto loginDto) {
User user = this.getOne(new QueryWrapper<User>().eq("user", loginDto.getUsername()));
if (user == null) {
return LoginValidationResult.error(ERROR_LOGIN_CREDENTIALS);
}
if (!user.getPwd().equals(Md5Util.md5(loginDto.getPassword()))) {
return LoginValidationResult.error(ERROR_LOGIN_CREDENTIALS);
}
if (user.getStatus() == USER_STATUS_DISABLED) {
return LoginValidationResult.error(ERROR_ACCOUNT_DISABLED);
}
return LoginValidationResult.success(user);
}
/**
* 检查是否使用默认账号密码
*
* @param username 用户名
* @param password 密码
* @return 是否是默认凭据
*/
private boolean isDefaultCredentials(String username, String password) {
return DEFAULT_USERNAME.equals(username) || DEFAULT_PASSWORD.equals(password);
}
/**
* 验证用户名唯一性
*
* @param username 用户名
* @param excludeUserId 排除的用户ID(用于更新时排除自己)
* @return 验证结果响应
*/
private R validateUsernameUniqueness(String username, Long excludeUserId) {
QueryWrapper<User> queryWrapper = new QueryWrapper<User>().eq("user", username);
if (excludeUserId != null) {
queryWrapper.ne("id", excludeUserId);
}
User existUser = this.getOne(queryWrapper);
if (existUser != null) {
String errorMsg = excludeUserId != null ? ERROR_USERNAME_TAKEN : ERROR_USERNAME_EXISTS;
return R.err(errorMsg);
}
return R.ok();
}
/**
* 构建新用户实体对象
*
* @param userDto 用户创建DTO
* @return 构建完成的用户对象
*/
private User buildNewUserEntity(UserDto userDto) {
User user = new User();
BeanUtils.copyProperties(userDto, user);
// 设置加密密码
user.setPwd(Md5Util.md5(userDto.getPwd()));
// 设置默认属性
user.setStatus(userDto.getStatus() != null ? userDto.getStatus() : USER_STATUS_ACTIVE);
user.setRoleId(USER_ROLE_ID);
// 设置时间戳
long currentTime = System.currentTimeMillis();
user.setCreatedTime(currentTime);
user.setUpdatedTime(currentTime);
return user;
}
/**
* 检查用户是否存在
*
* @param userId 用户ID
* @return 用户是否存在
*/
private boolean isUserExists(Long userId) {
return this.getById(userId) != null;
}
/**
* 构建用户更新实体对象
*
* @param userUpdateDto 用户更新DTO
* @return 构建完成的更新对象
*/
private User buildUpdateUserEntity(UserUpdateDto userUpdateDto) {
User user = new User();
BeanUtils.copyProperties(userUpdateDto, user);
// 处理密码更新
if (StrUtil.isNotBlank(userUpdateDto.getPwd())) {
user.setPwd(Md5Util.md5(userUpdateDto.getPwd()));
} else {
user.setPwd(null); // 不更新密码字段
}
// 设置更新时间
user.setUpdatedTime(System.currentTimeMillis());
return user;
}
/**
* 验证用户删除条件
*
* @param userId 用户ID
* @return 验证结果响应
*/
private R validateUserDeletion(Long userId) {
User user = this.getById(userId);
if (user == null) {
return R.err(ERROR_USER_NOT_FOUND);
}
if (user.getRoleId() == ADMIN_ROLE_ID) {
return R.err(ERROR_CANNOT_DELETE_ADMIN);
}
return R.ok();
}
/**
* 验证用户更新条件
*
* @param userId 用户ID
* @return 验证结果响应
*/
private R validateUserUpdate(Long userId) {
User user = this.getById(userId);
if (user == null) {
return R.err(ERROR_USER_NOT_FOUND);
}
if (user.getRoleId() == ADMIN_ROLE_ID) {
return R.err(ERROR_CANNOT_UPDATE_ADMIN);
}
return R.ok();
}
/**
* 删除用户相关的所有数据
*
* @param userId 用户ID
*/
private void deleteUserRelatedData(Long userId) {
// 1. 删除用户的所有转发和对应的Gost服务
deleteUserForwardsAndGostServices(userId);
// 2. 删除用户隧道权限
deleteUserTunnelPermissions(userId);
}
/**
* 删除用户转发和对应的Gost服务
*
* @param userId 用户ID
*/
private void deleteUserForwardsAndGostServices(Long userId) {
QueryWrapper<Forward> forwardQuery = new QueryWrapper<>();
forwardQuery.eq("user_id", userId);
List<Forward> userForwards = forwardMapper.selectList(forwardQuery);
for (Forward forward : userForwards) {
try {
// 删除Gost服务
deleteGostServicesForForward(forward, userId);
} catch (Exception e) {
// 记录错误但继续删除,避免因为Gost服务删除失败而阻断用户删除
System.err.println("删除用户转发对应的Gost服务失败,转发ID: " + forward.getId() + ", 错误: " + e.getMessage());
}
// 删除数据库中的转发记录
forwardMapper.deleteById(forward.getId());
}
}
/**
* 删除转发对应的Gost服务
*
* @param forward 转发对象
* @param userId 用户ID
*/
private void deleteGostServicesForForward(Forward forward, Long userId) {
Tunnel tunnel = tunnelService.getById(forward.getTunnelId());
if (tunnel == null) return;
Node inNode = nodeService.getNodeById(tunnel.getInNodeId());
if (inNode == null) return;
// 获取用户隧道关系
UserTunnel userTunnel = getUserTunnelRelation(userId, tunnel.getId());
if (userTunnel == null) return;
String serviceName = buildServiceName(forward.getId(), userId, userTunnel.getId());
// 删除主服务
GostUtil.DeleteService(inNode.getId(), serviceName);
// 如果是隧道转发,还需要删除链和远程服务
if (tunnel.getType() == TUNNEL_TYPE_TUNNEL_FORWARD) {
deleteGostTunnelForwardServices(tunnel, serviceName, inNode);
}
}
/**
* 删除隧道转发相关的Gost服务
*
* @param tunnel 隧道对象
* @param serviceName 服务名称
* @param inNode 入口节点
*/
private void deleteGostTunnelForwardServices(Tunnel tunnel, String serviceName, Node inNode) {
Node outNode = nodeService.getNodeById(tunnel.getOutNodeId());
if (outNode != null) {
GostUtil.DeleteChains(inNode.getId(), serviceName);
GostUtil.DeleteRemoteService(outNode.getId(), serviceName);
}
}
/**
* 获取用户隧道关系
*
* @param userId 用户ID
* @param tunnelId 隧道ID
* @return 用户隧道关系对象
*/
private UserTunnel getUserTunnelRelation(Long userId, Long tunnelId) {
return userTunnelService.getOne(new QueryWrapper<UserTunnel>()
.eq("user_id", userId)
.eq("tunnel_id", tunnelId));
}
/**
* 构建服务名称
*
* @param forwardId 转发ID
* @param userId 用户ID
* @param userTunnelId 用户隧道ID
* @return 服务名称
*/
private String buildServiceName(Long forwardId, Long userId, Integer userTunnelId) {
return forwardId + "_" + userId + "_" + userTunnelId;
}
/**
* 删除用户隧道权限
*
* @param userId 用户ID
*/
private void deleteUserTunnelPermissions(Long userId) {
QueryWrapper<UserTunnel> userTunnelQuery = new QueryWrapper<>();
userTunnelQuery.eq("user_id", userId);
userTunnelMapper.delete(userTunnelQuery);
}
/**
* 获取当前用户信息
*
* @return 当前用户信息结果
*/
private CurrentUserInfo getCurrentUserInfo() {
Integer userId = JwtUtil.getUserIdFromToken();
Integer roleId = JwtUtil.getRoleIdFromToken();
if (userId == null) {
return CurrentUserInfo.error(ERROR_USER_NOT_LOGGED_IN);
}
User user = this.getById(userId);
if (user == null) {
return CurrentUserInfo.error(ERROR_USER_NOT_FOUND);
}
return CurrentUserInfo.success(user, roleId);
}
/**
* 构建用户套餐信息DTO
*
* @param currentUser 当前用户信息
* @return 用户套餐信息DTO
*/
private UserPackageDto buildUserPackageDto(CurrentUserInfo currentUser) {
User user = currentUser.getUser();
Integer roleId = currentUser.getRoleId();
// 1. 构造用户基本信息
UserPackageDto.UserInfoDto userInfo = buildUserInfoDto(user);
// 2. 获取隧道权限详情
List<UserPackageDto.UserTunnelDetailDto> tunnelPermissions = getTunnelPermissions(user.getId());
// 3. 获取转发详情
List<UserPackageDto.UserForwardDetailDto> forwards = userMapper.getUserForwardDetails(user.getId().intValue());
// 4. 查询最近24小时流量信息,没有的补0
List<StatisticsFlow> statisticsFlows = getLast24HoursFlowStatistics(user.getId());
// 5. 构造返回结果
UserPackageDto packageDto = new UserPackageDto();
packageDto.setUserInfo(userInfo);
packageDto.setTunnelPermissions(tunnelPermissions);
packageDto.setForwards(forwards);
packageDto.setStatisticsFlows(statisticsFlows);
return packageDto;
}
/**
* 构建用户基本信息DTO
*
* @param user 用户对象
* @return 用户基本信息DTO
*/
private UserPackageDto.UserInfoDto buildUserInfoDto(User user) {
UserPackageDto.UserInfoDto userInfo = new UserPackageDto.UserInfoDto();
userInfo.setId(user.getId());
@@ -710,134 +225,102 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
return userInfo;
}
/**
* 获取隧道权限详情
*
* @param userId 用户ID
* @return 隧道权限详情列表
*/
private List<UserPackageDto.UserTunnelDetailDto> getTunnelPermissions(Long userId) {
return userMapper.getUserTunnelDetails(userId.intValue());
}
/**
* 获取用户最近24小时的流量统计数据,没有数据的时间点补0
*
* @param userId 用户ID
* @return 最近24小时流量统计列表
*/
private List<StatisticsFlow> getLast24HoursFlowStatistics(Long userId) {
// 按ID倒序查最近24条记录(ID越大越新,时间就是23:00, 22:00, 21:00...这样倒序)
List<StatisticsFlow> recentFlows = statisticsFlowService.list(
new QueryWrapper<StatisticsFlow>()
.eq("user_id", userId)
.orderByDesc("id")
.last("LIMIT 24")
);
List<StatisticsFlow> result = new ArrayList<>(recentFlows);
// 如果查出来的记录不足24条,需要补0和对应的时间
if (result.size() < 24) {
// 获取最早记录的时间,继续往前推
int startHour = getCurrentHour();
int startHour = java.time.LocalDateTime.now().getHour();
if (!result.isEmpty()) {
// 从最后一条记录的时间继续往前推
String lastTime = result.get(result.size() - 1).getTime();
String lastTime = result.getLast().getTime();
startHour = parseHour(lastTime) - 1;
}
// 补0到24条
while (result.size() < 24) {
if (startHour < 0) startHour = 23; // 跨天处理
if (startHour < 0) startHour = 23;
StatisticsFlow emptyFlow = new StatisticsFlow();
emptyFlow.setUserId(userId);
emptyFlow.setFlow(0L);
emptyFlow.setTotalFlow(0L);
emptyFlow.setTime(String.format("%02d:00", startHour));
result.add(emptyFlow);
startHour--;
}
}
log.info("用户 {} 获取到 {} 条实际记录,补齐为 {} 条24小时记录", userId, recentFlows.size(), result.size());
return result;
}
/**
* 获取当前小时(0-23)
*/
private int getCurrentHour() {
private int parseHour(String timeStr) {
if (timeStr != null && timeStr.contains(":")) {
return Integer.parseInt(timeStr.split(":")[0]);
}
return java.time.LocalDateTime.now().getHour();
}
/**
* 解析时间字符串获取小时数
*/
private int parseHour(String timeStr) {
try {
if (timeStr != null && timeStr.contains(":")) {
return Integer.parseInt(timeStr.split(":")[0]);
private void fillForwardInIpAndPort(List<UserPackageDto.UserForwardDetailDto> forwards) {
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();
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());
}
}
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();
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));
}
} catch (Exception e) {
// 解析失败,返回当前小时
}
return getCurrentHour();
}
// ========== 内部数据类 ==========
/**
* 登录验证结果封装类
*/
@Data
private static class LoginValidationResult {
private final boolean hasError;
private final String errorMessage;
private final User user;
private LoginValidationResult(boolean hasError, String errorMessage, User user) {
this.hasError = hasError;
this.errorMessage = errorMessage;
this.user = user;
}
public static LoginValidationResult success(User user) {
return new LoginValidationResult(false, null, user);
}
public static LoginValidationResult error(String errorMessage) {
return new LoginValidationResult(true, errorMessage, null);
}
}
/**
* 当前用户信息封装类
*/
@Data
private static class CurrentUserInfo {
private final boolean hasError;
private final String errorMessage;
private final User user;
private final Integer roleId;
private CurrentUserInfo(boolean hasError, String errorMessage, User user, Integer roleId) {
this.hasError = hasError;
this.errorMessage = errorMessage;
this.user = user;
this.roleId = roleId;
}
public static CurrentUserInfo success(User user, Integer roleId) {
return new CurrentUserInfo(false, null, user, roleId);
}
public static CurrentUserInfo error(String errorMessage) {
return new CurrentUserInfo(true, errorMessage, null, null);
}
}
}
@@ -1,447 +1,181 @@
package com.admin.service.impl;
import com.admin.common.dto.UserTunnelDto;
import com.admin.common.dto.UserTunnelQueryDto;
import com.admin.common.dto.UserTunnelUpdateDto;
import com.admin.common.dto.UserTunnelWithDetailDto;
import com.admin.common.lang.R;
import com.admin.entity.UserTunnel;
import com.admin.mapper.TunnelMapper;
import com.admin.mapper.UserTunnelMapper;
import com.admin.service.TunnelService;
import com.admin.service.UserTunnelService;
import com.admin.service.ForwardService;
import com.admin.service.NodeService;
import com.admin.common.utils.GostUtil;
import com.admin.entity.Forward;
import com.admin.entity.Tunnel;
import com.admin.entity.Node;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.List;
import java.util.Map;
/**
* <p>
* 用户隧道权限服务实现类
* 提供用户隧道权限的分配、查询、更新和删除功能
* 支持流量限制、数量限制、过期时间和限速规则的管理
* </p>
*
* @author QAQ
* @since 2025-06-03
*/
@Service
public class UserTunnelServiceImpl extends ServiceImpl<UserTunnelMapper, UserTunnel> implements UserTunnelService {
// ========== 常量定义 ==========
/** 成功响应消息 */
private static final String SUCCESS_ASSIGN_MSG = "用户隧道权限分配成功";
private static final String SUCCESS_REMOVE_MSG = "用户隧道权限删除成功";
private static final String SUCCESS_UPDATE_FLOW_MSG = "用户隧道流量限制更新成功";
private static final String SUCCESS_UPDATE_MSG = "用户隧道权限更新成功";
/** 错误响应消息 */
private static final String ERROR_ASSIGN_FAILED = "用户隧道权限分配失败";
private static final String ERROR_PERMISSION_EXISTS = "该用户已拥有此隧道权限";
private static final String ERROR_PERMISSION_NOT_FOUND = "未找到对应的用户隧道权限记录";
private static final String ERROR_USER_TUNNEL_NOT_EXISTS = "用户隧道权限不存在";
private static final String ERROR_NOT_EXISTS = "不存在";
private static final String ERROR_UPDATE_FAILED = "用户隧道权限更新失败";
// ========== 依赖注入 ==========
@Autowired
@Lazy
private ForwardService forwardService;
@Autowired
@Lazy
private TunnelService tunnelService;
@Autowired
private NodeService nodeService;
// ========== 公共接口实现 ==========
/**
* 分配用户隧道权限
* 检查权限是否已存在,避免重复分配
*
* @param userTunnelDto 用户隧道权限分配数据传输对象
* @return 分配结果响应
*/
@Override
public R assignUserTunnel(UserTunnelDto userTunnelDto) {
// 1. 检查权限是否已存在
if (isUserTunnelPermissionExists(userTunnelDto.getUserId(), userTunnelDto.getTunnelId())) {
return R.err(ERROR_PERMISSION_EXISTS);
}
// 2. 创建用户隧道权限实体并保存
UserTunnel userTunnel = buildUserTunnelEntity(userTunnelDto);
// 设置默认状态为启用
userTunnel.setStatus(1);
boolean success = this.save(userTunnel);
if (success) {
return R.ok(SUCCESS_ASSIGN_MSG);
}
return R.err(ERROR_ASSIGN_FAILED);
}
/**
* 获取用户隧道权限列表
* 通过连表查询获取用户隧道权限及隧道详细信息
*
* @param queryDto 用户隧道权限查询数据传输对象
* @return 用户隧道权限详情列表响应
*/
@Override
public R getUserTunnelList(UserTunnelQueryDto queryDto) {
List<UserTunnelWithDetailDto> userTunnelDetails = getUserTunnelDetailsFromDatabase(queryDto.getUserId());
return R.ok(userTunnelDetails);
}
/**
* 删除用户隧道权限
*
* @param id 用户隧道权限ID
* @return 删除结果响应
*/
@Override
public R removeUserTunnel(Integer id) {
// 1. 获取用户隧道权限信息
UserTunnel userTunnel = this.getById(id);
if (userTunnel == null) {
return R.err(ERROR_PERMISSION_NOT_FOUND);
}
// 2. 删除该用户在该隧道下的所有转发
try {
removeUserTunnelForwards(userTunnel.getUserId(), userTunnel.getTunnelId());
} catch (Exception e) {
// 转发删除失败,记录日志但不阻止权限删除
}
// 4. 删除用户隧道权限记录
boolean success = this.removeById(id);
return success ? R.ok(SUCCESS_REMOVE_MSG) : R.err(ERROR_PERMISSION_NOT_FOUND);
}
/**
* 更新用户隧道权限
* 支持更新流量限制、数量限制、流量重置时间、过期时间和限速规则
*
* @param updateDto 用户隧道权限更新数据传输对象
* @return 更新结果响应
*/
@Override
public R updateUserTunnel(UserTunnelUpdateDto updateDto) {
// 1. 验证用户隧道权限是否存在
UserTunnel existingUserTunnel = this.getById(updateDto.getId());
if (existingUserTunnel == null) {
return R.err(ERROR_USER_TUNNEL_NOT_EXISTS);
}
// 2. 检查是否更新了限速规则
boolean speedChanged = hasSpeedChanged(existingUserTunnel.getSpeedId(), updateDto.getSpeedId());
// 3. 更新用户隧道权限属性
updateUserTunnelProperties(existingUserTunnel, updateDto);
// 4. 保存更新
boolean success = this.updateById(existingUserTunnel);
if (success) {
// 6. 如果限速规则发生变化,更新该用户隧道下的所有转发
if (speedChanged) {
updateUserTunnelForwardsSpeed(existingUserTunnel.getUserId(), existingUserTunnel.getTunnelId(), updateDto.getSpeedId());
}
return R.ok(SUCCESS_UPDATE_MSG);
}
return R.err(ERROR_UPDATE_FAILED);
}
// ========== 私有辅助方法 ==========
/**
* 检查用户隧道权限是否已存在
*
* @param userId 用户ID
* @param tunnelId 隧道ID
* @return 权限是否已存在
*/
private boolean isUserTunnelPermissionExists(Integer userId, Integer tunnelId) {
QueryWrapper<UserTunnel> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("user_id", userId).eq("tunnel_id", tunnelId);
UserTunnel existingUserTunnel = this.getOne(queryWrapper);
return existingUserTunnel != null;
}
/**
* 构建用户隧道权限实体对象
*
* @param userTunnelDto 用户隧道权限DTO
* @return 构建完成的用户隧道权限对象
*/
private UserTunnel buildUserTunnelEntity(UserTunnelDto userTunnelDto) {
UserTunnel userTunnel = new UserTunnel();
BeanUtils.copyProperties(userTunnelDto, userTunnel);
return userTunnel;
}
/**
* 从数据库获取用户隧道权限详情
*
* @param userId 用户ID
* @return 用户隧道权限详情列表
*/
private List<UserTunnelWithDetailDto> getUserTunnelDetailsFromDatabase(Integer userId) {
return this.baseMapper.getUserTunnelWithDetails(userId);
}
/**
* 更新用户隧道权限属性
*
* @param existingUserTunnel 现有的用户隧道权限对象
* @param updateDto 更新数据传输对象
*/
private void updateUserTunnelProperties(UserTunnel existingUserTunnel, UserTunnelUpdateDto updateDto) {
// 更新基本属性
existingUserTunnel.setFlow(updateDto.getFlow());
existingUserTunnel.setNum(updateDto.getNum());
// 更新可选属性(仅在非空时更新)
updateOptionalProperty(existingUserTunnel::setFlowResetTime, updateDto.getFlowResetTime());
updateOptionalProperty(existingUserTunnel::setExpTime, updateDto.getExpTime());
updateOptionalProperty(existingUserTunnel::setStatus, updateDto.getStatus());
// 更新限速规则ID(允许设置为null,表示不限速)
existingUserTunnel.setSpeedId(updateDto.getSpeedId());
}
/**
* 更新可选属性(仅在值非空时更新)
*
* @param setter 属性设置方法
* @param value 属性值
* @param <T> 属性类型
*/
private <T> void updateOptionalProperty(java.util.function.Consumer<T> setter, T value) {
if (value != null) {
setter.accept(value);
}
}
/**
* 删除用户在指定隧道下的所有转发
*
* @param userId 用户ID
* @param tunnelId 隧道ID
*/
private void removeUserTunnelForwards(Integer userId, Integer tunnelId) {
// 查询该用户在该隧道下的所有转发
QueryWrapper<Forward> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("user_id", userId).eq("tunnel_id", tunnelId);
List<Forward> userTunnelForwards = forwardService.list(queryWrapper);
if (!userTunnelForwards.isEmpty()) {
// 获取用户隧道权限信息,用于构建服务名称
UserTunnel userTunnel = getUserTunnelByUserAndTunnel(userId, tunnelId);
for (Forward forward : userTunnelForwards) {
try {
// 先调用GostUtil删除/停止服务
stopForwardService(forward, userId, userTunnel != null ? userTunnel.getId() : 0);
// 然后删除数据库记录
forwardService.removeById(forward.getId());
} catch (Exception e) {
// 单个转发删除失败,记录错误但继续处理其他转发
}
}
}
}
/**
* 删除转发服务(按创建的反向顺序删除:主服务 -> 远端服务 -> 转发链)
*
* @param forward 转发对象
* @param userId 用户ID
* @param userTunnelId 用户隧道ID
*/
private void stopForwardService(Forward forward, Integer userId, Integer userTunnelId) {
try {
Tunnel tunnel = tunnelService.getById(forward.getTunnelId());
if (tunnel == null) {
return;
}
Node inNode = nodeService.getById(tunnel.getInNodeId());
Node outNode = nodeService.getById(tunnel.getOutNodeId());
String serviceName = buildServiceName(forward.getId(), Long.valueOf(userId), userTunnelId);
// 1. 先删除主服务
if (inNode != null) {
try {
GostUtil.DeleteService(inNode.getId(), serviceName);
} catch (Exception e) {
// 主服务删除失败,记录但继续
}
}
// 2. 如果是隧道转发,删除远端服务
if (tunnel.getType() == 1 && outNode != null && !outNode.getId().equals(inNode != null ? inNode.getId() : null)) {
try {
GostUtil.DeleteRemoteService(outNode.getId(), serviceName);
} catch (Exception e) {
// 远端服务删除失败,记录但继续
}
}
// 3. 如果是隧道转发,最后删除转发链
if (tunnel.getType() == 1 && inNode != null) {
try {
GostUtil.DeleteChains(inNode.getId(), serviceName);
} catch (Exception e) {
// 转发链删除失败,记录但继续
}
}
} catch (Exception e) {
// 服务删除失败,记录错误
throw new RuntimeException("删除转发服务失败,转发ID:" + forward.getId() + ",错误:" + e.getMessage(), e);
}
}
/**
* 根据用户ID和隧道ID获取用户隧道权限
*
* @param userId 用户ID
* @param tunnelId 隧道ID
* @return 用户隧道权限对象
*/
private UserTunnel getUserTunnelByUserAndTunnel(Integer userId, Integer tunnelId) {
try {
QueryWrapper<UserTunnel> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("user_id", userId).eq("tunnel_id", tunnelId);
return this.getOne(queryWrapper);
} catch (Exception e) {
return null;
}
}
/**
* 构建服务名称
*
* @param forwardId 转发ID
* @param userId 用户ID
* @param userTunnelId 用户隧道ID
* @return 服务名称
*/
private String buildServiceName(Long forwardId, Long userId, Integer userTunnelId) {
return forwardId + "_" + userId + "_" + userTunnelId;
}
/**
* 检查用户隧道是否启用且有到期时间
*
* @param userTunnel 用户隧道对象
* @return 是否启用且有到期时间
*/
private boolean isEnabledAndHasExpTime(UserTunnel userTunnel) {
return userTunnel.getStatus() != null && userTunnel.getStatus() == 1
&& userTunnel.getExpTime() != null;
}
/**
* 检查限速规则是否发生变化
*
* @param oldSpeedId 原始限速规则ID
* @param newSpeedId 新的限速规则ID
* @return 限速规则是否发生变化
*/
private boolean hasSpeedChanged(Integer oldSpeedId, Integer newSpeedId) {
if (oldSpeedId == null && newSpeedId == null) {
return false;
}
if (oldSpeedId == null || newSpeedId == null) {
return true;
}
return !oldSpeedId.equals(newSpeedId);
}
/**
* 更新用户隧道下所有转发的限速规则
* 管理员操作,不需要权限检查,直接查出该用户在该隧道下的所有转发并应用新的限速
*
* @param userId 用户ID
* @param tunnelId 隧道ID
* @param speedId 新的限速规则ID
*/
private void updateUserTunnelForwardsSpeed(Integer userId, Integer tunnelId, Integer speedId) {
// 1. 查询该用户在该隧道下的所有转发
QueryWrapper<Forward> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("user_id", userId).eq("tunnel_id", tunnelId);
List<Forward> userTunnelForwards = forwardService.list(queryWrapper);
if (userTunnelForwards.isEmpty()) {
return;
}
// 2. 获取隧道信息
Tunnel tunnel = tunnelService.getById(tunnelId);
if (tunnel == null) {
return;
}
// 3. 获取用户隧道权限信息
UserTunnel userTunnel = getUserTunnelByUserAndTunnel(userId, tunnelId);
if (userTunnel == null) {
return;
}
// 4. 获取入口节点信息
Node inNode = nodeService.getById(tunnel.getInNodeId());
if (inNode == null) {
return;
}
// 5. 批量更新该用户在该隧道下所有转发的限速配置(只更新入口节点)
for (Forward forward : userTunnelForwards) {
String serviceName = buildServiceName(forward.getId(), Long.valueOf(userId), userTunnel.getId());
String interfaceName = null;
// 创建主服务
if (tunnel.getType() != 2) { // 不是隧道转发服务才会存在网络接口
interfaceName = forward.getInterfaceName();
}
// 6. 更新入口节点的主服务限速配置(使用批量UpdateService接口)
GostUtil.UpdateService(inNode.getId(), serviceName, forward.getInPort(), speedId, forward.getRemoteAddr(), tunnel.getType(), tunnel, forward.getStrategy(), interfaceName);
}
}
}
package com.admin.service.impl;
import com.admin.common.dto.*;
import com.admin.common.lang.R;
import com.admin.entity.User;
import com.admin.entity.UserTunnel;
import com.admin.mapper.UserTunnelMapper;
import com.admin.service.UserService;
import com.admin.service.UserTunnelService;
import com.admin.service.ForwardService;
import com.admin.entity.Forward;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.beans.BeanUtils;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class UserTunnelServiceImpl extends ServiceImpl<UserTunnelMapper, UserTunnel> implements UserTunnelService {
@Resource
@Lazy
private ForwardService forwardService;
@Resource
@Lazy
private UserService userService;
@Override
public R assignUserTunnel(UserTunnelDto userTunnelDto) {
int count = this.count(new QueryWrapper<UserTunnel>()
.eq("user_id", userTunnelDto.getUserId())
.eq("tunnel_id", userTunnelDto.getTunnelId()));
if (count > 0) return R.err("该用户已拥有此隧道权限");
User user = userService.getById(userTunnelDto.getUserId());
if (user == null) return R.err("用户不存在");
UserTunnel userTunnel = new UserTunnel();
userTunnel.setUserId(userTunnelDto.getUserId());
userTunnel.setTunnelId(userTunnelDto.getTunnelId());
userTunnel.setSpeedId(userTunnelDto.getSpeedId());
userTunnel.setStatus(1);
userTunnel.setInFlow(0L);
userTunnel.setOutFlow(0L);
userTunnel.setFlow(userTunnelDto.getFlow() != null ? userTunnelDto.getFlow() : user.getFlow());
userTunnel.setNum(userTunnelDto.getNum() != null ? userTunnelDto.getNum() : user.getNum());
userTunnel.setFlowResetTime(userTunnelDto.getFlowResetTime() != null ? userTunnelDto.getFlowResetTime() : user.getFlowResetTime());
userTunnel.setExpTime(userTunnelDto.getExpTime() != null ? userTunnelDto.getExpTime() : user.getExpTime());
this.save(userTunnel);
return R.ok();
}
@Override
public R batchAssignUserTunnel(UserTunnelBatchAssignDto batchAssignDto) {
User user = userService.getById(batchAssignDto.getUserId());
if (user == null) return R.err("用户不存在");
Map<Integer, UserTunnelBatchAssignDto.TunnelAssignItem> uniqueTunnels = new LinkedHashMap<>();
for (UserTunnelBatchAssignDto.TunnelAssignItem item : batchAssignDto.getTunnels()) {
uniqueTunnels.putIfAbsent(item.getTunnelId(), item);
}
Set<Integer> existingTunnelIds = this.list(
new QueryWrapper<UserTunnel>()
.eq("user_id", batchAssignDto.getUserId())
.in("tunnel_id", uniqueTunnels.keySet())
).stream().map(UserTunnel::getTunnelId).collect(Collectors.toSet());
List<UserTunnel> toSave = new ArrayList<>();
List<Integer> skippedIds = new ArrayList<>();
for (UserTunnelBatchAssignDto.TunnelAssignItem item : uniqueTunnels.values()) {
if (existingTunnelIds.contains(item.getTunnelId())) {
skippedIds.add(item.getTunnelId());
continue;
}
UserTunnel ut = new UserTunnel();
ut.setUserId(batchAssignDto.getUserId());
ut.setTunnelId(item.getTunnelId());
ut.setSpeedId(item.getSpeedId());
ut.setStatus(1);
ut.setInFlow(0L);
ut.setOutFlow(0L);
ut.setFlow(user.getFlow());
ut.setNum(user.getNum());
ut.setFlowResetTime(user.getFlowResetTime());
ut.setExpTime(user.getExpTime());
toSave.add(ut);
}
if (toSave.isEmpty()) {
return R.err("所选隧道用户均已拥有权限");
}
this.saveBatch(toSave);
if (!skippedIds.isEmpty()) {
return R.ok("成功分配 " + toSave.size() + " 个隧道,跳过 " + skippedIds.size() + " 个已有权限的隧道");
}
return R.ok();
}
@Override
public R getUserTunnelList(UserTunnelQueryDto queryDto) {
List<UserTunnelWithDetailDto> userTunnelWithDetails = this.baseMapper.getUserTunnelWithDetails(queryDto.getUserId());
return R.ok(userTunnelWithDetails);
}
@Override
public R removeUserTunnel(Integer id) {
UserTunnel userTunnel = this.getById(id);
if (userTunnel == null) return R.err("未找到对应的用户隧道权限记录");
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>()
.eq("user_id", userTunnel.getUserId())
.eq("tunnel_id", userTunnel.getTunnelId()));
for (Forward forward : forwardList) {
forwardService.deleteForward(forward.getId());
}
this.removeById(id);
return R.ok();
}
@Override
public R updateUserTunnel(UserTunnelUpdateDto updateDto) {
UserTunnel userTunnel = this.getById(updateDto.getId());
if (userTunnel == null) return R.err("隧道不存在");
boolean speedChanged = hasSpeedChanged(userTunnel.getSpeedId(), updateDto.getSpeedId());
userTunnel.setFlow(updateDto.getFlow());
userTunnel.setNum(updateDto.getNum());
updateOptionalProperty(userTunnel::setFlowResetTime, updateDto.getFlowResetTime());
updateOptionalProperty(userTunnel::setExpTime, updateDto.getExpTime());
updateOptionalProperty(userTunnel::setStatus, updateDto.getStatus());
userTunnel.setSpeedId(updateDto.getSpeedId());
this.updateById(userTunnel);
if (speedChanged) {
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>()
.eq("user_id", userTunnel.getUserId())
.eq("tunnel_id", userTunnel.getTunnelId()));
for (Forward forward : forwardList) {
ForwardUpdateDto forwardUpdateDto = new ForwardUpdateDto();
forwardUpdateDto.setId(forward.getId());
forwardUpdateDto.setUserId(forward.getUserId());
forwardUpdateDto.setName(forward.getName());
forwardUpdateDto.setRemoteAddr(forward.getRemoteAddr());
forwardUpdateDto.setStrategy(forward.getStrategy());
forwardService.updateForward(forwardUpdateDto);
}
}
return R.ok();
}
private <T> void updateOptionalProperty(java.util.function.Consumer<T> setter, T value) {
if (value != null) {
setter.accept(value);
}
}
private boolean hasSpeedChanged(Integer oldSpeedId, Integer newSpeedId) {
if (oldSpeedId == null && newSpeedId == null) {
return false;
}
if (oldSpeedId == null || newSpeedId == null) {
return true;
}
return !oldSpeedId.equals(newSpeedId);
}
}
@@ -24,24 +24,7 @@ import java.util.Map;
@Service
public class ViteConfigServiceImpl extends ServiceImpl<ViteConfigMapper, ViteConfig> implements ViteConfigService {
// ========== 常量定义 ==========
/** 成功响应消息 */
private static final String SUCCESS_UPDATE_MSG = "配置更新成功";
/** 错误响应消息 */
private static final String ERROR_UPDATE_MSG = "配置更新失败";
private static final String ERROR_CONFIG_NOT_FOUND = "配置不存在";
private static final String ERROR_CONFIG_NAME_REQUIRED = "配置名称不能为空";
private static final String ERROR_CONFIG_VALUE_REQUIRED = "配置值不能为空";
// ========== 公共接口实现 ==========
/**
* 获取所有网站配置
*
* @return 包含所有配置的Map
*/
@Override
public R getConfigs() {
List<ViteConfig> configList = this.list();
@@ -54,89 +37,48 @@ public class ViteConfigServiceImpl extends ServiceImpl<ViteConfigMapper, ViteCon
return R.ok(configMap);
}
/**
* 根据配置名称获取配置值
*
* @param name 配置名称
* @return 配置响应对象
*/
@Override
public R getConfigByName(String name) {
if (!StringUtils.hasText(name)) {
return R.err(ERROR_CONFIG_NAME_REQUIRED);
}
if (!StringUtils.hasText(name)) return R.err("配置名称不能为空");
QueryWrapper<ViteConfig> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("name", name);
ViteConfig config = this.getOne(queryWrapper);
if (config == null) {
return R.err(ERROR_CONFIG_NOT_FOUND);
}
if (config == null) return R.err("配置不存在");
return R.ok(config);
}
/**
* 批量更新网站配置
*
* @param configMap 配置Map
* @return 更新结果响应
*/
@Override
public R updateConfigs(Map<String, String> configMap) {
if (configMap == null || configMap.isEmpty()) {
return R.err("配置数据不能为空");
}
if (configMap == null || configMap.isEmpty()) return R.err("配置数据不能为空");
try {
for (Map.Entry<String, String> entry : configMap.entrySet()) {
String name = entry.getKey();
String value = entry.getValue();
if (!StringUtils.hasText(name)) {
continue; // 跳过无效的配置名
}
updateOrCreateConfig(name, value);
for (Map.Entry<String, String> entry : configMap.entrySet()) {
String name = entry.getKey();
String value = entry.getValue();
if (!StringUtils.hasText(name)) {
continue;
}
return R.ok(SUCCESS_UPDATE_MSG);
} catch (Exception e) {
return R.err(ERROR_UPDATE_MSG + ": " + e.getMessage());
updateOrCreateConfig(name, value);
}
return R.ok();
}
/**
* 更新单个配置项
*
* @param name 配置名
* @param value 配置值
* @return 更新结果响应
*/
@Override
public R updateConfig(String name, String value) {
// 1. 验证必填字段
if (!StringUtils.hasText(name)) {
return R.err(ERROR_CONFIG_NAME_REQUIRED);
}
if (!StringUtils.hasText(value)) {
return R.err(ERROR_CONFIG_VALUE_REQUIRED);
}
try {
updateOrCreateConfig(name, value);
return R.ok(SUCCESS_UPDATE_MSG);
} catch (Exception e) {
return R.err(ERROR_UPDATE_MSG + ": " + e.getMessage());
}
if (!StringUtils.hasText(name)) return R.err("配置名称不能为空");
if (!StringUtils.hasText(value)) return R.err("配置值不能为空");
updateOrCreateConfig(name, value);
return R.ok();
}
// ========== 私有辅助方法 ==========
/**
* 更新或创建配置项
* 如果配置存在则更新,不存在则创建
*/
private void updateOrCreateConfig(String name, String value) {
QueryWrapper<ViteConfig> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("name", name);
@@ -1,9 +1,7 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://${DB_HOST}:3306/${DB_NAME}?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
username: ${DB_USER}
password: ${DB_PASSWORD}
driver-class-name: org.sqlite.JDBC
url: jdbc:sqlite:${DB_PATH:/app/data/gost.db}
hikari:
max-lifetime: 500000
connection-timeout: 30000
@@ -12,7 +10,11 @@ spring:
minimum-idle: 5
pool-name: HikariCP
auto-commit: true
connection-test-query: SELECT 1
sql:
init:
mode: always
platform: sqlite
continue-on-error: false
servlet:
multipart:
max-file-size: 50MB
@@ -0,0 +1,7 @@
INSERT OR IGNORE INTO user (id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES (1, 'admin_user', '3c85cdebade1c51cf64ca9f3c09d182d', 0, 2727251700000, 99999, 0, 0, 1, 99999, 1748914865000, 1754011744252, 1);
INSERT OR IGNORE INTO vite_config (id, name, value, time)
VALUES (1, 'app_name', 'flux', 1755147963000);
@@ -20,7 +20,7 @@
<Prudent>true</Prudent>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<FileNamePattern>${FILE_PATH}</FileNamePattern>
<maxHistory>30</maxHistory>
<maxHistory>7</maxHistory>
</rollingPolicy>
<layout class="ch.qos.logback.classic.PatternLayout">
<Pattern>
@@ -9,8 +9,6 @@
f.user_id AS userId,
f.name,
f.tunnel_id AS tunnelId,
f.in_port AS inPort,
f.out_port AS outPort,
f.remote_addr AS remoteAddr,
f.status,
f.created_time AS createdTime,
@@ -20,13 +18,9 @@
f.strategy AS strategy,
f.out_flow AS outFlow,
f.inx AS inx,
f.interface_name AS interfaceName,
t.name AS tunnelName,
t.in_ip AS inIp,
t.out_ip AS outIp,
t.type,
t.protocol
FROM
t.type
FROM
forward f
LEFT JOIN
tunnel t ON f.tunnel_id = t.id
@@ -41,8 +35,6 @@
f.user_id AS userId,
f.name,
f.tunnel_id AS tunnelId,
f.in_port AS inPort,
f.out_port AS outPort,
f.remote_addr AS remoteAddr,
f.status,
f.created_time AS createdTime,
@@ -52,13 +44,9 @@
f.out_flow AS outFlow,
f.strategy AS strategy,
f.inx AS inx,
f.interface_name AS interfaceName,
t.name AS tunnelName,
t.in_ip AS inIp,
t.out_ip AS outIp,
t.type,
t.protocol
FROM
t.type
FROM
forward f
LEFT JOIN
tunnel t ON f.tunnel_id = t.id
@@ -34,7 +34,6 @@
f.tunnel_id as tunnelId,
t.name as tunnelName,
t.in_ip as inIp,
f.in_port as inPort,
f.remote_addr as remoteAddr,
f.in_flow as inFlow,
f.out_flow as outFlow,
@@ -19,7 +19,6 @@
t.name as tunnelName,
t.flow as tunnelFlow,
t.in_ip as inIp,
t.out_ip as outIp,
t.type,
t.protocol,
sl.name as speedLimitName,

Some files were not shown because too many files have changed in this diff Show More