Compare commits

...

83 Commits

Author SHA1 Message Date
sagit e122e7460d Merge pull request #23 from Sagit-chu/opencode/crisp-cabin
fix(gost): remove single-node optimization to enable forwarder failover
2026-02-05 10:18:00 +08:00
root 7c898154b3 fix(gost): remove single-node optimization to enable forwarder failover
The single-node bypass in hop.Select() was preventing FailFilter from
being applied when retry excludes reduced available nodes to one.
This caused failed forwarder nodes to keep being selected instead of
failing over to healthy alternatives.

FailFilter's built-in safety guard (len <= 1 returns as-is) ensures
the last remaining node is never permanently blocked.
2026-02-05 02:14:37 +00:00
sagit 1d19d68019 Merge pull request #22 from Sagit-chu/feat/failover-debug-logging
feat(gost): add debug logging for failover mechanism analysis
2026-02-05 09:09:09 +08:00
root 09c58e2298 feat(gost): add debug logging for failover mechanism analysis
Add debug logs to trace failover behavior:
- FailFilter.Filter(): log node name, fail count, maxFails, timeSince, failTimeout
- hop.Select(): log excludeNodes list, node selection results
- handler retry loop: log maxRetries, selected nodes, dial failures

This helps diagnose issues where failover between multiple target nodes
is not working as expected.
2026-02-05 01:06:56 +00:00
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 15231 additions and 11492 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 ./...
```
+76 -36
View File
@@ -176,51 +176,91 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand
}
}
target := &chain.Node{}
if h.hop != nil {
target = h.hop.Select(ctx,
hop.ProtocolSelectOption(proto),
)
}
if target == nil {
err := errors.New("node not available")
return err
// Determine max retry attempts
maxRetries := h.md.maxRetries
if maxRetries <= 0 {
// Default: try all available nodes
if nl, ok := h.hop.(hop.NodeList); ok {
maxRetries = len(nl.Nodes())
}
if maxRetries <= 0 {
maxRetries = 1
}
}
addr := target.Addr
if opts := target.Options(); opts != nil {
switch opts.Network {
case "unix":
network = opts.Network
default:
if _, _, err := net.SplitHostPort(addr); err != nil {
addr += ":0"
var triedNodes []string
var lastErr error
var cc net.Conn
h.options.Logger.Debugf("[handler.retry] starting retry loop: maxRetries=%d", maxRetries)
for attempt := 0; attempt < maxRetries; attempt++ {
// Select a target node, excluding previously tried nodes
selectCtx := ctxvalue.ContextWithExcludeNodes(ctx, triedNodes)
var target *chain.Node
if h.hop != nil {
target = h.hop.Select(selectCtx,
hop.ProtocolSelectOption(proto),
)
}
if target == nil {
h.options.Logger.Debugf("[handler.retry] attempt=%d target=nil, triedNodes=%v", attempt, triedNodes)
if lastErr != nil {
return lastErr
}
return errors.New("node not available")
}
h.options.Logger.Debugf("[handler.retry] attempt=%d selected node=%s addr=%s", attempt, target.Name, target.Addr)
// Track this node as tried
triedNodes = append(triedNodes, target.Addr)
addr := target.Addr
if opts := target.Options(); opts != nil {
switch opts.Network {
case "unix":
network = opts.Network
default:
if _, _, err := net.SplitHostPort(addr); err != nil {
addr += ":0"
}
}
}
}
ro.Network = network
ro.Host = addr
ro.Network = network
ro.Host = addr
var buf bytes.Buffer
cc, err := h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, addr)
ro.Route = buf.String()
if err != nil {
// TODO: the router itself may be failed due to the failed node in the router,
// the dead marker may be a wrong operation.
if marker := target.Marker(); marker != nil {
marker.Mark()
var buf bytes.Buffer
cc, err = h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, addr)
ro.Route = buf.String()
if err != nil {
// Mark node as failed for future selections
if marker := target.Marker(); marker != nil {
marker.Mark()
h.options.Logger.Debugf("[handler.retry] attempt=%d dial failed, marked node=%s count=%d err=%v",
attempt, target.Addr, marker.Count(), err)
}
lastErr = err
// Try next node
continue
}
return err
}
if marker := target.Marker(); marker != nil {
marker.Reset()
}
defer cc.Close()
xnet.Transport(conn, cc)
// Success - reset marker and proceed
if marker := target.Marker(); marker != nil {
marker.Reset()
}
defer cc.Close()
return nil
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
}
+35 -4
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,28 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
return nil
}
// Get list of nodes to exclude (for failover retry)
excludeNodes := ctxvalue.ExcludeNodesFromContext(ctx)
excludeSet := make(map[string]bool)
for _, addr := range excludeNodes {
excludeSet[addr] = true
}
// Debug logging for failover analysis
log.Debugf("[hop.Select] excludeNodes=%v, totalNodes=%d", excludeNodes, len(p.Nodes()))
var nodes []*chain.Node
for _, node := range p.Nodes() {
if node == nil {
continue
}
// Skip nodes in the exclude list (failover retry)
if excludeSet[node.Addr] || excludeSet[node.Name] {
log.Debugf("node %s(%s) excluded for failover retry", node.Name, node.Addr)
continue
}
// node level bypass
if node.Options().Bypass != nil &&
node.Options().Bypass.Contains(ctx, options.Network, options.Addr, bypass.WithHostOpton(options.Host)) {
@@ -177,9 +195,6 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
if len(nodes) == 0 {
return nil
}
if len(nodes) == 1 {
return nodes[0]
}
sort.Slice(nodes, func(i, j int) bool {
return nodes[i].Options().Priority > nodes[j].Options().Priority
@@ -189,9 +204,25 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.
return nodes[0]
}
// Use selector with FailFilter for proper failover.
// FailFilter will exclude recently-failed nodes, allowing traffic to
// be routed to healthy alternatives.
// Note: FailFilter has a safety guard (len <= 1 returns as-is) to ensure
// the last remaining node is never permanently blocked.
if s := p.options.selector; s != nil {
return s.Select(ctx, nodes...)
log.Debugf("[hop.Select] calling selector.Select with %d nodes", len(nodes))
if node := s.Select(ctx, nodes...); node != nil {
log.Debugf("[hop.Select] selected node=%s addr=%s", node.Name, node.Addr)
return node
}
// All nodes filtered out by FailFilter - all are marked as failed.
// Return nil to signal "no healthy nodes available" to the caller.
// The handler's retry loop will handle this appropriately.
log.Debugf("all %d nodes filtered out by FailFilter, no healthy nodes available", len(nodes))
return nil
}
// Fallback: return first node if no selector configured
return nodes[0]
}
+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`).
+20 -3
View File
@@ -2,11 +2,13 @@ package selector
import (
"context"
"fmt"
"time"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/metadata"
mdutil "github.com/go-gost/x/metadata/util"
"github.com/go-gost/core/selector"
mdutil "github.com/go-gost/x/metadata/util"
)
type failFilter[T any] struct {
@@ -24,6 +26,8 @@ func FailFilter[T any](maxFails int, timeout time.Duration) selector.Filter[T] {
}
// Filter filters dead objects.
// For single-node case, skip filtering to ensure availability (matches upstream).
// For multi-node case, filter out failed nodes to enable failover.
func (f *failFilter[T]) Filter(ctx context.Context, vs ...T) []T {
if len(vs) <= 1 {
return vs
@@ -51,8 +55,21 @@ func (f *failFilter[T]) Filter(ctx context.Context, vs ...T) []T {
if mi, _ := any(v).(selector.Markable); mi != nil {
if marker := mi.Marker(); marker != nil {
if marker.Count() < int64(maxFails) ||
time.Since(marker.Time()) >= failTimeout {
count := marker.Count()
timeSince := time.Since(marker.Time())
passed := count < int64(maxFails) || timeSince >= failTimeout
// Debug logging for failover analysis
nodeName := "unknown"
nodeAddr := "unknown"
if node, ok := any(v).(*chain.Node); ok {
nodeName = node.Name
nodeAddr = node.Addr
}
fmt.Printf("[FailFilter] node=%s addr=%s count=%d maxFails=%d timeSince=%v failTimeout=%v passed=%v\n",
nodeName, nodeAddr, count, maxFails, timeSince, failTimeout, passed)
if passed {
l = append(l, v)
}
continue
+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