Compare commits

...

93 Commits

Author SHA1 Message Date
sagit ae6c65e9a9 Merge pull request #40 from Sagit-chu/fix/backend-image-name
fix(ci): rename go-backend image to flux-panel-backend to avoid permi…
2026-02-07 23:04:19 +08:00
sagit 8a85ab2844 fix(ci): rename go-backend image to flux-panel-backend to avoid permission issues 2026-02-07 15:01:21 +00:00
sagit b9b4312768 Merge pull request #36 from Sagit-chu/opencode/proud-cactus
feat: add Go backend compatibility service scaffold
2026-02-07 22:23:42 +08:00
sagit a2b819dbcc merge: sync origin/main and resolve compose backend conflicts 2026-02-07 14:16:00 +00:00
sagit 6f0412de0f fix: align forward pause/resume service control with Java 2026-02-07 13:52:54 +00:00
sagit e2ae241f8c fix: complete tunnel-create parity with runtime rollback 2026-02-07 13:36:30 +00:00
sagit 1a8f424d53 fix: complete diagnosis parity and tunnel visibility on Go backend 2026-02-07 12:48:41 +00:00
sagit 1977ba9eab fix: adapt frontend captcha flow for Go backend 2026-02-07 12:10:34 +00:00
sagit b2407c3442 feat: finalize Go backend migration and deployment cutover 2026-02-07 11:03:12 +00:00
sagit f9bc165c16 feat: complete Go backend control-plane parity
Bridge Java-to-Go runtime behavior by enforcing forward ownership checks, wiring node command dispatch/diagnostics, and adding contract coverage so migrated APIs can run with production semantics.
2026-02-07 07:10:45 +00:00
sagit b6333d81a2 fix: restore panel list endpoints and bootstrap sqlite state 2026-02-06 12:23:46 +00:00
sagit 507893dc55 Merge pull request #37 from Sagit-chu/opencode/witty-garden
fix: ensure group sync grants all tunnel-group tunnels
2026-02-06 19:11:23 +08:00
sagit 652b44e08e Merge branch 'main' into opencode/witty-garden 2026-02-06 19:07:55 +08:00
sagit 95b3803745 fix: use mutable empty set in group tunnel sync 2026-02-06 11:01:30 +00:00
sagit 0b85cd2af1 fix: ensure group sync grants all tunnel-group tunnels 2026-02-06 10:42:56 +00:00
sagit 0f37017760 feat: add Go backend compatibility service scaffold 2026-02-06 10:42:48 +00:00
sagit 4d0dcf5db5 Merge pull request #35 from Sagit-chu/opencode/align-compose-installer-version
chore: align compose image tags with installer release version
2026-02-06 18:03:41 +08:00
sagit 4c756e9156 chore: align compose image tags with installer release version 2026-02-06 09:59:49 +00:00
sagit f5a40bf530 Merge pull request #33 from Sagit-chu/opencode/witty-garden
fix: migrate frontend to Vite 7 and add group
2026-02-06 17:19:05 +08:00
sagit c7dbbef0d9 Merge branch 'main' into opencode/witty-garden 2026-02-06 17:15:58 +08:00
sagit a6773fe65d fix: preserve manual tunnel access when revoking group grants 2026-02-06 09:04:27 +00:00
sagit 27a32b3ff4 fix: always create group grants for existing user tunnel pairs 2026-02-06 08:46:42 +00:00
sagit 06a45a87f6 fix: block manual removal of group-granted tunnels 2026-02-06 08:22:17 +00:00
sagit 053aef42c0 fix: use mutable map for empty grant count sync 2026-02-06 08:03:43 +00:00
sagit 9f79efd44b feat: add revocable group-based tunnel permission management 2026-02-06 07:32:14 +00:00
sagit 08ba876291 Merge pull request #34 from Sagit-chu/opencode/ghcr-log-config
chore: migrate compose images to ghcr and reduce log output
2026-02-06 15:22:28 +08:00
sagit 59af67a5b5 chore: migrate compose images to ghcr and reduce log output 2026-02-06 07:11:05 +00:00
root 65f8f7506e chore(frontend): enable rolldown-vite rust build 2026-02-06 06:27:27 +00:00
root efd5a107b9 fix: migrate frontend to Vite 7 and resolve lint warnings 2026-02-06 06:09:17 +00:00
sagit 35c8063ac5 Merge pull request #32 from Sagit-chu/opencode/kind-wolf
fix: improve batch tunnel migration and simplify batch action labels
2026-02-06 13:36:47 +08:00
root e0efadf298 feat: add batch pause/resume controls and improve batch toolbar usability 2026-02-06 05:30:50 +00:00
root a9fadfc08c fix: improve batch tunnel migration and simplify batch action labels 2026-02-06 04:00:06 +00:00
sagit 529257c8d0 Merge pull request #31 from Sagit-chu/opencode/nimble-moon
feat: add batch operations for forwards, tunnels, and nodes
2026-02-05 20:29:50 +08:00
root a667c03b6c fix: compilation errors in batch operations
- Fix R.ok() usage in backend services (remove message argument)
- Fix batchDeleteNodes call in frontend (pass array directly)
2026-02-05 11:04:33 +00:00
root 5a1fc808b2 feat: add batch operations for forwards, tunnels, and nodes
- Add batch delete, redeploy, and change-tunnel for forwards
- Add batch delete and redeploy for tunnels
- Add batch delete for nodes
- Add multi-select UI with floating toolbar on all three pages
- Create DTOs: BatchDeleteDto, BatchRedeployDto, BatchChangeTunnelDto, BatchOperationResultDto
2026-02-05 10:59:49 +00:00
sagit 02ff215f99 Merge pull request #28 from Sagit-chu/opencode/lucky-eagle
fix(gost): process WebSocket commands concurrently to prevent diagnos…
2026-02-05 14:25:24 +08:00
root 2a4e7777ab fix(gost): run TcpPing commands concurrently without config save race
When multiple TcpPing requests are sent in parallel for diagnosing
multiple remote addresses, the Go agent was processing them serially.
This caused later requests to timeout (10s) while waiting for earlier
requests to complete.

Changes:
- Only TcpPing commands run in goroutines for parallel execution
- TcpPing (read-only diagnostic) no longer triggers saveConfig()
- Other state-mutating commands remain synchronous with config save
- Add mutex to saveConfig() to protect concurrent file writes
2026-02-05 06:18:56 +00:00
sagit eac94a5719 Merge pull request #26 from Sagit-chu/opencode/hidden-pixel
fix(diagnose): parallelize TCP ping diagnostics to prevent timeout ca…
2026-02-05 12:57:10 +08:00
root 96fcd0fc57 fix(diagnose): parallelize TCP ping diagnostics to prevent timeout cascade
Previously, forward/tunnel diagnosis executed TCP pings sequentially,
causing total time to accumulate. If the first remote address timed out
(5s), subsequent checks could push total time beyond the frontend's 30s
timeout, resulting in diagnosis failure even for healthy endpoints.

Now all diagnostic tasks run in parallel using CompletableFuture, so
total time equals max(individual ping time) instead of sum.
2026-02-05 04:52:42 +00:00
sagit 06869aedfd Merge pull request #25 from Sagit-chu/opencode/kind-sailor
fix(gost): mark node failed when transport detects relay error
2026-02-05 12:21:32 +08:00
sagit 6a201131a3 Merge branch 'main' into opencode/kind-sailor 2026-02-05 12:17:27 +08:00
root 1130a55ef5 fix(gost): mark node failed when transport detects relay error
When using relay connector with noDelay=false (default), connection
errors to the final target are deferred until first read/write during
Transport(). Previously the Transport() return value was ignored,
causing the marker to never be called for unreachable targets.

Now we capture the Transport() error and mark the node as failed,
enabling failover for subsequent connections.
2026-02-05 04:09:57 +00:00
root 265cd0a50e Revert "fix(backend): enable noDelay for relay connector to fix chain failover"
This reverts commit 51cbd4b9de.
2026-02-05 04:06:46 +00:00
sagit e7ffa77b15 Merge pull request #24 from Sagit-chu/opencode/kind-sailor
fix(backend): enable noDelay for relay connector to fix chain failover
2026-02-05 11:14:46 +08:00
root 51cbd4b9de fix(backend): enable noDelay for relay connector to fix chain failover
When using relay connector with noDelay=false (default), connection
errors are deferred until first read/write. This prevents the forwarder
marker from being called, causing failover to never trigger.

Setting nodelay=true ensures connection errors propagate immediately,
allowing proper failover behavior when chain targets are unreachable.
2026-02-05 03:11:52 +00:00
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
161 changed files with 23858 additions and 6753 deletions
+70
View File
@@ -0,0 +1,70 @@
name: CI Build Check
on:
push:
branches: ['**']
pull_request:
branches: ['**']
jobs:
frontend:
name: Build Frontend
runs-on: ubuntu-latest
defaults:
run:
working-directory: vite-frontend
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.19.0'
- name: Install dependencies
run: npm install --legacy-peer-deps
- name: Build
run: npm run build
backend:
name: Build Go Backend
runs-on: ubuntu-latest
defaults:
run:
working-directory: go-backend
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
cache-dependency-path: go-backend/go.sum
- name: Download dependencies
run: go mod download
- name: Build
run: go build -v ./...
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 .
+31 -29
View File
@@ -6,9 +6,6 @@ env:
on:
push:
branches:
- main
- beta
tags:
- '[0-9]*' # 匹配 2.0.8, 2.0.8-beta 等格式
@@ -103,11 +100,11 @@ jobs:
- name: Build GOST binary (AMD64)
working-directory: ./go-gost
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o gost-amd64
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=${{ needs.check-version.outputs.version }}" -o gost-amd64
- name: Build GOST binary (ARM64)
working-directory: ./go-gost
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o gost-arm64
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=${{ needs.check-version.outputs.version }}" -o gost-arm64
- name: Compress with UPX
working-directory: ./go-gost
@@ -150,7 +147,11 @@ jobs:
- name: Prepare build args
run: |
echo "VITE_GITHUB_REPO=https://github.com/${{ github.repository }}" > ./vite-frontend/.env.production
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: |
@@ -164,8 +165,8 @@ jobs:
-t ${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION} \
./vite-frontend
build-java:
name: Build & Push Spring Boot Backend
build-go-backend:
name: Build & Push Go Backend
needs: check-version
if: needs.check-version.outputs.should_build == 'true'
runs-on: ubuntu-latest
@@ -175,22 +176,23 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up JDK and Maven
uses: actions/setup-java@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
java-version: 21
distribution: 'temurin'
go-version: '1.23'
- name: Cache Maven dependencies
- name: Cache Go dependencies
uses: actions/cache@v4
with:
path: ~/.m2
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-m2
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-backend-${{ hashFiles('go-backend/go.sum') }}
restore-keys: ${{ runner.os }}-go-backend-
- name: Build Java JAR
working-directory: ./springboot-backend
run: mvn clean package -DskipTests
- name: Download dependencies
working-directory: ./go-backend
run: go mod download
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -202,7 +204,7 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Java Docker images
- name: Build and push Go backend Docker images
run: |
VERSION="${{ needs.check-version.outputs.version }}"
OWNER="${{ needs.check-version.outputs.image_owner }}"
@@ -210,13 +212,13 @@ jobs:
docker buildx build \
--platform linux/amd64,linux/arm64 \
--push \
-t ${{ env.REGISTRY }}/${OWNER}/springboot-backend:latest \
-t ${{ env.REGISTRY }}/${OWNER}/springboot-backend:${VERSION} \
./springboot-backend
-t ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:latest \
-t ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:${VERSION} \
./go-backend
create-release:
name: Create Release (Tag Only)
needs: [check-version, build-gost, build-vite, build-java]
needs: [check-version, build-gost, build-vite, build-go-backend]
if: needs.check-version.outputs.is_tag == 'true'
runs-on: ubuntu-latest
permissions:
@@ -251,10 +253,10 @@ jobs:
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
sed -i "s|image: .*flux-panel-backend:[^[:space:]]*|image: ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:${VERSION}|g" ./artifacts/docker-compose-v4.yml
sed -i "s|image: .*vite-frontend:[^[:space:]]*|image: ${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION}|g" ./artifacts/docker-compose-v4.yml
sed -i "s|image: .*flux-panel-backend:[^[:space:]]*|image: ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:${VERSION}|g" ./artifacts/docker-compose-v6.yml
sed -i "s|image: .*vite-frontend:[^[:space:]]*|image: ${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION}|g" ./artifacts/docker-compose-v6.yml
# 复制并修改安装脚本
cp install.sh ./artifacts/install.sh
@@ -293,7 +295,7 @@ jobs:
\`\`\`bash
# Backend
docker pull ${{ env.REGISTRY }}/${OWNER}/springboot-backend:${VERSION}
docker pull ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:${VERSION}
# Frontend
docker pull ${{ env.REGISTRY }}/${OWNER}/vite-frontend:${VERSION}
+4 -1
View File
@@ -257,4 +257,7 @@ gitee/
doraemon.jks
device.id
commit.sh
sql/
sql/
!go-backend/internal/store/sqlite/sql/
!go-backend/internal/store/sqlite/sql/schema.sql
!go-backend/internal/store/sqlite/sql/data.sql
+42
View File
@@ -0,0 +1,42 @@
// Package controller provides HTTP request handlers and controllers for the 3x-ui web management panel.
// It handles routing, authentication, and API endpoints for managing Xray inbounds, settings, and more.
package controller
import (
"net/http"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/locale"
"github.com/mhsanaei/3x-ui/v2/web/session"
"github.com/gin-gonic/gin"
)
// BaseController provides common functionality for all controllers, including authentication checks.
type BaseController struct{}
// checkLogin is a middleware that verifies user authentication and handles unauthorized access.
func (a *BaseController) checkLogin(c *gin.Context) {
if !session.IsLogin(c) {
if isAjax(c) {
pureJsonMsg(c, http.StatusUnauthorized, false, I18nWeb(c, "pages.login.loginAgain"))
} else {
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
}
c.Abort()
} else {
c.Next()
}
}
// I18nWeb retrieves an internationalized message for the web interface based on the current locale.
func I18nWeb(c *gin.Context, name string, params ...string) string {
anyfunc, funcExists := c.Get("I18n")
if !funcExists {
logger.Warning("I18n function not exists in gin context!")
return ""
}
i18nFunc, _ := anyfunc.(func(i18nType locale.I18nType, key string, keyParams ...string) string)
msg := i18nFunc(locale.Web, name, params...)
return msg
}
+424
View File
@@ -0,0 +1,424 @@
package controller
import (
"encoding/json"
"fmt"
"strconv"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/mhsanaei/3x-ui/v2/web/session"
"github.com/mhsanaei/3x-ui/v2/web/websocket"
"github.com/gin-gonic/gin"
)
// InboundController handles HTTP requests related to Xray inbounds management.
type InboundController struct {
inboundService service.InboundService
xrayService service.XrayService
}
// NewInboundController creates a new InboundController and sets up its routes.
func NewInboundController(g *gin.RouterGroup) *InboundController {
a := &InboundController{}
a.initRouter(g)
return a
}
// initRouter initializes the routes for inbound-related operations.
func (a *InboundController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.getInbounds)
g.GET("/get/:id", a.getInbound)
g.GET("/getClientTraffics/:email", a.getClientTraffics)
g.GET("/getClientTrafficsById/:id", a.getClientTrafficsById)
g.POST("/add", a.addInbound)
g.POST("/del/:id", a.delInbound)
g.POST("/update/:id", a.updateInbound)
g.POST("/clientIps/:email", a.getClientIps)
g.POST("/clearClientIps/:email", a.clearClientIps)
g.POST("/addClient", a.addInboundClient)
g.POST("/:id/delClient/:clientId", a.delInboundClient)
g.POST("/updateClient/:clientId", a.updateInboundClient)
g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
g.POST("/resetAllTraffics", a.resetAllTraffics)
g.POST("/resetAllClientTraffics/:id", a.resetAllClientTraffics)
g.POST("/delDepletedClients/:id", a.delDepletedClients)
g.POST("/import", a.importInbound)
g.POST("/onlines", a.onlines)
g.POST("/lastOnline", a.lastOnline)
g.POST("/updateClientTraffic/:email", a.updateClientTraffic)
g.POST("/:id/delClientByEmail/:email", a.delInboundClientByEmail)
}
// getInbounds retrieves the list of inbounds for the logged-in user.
func (a *InboundController) getInbounds(c *gin.Context) {
user := session.GetLoginUser(c)
inbounds, err := a.inboundService.GetInbounds(user.Id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, inbounds, nil)
}
// getInbound retrieves a specific inbound by its ID.
func (a *InboundController) getInbound(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
inbound, err := a.inboundService.GetInbound(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, inbound, nil)
}
// getClientTraffics retrieves client traffic information by email.
func (a *InboundController) getClientTraffics(c *gin.Context) {
email := c.Param("email")
clientTraffics, err := a.inboundService.GetClientTrafficByEmail(email)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
return
}
jsonObj(c, clientTraffics, nil)
}
// getClientTrafficsById retrieves client traffic information by inbound ID.
func (a *InboundController) getClientTrafficsById(c *gin.Context) {
id := c.Param("id")
clientTraffics, err := a.inboundService.GetClientTrafficByID(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
return
}
jsonObj(c, clientTraffics, nil)
}
// addInbound creates a new inbound configuration.
func (a *InboundController) addInbound(c *gin.Context) {
inbound := &model.Inbound{}
err := c.ShouldBind(inbound)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), err)
return
}
user := session.GetLoginUser(c)
inbound.UserId = user.Id
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
inbound.Tag = fmt.Sprintf("inbound-%v", inbound.Port)
} else {
inbound.Tag = fmt.Sprintf("inbound-%v:%v", inbound.Listen, inbound.Port)
}
inbound, needRestart, err := a.inboundService.AddInbound(inbound)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), inbound, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
// Broadcast inbounds update via WebSocket
inbounds, _ := a.inboundService.GetInbounds(user.Id)
websocket.BroadcastInbounds(inbounds)
}
// delInbound deletes an inbound configuration by its ID.
func (a *InboundController) delInbound(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundDeleteSuccess"), err)
return
}
needRestart, err := a.inboundService.DelInbound(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundDeleteSuccess"), id, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
// Broadcast inbounds update via WebSocket
user := session.GetLoginUser(c)
inbounds, _ := a.inboundService.GetInbounds(user.Id)
websocket.BroadcastInbounds(inbounds)
}
// updateInbound updates an existing inbound configuration.
func (a *InboundController) updateInbound(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
inbound := &model.Inbound{
Id: id,
}
err = c.ShouldBind(inbound)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
inbound, needRestart, err := a.inboundService.UpdateInbound(inbound)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), inbound, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
// Broadcast inbounds update via WebSocket
user := session.GetLoginUser(c)
inbounds, _ := a.inboundService.GetInbounds(user.Id)
websocket.BroadcastInbounds(inbounds)
}
// getClientIps retrieves the IP addresses associated with a client by email.
func (a *InboundController) getClientIps(c *gin.Context) {
email := c.Param("email")
ips, err := a.inboundService.GetInboundClientIps(email)
if err != nil || ips == "" {
jsonObj(c, "No IP Record", nil)
return
}
jsonObj(c, ips, nil)
}
// clearClientIps clears the IP addresses for a client by email.
func (a *InboundController) clearClientIps(c *gin.Context) {
email := c.Param("email")
err := a.inboundService.ClearClientIps(email)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.updateSuccess"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
}
// addInboundClient adds a new client to an existing inbound.
func (a *InboundController) addInboundClient(c *gin.Context) {
data := &model.Inbound{}
err := c.ShouldBind(data)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
needRestart, err := a.inboundService.AddInboundClient(data)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}
// delInboundClient deletes a client from an inbound by inbound ID and client ID.
func (a *InboundController) delInboundClient(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
clientId := c.Param("clientId")
needRestart, err := a.inboundService.DelInboundClient(id, clientId)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}
// updateInboundClient updates a client's configuration in an inbound.
func (a *InboundController) updateInboundClient(c *gin.Context) {
clientId := c.Param("clientId")
inbound := &model.Inbound{}
err := c.ShouldBind(inbound)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
needRestart, err := a.inboundService.UpdateInboundClient(inbound, clientId)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}
// resetClientTraffic resets the traffic counter for a specific client in an inbound.
func (a *InboundController) resetClientTraffic(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
email := c.Param("email")
needRestart, err := a.inboundService.ResetClientTraffic(id, email)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundClientTrafficSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}
// resetAllTraffics resets all traffic counters across all inbounds.
func (a *InboundController) resetAllTraffics(c *gin.Context) {
err := a.inboundService.ResetAllTraffics()
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
} else {
a.xrayService.SetToNeedRestart()
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil)
}
// resetAllClientTraffics resets traffic counters for all clients in a specific inbound.
func (a *InboundController) resetAllClientTraffics(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
err = a.inboundService.ResetAllClientTraffics(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
} else {
a.xrayService.SetToNeedRestart()
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllClientTrafficSuccess"), nil)
}
// importInbound imports an inbound configuration from provided data.
func (a *InboundController) importInbound(c *gin.Context) {
inbound := &model.Inbound{}
err := json.Unmarshal([]byte(c.PostForm("data")), inbound)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
user := session.GetLoginUser(c)
inbound.Id = 0
inbound.UserId = user.Id
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
inbound.Tag = fmt.Sprintf("inbound-%v", inbound.Port)
} else {
inbound.Tag = fmt.Sprintf("inbound-%v:%v", inbound.Listen, inbound.Port)
}
for index := range inbound.ClientStats {
inbound.ClientStats[index].Id = 0
inbound.ClientStats[index].Enable = true
}
needRestart := false
inbound, needRestart, err = a.inboundService.AddInbound(inbound)
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), inbound, err)
if err == nil && needRestart {
a.xrayService.SetToNeedRestart()
}
}
// delDepletedClients deletes clients in an inbound who have exhausted their traffic limits.
func (a *InboundController) delDepletedClients(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
err = a.inboundService.DelDepletedClients(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.delDepletedClientsSuccess"), nil)
}
// onlines retrieves the list of currently online clients.
func (a *InboundController) onlines(c *gin.Context) {
jsonObj(c, a.inboundService.GetOnlineClients(), nil)
}
// lastOnline retrieves the last online timestamps for clients.
func (a *InboundController) lastOnline(c *gin.Context) {
data, err := a.inboundService.GetClientsLastOnline()
jsonObj(c, data, err)
}
// updateClientTraffic updates the traffic statistics for a client by email.
func (a *InboundController) updateClientTraffic(c *gin.Context) {
email := c.Param("email")
// Define the request structure for traffic update
type TrafficUpdateRequest struct {
Upload int64 `json:"upload"`
Download int64 `json:"download"`
}
var request TrafficUpdateRequest
err := c.ShouldBindJSON(&request)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
err = a.inboundService.UpdateClientTrafficByEmail(email, request.Upload, request.Download)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
}
// delInboundClientByEmail deletes a client from an inbound by email address.
func (a *InboundController) delInboundClientByEmail(c *gin.Context) {
inboundId, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, "Invalid inbound ID", err)
return
}
email := c.Param("email")
needRestart, err := a.inboundService.DelInboundClientByEmail(inboundId, email)
if err != nil {
jsonMsg(c, "Failed to delete client by email", err)
return
}
jsonMsg(c, "Client deleted successfully", nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}
+54
View File
@@ -0,0 +1,54 @@
package controller
import (
"github.com/gin-gonic/gin"
)
// XUIController is the main controller for the X-UI panel, managing sub-controllers.
type XUIController struct {
BaseController
settingController *SettingController
xraySettingController *XraySettingController
}
// NewXUIController creates a new XUIController and initializes its routes.
func NewXUIController(g *gin.RouterGroup) *XUIController {
a := &XUIController{}
a.initRouter(g)
return a
}
// initRouter sets up the main panel routes and initializes sub-controllers.
func (a *XUIController) initRouter(g *gin.RouterGroup) {
g = g.Group("/panel")
g.Use(a.checkLogin)
g.GET("/", a.index)
g.GET("/inbounds", a.inbounds)
g.GET("/settings", a.settings)
g.GET("/xray", a.xraySettings)
a.settingController = NewSettingController(g)
a.xraySettingController = NewXraySettingController(g)
}
// index renders the main panel index page.
func (a *XUIController) index(c *gin.Context) {
html(c, "index.html", "pages.index.title", nil)
}
// inbounds renders the inbounds management page.
func (a *XUIController) inbounds(c *gin.Context) {
html(c, "inbounds.html", "pages.inbounds.title", nil)
}
// settings renders the settings management page.
func (a *XUIController) settings(c *gin.Context) {
html(c, "settings.html", "pages.settings.title", nil)
}
// xraySettings renders the Xray settings page.
func (a *XUIController) xraySettings(c *gin.Context) {
html(c, "xray.html", "pages.xray.title", nil)
}
+45 -22
View File
@@ -1,43 +1,66 @@
# PROJECT KNOWLEDGE BASE
**Generated:** Sat Jan 24 2026
**Context:** Monorepo for Flux Panel (Traffic Forwarding)
**Generated:** Mon Feb 02 2026
**Commit:** 7ca01ab
**Branch:** beta
## OVERVIEW
Flux Panel is a traffic forwarding management system based on [go-gost](https://github.com/go-gost/gost). It manages tunnels, port forwarding, and user quotas.
**Stack:** Monorepo (Java/Spring Boot Backend + React/Vite Frontend + Go/GOST Service).
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
```
/root/flux-panel/
├── springboot-backend/ # Java 21 + Spring Boot 2.7 Admin API
├── vite-frontend/ # React 18 + Vite + HeroUI/NextUI
├── go-gost/ # Go 1.23 + GOST Extensions (Core logic)
├── docker-compose*.yml # Deployment configs (v4/v6)
└── *.sh # Install scripts (panel_install.sh, install.sh)
./
├── 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 |
|------|----------|-------|
| **Admin API** | `springboot-backend/` | Users, quotas, billing logic |
| **UI/Dashboard** | `vite-frontend/` | Management console |
| **Core Forwarding** | `go-gost/` | GOST implementation & extensions |
| **Deploy** | `docker-compose-v4.yml` | Container orchestration |
| **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
- **Monorepo**: 3 distinct languages/stacks. Treat each subdir as a separate project.
- **Docker**: Primary deployment method.
- **Scripts**: `panel_install.sh` for panel, `install.sh` for nodes.
- `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
# Quick Deploy (Panel)
./panel_install.sh
# Panel (Docker)
docker compose -f docker-compose-v4.yml up -d
docker compose -f docker-compose-v6.yml up -d
# Quick Deploy (Node)
# Release-based install scripts
./panel_install.sh
./install.sh
# Docker
docker-compose -f docker-compose-v4.yml up -d
# 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 -14
View File
@@ -16,24 +16,13 @@
---
### Docker Compose部署
#### 快速部署
面板端(稳定版):
面板端:
```bash
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
```
面板端(开发版):
```bash
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/beta/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
```
节点端(开发版):
```bash
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/beta/install.sh -o install.sh && chmod +x install.sh && ./install.sh
```
#### 默认管理员账号
@@ -66,4 +55,14 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/beta/install.sh -
作者对因使用本项目所造成的任何直接或间接损失概不负责,亦不提供任何形式的担保、承诺或技术支持。
请务必在合法、合规、安全的前提下使用本项目。
请务必在合法、合规、安全的前提下使用本项目。
---
## ⭐ 喝杯咖啡!(USDT)
| 网络 | 地址 |
|------------|----------------------------------------------------------------------|
| BNB(BEP20) | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
| TRC20 | `TM8VYdU3s3gSX5PC8swjAJrAzZFCHKqG2k` |
| Aptos | `0x49427bfcba1006a346447430689b2307ac156316bb34850d1d3029ff9d118da5` |
| polygon | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
+14 -6
View File
@@ -1,13 +1,17 @@
services:
backend:
image: bqlpfy/springboot-backend:2.0.7-beta
container_name: springboot-backend
image: ghcr.io/sagit-chu/flux-panel-backend:${FLUX_VERSION:-latest}
container_name: flux-panel-backend
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "20m"
environment:
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"
SERVER_ADDR: :6365
ports:
- "${BACKEND_PORT}:6365"
volumes:
@@ -22,12 +26,16 @@ services:
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
start_period: 30s
frontend:
image: bqlpfy/vite-frontend:2.0.7-beta
image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}
container_name: vite-frontend
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "20m"
ports:
- "${FRONTEND_PORT}:80"
depends_on:
@@ -52,4 +60,4 @@ networks:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
- subnet: 172.20.0.0/16
+14 -6
View File
@@ -1,13 +1,17 @@
services:
backend:
image: bqlpfy/springboot-backend:2.0.7-beta
container_name: springboot-backend
image: ghcr.io/sagit-chu/flux-panel-backend:${FLUX_VERSION:-latest}
container_name: flux-panel-backend
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "20m"
environment:
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"
SERVER_ADDR: :6365
ports:
- "${BACKEND_PORT}:6365"
volumes:
@@ -22,12 +26,16 @@ services:
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
start_period: 30s
frontend:
image: bqlpfy/vite-frontend:2.0.7-beta
image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}
container_name: vite-frontend
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "20m"
ports:
- "${FRONTEND_PORT}:80"
depends_on:
@@ -54,4 +62,4 @@ networks:
ipam:
config:
- subnet: 172.20.0.0/16
- subnet: fd00:dead:beef::/48
- subnet: fd00:dead:beef::/48
+17
View File
@@ -0,0 +1,17 @@
FROM golang:1.23-bookworm AS builder
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/paneld ./cmd/paneld
FROM debian:bookworm-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget && rm -rf /var/lib/apt/lists/*
COPY --from=builder /out/paneld /app/paneld
ENV SERVER_ADDR=:6365
EXPOSE 6365
ENTRYPOINT ["/app/paneld"]
+12
View File
@@ -0,0 +1,12 @@
GO ?= go
.PHONY: test build run
test:
$(GO) test ./...
build:
$(GO) build ./cmd/paneld
run:
SERVER_ADDR=:6365 $(GO) run ./cmd/paneld
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"go-backend/internal/app"
"go-backend/internal/config"
)
func main() {
cfg := config.FromEnv()
if cfg.JWTSecret == "" {
log.Println("warning: JWT_SECRET is empty")
}
log.Printf("starting go-backend on %s (db=%s)", cfg.Addr, cfg.DBPath)
a, err := app.New(cfg)
if err != nil {
log.Fatalf("failed to create app: %v", err)
}
errCh := make(chan error, 1)
go func() {
errCh <- a.Run()
}()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
select {
case sig := <-sigCh:
log.Printf("received signal %s, shutting down", sig)
case runErr := <-errCh:
if runErr != nil && !errors.Is(runErr, http.ErrServerClosed) {
log.Fatalf("server stopped unexpectedly: %v", runErr)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := a.Shutdown(ctx); err != nil {
log.Fatalf("shutdown failed: %v", err)
}
}
+23
View File
@@ -0,0 +1,23 @@
module go-backend
go 1.23.0
toolchain go1.24.4
require (
github.com/gorilla/websocket v1.5.3
modernc.org/sqlite v1.37.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/sys v0.33.0 // indirect
modernc.org/libc v1.65.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+49
View File
@@ -0,0 +1,49 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+60
View File
@@ -0,0 +1,60 @@
package app
import (
"context"
"fmt"
"net/http"
"time"
"go-backend/internal/config"
httpserver "go-backend/internal/http"
"go-backend/internal/http/handler"
"go-backend/internal/store/sqlite"
)
type App struct {
cfg config.Config
server *http.Server
repo *sqlite.Repository
h *handler.Handler
}
func New(cfg config.Config) (*App, error) {
repo, err := sqlite.Open(cfg.DBPath)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
h := handler.New(repo, cfg.JWTSecret)
router := httpserver.NewRouter(h, cfg.JWTSecret)
s := &http.Server{
Addr: cfg.Addr,
Handler: router,
ReadTimeout: 30 * time.Second,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
return &App{cfg: cfg, server: s, repo: repo, h: h}, nil
}
func (a *App) Run() error {
if a.h != nil {
a.h.StartBackgroundJobs()
}
return a.server.ListenAndServe()
}
func (a *App) Shutdown(ctx context.Context) error {
if a.h != nil {
a.h.StopBackgroundJobs()
}
shutdownErr := a.server.Shutdown(ctx)
closeErr := a.repo.Close()
if shutdownErr != nil {
return shutdownErr
}
return closeErr
}
+121
View File
@@ -0,0 +1,121 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"strconv"
"time"
)
const (
algorithm = "HmacSHA256"
expireTime = 90 * 24 * time.Hour
)
type Claims struct {
Sub string `json:"sub"`
Iat int64 `json:"iat"`
Exp int64 `json:"exp"`
User string `json:"user"`
Name string `json:"name"`
RoleID int `json:"role_id"`
}
type tokenHeader struct {
Alg string `json:"alg"`
Typ string `json:"typ"`
}
func GenerateToken(userID int64, username string, roleID int, secret string) (string, error) {
now := time.Now()
header := tokenHeader{Alg: algorithm, Typ: "JWT"}
claims := Claims{
Sub: strconv.FormatInt(userID, 10),
Iat: now.Unix(),
Exp: now.Add(expireTime).Unix(),
User: username,
Name: username,
RoleID: roleID,
}
headerPart, err := encodeJSON(header)
if err != nil {
return "", err
}
payloadPart, err := encodeJSON(claims)
if err != nil {
return "", err
}
sig := sign(headerPart+"."+payloadPart, secret)
return headerPart + "." + payloadPart + "." + sig, nil
}
func ValidateToken(token, secret string) (Claims, bool) {
claims, err := ParseClaims(token, secret)
if err != nil {
return Claims{}, false
}
return claims, true
}
func ParseClaims(token, secret string) (Claims, error) {
parts := splitToken(token)
if len(parts) != 3 {
return Claims{}, errors.New("invalid token")
}
signedContent := parts[0] + "." + parts[1]
expected := sign(signedContent, secret)
if !hmac.Equal([]byte(expected), []byte(parts[2])) {
return Claims{}, errors.New("invalid signature")
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return Claims{}, err
}
var claims Claims
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
return Claims{}, err
}
if claims.Exp <= time.Now().Unix() {
return Claims{}, errors.New("token expired")
}
return claims, nil
}
func splitToken(token string) []string {
parts := make([]string, 0, 3)
current := ""
for i := 0; i < len(token); i++ {
if token[i] == '.' {
parts = append(parts, current)
current = ""
continue
}
current += string(token[i])
}
parts = append(parts, current)
return parts
}
func encodeJSON(v interface{}) (string, error) {
raw, err := json.Marshal(v)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func sign(content, secret string) string {
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(content))
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
}
+28
View File
@@ -0,0 +1,28 @@
package config
import "os"
type Config struct {
Addr string
DBPath string
JWTSecret string
LogDir string
}
func FromEnv() Config {
cfg := Config{
Addr: getEnv("SERVER_ADDR", ":6365"),
DBPath: getEnv("DB_PATH", "/app/data/gost.db"),
JWTSecret: getEnv("JWT_SECRET", ""),
LogDir: getEnv("LOG_DIR", "/app/logs"),
}
return cfg
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
@@ -0,0 +1,67 @@
package handler
import "time"
const captchaTokenTTL = 5 * time.Minute
func (h *Handler) storeCaptchaToken(token string) {
if h == nil {
return
}
token = normalizeCaptchaToken(token)
if token == "" {
return
}
h.captchaMu.Lock()
defer h.captchaMu.Unlock()
now := time.Now().UnixMilli()
h.pruneExpiredCaptchaTokensLocked(now)
h.captchaTokens[token] = now + int64(captchaTokenTTL/time.Millisecond)
}
func (h *Handler) consumeCaptchaToken(token string) bool {
if h == nil {
return false
}
token = normalizeCaptchaToken(token)
if token == "" {
return false
}
h.captchaMu.Lock()
defer h.captchaMu.Unlock()
now := time.Now().UnixMilli()
h.pruneExpiredCaptchaTokensLocked(now)
expiresAt, ok := h.captchaTokens[token]
if !ok || expiresAt <= now {
delete(h.captchaTokens, token)
return false
}
delete(h.captchaTokens, token)
return true
}
func (h *Handler) pruneExpiredCaptchaTokensLocked(now int64) {
for token, expiresAt := range h.captchaTokens {
if expiresAt <= now {
delete(h.captchaTokens, token)
}
}
}
func normalizeCaptchaToken(token string) string {
return trimToken(token)
}
func trimToken(token string) string {
for len(token) > 0 && (token[0] == ' ' || token[0] == '\t' || token[0] == '\n' || token[0] == '\r') {
token = token[1:]
}
for len(token) > 0 && (token[len(token)-1] == ' ' || token[len(token)-1] == '\t' || token[len(token)-1] == '\n' || token[len(token)-1] == '\r') {
token = token[:len(token)-1]
}
return token
}
@@ -0,0 +1,967 @@
package handler
import (
"database/sql"
"errors"
"fmt"
"net"
"net/http"
"sort"
"strconv"
"strings"
"time"
"go-backend/internal/ws"
)
var errForwardNotFound = errors.New("forward not found")
type forwardRecord struct {
ID int64
UserID int64
UserName string
Name string
TunnelID int64
RemoteAddr string
Strategy string
Status int
}
type tunnelRecord struct {
ID int64
Type int
Status int
Flow int64
TrafficRatio float64
}
type forwardPortRecord struct {
NodeID int64
Port int
}
type nodeRecord struct {
ID int64
Name string
ServerIP string
ServerIPv4 string
ServerIPv6 string
Status int
PortRange string
TCPListenAddr string
UDPListenAddr string
InterfaceName string
}
type chainNodeRecord struct {
ChainType int
Inx int64
NodeID int64
Port int
NodeName string
}
type diagnosisTarget struct {
Address string
IP string
Port int
}
func (h *Handler) resolveForwardAccess(r *http.Request, forwardID int64) (*forwardRecord, int64, int, error) {
userID, roleID, err := userRoleFromRequest(r)
if err != nil {
return nil, 0, 0, err
}
forward, err := h.ensureForwardAccessByActor(userID, roleID, forwardID)
if err != nil {
return nil, userID, roleID, err
}
return forward, userID, roleID, nil
}
func (h *Handler) ensureForwardAccessByActor(actorUserID int64, actorRole int, forwardID int64) (*forwardRecord, error) {
forward, err := h.getForwardRecord(forwardID)
if err != nil {
return nil, err
}
if actorRole != 0 && forward.UserID != actorUserID {
return nil, errForwardNotFound
}
return forward, nil
}
func (h *Handler) ensureTunnelPermission(userID int64, roleID int, tunnelID int64) error {
if roleID == 0 {
return nil
}
var count int
err := h.repo.DB().QueryRow(`SELECT COUNT(1) FROM user_tunnel WHERE user_id = ? AND tunnel_id = ? AND status = 1`, userID, tunnelID).Scan(&count)
if err != nil {
return err
}
if count <= 0 {
return errors.New("你没有该隧道的权限")
}
return nil
}
func (h *Handler) getForwardRecord(forwardID int64) (*forwardRecord, error) {
row := h.repo.DB().QueryRow(`
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, strategy, status
FROM forward WHERE id = ? LIMIT 1
`, forwardID)
var fr forwardRecord
err := row.Scan(&fr.ID, &fr.UserID, &fr.UserName, &fr.Name, &fr.TunnelID, &fr.RemoteAddr, &fr.Strategy, &fr.Status)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errForwardNotFound
}
return nil, err
}
if strings.TrimSpace(fr.Strategy) == "" {
fr.Strategy = "fifo"
}
return &fr, nil
}
func (h *Handler) getTunnelRecord(tunnelID int64) (*tunnelRecord, error) {
row := h.repo.DB().QueryRow(`SELECT id, type, status, flow, traffic_ratio FROM tunnel WHERE id = ? LIMIT 1`, tunnelID)
var tr tunnelRecord
err := row.Scan(&tr.ID, &tr.Type, &tr.Status, &tr.Flow, &tr.TrafficRatio)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errors.New("隧道不存在")
}
return nil, err
}
if tr.Flow <= 0 {
tr.Flow = 1
}
if tr.TrafficRatio <= 0 {
tr.TrafficRatio = 1
}
return &tr, nil
}
func (h *Handler) listForwardsByTunnel(tunnelID int64) ([]forwardRecord, error) {
rows, err := h.repo.DB().Query(`
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, strategy, status
FROM forward
WHERE tunnel_id = ?
ORDER BY id ASC
`, tunnelID)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]forwardRecord, 0)
for rows.Next() {
var fr forwardRecord
if err := rows.Scan(&fr.ID, &fr.UserID, &fr.UserName, &fr.Name, &fr.TunnelID, &fr.RemoteAddr, &fr.Strategy, &fr.Status); err != nil {
return nil, err
}
if strings.TrimSpace(fr.Strategy) == "" {
fr.Strategy = "fifo"
}
result = append(result, fr)
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
func (h *Handler) listForwardPorts(forwardID int64) ([]forwardPortRecord, error) {
rows, err := h.repo.DB().Query(`SELECT node_id, port FROM forward_port WHERE forward_id = ? ORDER BY id ASC`, forwardID)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]forwardPortRecord, 0)
for rows.Next() {
var item forwardPortRecord
if err := rows.Scan(&item.NodeID, &item.Port); err != nil {
return nil, err
}
result = append(result, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
func (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {
row := h.repo.DB().QueryRow(`
SELECT id, name, server_ip, server_ip_v4, server_ip_v6, status, port, tcp_listen_addr, udp_listen_addr, interface_name
FROM node
WHERE id = ?
LIMIT 1
`, nodeID)
var n nodeRecord
var serverIPv4 sql.NullString
var serverIPv6 sql.NullString
var portRange sql.NullString
var tcpListen sql.NullString
var udpListen sql.NullString
var iface sql.NullString
err := row.Scan(&n.ID, &n.Name, &n.ServerIP, &serverIPv4, &serverIPv6, &n.Status, &portRange, &tcpListen, &udpListen, &iface)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errors.New("节点不存在")
}
return nil, err
}
n.ServerIPv4 = strings.TrimSpace(serverIPv4.String)
n.ServerIPv6 = strings.TrimSpace(serverIPv6.String)
n.PortRange = strings.TrimSpace(portRange.String)
n.TCPListenAddr = strings.TrimSpace(tcpListen.String)
n.UDPListenAddr = strings.TrimSpace(udpListen.String)
n.InterfaceName = strings.TrimSpace(iface.String)
if n.TCPListenAddr == "" {
n.TCPListenAddr = "[::]"
}
if n.UDPListenAddr == "" {
n.UDPListenAddr = "[::]"
}
if strings.TrimSpace(n.Name) == "" {
n.Name = fmt.Sprintf("node_%d", n.ID)
}
return &n, nil
}
func (h *Handler) resolveUserTunnelAndLimiter(userID, tunnelID int64) (int64, *int, error) {
row := h.repo.DB().QueryRow(`
SELECT ut.id, sl.speed
FROM user_tunnel ut
LEFT JOIN speed_limit sl ON sl.id = ut.speed_id
WHERE ut.user_id = ? AND ut.tunnel_id = ?
LIMIT 1
`, userID, tunnelID)
var userTunnelID int64
var speed sql.NullInt64
err := row.Scan(&userTunnelID, &speed)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, nil, nil
}
return 0, nil, err
}
if !speed.Valid || speed.Int64 <= 0 {
return userTunnelID, nil, nil
}
v := int(speed.Int64)
return userTunnelID, &v, nil
}
func (h *Handler) syncForwardServices(forward *forwardRecord, method string, allowFallbackAdd bool) error {
if h == nil || forward == nil {
return errors.New("invalid forward sync context")
}
tunnel, err := h.getTunnelRecord(forward.TunnelID)
if err != nil {
return err
}
ports, err := h.listForwardPorts(forward.ID)
if err != nil {
return err
}
if len(ports) == 0 {
return errors.New("转发入口端口不存在")
}
userTunnelID, limiter, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
if err != nil {
return err
}
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, userTunnelID)
for _, fp := range ports {
node, err := h.getNodeRecord(fp.NodeID)
if err != nil {
return err
}
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiter)
_, err = h.sendNodeCommand(node.ID, method, services, true, false)
if err != nil && allowFallbackAdd && method == "UpdateService" {
_, err = h.sendNodeCommand(node.ID, "AddService", services, true, false)
}
if err != nil {
return fmt.Errorf("节点 %s 下发失败: %w", node.Name, err)
}
}
return nil
}
func (h *Handler) controlForwardServices(forward *forwardRecord, commandType string, tolerateNotFound bool) error {
if h == nil || forward == nil {
return errors.New("invalid forward control context")
}
ports, err := h.listForwardPorts(forward.ID)
if err != nil {
return err
}
if len(ports) == 0 {
return nil
}
userTunnelID, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
if err != nil {
return err
}
base := buildForwardServiceBase(forward.ID, forward.UserID, userTunnelID)
payload := map[string]interface{}{
"services": buildForwardControlServiceNames(base, commandType),
}
seen := map[int64]struct{}{}
for _, fp := range ports {
if _, ok := seen[fp.NodeID]; ok {
continue
}
seen[fp.NodeID] = struct{}{}
_, err := h.sendNodeCommand(fp.NodeID, commandType, payload, false, tolerateNotFound)
if err != nil {
return err
}
}
return nil
}
func (h *Handler) applyNodeProtocolChange(nodeID int64, httpVal, tlsVal, socksVal int) error {
_, err := h.sendNodeCommand(nodeID, "SetProtocol", map[string]interface{}{
"http": httpVal,
"tls": tlsVal,
"socks": socksVal,
}, false, false)
return err
}
func (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interface{}, tolerateExists bool, tolerateNotFound bool) (ws.CommandResult, error) {
result, err := h.wsServer.SendCommand(nodeID, commandType, data, 12*time.Second)
if err == nil {
return result, nil
}
msg := strings.ToLower(strings.TrimSpace(err.Error()))
if tolerateExists {
if strings.Contains(msg, "exists") || strings.Contains(msg, "already") || strings.Contains(msg, "已存在") {
return result, nil
}
}
if tolerateNotFound {
if strings.Contains(msg, "not found") || strings.Contains(msg, "不存在") {
return result, nil
}
}
return result, err
}
func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]interface{}, error) {
if forward == nil {
return nil, errForwardNotFound
}
targets, err := resolveDiagnosisTargets(forward.RemoteAddr)
if err != nil {
return nil, err
}
tunnel, err := h.getTunnelRecord(forward.TunnelID)
if err != nil {
return nil, err
}
chainRows, err := h.listChainNodesForTunnel(forward.TunnelID)
if err != nil {
return nil, err
}
if len(chainRows) == 0 {
return nil, errors.New("隧道配置不完整")
}
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
results := make([]map[string]interface{}, 0, len(chainRows)*2+len(targets))
nodeCache := map[int64]*nodeRecord{}
switch tunnel.Type {
case 1:
for _, inNode := range inNodes {
for _, target := range targets {
description := fmt.Sprintf("入口(%s)->目标(%s)", inNode.NodeName, target.Address)
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, target.IP, target.Port, description, map[string]interface{}{
"fromChainType": 1,
})
}
}
case 2:
for _, inNode := range inNodes {
if len(chainHops) > 0 {
for _, firstNode := range chainHops[0] {
description := fmt.Sprintf("入口(%s)->第1跳(%s)", inNode.NodeName, firstNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, firstNode, description, map[string]interface{}{
"fromChainType": 1,
"toChainType": 2,
"toInx": firstNode.Inx,
})
}
} else {
for _, outNode := range outNodes {
description := fmt.Sprintf("入口(%s)->出口(%s)", inNode.NodeName, outNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, outNode, description, map[string]interface{}{
"fromChainType": 1,
"toChainType": 3,
})
}
}
}
for i, hop := range chainHops {
for _, currentNode := range hop {
if i+1 < len(chainHops) {
for _, nextNode := range chainHops[i+1] {
description := fmt.Sprintf("第%d跳(%s)->第%d跳(%s)", i+1, currentNode.NodeName, i+2, nextNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, currentNode.NodeID, nextNode, description, map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
"toChainType": 2,
"toInx": nextNode.Inx,
})
}
} else {
for _, outNode := range outNodes {
description := fmt.Sprintf("第%d跳(%s)->出口(%s)", i+1, currentNode.NodeName, outNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, currentNode.NodeID, outNode, description, map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
"toChainType": 3,
})
}
}
}
}
for _, outNode := range outNodes {
for _, target := range targets {
description := fmt.Sprintf("出口(%s)->目标(%s)", outNode.NodeName, target.Address)
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, target.IP, target.Port, description, map[string]interface{}{
"fromChainType": 3,
})
}
}
default:
for _, inNode := range inNodes {
for _, target := range targets {
description := fmt.Sprintf("入口(%s)->目标(%s)", inNode.NodeName, target.Address)
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, target.IP, target.Port, description, map[string]interface{}{
"fromChainType": 1,
})
}
}
}
payload := map[string]interface{}{
"forwardName": forward.Name,
"timestamp": time.Now().UnixMilli(),
"results": results,
}
return payload, nil
}
func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{}, error) {
tunnel, err := h.getTunnelRecord(tunnelID)
if err != nil {
return nil, err
}
var tunnelName string
if err := h.repo.DB().QueryRow(`SELECT name FROM tunnel WHERE id = ?`, tunnelID).Scan(&tunnelName); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errors.New("隧道不存在")
}
return nil, err
}
chainRows, err := h.listChainNodesForTunnel(tunnelID)
if err != nil {
return nil, err
}
if len(chainRows) == 0 {
return nil, errors.New("隧道配置不完整")
}
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
results := make([]map[string]interface{}, 0, len(chainRows)*2)
nodeCache := map[int64]*nodeRecord{}
switch tunnel.Type {
case 1:
for _, inNode := range inNodes {
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.google.com", 443, description, map[string]interface{}{
"fromChainType": 1,
})
}
case 2:
for _, inNode := range inNodes {
if len(chainHops) > 0 {
for _, firstNode := range chainHops[0] {
description := fmt.Sprintf("入口(%s)->第1跳(%s)", inNode.NodeName, firstNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, firstNode, description, map[string]interface{}{
"fromChainType": 1,
"toChainType": 2,
"toInx": firstNode.Inx,
})
}
} else {
for _, outNode := range outNodes {
description := fmt.Sprintf("入口(%s)->出口(%s)", inNode.NodeName, outNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, outNode, description, map[string]interface{}{
"fromChainType": 1,
"toChainType": 3,
})
}
}
}
for i, hop := range chainHops {
for _, currentNode := range hop {
if i+1 < len(chainHops) {
for _, nextNode := range chainHops[i+1] {
description := fmt.Sprintf("第%d跳(%s)->第%d跳(%s)", i+1, currentNode.NodeName, i+2, nextNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, currentNode.NodeID, nextNode, description, map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
"toChainType": 2,
"toInx": nextNode.Inx,
})
}
} else {
for _, outNode := range outNodes {
description := fmt.Sprintf("第%d跳(%s)->出口(%s)", i+1, currentNode.NodeName, outNode.NodeName)
h.appendChainHopDiagnosis(&results, nodeCache, currentNode.NodeID, outNode, description, map[string]interface{}{
"fromChainType": 2,
"fromInx": currentNode.Inx,
"toChainType": 3,
})
}
}
}
}
for _, outNode := range outNodes {
description := fmt.Sprintf("出口(%s)->外网", outNode.NodeName)
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, "www.google.com", 443, description, map[string]interface{}{
"fromChainType": 3,
})
}
default:
for _, inNode := range inNodes {
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.google.com", 443, description, map[string]interface{}{
"fromChainType": 1,
})
}
}
payload := map[string]interface{}{
"tunnelName": tunnelName,
"tunnelType": map[bool]string{true: "端口转发", false: "隧道转发"}[tunnel.Type == 1],
"timestamp": time.Now().UnixMilli(),
"results": results,
}
return payload, nil
}
func splitChainNodeGroups(rows []chainNodeRecord) ([]chainNodeRecord, [][]chainNodeRecord, []chainNodeRecord) {
inNodes := make([]chainNodeRecord, 0)
outNodes := make([]chainNodeRecord, 0)
chainByInx := map[int64][]chainNodeRecord{}
hopOrder := make([]int64, 0)
for _, row := range rows {
switch row.ChainType {
case 1:
inNodes = append(inNodes, row)
case 2:
if _, ok := chainByInx[row.Inx]; !ok {
hopOrder = append(hopOrder, row.Inx)
}
chainByInx[row.Inx] = append(chainByInx[row.Inx], row)
case 3:
outNodes = append(outNodes, row)
}
}
sort.Slice(hopOrder, func(i, j int) bool { return hopOrder[i] < hopOrder[j] })
chainHops := make([][]chainNodeRecord, 0, len(hopOrder))
for _, inx := range hopOrder {
chainHops = append(chainHops, chainByInx[inx])
}
return inNodes, chainHops, outNodes
}
func resolveDiagnosisTargets(remoteAddr string) ([]diagnosisTarget, error) {
rawTargets := splitRemoteTargets(remoteAddr)
if len(rawTargets) == 0 {
return nil, errors.New("目标地址不能为空")
}
targets := make([]diagnosisTarget, 0, len(rawTargets))
for _, raw := range rawTargets {
ip, port, err := parseTargetAddress(raw)
if err != nil {
continue
}
targets = append(targets, diagnosisTarget{Address: raw, IP: ip, Port: port})
}
if len(targets) == 0 {
return nil, errors.New("目标地址格式错误")
}
return targets, nil
}
func (h *Handler) cachedNode(nodeCache map[int64]*nodeRecord, nodeID int64) (*nodeRecord, error) {
if node, ok := nodeCache[nodeID]; ok {
return node, nil
}
node, err := h.getNodeRecord(nodeID)
if err != nil {
return nil, err
}
nodeCache[nodeID] = node
return node, nil
}
func newDiagnosisResultItem(fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}) map[string]interface{} {
item := map[string]interface{}{
"nodeName": fmt.Sprintf("node_%d", fromNodeID),
"nodeId": strconv.FormatInt(fromNodeID, 10),
"targetIp": targetIP,
"targetPort": targetPort,
"description": description,
"averageTime": 0,
"packetLoss": 100,
}
for k, v := range metadata {
item[k] = v
}
return item
}
func (h *Handler) appendFailedDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}, message string) {
item := newDiagnosisResultItem(fromNodeID, targetIP, targetPort, description, metadata)
if node, err := h.cachedNode(nodeCache, fromNodeID); err == nil {
item["nodeName"] = node.Name
}
if strings.TrimSpace(message) == "" {
message = "TCP连接失败"
}
item["success"] = false
item["message"] = message
*results = append(*results, item)
}
func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}) {
item := newDiagnosisResultItem(fromNodeID, targetIP, targetPort, description, metadata)
fromNode, err := h.cachedNode(nodeCache, fromNodeID)
if err != nil {
item["success"] = false
item["message"] = err.Error()
*results = append(*results, item)
return
}
item["nodeName"] = fromNode.Name
pingData, pingErr := h.tcpPingViaNode(fromNodeID, targetIP, targetPort)
if pingErr != nil {
item["success"] = false
item["message"] = pingErr.Error()
*results = append(*results, item)
return
}
success := asBool(pingData["success"], false)
item["success"] = success
item["averageTime"] = asFloat(pingData["averageTime"], 0)
item["packetLoss"] = asFloat(pingData["packetLoss"], 100)
message := strings.TrimSpace(asString(pingData["message"]))
if success {
if message == "" {
message = "TCP连接成功"
}
} else {
if message == "" {
message = strings.TrimSpace(asString(pingData["errorMessage"]))
}
if message == "" {
message = "TCP连接失败"
}
}
item["message"] = message
*results = append(*results, item)
}
func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, toNode chainNodeRecord, description string, metadata map[string]interface{}) {
targetNode, err := h.cachedNode(nodeCache, toNode.NodeID)
if err != nil {
h.appendFailedDiagnosis(results, nodeCache, fromNodeID, "", 0, description, metadata, err.Error())
return
}
targetIP, targetPort, err := resolveChainProbeTarget(targetNode, toNode.Port)
if err != nil {
h.appendFailedDiagnosis(results, nodeCache, fromNodeID, strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]"), toNode.Port, description, metadata, err.Error())
return
}
h.appendPathDiagnosis(results, nodeCache, fromNodeID, targetIP, targetPort, description, metadata)
}
func resolveChainProbeTarget(targetNode *nodeRecord, preferredPort int) (string, int, error) {
if targetNode == nil {
return "", 0, errors.New("目标节点不存在")
}
host := strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]")
if host == "" {
return "", 0, errors.New("目标节点地址为空")
}
port := preferredPort
if port <= 0 {
port = firstPortFromRange(targetNode.PortRange)
}
if port <= 0 {
port = 443
}
return host, port, nil
}
func firstPortFromRange(portRange string) int {
portRange = strings.TrimSpace(portRange)
if portRange == "" {
return 0
}
first := strings.Split(portRange, ",")[0]
first = strings.TrimSpace(first)
if strings.Contains(first, "-") {
parts := strings.SplitN(first, "-", 2)
if len(parts) != 2 {
return 0
}
p, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil || p <= 0 {
return 0
}
return p
}
p, err := strconv.Atoi(first)
if err != nil || p <= 0 {
return 0
}
return p
}
func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, error) {
rows, err := h.repo.DB().Query(`
SELECT ct.chain_type, COALESCE(ct.inx, 0), ct.node_id, COALESCE(ct.port, 0), n.name
FROM chain_tunnel ct
LEFT JOIN node n ON n.id = ct.node_id
WHERE ct.tunnel_id = ?
ORDER BY ct.chain_type ASC, COALESCE(ct.inx, 0) ASC, ct.id ASC
`, tunnelID)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]chainNodeRecord, 0)
for rows.Next() {
var item chainNodeRecord
var name sql.NullString
if err := rows.Scan(&item.ChainType, &item.Inx, &item.NodeID, &item.Port, &name); err != nil {
return nil, err
}
if strings.TrimSpace(name.String) == "" {
item.NodeName = fmt.Sprintf("node_%d", item.NodeID)
} else {
item.NodeName = name.String
}
result = append(result, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
func (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int) (map[string]interface{}, error) {
res, err := h.sendNodeCommand(nodeID, "TcpPing", map[string]interface{}{
"ip": ip,
"port": port,
"count": 4,
"timeout": 5000,
}, false, false)
if err != nil {
return nil, err
}
if res.Data == nil {
return nil, errors.New("节点未返回诊断数据")
}
return res.Data, nil
}
func splitRemoteTargets(remoteAddr string) []string {
parts := strings.Split(remoteAddr, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
out = append(out, processServerAddress(part))
}
return out
}
func parseTargetAddress(addr string) (string, int, error) {
addr = strings.TrimSpace(addr)
if addr == "" {
return "", 0, errors.New("empty address")
}
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
idx := strings.LastIndex(addr, ":")
if idx <= 0 || idx >= len(addr)-1 {
return "", 0, err
}
host = strings.TrimSpace(addr[:idx])
portStr = strings.TrimSpace(addr[idx+1:])
}
port, err := strconv.Atoi(strings.TrimSpace(portStr))
if err != nil || port <= 0 || port > 65535 {
return "", 0, errors.New("invalid port")
}
host = strings.Trim(strings.TrimSpace(host), "[]")
if host == "" {
return "", 0, errors.New("invalid host")
}
return host, port, nil
}
func buildForwardServiceBase(forwardID, userID, userTunnelID int64) string {
return fmt.Sprintf("%d_%d_%d", forwardID, userID, userTunnelID)
}
func buildForwardControlServiceNames(base, commandType string) []string {
names := []string{base + "_tcp", base + "_udp"}
if strings.EqualFold(strings.TrimSpace(commandType), "DeleteService") {
return append([]string{base}, names...)
}
return names
}
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiter *int) []map[string]interface{} {
protocols := []string{"tcp", "udp"}
services := make([]map[string]interface{}, 0, 2)
targets := splitRemoteTargets(forward.RemoteAddr)
strategy := strings.TrimSpace(forward.Strategy)
if strategy == "" {
strategy = "fifo"
}
for _, protocol := range protocols {
listenerAddr := node.TCPListenAddr
if protocol == "udp" {
listenerAddr = node.UDPListenAddr
}
service := map[string]interface{}{
"name": fmt.Sprintf("%s_%s", baseName, protocol),
"addr": fmt.Sprintf("%s:%d", listenerAddr, port),
"handler": map[string]interface{}{
"type": protocol,
},
"listener": map[string]interface{}{
"type": protocol,
},
"forwarder": map[string]interface{}{
"nodes": buildForwarderNodes(targets),
"selector": map[string]interface{}{
"strategy": strategy,
"maxFails": 1,
"failTimeout": "600s",
},
},
}
if protocol == "udp" {
service["listener"].(map[string]interface{})["metadata"] = map[string]interface{}{"keepAlive": true}
}
if tunnel != nil && tunnel.Type == 2 {
service["handler"].(map[string]interface{})["chain"] = fmt.Sprintf("chains_%d", forward.TunnelID)
}
if tunnel != nil && tunnel.Type == 1 && strings.TrimSpace(node.InterfaceName) != "" {
service["metadata"] = map[string]interface{}{"interface": node.InterfaceName}
}
if limiter != nil && *limiter > 0 {
service["limiter"] = strconv.Itoa(*limiter)
}
services = append(services, service)
}
return services
}
func buildForwarderNodes(targets []string) []map[string]interface{} {
nodes := make([]map[string]interface{}, 0, len(targets))
for i, addr := range targets {
nodes = append(nodes, map[string]interface{}{
"name": fmt.Sprintf("node_%d", i+1),
"addr": addr,
})
}
return nodes
}
func processServerAddress(serverAddr string) string {
serverAddr = strings.TrimSpace(serverAddr)
if serverAddr == "" {
return serverAddr
}
if strings.HasPrefix(serverAddr, "[") {
return serverAddr
}
idx := strings.LastIndex(serverAddr, ":")
if idx < 0 {
if looksLikeIPv6(serverAddr) {
return "[" + serverAddr + "]"
}
return serverAddr
}
host := strings.TrimSpace(serverAddr[:idx])
port := strings.TrimSpace(serverAddr[idx+1:])
if host == "" || port == "" {
return serverAddr
}
if looksLikeIPv6(host) {
return "[" + host + "]:" + port
}
return serverAddr
}
func looksLikeIPv6(address string) bool {
return strings.Count(address, ":") >= 2
}
func asBool(v interface{}, def bool) bool {
s := strings.TrimSpace(strings.ToLower(asString(v)))
if s == "" {
return def
}
switch s {
case "1", "t", "true", "yes", "y":
return true
case "0", "f", "false", "no", "n":
return false
default:
return def
}
}
@@ -0,0 +1,27 @@
package handler
import (
"reflect"
"testing"
)
func TestBuildForwardControlServiceNamesPauseResume(t *testing.T) {
base := "12_34_56"
want := []string{base + "_tcp", base + "_udp"}
for _, command := range []string{"PauseService", "ResumeService"} {
got := buildForwardControlServiceNames(base, command)
if !reflect.DeepEqual(got, want) {
t.Fatalf("command %s expected %v, got %v", command, want, got)
}
}
}
func TestBuildForwardControlServiceNamesDelete(t *testing.T) {
base := "12_34_56"
want := []string{base, base + "_tcp", base + "_udp"}
got := buildForwardControlServiceNames(base, " DeleteService ")
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected %v, got %v", want, got)
}
}
@@ -0,0 +1,338 @@
package handler
import (
"database/sql"
"encoding/json"
"strconv"
"strings"
"time"
)
const bytesPerGB int64 = 1024 * 1024 * 1024
type userTunnelPolicy struct {
ID int64
UserID int64
TunnelID int64
Flow int64
InFlow int64
OutFlow int64
ExpTime int64
Status int
}
type gostConfigSnapshot struct {
Services []namedConfigItem `json:"services"`
Chains []namedConfigItem `json:"chains"`
Limiters []namedConfigItem `json:"limiters"`
}
type namedConfigItem struct {
Name string `json:"name"`
}
func (h *Handler) processFlowItem(item flowItem) {
serviceName := strings.TrimSpace(item.N)
if serviceName == "" || serviceName == "web_api" {
return
}
forwardID, userID, userTunnelID, ok := parseFlowServiceIDs(serviceName)
if !ok {
return
}
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
if userTunnelID > 0 {
h.enforceFlowPolicies(userID, userTunnelID)
}
}
func parseFlowServiceIDs(serviceName string) (int64, int64, int64, bool) {
parts := strings.Split(serviceName, "_")
if len(parts) < 3 {
return 0, 0, 0, false
}
forwardID, err1 := strconv.ParseInt(parts[0], 10, 64)
userID, err2 := strconv.ParseInt(parts[1], 10, 64)
userTunnelID, err3 := strconv.ParseInt(parts[2], 10, 64)
if err1 != nil || err2 != nil || err3 != nil || forwardID <= 0 || userID <= 0 {
return 0, 0, 0, false
}
return forwardID, userID, userTunnelID, true
}
func (h *Handler) scaleFlowByTunnel(forwardID int64, inFlow int64, outFlow int64) (int64, int64) {
forward, err := h.getForwardRecord(forwardID)
if err != nil || forward == nil {
return inFlow, outFlow
}
tunnel, err := h.getTunnelRecord(forward.TunnelID)
if err != nil || tunnel == nil {
return inFlow, outFlow
}
scaledIn := int64(float64(inFlow)*tunnel.TrafficRatio) * tunnel.Flow
scaledOut := int64(float64(outFlow)*tunnel.TrafficRatio) * tunnel.Flow
return scaledIn, scaledOut
}
func (h *Handler) enforceFlowPolicies(userID int64, userTunnelID int64) {
now := time.Now().UnixMilli()
if h.shouldPauseUser(userID, now) {
h.pauseUserForwards(userID, now)
}
policy, err := h.getUserTunnelPolicy(userTunnelID)
if err != nil || policy == nil {
return
}
if shouldPauseUserTunnel(policy, now) {
h.pauseUserTunnelForwards(policy.UserID, policy.TunnelID, now)
}
}
func (h *Handler) shouldPauseUser(userID int64, now int64) bool {
user, err := h.repo.GetUserByID(userID)
if err != nil || user == nil {
return false
}
flowLimit := user.Flow * bytesPerGB
current := user.InFlow + user.OutFlow
if flowLimit < current {
return true
}
if user.ExpTime > 0 && user.ExpTime <= now {
return true
}
return user.Status != 1
}
func shouldPauseUserTunnel(policy *userTunnelPolicy, now int64) bool {
if policy == nil {
return false
}
flowLimit := policy.Flow * bytesPerGB
current := policy.InFlow + policy.OutFlow
if current >= flowLimit {
return true
}
if policy.ExpTime > 0 && policy.ExpTime <= now {
return true
}
return policy.Status != 1
}
func (h *Handler) getUserTunnelPolicy(userTunnelID int64) (*userTunnelPolicy, error) {
if userTunnelID <= 0 {
return nil, nil
}
row := h.repo.DB().QueryRow(`
SELECT id, user_id, tunnel_id, flow, in_flow, out_flow, exp_time, status
FROM user_tunnel
WHERE id = ?
LIMIT 1
`, userTunnelID)
var policy userTunnelPolicy
if err := row.Scan(&policy.ID, &policy.UserID, &policy.TunnelID, &policy.Flow, &policy.InFlow, &policy.OutFlow, &policy.ExpTime, &policy.Status); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return &policy, nil
}
func (h *Handler) pauseUserForwards(userID int64, now int64) {
forwards, err := h.listActiveForwardsByUser(userID)
if err != nil {
return
}
h.pauseForwardRecords(forwards, now)
}
func (h *Handler) pauseUserTunnelForwards(userID int64, tunnelID int64, now int64) {
forwards, err := h.listActiveForwardsByUserTunnel(userID, tunnelID)
if err != nil {
return
}
h.pauseForwardRecords(forwards, now)
}
func (h *Handler) pauseForwardRecords(forwards []forwardRecord, now int64) {
for i := range forwards {
forward := forwards[i]
_ = h.controlForwardServices(&forward, "PauseService", false)
_, _ = h.repo.DB().Exec(`UPDATE forward SET status = 0, updated_time = ? WHERE id = ?`, now, forward.ID)
}
}
func (h *Handler) listActiveForwardsByUser(userID int64) ([]forwardRecord, error) {
rows, err := h.repo.DB().Query(`
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, strategy, status
FROM forward
WHERE user_id = ? AND status = 1
ORDER BY id ASC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanForwardRecords(rows)
}
func (h *Handler) listActiveForwardsByUserTunnel(userID int64, tunnelID int64) ([]forwardRecord, error) {
rows, err := h.repo.DB().Query(`
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, strategy, status
FROM forward
WHERE user_id = ? AND tunnel_id = ? AND status = 1
ORDER BY id ASC
`, userID, tunnelID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanForwardRecords(rows)
}
func scanForwardRecords(rows *sql.Rows) ([]forwardRecord, error) {
out := make([]forwardRecord, 0)
for rows.Next() {
var record forwardRecord
if err := rows.Scan(&record.ID, &record.UserID, &record.UserName, &record.Name, &record.TunnelID, &record.RemoteAddr, &record.Strategy, &record.Status); err != nil {
return nil, err
}
if strings.TrimSpace(record.Strategy) == "" {
record.Strategy = "fifo"
}
out = append(out, record)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (h *Handler) cleanNodeConfigs(nodeID int64, rawConfig string) {
if h == nil || h.repo == nil || h.repo.DB() == nil || nodeID <= 0 {
return
}
if strings.TrimSpace(rawConfig) == "" {
return
}
var snapshot gostConfigSnapshot
if err := json.Unmarshal([]byte(rawConfig), &snapshot); err != nil {
return
}
h.cleanOrphanedServices(nodeID, snapshot.Services)
h.cleanOrphanedChains(nodeID, snapshot.Chains)
h.cleanOrphanedLimiters(nodeID, snapshot.Limiters)
}
func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem) {
for _, item := range services {
name := strings.TrimSpace(item.Name)
if name == "" || name == "web_api" {
continue
}
parts := strings.Split(name, "_")
if len(parts) >= 3 {
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
if err == nil && forwardID > 0 && !h.forwardExists(forwardID) {
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name, parts[0] + "_" + parts[1] + "_" + parts[2], parts[0] + "_" + parts[1] + "_" + parts[2] + "_tcp", parts[0] + "_" + parts[1] + "_" + parts[2] + "_udp"}}, false, true)
continue
}
}
suffix := parts[len(parts)-1]
switch suffix {
case "tls":
tunnelID, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || tunnelID <= 0 || h.tunnelExists(tunnelID) {
continue
}
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name}}, false, true)
case "tcp":
if len(parts) < 4 {
continue
}
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || forwardID <= 0 || h.forwardExists(forwardID) {
continue
}
base := strings.TrimSuffix(name, "_tcp")
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{base + "_tcp", base + "_udp"}}, false, true)
}
}
}
func (h *Handler) cleanOrphanedChains(nodeID int64, chains []namedConfigItem) {
for _, item := range chains {
name := strings.TrimSpace(item.Name)
if name == "" {
continue
}
idx := strings.LastIndex(name, "_")
if idx <= 0 || idx >= len(name)-1 {
continue
}
tunnelID, err := strconv.ParseInt(name[idx+1:], 10, 64)
if err != nil || tunnelID <= 0 || h.tunnelExists(tunnelID) {
continue
}
_, _ = h.sendNodeCommand(nodeID, "DeleteChains", map[string]interface{}{"chain": name}, false, true)
}
}
func (h *Handler) cleanOrphanedLimiters(nodeID int64, limiters []namedConfigItem) {
for _, item := range limiters {
name := strings.TrimSpace(item.Name)
if name == "" || h.speedLimiterExists(name) {
continue
}
_, _ = h.sendNodeCommand(nodeID, "DeleteLimiters", map[string]interface{}{"limiter": name}, false, true)
}
}
func (h *Handler) tunnelExists(tunnelID int64) bool {
var count int
err := h.repo.DB().QueryRow(`SELECT COUNT(1) FROM tunnel WHERE id = ?`, tunnelID).Scan(&count)
return err == nil && count > 0
}
func (h *Handler) forwardExists(forwardID int64) bool {
var count int
err := h.repo.DB().QueryRow(`SELECT COUNT(1) FROM forward WHERE id = ?`, forwardID).Scan(&count)
return err == nil && count > 0
}
func (h *Handler) speedLimiterExists(name string) bool {
if name == "" {
return false
}
id, err := strconv.ParseInt(name, 10, 64)
if err != nil || id <= 0 {
return false
}
var count int
err = h.repo.DB().QueryRow(`SELECT COUNT(1) FROM speed_limit WHERE id = ?`, id).Scan(&count)
return err == nil && count > 0
}
+963
View File
@@ -0,0 +1,963 @@
package handler
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/middleware"
"go-backend/internal/http/response"
"go-backend/internal/security"
"go-backend/internal/store/sqlite"
"go-backend/internal/ws"
)
type Handler struct {
repo *sqlite.Repository
jwtSecret string
wsServer *ws.Server
captchaMu sync.Mutex
captchaTokens map[string]int64
jobsMu sync.Mutex
jobsCancel context.CancelFunc
jobsStarted bool
jobsWG sync.WaitGroup
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
CaptchaID string `json:"captchaId"`
}
type nameRequest struct {
Name string `json:"name"`
}
type configSingleRequest struct {
Name string `json:"name"`
Value string `json:"value"`
}
type changePasswordRequest struct {
NewUsername string `json:"newUsername"`
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
ConfirmPassword string `json:"confirmPassword"`
}
type flowItem struct {
N string `json:"n"`
U int64 `json:"u"`
D int64 `json:"d"`
}
func New(repo *sqlite.Repository, jwtSecret string) *Handler {
return &Handler{
repo: repo,
jwtSecret: jwtSecret,
wsServer: ws.NewServer(repo, jwtSecret),
captchaTokens: make(map[string]int64),
}
}
func (h *Handler) WebSocketHandler() http.Handler {
return h.wsServer
}
func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/user/login", h.login)
mux.HandleFunc("/api/v1/user/list", h.userList)
mux.HandleFunc("/api/v1/user/create", h.userCreate)
mux.HandleFunc("/api/v1/user/update", h.userUpdate)
mux.HandleFunc("/api/v1/user/delete", h.userDelete)
mux.HandleFunc("/api/v1/user/reset", h.userResetFlow)
mux.HandleFunc("/api/v1/config/get", h.getConfigByName)
mux.HandleFunc("/api/v1/config/list", h.getConfigs)
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig)
mux.HandleFunc("/api/v1/captcha/check", h.checkCaptcha)
mux.HandleFunc("/api/v1/captcha/generate", h.captchaGenerate)
mux.HandleFunc("/api/v1/captcha/verify", h.captchaVerify)
mux.HandleFunc("/api/v1/user/package", h.userPackage)
mux.HandleFunc("/api/v1/user/updatePassword", h.updatePassword)
mux.HandleFunc("/api/v1/node/list", h.nodeList)
mux.HandleFunc("/api/v1/node/create", h.nodeCreate)
mux.HandleFunc("/api/v1/node/update", h.nodeUpdate)
mux.HandleFunc("/api/v1/node/delete", h.nodeDelete)
mux.HandleFunc("/api/v1/node/install", h.nodeInstall)
mux.HandleFunc("/api/v1/node/update-order", h.nodeUpdateOrder)
mux.HandleFunc("/api/v1/node/batch-delete", h.nodeBatchDelete)
mux.HandleFunc("/api/v1/node/check-status", h.nodeCheckStatus)
mux.HandleFunc("/api/v1/tunnel/list", h.tunnelList)
mux.HandleFunc("/api/v1/tunnel/create", h.tunnelCreate)
mux.HandleFunc("/api/v1/tunnel/get", h.tunnelGet)
mux.HandleFunc("/api/v1/tunnel/update", h.tunnelUpdate)
mux.HandleFunc("/api/v1/tunnel/delete", h.tunnelDelete)
mux.HandleFunc("/api/v1/tunnel/diagnose", h.tunnelDiagnose)
mux.HandleFunc("/api/v1/tunnel/update-order", h.tunnelUpdateOrder)
mux.HandleFunc("/api/v1/tunnel/batch-delete", h.tunnelBatchDelete)
mux.HandleFunc("/api/v1/tunnel/batch-redeploy", h.tunnelBatchRedeploy)
mux.HandleFunc("/api/v1/tunnel/user/assign", h.userTunnelAssign)
mux.HandleFunc("/api/v1/tunnel/user/batch-assign", h.userTunnelBatchAssign)
mux.HandleFunc("/api/v1/tunnel/user/remove", h.userTunnelRemove)
mux.HandleFunc("/api/v1/tunnel/user/update", h.userTunnelUpdate)
mux.HandleFunc("/api/v1/forward/list", h.forwardList)
mux.HandleFunc("/api/v1/forward/create", h.forwardCreate)
mux.HandleFunc("/api/v1/forward/update", h.forwardUpdate)
mux.HandleFunc("/api/v1/forward/delete", h.forwardDelete)
mux.HandleFunc("/api/v1/forward/force-delete", h.forwardForceDelete)
mux.HandleFunc("/api/v1/forward/pause", h.forwardPause)
mux.HandleFunc("/api/v1/forward/resume", h.forwardResume)
mux.HandleFunc("/api/v1/forward/diagnose", h.forwardDiagnose)
mux.HandleFunc("/api/v1/forward/update-order", h.forwardUpdateOrder)
mux.HandleFunc("/api/v1/forward/batch-delete", h.forwardBatchDelete)
mux.HandleFunc("/api/v1/forward/batch-pause", h.forwardBatchPause)
mux.HandleFunc("/api/v1/forward/batch-resume", h.forwardBatchResume)
mux.HandleFunc("/api/v1/forward/batch-redeploy", h.forwardBatchRedeploy)
mux.HandleFunc("/api/v1/forward/batch-change-tunnel", h.forwardBatchChangeTunnel)
mux.HandleFunc("/api/v1/speed-limit/list", h.speedLimitList)
mux.HandleFunc("/api/v1/speed-limit/create", h.speedLimitCreate)
mux.HandleFunc("/api/v1/speed-limit/update", h.speedLimitUpdate)
mux.HandleFunc("/api/v1/speed-limit/delete", h.speedLimitDelete)
mux.HandleFunc("/api/v1/speed-limit/tunnels", h.tunnelList)
mux.HandleFunc("/api/v1/tunnel/user/tunnel", h.userTunnelVisibleList)
mux.HandleFunc("/api/v1/tunnel/user/list", h.userTunnelList)
mux.HandleFunc("/api/v1/group/tunnel/list", h.tunnelGroupList)
mux.HandleFunc("/api/v1/group/tunnel/create", h.groupTunnelCreate)
mux.HandleFunc("/api/v1/group/tunnel/update", h.groupTunnelUpdate)
mux.HandleFunc("/api/v1/group/tunnel/delete", h.groupTunnelDelete)
mux.HandleFunc("/api/v1/group/tunnel/assign", h.groupTunnelAssign)
mux.HandleFunc("/api/v1/group/user/list", h.userGroupList)
mux.HandleFunc("/api/v1/group/user/create", h.groupUserCreate)
mux.HandleFunc("/api/v1/group/user/update", h.groupUserUpdate)
mux.HandleFunc("/api/v1/group/user/delete", h.groupUserDelete)
mux.HandleFunc("/api/v1/group/user/assign", h.groupUserAssign)
mux.HandleFunc("/api/v1/group/permission/list", h.groupPermissionList)
mux.HandleFunc("/api/v1/group/permission/assign", h.groupPermissionAssign)
mux.HandleFunc("/api/v1/group/permission/remove", h.groupPermissionRemove)
mux.HandleFunc("/api/v1/open_api/sub_store", h.openAPISubStore)
mux.HandleFunc("/flow/test", h.flowTest)
mux.HandleFunc("/flow/config", h.flowConfig)
mux.HandleFunc("/flow/upload", h.flowUpload)
mux.HandleFunc("/error", h.errorPage)
}
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req loginRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.Err(500, "请求参数错误"))
return
}
if strings.TrimSpace(req.Username) == "" {
response.WriteJSON(w, response.Err(500, "用户名不能为空"))
return
}
if strings.TrimSpace(req.Password) == "" {
response.WriteJSON(w, response.Err(500, "密码不能为空"))
return
}
captchaEnabled, err := h.captchaEnabled()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if captchaEnabled && strings.TrimSpace(req.CaptchaID) == "" {
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
return
}
if captchaEnabled && !h.consumeCaptchaToken(req.CaptchaID) {
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
return
}
user, err := h.repo.GetUserByUsername(req.Username)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if user == nil {
response.WriteJSON(w, response.ErrDefault("账号或密码错误"))
return
}
if user.Pwd != security.MD5(req.Password) {
response.WriteJSON(w, response.ErrDefault("账号或密码错误"))
return
}
if user.Status == 0 {
response.WriteJSON(w, response.ErrDefault("账号被停用"))
return
}
token, err := auth.GenerateToken(user.ID, user.User, user.RoleID, h.jwtSecret)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
requirePasswordChange := req.Username == "admin_user" || req.Password == "admin_user"
response.WriteJSON(w, response.OK(map[string]interface{}{
"token": token,
"name": user.User,
"role_id": user.RoleID,
"requirePasswordChange": requirePasswordChange,
}))
}
func (h *Handler) getConfigByName(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req nameRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
return
}
if strings.TrimSpace(req.Name) == "" {
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
return
}
cfg, err := h.repo.GetConfigByName(req.Name)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if cfg == nil {
response.WriteJSON(w, response.ErrDefault("配置不存在"))
return
}
response.WriteJSON(w, response.OK(cfg))
}
func (h *Handler) getConfigs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
cfgMap, err := h.repo.ListConfigs()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(cfgMap))
}
func (h *Handler) userList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
users, err := h.repo.ListUsers()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(users))
}
func (h *Handler) nodeList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
items, err := h.repo.ListNodes()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(items))
}
func (h *Handler) tunnelList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
items, err := h.repo.ListTunnels()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(items))
}
func (h *Handler) forwardList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
userID, roleID, err := userRoleFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
return
}
items, err := h.repo.ListForwards()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if roleID != 0 {
filtered := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
if asInt64(item["userId"], 0) == userID {
filtered = append(filtered, item)
}
}
items = filtered
}
response.WriteJSON(w, response.OK(items))
}
func (h *Handler) speedLimitList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
items, err := h.repo.ListSpeedLimits()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(items))
}
func (h *Handler) openAPISubStore(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if h == nil || h.repo == nil || h.repo.DB() == nil {
response.WriteJSON(w, response.Err(-2, "database unavailable"))
return
}
username := strings.TrimSpace(r.URL.Query().Get("user"))
password := strings.TrimSpace(r.URL.Query().Get("pwd"))
tunnel := strings.TrimSpace(r.URL.Query().Get("tunnel"))
if tunnel == "" {
tunnel = "-1"
}
if username == "" {
response.WriteJSON(w, response.ErrDefault("用户不能为空"))
return
}
if password == "" {
response.WriteJSON(w, response.ErrDefault("密码不能为空"))
return
}
user, err := h.repo.GetUserByUsername(username)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if user == nil || user.Pwd != security.MD5(password) {
response.WriteJSON(w, response.ErrDefault("鉴权失败"))
return
}
const giga = int64(1024 * 1024 * 1024)
headerValue := ""
if tunnel == "-1" {
headerValue = buildSubscriptionHeader(user.OutFlow, user.InFlow, user.Flow*giga, user.ExpTime/1000)
} else {
tunnelID, parseErr := strconv.ParseInt(tunnel, 10, 64)
if parseErr != nil || tunnelID <= 0 {
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
return
}
var userID int64
var inFlow int64
var outFlow int64
var flow int64
var expTime int64
err = h.repo.DB().QueryRow(`SELECT user_id, in_flow, out_flow, flow, exp_time FROM user_tunnel WHERE id = ? LIMIT 1`, tunnelID).
Scan(&userID, &inFlow, &outFlow, &flow, &expTime)
if err != nil {
if err == sql.ErrNoRows {
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
return
}
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if userID != user.ID {
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
return
}
headerValue = buildSubscriptionHeader(outFlow, inFlow, flow*giga, expTime/1000)
}
w.Header().Set("subscription-userinfo", headerValue)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte(headerValue))
}
func (h *Handler) errorPage(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("<!DOCTYPE html><html lang='zh-CN'><head><meta charset='UTF-8'><meta name='viewport' content='width=device-width, initial-scale=1.0'><title>错误 404</title></head><body><div style='min-height:100vh;display:flex;align-items:center;justify-content:center;flex-direction:column;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Arial,sans-serif;'><div style='font-size:6rem;color:#333;font-weight:300;'>404</div><div style='font-size:1.2rem;color:#666;'>你推开了后端的大门,却发现里面只有寂寞。</div></div></body></html>"))
}
func buildSubscriptionHeader(upload, download, total, expire int64) string {
return fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", download, upload, total, expire)
}
func (h *Handler) userTunnelVisibleList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
userID, roleID, err := userRoleFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
return
}
items := make([]map[string]interface{}, 0)
if roleID == 0 {
items, err = h.repo.ListEnabledTunnelSummaries()
} else {
items, err = h.repo.ListUserAccessibleTunnels(userID)
}
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(items))
}
func (h *Handler) userTunnelList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req struct {
UserID int64 `json:"userId"`
}
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if req.UserID <= 0 {
response.WriteJSON(w, response.OK([]interface{}{}))
return
}
tunnels, err := h.repo.GetUserPackageTunnels(req.UserID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
out := make([]map[string]interface{}, 0, len(tunnels))
for _, t := range tunnels {
item := map[string]interface{}{
"id": t.ID,
"userId": t.UserID,
"tunnelId": t.TunnelID,
"tunnelName": t.TunnelName,
"status": 1,
"flow": t.Flow,
"num": t.Num,
"expTime": t.ExpTime,
"flowResetTime": t.FlowResetTime,
"inFlow": t.InFlow,
"outFlow": t.OutFlow,
"tunnelFlow": t.TunnelFlow,
"speedId": nil,
"speedLimitName": nil,
}
if t.SpeedID.Valid {
item["speedId"] = t.SpeedID.Int64
}
if t.SpeedLimit.Valid {
item["speedLimitName"] = t.SpeedLimit.String
}
out = append(out, item)
}
response.WriteJSON(w, response.OK(out))
}
func (h *Handler) tunnelGroupList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
items, err := h.repo.ListTunnelGroups()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(items))
}
func (h *Handler) userGroupList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
items, err := h.repo.ListUserGroups()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(items))
}
func (h *Handler) groupPermissionList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
items, err := h.repo.ListGroupPermissions()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(items))
}
func (h *Handler) checkCaptcha(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
enabled, err := h.captchaEnabled()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if enabled {
response.WriteJSON(w, response.OK(1))
return
}
response.WriteJSON(w, response.OK(0))
}
func (h *Handler) flowTest(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("test"))
}
func (h *Handler) flowConfig(w http.ResponseWriter, r *http.Request) {
secret := r.URL.Query().Get("secret")
node, err := h.repo.GetNodeBySecret(secret)
if err != nil || node == nil {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok"))
return
}
rawData, err := readAndDecryptFlowBody(r.Body, secret)
if err == nil && strings.TrimSpace(rawData) != "" {
h.cleanNodeConfigs(node.ID, rawData)
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok"))
}
func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
secret := r.URL.Query().Get("secret")
if ok, _ := h.repo.NodeExistsBySecret(secret); !ok {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok"))
return
}
raw, err := readAndDecryptFlowBody(r.Body, secret)
if err == nil && strings.TrimSpace(raw) != "" {
var items []flowItem
if json.Unmarshal([]byte(raw), &items) == nil {
for _, item := range items {
h.processFlowItem(item)
}
}
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok"))
}
func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var payload map[string]string
if err := decodeJSON(r.Body, &payload); err != nil {
response.WriteJSON(w, response.ErrDefault("配置数据不能为空"))
return
}
if len(payload) == 0 {
response.WriteJSON(w, response.ErrDefault("配置数据不能为空"))
return
}
now := time.Now().UnixMilli()
for k, v := range payload {
key := strings.TrimSpace(k)
if key == "" {
continue
}
if err := h.repo.UpsertConfig(key, v, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
}
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
var req configSingleRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
return
}
if strings.TrimSpace(req.Name) == "" {
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
return
}
if strings.TrimSpace(req.Value) == "" {
response.WriteJSON(w, response.ErrDefault("配置值不能为空"))
return
}
if err := h.repo.UpsertConfig(strings.TrimSpace(req.Name), req.Value, time.Now().UnixMilli()); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
claims, ok := r.Context().Value(middleware.ClaimsContextKey).(auth.Claims)
if !ok {
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
return
}
userID, err := parseUserID(claims.Sub)
if err != nil {
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
return
}
user, err := h.repo.GetUserByID(userID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if user == nil {
response.WriteJSON(w, response.ErrDefault("用户不存在"))
return
}
tunnels, err := h.repo.GetUserPackageTunnels(userID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
forwards, err := h.repo.GetUserPackageForwards(userID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
stats, err := h.repo.GetStatisticsFlows(userID, 24)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
sort.Slice(stats, func(i, j int) bool { return stats[i].ID < stats[j].ID })
tunnelOut := make([]map[string]interface{}, 0, len(tunnels))
for _, t := range tunnels {
item := map[string]interface{}{
"id": t.ID,
"userId": t.UserID,
"tunnelId": t.TunnelID,
"tunnelName": t.TunnelName,
"tunnelFlow": t.TunnelFlow,
"flow": t.Flow,
"inFlow": t.InFlow,
"outFlow": t.OutFlow,
"num": t.Num,
"flowResetTime": t.FlowResetTime,
"expTime": t.ExpTime,
"speedId": nil,
"speedLimitName": nil,
"speed": nil,
}
if t.SpeedID.Valid {
item["speedId"] = t.SpeedID.Int64
}
if t.SpeedLimit.Valid {
item["speedLimitName"] = t.SpeedLimit.String
}
if t.Speed.Valid {
item["speed"] = t.Speed.Int64
}
tunnelOut = append(tunnelOut, item)
}
forwardOut := make([]map[string]interface{}, 0, len(forwards))
for _, f := range forwards {
item := map[string]interface{}{
"id": f.ID,
"name": f.Name,
"tunnelId": f.TunnelID,
"tunnelName": f.TunnelName,
"inIp": f.InIP,
"inPort": nil,
"remoteAddr": f.RemoteAddr,
"inFlow": f.InFlow,
"outFlow": f.OutFlow,
"status": f.Status,
"createdTime": f.CreatedAt,
}
if f.InPort.Valid {
item["inPort"] = f.InPort.Int64
}
forwardOut = append(forwardOut, item)
}
payload := map[string]interface{}{
"userInfo": map[string]interface{}{
"id": user.ID,
"name": user.User,
"user": user.User,
"status": user.Status,
"flow": user.Flow,
"inFlow": user.InFlow,
"outFlow": user.OutFlow,
"num": user.Num,
"expTime": user.ExpTime,
"flowResetTime": user.FlowResetTime,
"createdTime": user.CreatedTime,
"updatedTime": nullableNullInt64(user.UpdatedTime),
},
"tunnelPermissions": tunnelOut,
"forwards": forwardOut,
"statisticsFlows": stats,
}
response.WriteJSON(w, response.OK(payload))
}
func (h *Handler) updatePassword(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
claims, ok := r.Context().Value(middleware.ClaimsContextKey).(auth.Claims)
if !ok {
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
return
}
userID, err := parseUserID(claims.Sub)
if err != nil {
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
return
}
var req changePasswordRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("修改账号密码时发生错误"))
return
}
if strings.TrimSpace(req.NewUsername) == "" {
response.WriteJSON(w, response.ErrDefault("新用户名不能为空"))
return
}
if strings.TrimSpace(req.CurrentPassword) == "" {
response.WriteJSON(w, response.ErrDefault("当前密码不能为空"))
return
}
if strings.TrimSpace(req.NewPassword) == "" {
response.WriteJSON(w, response.ErrDefault("新密码不能为空"))
return
}
if strings.TrimSpace(req.ConfirmPassword) == "" {
response.WriteJSON(w, response.ErrDefault("确认密码不能为空"))
return
}
if req.NewPassword != req.ConfirmPassword {
response.WriteJSON(w, response.ErrDefault("新密码和确认密码不匹配"))
return
}
user, err := h.repo.GetUserByID(userID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if user == nil {
response.WriteJSON(w, response.ErrDefault("用户不存在"))
return
}
if user.Pwd != security.MD5(req.CurrentPassword) {
response.WriteJSON(w, response.ErrDefault("当前密码错误"))
return
}
exists, err := h.repo.UsernameExistsExceptID(req.NewUsername, userID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if exists {
response.WriteJSON(w, response.ErrDefault("用户名已存在"))
return
}
if err := h.repo.UpdateUserNameAndPassword(userID, req.NewUsername, security.MD5(req.NewPassword), time.Now().UnixMilli()); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) captchaEnabled() (bool, error) {
cfg, err := h.repo.GetConfigByName("captcha_enabled")
if err != nil {
return false, err
}
if cfg == nil {
return false, nil
}
return strings.EqualFold(cfg.Value, "true"), nil
}
func decodeJSON(body io.ReadCloser, out interface{}) error {
defer body.Close()
decoder := json.NewDecoder(body)
decoder.DisallowUnknownFields()
return decoder.Decode(out)
}
func parseUserID(sub string) (int64, error) {
id, err := strconv.ParseInt(sub, 10, 64)
if err != nil || id <= 0 {
return 0, strconv.ErrSyntax
}
return id, nil
}
func userIDFromRequest(r *http.Request) (int64, error) {
claims, ok := r.Context().Value(middleware.ClaimsContextKey).(auth.Claims)
if !ok {
return 0, strconv.ErrSyntax
}
return parseUserID(claims.Sub)
}
func userRoleFromRequest(r *http.Request) (int64, int, error) {
claims, ok := r.Context().Value(middleware.ClaimsContextKey).(auth.Claims)
if !ok {
return 0, 0, strconv.ErrSyntax
}
userID, err := parseUserID(claims.Sub)
if err != nil {
return 0, 0, err
}
return userID, claims.RoleID, nil
}
func nullableNullInt64(v sql.NullInt64) interface{} {
if v.Valid {
return v.Int64
}
return nil
}
func readAndDecryptFlowBody(body io.ReadCloser, secret string) (string, error) {
defer body.Close()
raw, err := io.ReadAll(body)
if err != nil {
return "", err
}
text := strings.TrimSpace(string(raw))
if text == "" {
return "", nil
}
var wrap struct {
Encrypted bool `json:"encrypted"`
Data string `json:"data"`
Timestamp int64 `json:"timestamp"`
}
if err := json.Unmarshal(raw, &wrap); err != nil || !wrap.Encrypted || strings.TrimSpace(wrap.Data) == "" {
return text, nil
}
crypto, err := security.NewAESCrypto(secret)
if err != nil {
return text, nil
}
plain, err := crypto.Decrypt(wrap.Data)
if err != nil {
return text, nil
}
return string(plain), nil
}
+270
View File
@@ -0,0 +1,270 @@
package handler
import (
"context"
"database/sql"
"time"
)
func (h *Handler) StartBackgroundJobs() {
if h == nil || h.repo == nil || h.repo.DB() == nil {
return
}
h.jobsMu.Lock()
if h.jobsStarted {
h.jobsMu.Unlock()
return
}
ctx, cancel := context.WithCancel(context.Background())
h.jobsCancel = cancel
h.jobsStarted = true
h.jobsWG.Add(2)
h.jobsMu.Unlock()
go h.runHourlyStatsLoop(ctx)
go h.runDailyMaintenanceLoop(ctx)
}
func (h *Handler) StopBackgroundJobs() {
if h == nil {
return
}
h.jobsMu.Lock()
if !h.jobsStarted {
h.jobsMu.Unlock()
return
}
cancel := h.jobsCancel
h.jobsCancel = nil
h.jobsStarted = false
h.jobsMu.Unlock()
if cancel != nil {
cancel()
}
h.jobsWG.Wait()
}
func (h *Handler) runHourlyStatsLoop(ctx context.Context) {
defer h.jobsWG.Done()
for {
wait := durationUntilNextHour(time.Now())
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return
case <-timer.C:
h.runStatisticsFlowJob(time.Now())
}
}
}
func (h *Handler) runDailyMaintenanceLoop(ctx context.Context) {
defer h.jobsWG.Done()
for {
wait := durationUntilNextDailyMaintenance(time.Now())
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return
case <-timer.C:
h.runResetAndExpiryJob(time.Now())
}
}
}
func durationUntilNextHour(now time.Time) time.Duration {
next := now.Truncate(time.Hour).Add(time.Hour)
return next.Sub(now)
}
func durationUntilNextDailyMaintenance(now time.Time) time.Duration {
next := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 5, 0, now.Location())
if !next.After(now) {
next = next.Add(24 * time.Hour)
}
return next.Sub(now)
}
func (h *Handler) runStatisticsFlowJob(now time.Time) {
if h == nil || h.repo == nil || h.repo.DB() == nil {
return
}
db := h.repo.DB()
nowMs := now.UnixMilli()
cutoffMs := nowMs - int64((48*time.Hour)/time.Millisecond)
_, _ = db.Exec(`DELETE FROM statistics_flow WHERE created_time < ?`, cutoffMs)
hourMark := now.Truncate(time.Hour)
hourText := hourMark.Format("15:04")
createdTime := hourMark.UnixMilli()
rows, err := db.Query(`SELECT id, in_flow, out_flow FROM user ORDER BY id ASC`)
if err != nil {
return
}
type userFlowSnapshot struct {
userID int64
inFlow int64
outFlow int64
}
users := make([]userFlowSnapshot, 0)
for rows.Next() {
var userID int64
var inFlow int64
var outFlow int64
if err := rows.Scan(&userID, &inFlow, &outFlow); err != nil {
continue
}
users = append(users, userFlowSnapshot{userID: userID, inFlow: inFlow, outFlow: outFlow})
}
_ = rows.Close()
for _, user := range users {
currentTotal := user.inFlow + user.outFlow
increment := currentTotal
var lastTotal sql.NullInt64
err := db.QueryRow(`SELECT total_flow FROM statistics_flow WHERE user_id = ? ORDER BY id DESC LIMIT 1`, user.userID).Scan(&lastTotal)
if err == nil && lastTotal.Valid {
increment = currentTotal - lastTotal.Int64
if increment < 0 {
increment = currentTotal
}
}
_, _ = db.Exec(`
INSERT INTO statistics_flow(user_id, flow, total_flow, time, created_time)
VALUES(?, ?, ?, ?, ?)
`, user.userID, increment, currentTotal, hourText, createdTime)
}
}
func (h *Handler) runResetAndExpiryJob(now time.Time) {
if h == nil || h.repo == nil || h.repo.DB() == nil {
return
}
h.resetMonthlyFlow(now)
h.disableExpiredUsers(now.UnixMilli())
h.disableExpiredUserTunnels(now.UnixMilli())
}
func (h *Handler) resetMonthlyFlow(now time.Time) {
db := h.repo.DB()
currentDay := now.Day()
lastDay := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()).Day()
if currentDay == lastDay {
_, _ = db.Exec(`
UPDATE user
SET in_flow = 0, out_flow = 0
WHERE flow_reset_time != 0
AND (flow_reset_time = ? OR flow_reset_time > ?)
`, currentDay, lastDay)
_, _ = db.Exec(`
UPDATE user_tunnel
SET in_flow = 0, out_flow = 0
WHERE flow_reset_time != 0
AND (flow_reset_time = ? OR flow_reset_time > ?)
`, currentDay, lastDay)
return
}
_, _ = db.Exec(`
UPDATE user
SET in_flow = 0, out_flow = 0
WHERE flow_reset_time != 0
AND flow_reset_time = ?
`, currentDay)
_, _ = db.Exec(`
UPDATE user_tunnel
SET in_flow = 0, out_flow = 0
WHERE flow_reset_time != 0
AND flow_reset_time = ?
`, currentDay)
}
func (h *Handler) disableExpiredUsers(nowMs int64) {
db := h.repo.DB()
rows, err := db.Query(`
SELECT id
FROM user
WHERE role_id != 0
AND status = 1
AND exp_time IS NOT NULL
AND exp_time < ?
`, nowMs)
if err != nil {
return
}
userIDs := make([]int64, 0)
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
continue
}
userIDs = append(userIDs, userID)
}
_ = rows.Close()
for _, userID := range userIDs {
forwards, err := h.listActiveForwardsByUser(userID)
if err == nil {
h.pauseForwardRecords(forwards, nowMs)
}
_, _ = db.Exec(`UPDATE user SET status = 0 WHERE id = ?`, userID)
}
}
func (h *Handler) disableExpiredUserTunnels(nowMs int64) {
db := h.repo.DB()
rows, err := db.Query(`
SELECT id, user_id, tunnel_id
FROM user_tunnel
WHERE status = 1
AND exp_time IS NOT NULL
AND exp_time < ?
`, nowMs)
if err != nil {
return
}
type expiredUserTunnel struct {
userTunnelID int64
userID int64
tunnelID int64
}
items := make([]expiredUserTunnel, 0)
for rows.Next() {
var userTunnelID int64
var userID int64
var tunnelID int64
if err := rows.Scan(&userTunnelID, &userID, &tunnelID); err != nil {
continue
}
items = append(items, expiredUserTunnel{userTunnelID: userTunnelID, userID: userID, tunnelID: tunnelID})
}
_ = rows.Close()
for _, item := range items {
forwards, err := h.listActiveForwardsByUserTunnel(item.userID, item.tunnelID)
if err == nil {
h.pauseForwardRecords(forwards, nowMs)
}
_, _ = db.Exec(`UPDATE user_tunnel SET status = 0 WHERE id = ?`, item.userTunnelID)
}
}
@@ -0,0 +1,128 @@
package handler
import (
"path/filepath"
"testing"
"time"
"go-backend/internal/store/sqlite"
)
func TestRunStatisticsFlowJobTracksIncrementAndPrunes(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "jobs-stats.db")
repo, err := sqlite.Open(dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
h := New(repo, "secret")
now := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)
nowMs := now.UnixMilli()
if _, err := repo.DB().Exec(`UPDATE user SET in_flow = 100, out_flow = 200 WHERE id = 1`); err != nil {
t.Fatalf("seed user flow: %v", err)
}
if _, err := repo.DB().Exec(`INSERT INTO statistics_flow(user_id, flow, total_flow, time, created_time) VALUES(1, 250, 250, '11:00', ?)`, now.Add(-time.Hour).UnixMilli()); err != nil {
t.Fatalf("seed recent statistics row: %v", err)
}
if _, err := repo.DB().Exec(`INSERT INTO statistics_flow(user_id, flow, total_flow, time, created_time) VALUES(1, 10, 10, '00:00', ?)`, now.Add(-49*time.Hour).UnixMilli()); err != nil {
t.Fatalf("seed stale statistics row: %v", err)
}
h.runStatisticsFlowJob(now)
var staleCount int
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM statistics_flow WHERE created_time < ?`, nowMs-int64((48*time.Hour)/time.Millisecond)).Scan(&staleCount); err != nil {
t.Fatalf("query stale statistics rows: %v", err)
}
if staleCount != 0 {
t.Fatalf("expected stale statistics rows to be pruned, got %d", staleCount)
}
var flow int64
var total int64
var hour string
if err := repo.DB().QueryRow(`SELECT flow, total_flow, time FROM statistics_flow WHERE user_id = 1 ORDER BY id DESC LIMIT 1`).Scan(&flow, &total, &hour); err != nil {
t.Fatalf("query latest statistics row: %v", err)
}
if flow != 50 {
t.Fatalf("expected increment flow 50, got %d", flow)
}
if total != 300 {
t.Fatalf("expected total flow 300, got %d", total)
}
if hour != "12:00" {
t.Fatalf("expected hour mark 12:00, got %s", hour)
}
}
func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "jobs-reset.db")
repo, err := sqlite.Open(dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
h := New(repo, "secret")
now := time.Date(2026, 3, 15, 0, 0, 5, 0, time.UTC)
nowMs := now.UnixMilli()
if _, err := repo.DB().Exec(`
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(2, 'expired_user', 'x', 1, ?, 100, 1000, 2000, 15, 1, ?, ?, 1)
`, nowMs-1000, nowMs, nowMs); err != nil {
t.Fatalf("insert expired user: %v", err)
}
if _, err := repo.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(1, 't1', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
`, nowMs, nowMs); err != nil {
t.Fatalf("insert tunnel: %v", err)
}
if _, err := repo.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(10, 2, 1, NULL, 1, 1, 300, 400, 15, ?, 1)
`, nowMs-1000); err != nil {
t.Fatalf("insert expired user_tunnel: %v", err)
}
if _, err := repo.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(20, 2, 'expired_user', 'f1', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, nowMs, nowMs); err != nil {
t.Fatalf("insert forward: %v", err)
}
h.runResetAndExpiryJob(now)
var userIn, userOut int64
var userStatus int
if err := repo.DB().QueryRow(`SELECT in_flow, out_flow, status FROM user WHERE id = 2`).Scan(&userIn, &userOut, &userStatus); err != nil {
t.Fatalf("query user after maintenance: %v", err)
}
if userIn != 0 || userOut != 0 || userStatus != 0 {
t.Fatalf("expected user reset+disabled, got in=%d out=%d status=%d", userIn, userOut, userStatus)
}
var utIn, utOut int64
var utStatus int
if err := repo.DB().QueryRow(`SELECT in_flow, out_flow, status FROM user_tunnel WHERE id = 10`).Scan(&utIn, &utOut, &utStatus); err != nil {
t.Fatalf("query user_tunnel after maintenance: %v", err)
}
if utIn != 0 || utOut != 0 || utStatus != 0 {
t.Fatalf("expected user_tunnel reset+disabled, got in=%d out=%d status=%d", utIn, utOut, utStatus)
}
var forwardStatus int
if err := repo.DB().QueryRow(`SELECT status FROM forward WHERE id = 20`).Scan(&forwardStatus); err != nil {
t.Fatalf("query forward after maintenance: %v", err)
}
if forwardStatus != 0 {
t.Fatalf("expected forward status=0 after expiry handling, got %d", forwardStatus)
}
}
File diff suppressed because it is too large Load Diff
+117
View File
@@ -0,0 +1,117 @@
package middleware
import (
"context"
"net/http"
"strings"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
type contextKey string
const ClaimsContextKey contextKey = "claims"
type AuthOptions struct {
JWTSecret string
}
func JWT(opts AuthOptions) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if shouldSkip(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
if !strings.HasPrefix(r.URL.Path, "/api/") {
next.ServeHTTP(w, r)
return
}
token := strings.TrimSpace(r.Header.Get("Authorization"))
if token == "" {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return
}
claims, ok := auth.ValidateToken(token, opts.JWTSecret)
if !ok {
response.WriteJSON(w, response.Err(401, "无效的token或token已过期"))
return
}
if requiresAdmin(r.URL.Path) && claims.RoleID != 0 {
response.WriteJSON(w, response.Err(403, "权限不足,仅管理员可操作"))
return
}
ctx := context.WithValue(r.Context(), ClaimsContextKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := r.Context().Value(ClaimsContextKey)
claims, ok := raw.(auth.Claims)
if !ok {
response.WriteJSON(w, response.Err(401, "无法获取用户权限信息"))
return
}
if claims.RoleID != 0 {
response.WriteJSON(w, response.Err(403, "权限不足,仅管理员可操作"))
return
}
next.ServeHTTP(w, r)
})
}
func shouldSkip(path string) bool {
switch {
case strings.HasPrefix(path, "/flow/"):
return true
case strings.HasPrefix(path, "/api/v1/open_api/"):
return true
case strings.HasPrefix(path, "/api/v1/captcha/"):
return true
case path == "/api/v1/config/get":
return true
case path == "/api/v1/user/login":
return true
default:
return false
}
}
func requiresAdmin(path string) bool {
if strings.HasPrefix(path, "/api/v1/group/") {
return true
}
if strings.HasPrefix(path, "/api/v1/node/") {
return true
}
if strings.HasPrefix(path, "/api/v1/speed-limit/") {
return true
}
if strings.HasPrefix(path, "/api/v1/tunnel/") {
if strings.HasPrefix(path, "/api/v1/tunnel/user/tunnel") {
return false
}
return true
}
switch path {
case "/api/v1/user/create", "/api/v1/user/list", "/api/v1/user/update", "/api/v1/user/delete", "/api/v1/user/reset":
return true
case "/api/v1/config/update", "/api/v1/config/update-single":
return true
default:
return false
}
}
@@ -0,0 +1,17 @@
package middleware
import "net/http"
func CORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS")
w.Header().Set("Access-Control-Expose-Headers", "Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
@@ -0,0 +1,19 @@
package middleware
import (
"fmt"
"net/http"
"go-backend/internal/http/response"
)
func Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprint(rec)))
}
}()
next.ServeHTTP(w, r)
})
}
@@ -0,0 +1,57 @@
package middleware
import (
"bufio"
"io"
"log"
"net"
"net/http"
"time"
)
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hj, ok := w.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, http.ErrNotSupported
}
return hj.Hijack()
}
func (w *statusWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (w *statusWriter) ReadFrom(r io.Reader) (int64, error) {
if rf, ok := w.ResponseWriter.(io.ReaderFrom); ok {
return rf.ReadFrom(r)
}
return io.Copy(w.ResponseWriter, r)
}
func (w *statusWriter) Push(target string, opts *http.PushOptions) error {
if p, ok := w.ResponseWriter.(http.Pusher); ok {
return p.Push(target, opts)
}
return http.ErrNotSupported
}
func RequestLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
start := time.Now()
next.ServeHTTP(sw, r)
log.Printf("%s %s -> %d (%s)", r.Method, r.URL.Path, sw.status, time.Since(start).String())
})
}
+48
View File
@@ -0,0 +1,48 @@
package response
import (
"encoding/json"
"net/http"
"time"
)
type R struct {
Code int `json:"code"`
Msg string `json:"msg"`
TS int64 `json:"ts"`
Data interface{} `json:"data,omitempty"`
}
func OK(data interface{}) R {
return R{
Code: 0,
Msg: "操作成功",
TS: time.Now().UnixMilli(),
Data: data,
}
}
func OKEmpty() R {
return R{
Code: 0,
Msg: "操作成功",
TS: time.Now().UnixMilli(),
}
}
func Err(code int, msg string) R {
return R{
Code: code,
Msg: msg,
TS: time.Now().UnixMilli(),
}
}
func ErrDefault(msg string) R {
return Err(-1, msg)
}
func WriteJSON(w http.ResponseWriter, payload R) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(payload)
}
+20
View File
@@ -0,0 +1,20 @@
package httpserver
import (
"net/http"
"go-backend/internal/http/handler"
"go-backend/internal/http/middleware"
)
func NewRouter(h *handler.Handler, jwtSecret string) http.Handler {
mux := http.NewServeMux()
h.Register(mux)
mux.Handle("/system-info", h.WebSocketHandler())
wrapped := middleware.Recover(mux)
wrapped = middleware.JWT(middleware.AuthOptions{JWTSecret: jwtSecret})(wrapped)
wrapped = middleware.RequestLog(wrapped)
wrapped = middleware.CORS(wrapped)
return wrapped
}
+65
View File
@@ -0,0 +1,65 @@
package security
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
)
type AESCrypto struct {
key []byte
}
func NewAESCrypto(secret string) (*AESCrypto, error) {
if secret == "" {
return nil, fmt.Errorf("secret is empty")
}
hash := sha256.Sum256([]byte(secret))
return &AESCrypto{key: hash[:]}, nil
}
func (a *AESCrypto) Encrypt(plain []byte) (string, error) {
if len(plain) == 0 {
return "", fmt.Errorf("empty plaintext")
}
block, err := aes.NewCipher(a.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
sealed := gcm.Seal(nil, nonce, plain, nil)
data := append(nonce, sealed...)
return base64.StdEncoding.EncodeToString(data), nil
}
func (a *AESCrypto) Decrypt(cipherText string) ([]byte, error) {
raw, err := base64.StdEncoding.DecodeString(cipherText)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(a.key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(raw) < nonceSize {
return nil, fmt.Errorf("ciphertext too short")
}
nonce := raw[:nonceSize]
data := raw[nonceSize:]
return gcm.Open(nil, nonce, data, nil)
}
+11
View File
@@ -0,0 +1,11 @@
package security
import (
"crypto/md5"
"fmt"
)
func MD5(input string) string {
hash := md5.Sum([]byte(input))
return fmt.Sprintf("%x", hash)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
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);
@@ -0,0 +1,182 @@
-- SQLite Auto-generated schema
-- This will be executed automatically on startup if tables don't exist
CREATE TABLE IF NOT EXISTS forward (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
user_name VARCHAR(100) NOT NULL,
name VARCHAR(100) NOT NULL,
tunnel_id INTEGER NOT NULL,
remote_addr TEXT NOT NULL,
strategy VARCHAR(100) NOT NULL DEFAULT 'fifo',
in_flow INTEGER NOT NULL DEFAULT 0,
out_flow INTEGER NOT NULL DEFAULT 0,
created_time INTEGER NOT NULL,
updated_time INTEGER NOT NULL,
status INTEGER NOT NULL,
inx INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS forward_port (
id INTEGER PRIMARY KEY AUTOINCREMENT,
forward_id INTEGER NOT NULL,
node_id INTEGER NOT NULL,
port INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS node (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
secret VARCHAR(100) NOT NULL,
server_ip VARCHAR(100) NOT NULL,
server_ip_v4 VARCHAR(100),
server_ip_v6 VARCHAR(100),
port TEXT NOT NULL,
interface_name VARCHAR(200),
version VARCHAR(100),
http INTEGER NOT NULL DEFAULT 0,
tls INTEGER NOT NULL DEFAULT 0,
socks INTEGER NOT NULL DEFAULT 0,
created_time INTEGER NOT NULL,
updated_time INTEGER,
status INTEGER NOT NULL,
tcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
udp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
inx INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS speed_limit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
speed INTEGER NOT NULL,
tunnel_id INTEGER NOT NULL,
tunnel_name VARCHAR(100) NOT NULL,
created_time INTEGER NOT NULL,
updated_time INTEGER,
status INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS statistics_flow (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
flow INTEGER NOT NULL,
total_flow INTEGER NOT NULL,
time VARCHAR(100) NOT NULL,
created_time INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tunnel (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
traffic_ratio REAL NOT NULL DEFAULT 1.0,
type INTEGER NOT NULL,
protocol VARCHAR(10) NOT NULL DEFAULT 'tls',
flow INTEGER NOT NULL,
created_time INTEGER NOT NULL,
updated_time INTEGER NOT NULL,
status INTEGER NOT NULL,
in_ip TEXT,
inx INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS chain_tunnel (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tunnel_id INTEGER NOT NULL ,
chain_type VARCHAR(10) NOT NULL,
node_id INTEGER NOT NULL ,
port INTEGER,
strategy VARCHAR(10),
inx INTEGER,
protocol VARCHAR(10)
);
CREATE TABLE IF NOT EXISTS user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user VARCHAR(100) NOT NULL,
pwd VARCHAR(100) NOT NULL,
role_id INTEGER NOT NULL,
exp_time INTEGER NOT NULL,
flow INTEGER NOT NULL,
in_flow INTEGER NOT NULL DEFAULT 0,
out_flow INTEGER NOT NULL DEFAULT 0,
flow_reset_time INTEGER NOT NULL,
num INTEGER NOT NULL,
created_time INTEGER NOT NULL,
updated_time INTEGER,
status INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_tunnel (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
tunnel_id INTEGER NOT NULL,
speed_id INTEGER,
num INTEGER NOT NULL,
flow INTEGER NOT NULL,
in_flow INTEGER NOT NULL DEFAULT 0,
out_flow INTEGER NOT NULL DEFAULT 0,
flow_reset_time INTEGER NOT NULL,
exp_time INTEGER NOT NULL,
status INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tunnel_group (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
created_time INTEGER NOT NULL,
updated_time INTEGER NOT NULL,
status INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_group (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
created_time INTEGER NOT NULL,
updated_time INTEGER NOT NULL,
status INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tunnel_group_tunnel (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tunnel_group_id INTEGER NOT NULL,
tunnel_id INTEGER NOT NULL,
created_time INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_group_user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_group_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
created_time INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS group_permission (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_group_id INTEGER NOT NULL,
tunnel_group_id INTEGER NOT NULL,
created_time INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS group_permission_grant (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_group_id INTEGER NOT NULL,
tunnel_group_id INTEGER NOT NULL,
user_tunnel_id INTEGER NOT NULL,
created_by_group INTEGER NOT NULL DEFAULT 0,
created_time INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_name ON tunnel_group(name);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_name ON user_group(name);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_tunnel_unique ON tunnel_group_tunnel(tunnel_group_id, tunnel_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_user_unique ON user_group_user(user_group_id, user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_unique ON group_permission(user_group_id, tunnel_group_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_grant_unique ON group_permission_grant(user_group_id, tunnel_group_id, user_tunnel_id);
CREATE TABLE IF NOT EXISTS vite_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(200) NOT NULL UNIQUE,
value VARCHAR(200) NOT NULL,
time INTEGER NOT NULL
);
+430
View File
@@ -0,0 +1,430 @@
package ws
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"go-backend/internal/auth"
"go-backend/internal/security"
"go-backend/internal/store/sqlite"
)
type encryptedMessage struct {
Encrypted bool `json:"encrypted"`
Data string `json:"data"`
Timestamp int64 `json:"timestamp"`
}
type broadcastMessage struct {
ID int64 `json:"id"`
Type string `json:"type"`
Data string `json:"data"`
}
type connWrap struct {
conn *websocket.Conn
mu sync.Mutex
}
type nodeSession struct {
nodeID int64
secret string
conn *connWrap
}
type commandResponse struct {
Type string `json:"type"`
Success bool `json:"success"`
Message string `json:"message"`
Data json.RawMessage `json:"data,omitempty"`
RequestID string `json:"requestId,omitempty"`
}
type pendingRequest struct {
nodeID int64
ch chan CommandResult
}
type CommandResult struct {
Type string `json:"type"`
Success bool `json:"success"`
Message string `json:"message"`
Data map[string]interface{} `json:"data,omitempty"`
}
type Server struct {
repo *sqlite.Repository
jwtSecret string
upgrader websocket.Upgrader
mu sync.RWMutex
admins map[*connWrap]struct{}
nodes map[int64]*nodeSession
byConn map[*websocket.Conn]*nodeSession
pending map[string]pendingRequest
}
func NewServer(repo *sqlite.Repository, jwtSecret string) *Server {
return &Server{
repo: repo,
jwtSecret: jwtSecret,
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
},
admins: make(map[*connWrap]struct{}),
nodes: make(map[int64]*nodeSession),
byConn: make(map[*websocket.Conn]*nodeSession),
pending: make(map[string]pendingRequest),
}
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
typeVal := query.Get("type")
secret := query.Get("secret")
if typeVal == "1" {
node, err := s.repo.GetNodeBySecret(secret)
if err != nil || node == nil {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
s.handleNode(w, r, node.ID, secret)
return
}
if typeVal == "0" {
if _, ok := auth.ValidateToken(secret, s.jwtSecret); !ok {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
s.handleAdmin(w, r)
return
}
http.Error(w, "bad request", http.StatusBadRequest)
}
func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
cw := &connWrap{conn: conn}
s.mu.Lock()
s.admins[cw] = struct{}{}
s.mu.Unlock()
defer func() {
s.mu.Lock()
delete(s.admins, cw)
s.mu.Unlock()
_ = conn.Close()
}()
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}
func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64, secret string) {
conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
cw := &connWrap{conn: conn}
version := r.URL.Query().Get("version")
httpVal := parseIntDefault(r.URL.Query().Get("http"), 0)
tlsVal := parseIntDefault(r.URL.Query().Get("tls"), 0)
socksVal := parseIntDefault(r.URL.Query().Get("socks"), 0)
s.mu.Lock()
if old, ok := s.nodes[nodeID]; ok {
_ = old.conn.conn.Close()
delete(s.byConn, old.conn.conn)
}
ns := &nodeSession{nodeID: nodeID, secret: secret, conn: cw}
s.nodes[nodeID] = ns
s.byConn[conn] = ns
s.mu.Unlock()
_ = s.repo.UpdateNodeOnline(nodeID, 1, version, httpVal, tlsVal, socksVal)
s.broadcastStatus(nodeID, 1)
defer func() {
needOfflineBroadcast := false
s.mu.Lock()
current, ok := s.nodes[nodeID]
if ok && current.conn.conn == conn {
delete(s.nodes, nodeID)
needOfflineBroadcast = true
}
delete(s.byConn, conn)
s.mu.Unlock()
if needOfflineBroadcast {
s.failPendingForNode(nodeID, "节点连接已断开")
_ = s.repo.UpdateNodeStatus(nodeID, 0)
s.broadcastStatus(nodeID, 0)
}
_ = conn.Close()
}()
for {
_, payload, err := conn.ReadMessage()
if err != nil {
return
}
msg := decryptIfNeeded(payload, secret)
s.tryResolvePending(nodeID, msg)
s.broadcastInfo(nodeID, msg)
}
}
func (s *Server) SendCommand(nodeID int64, cmdType string, data interface{}, timeout time.Duration) (CommandResult, error) {
if s == nil {
return CommandResult{}, errors.New("server not initialized")
}
if strings.TrimSpace(cmdType) == "" {
return CommandResult{}, errors.New("command type is empty")
}
if timeout <= 0 {
timeout = 10 * time.Second
}
s.mu.RLock()
ns, ok := s.nodes[nodeID]
s.mu.RUnlock()
if !ok || ns == nil || ns.conn == nil || ns.conn.conn == nil {
return CommandResult{}, errors.New("节点不在线")
}
requestID := fmt.Sprintf("%d_%d", nodeID, time.Now().UnixNano())
ch := make(chan CommandResult, 1)
s.mu.Lock()
s.pending[requestID] = pendingRequest{nodeID: nodeID, ch: ch}
s.mu.Unlock()
cleanup := func() {
s.mu.Lock()
if p, exists := s.pending[requestID]; exists {
delete(s.pending, requestID)
close(p.ch)
}
s.mu.Unlock()
}
cmdPayload := map[string]interface{}{
"type": cmdType,
"data": data,
"requestId": requestID,
}
rawCmd, err := json.Marshal(cmdPayload)
if err != nil {
cleanup()
return CommandResult{}, err
}
messageData := rawCmd
if strings.TrimSpace(ns.secret) != "" {
crypto, err := security.NewAESCrypto(ns.secret)
if err != nil {
cleanup()
return CommandResult{}, err
}
encrypted, err := crypto.Encrypt(rawCmd)
if err != nil {
cleanup()
return CommandResult{}, err
}
wrapper := map[string]interface{}{
"encrypted": true,
"data": encrypted,
"timestamp": time.Now().UnixMilli(),
}
messageData, err = json.Marshal(wrapper)
if err != nil {
cleanup()
return CommandResult{}, err
}
}
ns.conn.mu.Lock()
err = ns.conn.conn.WriteMessage(websocket.TextMessage, messageData)
ns.conn.mu.Unlock()
if err != nil {
cleanup()
return CommandResult{}, err
}
select {
case result, ok := <-ch:
if !ok {
return CommandResult{}, errors.New("命令通道已关闭")
}
if !result.Success {
if strings.TrimSpace(result.Message) == "" {
result.Message = "命令执行失败"
}
return result, errors.New(result.Message)
}
return result, nil
case <-time.After(timeout):
cleanup()
return CommandResult{}, errors.New("等待节点响应超时")
}
}
func (s *Server) tryResolvePending(nodeID int64, message string) {
if s == nil || strings.TrimSpace(message) == "" {
return
}
var resp commandResponse
if err := json.Unmarshal([]byte(message), &resp); err != nil {
return
}
if strings.TrimSpace(resp.RequestID) == "" {
return
}
s.mu.Lock()
p, ok := s.pending[resp.RequestID]
if ok {
delete(s.pending, resp.RequestID)
}
s.mu.Unlock()
if !ok {
return
}
if p.nodeID != nodeID {
select {
case p.ch <- CommandResult{Type: resp.Type, Success: false, Message: "节点响应与请求不匹配"}:
default:
}
close(p.ch)
return
}
result := CommandResult{
Type: resp.Type,
Success: resp.Success,
Message: resp.Message,
}
if len(resp.Data) > 0 {
var data map[string]interface{}
if err := json.Unmarshal(resp.Data, &data); err == nil {
result.Data = data
}
}
select {
case p.ch <- result:
default:
}
close(p.ch)
}
func (s *Server) failPendingForNode(nodeID int64, message string) {
if s == nil {
return
}
type pair struct {
id string
pr pendingRequest
}
items := make([]pair, 0)
s.mu.Lock()
for id, pr := range s.pending {
if pr.nodeID != nodeID {
continue
}
items = append(items, pair{id: id, pr: pr})
delete(s.pending, id)
}
s.mu.Unlock()
for _, item := range items {
select {
case item.pr.ch <- CommandResult{Success: false, Message: message}:
default:
}
close(item.pr.ch)
}
}
func (s *Server) broadcastStatus(nodeID int64, status int) {
payload := map[string]interface{}{
"id": strconv.FormatInt(nodeID, 10),
"type": "status",
"data": status,
}
raw, _ := json.Marshal(payload)
s.broadcastToAdmins(string(raw))
}
func (s *Server) broadcastInfo(nodeID int64, data string) {
payload := broadcastMessage{ID: nodeID, Type: "info", Data: data}
raw, _ := json.Marshal(payload)
s.broadcastToAdmins(string(raw))
}
func (s *Server) broadcastToAdmins(message string) {
s.mu.RLock()
admins := make([]*connWrap, 0, len(s.admins))
for c := range s.admins {
admins = append(admins, c)
}
s.mu.RUnlock()
for _, c := range admins {
c.mu.Lock()
err := c.conn.WriteMessage(websocket.TextMessage, []byte(message))
c.mu.Unlock()
if err != nil {
log.Printf("websocket broadcast failed: %v", err)
}
}
}
func decryptIfNeeded(payload []byte, secret string) string {
text := string(payload)
var wrap encryptedMessage
if err := json.Unmarshal(payload, &wrap); err != nil || !wrap.Encrypted || strings.TrimSpace(wrap.Data) == "" {
return text
}
crypto, err := security.NewAESCrypto(secret)
if err != nil {
return text
}
plain, err := crypto.Decrypt(wrap.Data)
if err != nil {
return text
}
return string(plain)
}
func parseIntDefault(v string, fallback int) int {
x, err := strconv.Atoi(v)
if err != nil {
return fallback
}
return x
}
@@ -0,0 +1,90 @@
package contract_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"go-backend/internal/auth"
"go-backend/internal/http/middleware"
"go-backend/internal/http/response"
)
func TestJWTMiddlewareContracts(t *testing.T) {
secret := "unit-test-secret"
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OK("pass"))
})
wrapped := middleware.JWT(middleware.AuthOptions{JWTSecret: secret})(next)
t.Run("login path is excluded", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", nil)
res := httptest.NewRecorder()
wrapped.ServeHTTP(res, req)
assertCode(t, res, 0)
})
t.Run("missing token returns 401 contract message", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
res := httptest.NewRecorder()
wrapped.ServeHTTP(res, req)
assertCodeMsg(t, res, 401, "未登录或token已过期")
})
t.Run("invalid token returns 401 contract message", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
req.Header.Set("Authorization", "invalid.token.value")
res := httptest.NewRecorder()
wrapped.ServeHTTP(res, req)
assertCodeMsg(t, res, 401, "无效的token或token已过期")
})
t.Run("valid token reaches next", func(t *testing.T) {
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
wrapped.ServeHTTP(res, req)
assertCode(t, res, 0)
})
t.Run("non-admin blocked on admin path", func(t *testing.T) {
token, err := auth.GenerateToken(2, "normal_user", 1, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/update", nil)
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
wrapped.ServeHTTP(res, req)
assertCodeMsg(t, res, 403, "权限不足,仅管理员可操作")
})
}
func assertCode(t *testing.T, rec *httptest.ResponseRecorder, expected int) {
t.Helper()
var out response.R
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != expected {
t.Fatalf("expected code %d, got %d", expected, out.Code)
}
}
func assertCodeMsg(t *testing.T, rec *httptest.ResponseRecorder, expectedCode int, expectedMsg string) {
t.Helper()
var out response.R
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != expectedCode || out.Msg != expectedMsg {
t.Fatalf("expected (%d,%q), got (%d,%q)", expectedCode, expectedMsg, out.Code, out.Msg)
}
}
@@ -0,0 +1,239 @@
package contract
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"go-backend/internal/auth"
httpserver "go-backend/internal/http"
"go-backend/internal/http/handler"
"go-backend/internal/http/response"
"go-backend/internal/store/sqlite"
)
func TestDiagnosisChainCoverageContracts(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupDiagnosisContractRouter(t, secret)
now := time.Now().UnixMilli()
if _, err := repo.DB().Exec(`
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(2, 'normal_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now); err != nil {
t.Fatalf("insert user: %v", err)
}
tunnelRes, err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "diagnose-chain-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0)
if err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID, err := tunnelRes.LastInsertId()
if err != nil {
t.Fatalf("get tunnel id: %v", err)
}
insertNode := func(name, ip string) int64 {
res, err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, name+"-secret", ip, ip, "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
if err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("get node id %s: %v", name, err)
}
return id
}
entryNodeID := insertNode("entry-node", "10.0.1.10")
chainNodeID := insertNode("chain-node", "10.0.1.20")
exitNodeID := insertNode("exit-node", "10.0.1.30")
if _, err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 30001, 'round', 1, 'tls')
`, tunnelID, entryNodeID); err != nil {
t.Fatalf("insert entry chain: %v", err)
}
if _, err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 2, ?, 30002, 'round', 1, 'tls')
`, tunnelID, chainNodeID); err != nil {
t.Fatalf("insert middle chain: %v", err)
}
if _, err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 3, ?, 30003, 'round', 1, 'tls')
`, tunnelID, exitNodeID); err != nil {
t.Fatalf("insert exit chain: %v", err)
}
forwardRes, err := repo.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
`, 2, "normal_user", "chain-forward", tunnelID, "8.8.8.8:53", "fifo", now, now, 0)
if err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID, err := forwardRes.LastInsertId()
if err != nil {
t.Fatalf("get forward id: %v", err)
}
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
if err != nil {
t.Fatalf("generate user token: %v", err)
}
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
t.Run("forward diagnose includes entry chain exit paths", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/diagnose", bytes.NewBufferString(`{"forwardId":`+strconv.FormatInt(forwardID, 10)+`}`))
req.Header.Set("Authorization", userToken)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
}
payload, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected object payload, got %T", out.Data)
}
results, ok := payload["results"].([]interface{})
if !ok || len(results) == 0 {
t.Fatalf("expected non-empty results, got %v", payload["results"])
}
hasEntryToChain := false
hasChainToExit := false
hasExitToTarget := false
for _, raw := range results {
item, ok := raw.(map[string]interface{})
if !ok {
t.Fatalf("expected result object, got %T", raw)
}
if strings.TrimSpace(valueAsString(item["message"])) == "" {
t.Fatalf("expected non-empty message field")
}
from := valueAsInt(item["fromChainType"])
to := valueAsInt(item["toChainType"])
if from == 1 && to == 2 {
hasEntryToChain = true
}
if from == 2 && to == 3 {
hasChainToExit = true
}
if from == 3 {
hasExitToTarget = true
}
}
if !hasEntryToChain || !hasChainToExit || !hasExitToTarget {
t.Fatalf("expected entry->chain, chain->exit, exit->target coverage; got entry=%v chain=%v exit=%v", hasEntryToChain, hasChainToExit, hasExitToTarget)
}
})
t.Run("tunnel diagnose includes entry chain exit groups", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/diagnose", bytes.NewBufferString(`{"tunnelId":`+strconv.FormatInt(tunnelID, 10)+`}`))
req.Header.Set("Authorization", adminToken)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
}
payload, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected object payload, got %T", out.Data)
}
results, ok := payload["results"].([]interface{})
if !ok || len(results) == 0 {
t.Fatalf("expected non-empty results, got %v", payload["results"])
}
hasEntry := false
hasChain := false
hasExit := false
for _, raw := range results {
item, ok := raw.(map[string]interface{})
if !ok {
t.Fatalf("expected result object, got %T", raw)
}
if strings.TrimSpace(valueAsString(item["message"])) == "" {
t.Fatalf("expected non-empty message field")
}
switch valueAsInt(item["fromChainType"]) {
case 1:
hasEntry = true
case 2:
hasChain = true
case 3:
hasExit = true
}
}
if !hasEntry || !hasChain || !hasExit {
t.Fatalf("expected entry/chain/exit groups, got entry=%v chain=%v exit=%v", hasEntry, hasChain, hasExit)
}
})
}
func valueAsInt(v interface{}) int {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
case int64:
return int(n)
default:
return 0
}
}
func valueAsString(v interface{}) string {
s, _ := v.(string)
return s
}
func setupDiagnosisContractRouter(t *testing.T, jwtSecret string) (http.Handler, *sqlite.Repository) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "diagnosis-contract.db")
repo, err := sqlite.Open(dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
_ = repo.Close()
})
h := handler.New(repo, jwtSecret)
return httpserver.NewRouter(h, jwtSecret), repo
}
@@ -0,0 +1,44 @@
package contract_test
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"go-backend/internal/http/handler"
)
func TestFlowEndpointsStringResponses(t *testing.T) {
h := handler.New(nil, "secret")
mux := http.NewServeMux()
h.Register(mux)
tests := []struct {
name string
method string
path string
expected string
}{
{name: "flow test", method: http.MethodGet, path: "/flow/test", expected: "test"},
{name: "flow config", method: http.MethodPost, path: "/flow/config?secret=abc", expected: "ok"},
{name: "flow upload", method: http.MethodPost, path: "/flow/upload?secret=abc", expected: "ok"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(tc.method, tc.path, nil)
res := httptest.NewRecorder()
mux.ServeHTTP(res, req)
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if string(body) != tc.expected {
t.Fatalf("expected %q, got %q", tc.expected, string(body))
}
})
}
}
@@ -0,0 +1,202 @@
package contract_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
func TestForwardOwnershipAndScopeContracts(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
if _, err := repo.DB().Exec(`
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(2, 'normal_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now); err != nil {
t.Fatalf("insert user: %v", err)
}
res, err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "contract-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0)
if err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID, err := res.LastInsertId()
if err != nil {
t.Fatalf("get tunnel id: %v", err)
}
nodeRes, err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "entry-node", "entry-secret", "10.0.0.10", "10.0.0.10", "", "20000-20010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
if err != nil {
t.Fatalf("insert node: %v", err)
}
entryNodeID, err := nodeRes.LastInsertId()
if err != nil {
t.Fatalf("get node id: %v", err)
}
if _, err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 20001, 'round', 1, 'tls')
`, tunnelID, entryNodeID); err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
resAdmin, err := repo.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
`, 1, "admin_user", "admin-forward", tunnelID, "1.1.1.1:443", "fifo", now, now, 0)
if err != nil {
t.Fatalf("insert admin forward: %v", err)
}
adminForwardID, err := resAdmin.LastInsertId()
if err != nil {
t.Fatalf("get admin forward id: %v", err)
}
resUser, err := repo.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
`, 2, "normal_user", "user-forward", tunnelID, "8.8.8.8:53", "fifo", now, now, 1)
if err != nil {
t.Fatalf("insert user forward: %v", err)
}
userForwardID, err := resUser.LastInsertId()
if err != nil {
t.Fatalf("get user forward id: %v", err)
}
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
if err != nil {
t.Fatalf("generate user token: %v", err)
}
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
t.Run("non-owner cannot delete another user's forward", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/delete", bytes.NewBufferString(`{"id":`+jsonNumber(adminForwardID)+`}`))
req.Header.Set("Authorization", userToken)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCodeMsg(t, res, -1, "转发不存在")
})
t.Run("non-admin forward list is scoped to owner", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/list", bytes.NewBufferString(`{}`))
req.Header.Set("Authorization", userToken)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
}
arr, ok := out.Data.([]interface{})
if !ok {
t.Fatalf("expected array data, got %T", out.Data)
}
if len(arr) != 1 {
t.Fatalf("expected 1 forward, got %d", len(arr))
}
item, ok := arr[0].(map[string]interface{})
if !ok {
t.Fatalf("expected object item, got %T", arr[0])
}
if got := int64(item["id"].(float64)); got != userForwardID {
t.Fatalf("expected forward id %d, got %d", userForwardID, got)
}
})
t.Run("forward diagnose returns structured payload", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/diagnose", bytes.NewBufferString(`{"forwardId":`+jsonNumber(userForwardID)+`}`))
req.Header.Set("Authorization", userToken)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
}
payload, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected object payload, got %T", out.Data)
}
results, ok := payload["results"].([]interface{})
if !ok || len(results) == 0 {
t.Fatalf("expected non-empty results, got %v", payload["results"])
}
first, ok := results[0].(map[string]interface{})
if !ok {
t.Fatalf("expected result object, got %T", results[0])
}
if _, ok := first["message"]; !ok {
t.Fatalf("expected message field in diagnosis result")
}
if got := int(first["fromChainType"].(float64)); got != 1 {
t.Fatalf("expected fromChainType=1, got %d", got)
}
})
t.Run("tunnel diagnose returns structured payload", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/diagnose", bytes.NewBufferString(`{"tunnelId":`+jsonNumber(tunnelID)+`}`))
req.Header.Set("Authorization", adminToken)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
}
payload, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected object payload, got %T", out.Data)
}
results, ok := payload["results"].([]interface{})
if !ok || len(results) == 0 {
t.Fatalf("expected non-empty results, got %v", payload["results"])
}
first, ok := results[0].(map[string]interface{})
if !ok {
t.Fatalf("expected result object, got %T", results[0])
}
if _, ok := first["message"]; !ok {
t.Fatalf("expected message field in tunnel diagnosis result")
}
})
}
func jsonNumber(v int64) string {
return strconv.FormatInt(v, 10)
}
@@ -0,0 +1,215 @@
package contract_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"go-backend/internal/auth"
httpserver "go-backend/internal/http"
"go-backend/internal/http/handler"
"go-backend/internal/http/response"
"go-backend/internal/store/sqlite"
)
func TestCaptchaVerifyLoginContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
_, err := repo.DB().Exec(`
INSERT INTO vite_config(name, value, time)
VALUES(?, ?, ?)
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
`, "captcha_enabled", "true", time.Now().UnixMilli())
if err != nil {
t.Fatalf("enable captcha: %v", err)
}
t.Run("login denied without verified captcha token", func(t *testing.T) {
body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertCodeMsg(t, resp, -1, "验证码校验失败")
})
t.Run("captcha token is one-time and consumed by login", func(t *testing.T) {
verifyReq := httptest.NewRequest(http.MethodPost, "/api/v1/captcha/verify", bytes.NewBufferString(`{"id":"captcha-token-1","data":"ok"}`))
verifyReq.Header.Set("Content-Type", "application/json")
verifyResp := httptest.NewRecorder()
router.ServeHTTP(verifyResp, verifyReq)
var verifyOut struct {
Success bool `json:"success"`
Data struct {
ValidToken string `json:"validToken"`
} `json:"data"`
}
if err := json.NewDecoder(verifyResp.Body).Decode(&verifyOut); err != nil {
t.Fatalf("decode captcha verify response: %v", err)
}
if !verifyOut.Success || verifyOut.Data.ValidToken != "captcha-token-1" {
t.Fatalf("unexpected captcha verify payload: success=%v token=%q", verifyOut.Success, verifyOut.Data.ValidToken)
}
loginBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"captcha-token-1"}`)
loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", loginBody)
loginReq.Header.Set("Content-Type", "application/json")
loginResp := httptest.NewRecorder()
router.ServeHTTP(loginResp, loginReq)
assertCode(t, loginResp, 0)
replayBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"captcha-token-1"}`)
replayReq := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", replayBody)
replayReq.Header.Set("Content-Type", "application/json")
replayResp := httptest.NewRecorder()
router.ServeHTTP(replayResp, replayReq)
assertCodeMsg(t, replayResp, -1, "验证码校验失败")
})
}
func TestOpenAPISubStoreContracts(t *testing.T) {
router, repo := setupContractRouter(t, "contract-jwt-secret")
const tunnelFlowGB = int64(500)
const tunnelInFlow = int64(123)
const tunnelOutFlow = int64(456)
const tunnelExpTimeMs = int64(2727251700000)
now := time.Now().UnixMilli()
res, err := repo.DB().Exec(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"contract-tunnel", 1.0, 1, "tls", 1, now, now, 1, nil, 0)
if err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID, err := res.LastInsertId()
if err != nil {
t.Fatalf("last insert id: %v", err)
}
if _, err := repo.DB().Exec(`INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)`,
1, tunnelID, 99999, tunnelFlowGB, tunnelInFlow, tunnelOutFlow, 1, tunnelExpTimeMs, 1); err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
t.Run("default user subscription payload", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
expected := "upload=0; download=0; total=107373108658176; expire=2727251700"
if string(body) != expected {
t.Fatalf("expected body %q, got %q", expected, string(body))
}
if got := resp.Header().Get("subscription-userinfo"); got != expected {
t.Fatalf("expected subscription-userinfo %q, got %q", expected, got)
}
if !strings.Contains(resp.Header().Get("Content-Type"), "text/plain") {
t.Fatalf("expected text/plain content type, got %q", resp.Header().Get("Content-Type"))
}
})
t.Run("tunnel scoped subscription payload", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user&tunnel="+strconv.FormatInt(tunnelID, 10), nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
expected := "upload=123; download=456; total=536870912000; expire=2727251700"
if string(body) != expected {
t.Fatalf("expected body %q, got %q", expected, string(body))
}
if got := resp.Header().Get("subscription-userinfo"); got != expected {
t.Fatalf("expected subscription-userinfo %q, got %q", expected, got)
}
})
t.Run("invalid credentials returns contract error", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/open_api/sub_store?user=admin_user&pwd=wrong", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertCodeMsg(t, resp, -1, "鉴权失败")
})
t.Run("missing tunnel returns contract error", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user&tunnel=999999", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertCodeMsg(t, resp, -1, "隧道不存在")
})
}
func TestSpeedLimitTunnelsRouteAlias(t *testing.T) {
secret := "contract-jwt-secret"
router, _ := setupContractRouter(t, secret)
t.Run("missing token blocked", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/tunnels", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertCodeMsg(t, resp, 401, "未登录或token已过期")
})
t.Run("admin token receives success envelope", func(t *testing.T) {
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/tunnels", nil)
req.Header.Set("Authorization", token)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
var out response.R
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
}
})
}
func setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *sqlite.Repository) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "contract.db")
repo, err := sqlite.Open(dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
_ = repo.Close()
})
h := handler.New(repo, jwtSecret)
return httpserver.NewRouter(h, jwtSecret), repo
}
@@ -0,0 +1,151 @@
package contract_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
func TestTunnelCreateRuntimeRollbackContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
insertNode := func(name, ip, portRange string) int64 {
res, err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
if err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("get node id %s: %v", name, err)
}
return id
}
entryID := insertNode("create-entry", "10.20.0.1", "30000-30010")
chainID := insertNode("create-chain", "10.20.0.2", "31000-31010")
exitID := insertNode("create-exit", "10.20.0.3", "32000-32010")
payload := `{"name":"runtime-rollback-tunnel","type":2,"flow":99999,"status":1,"inNodeId":[{"nodeId":` + jsonInt(entryID) + `,"protocol":"tls"}],"chainNodes":[[{"nodeId":` + jsonInt(chainID) + `,"protocol":"tls","strategy":"round"}]],"outNodeId":[{"nodeId":` + jsonInt(exitID) + `,"protocol":"tls"}]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewBufferString(payload))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code == 0 {
t.Fatalf("expected create failure when nodes are offline")
}
if !strings.Contains(out.Msg, "节点") {
t.Fatalf("expected node-related error, got %q", out.Msg)
}
var tunnelCount int
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM tunnel WHERE name = ?`, "runtime-rollback-tunnel").Scan(&tunnelCount); err != nil {
t.Fatalf("count tunnel: %v", err)
}
if tunnelCount != 0 {
t.Fatalf("expected tunnel rollback, found %d records", tunnelCount)
}
var chainCount int
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM chain_tunnel`).Scan(&chainCount); err != nil {
t.Fatalf("count chain_tunnel: %v", err)
}
if chainCount != 0 {
t.Fatalf("expected chain_tunnel rollback, found %d records", chainCount)
}
}
func TestTunnelUpdateAssignsChainPortsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
insertNode := func(name, ip, portRange string) int64 {
res, err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, name+"-secret", ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0)
if err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("get node id %s: %v", name, err)
}
return id
}
entryID := insertNode("update-entry", "10.30.0.1", "40000-40010")
chainID := insertNode("update-chain", "10.30.0.2", "41000-41010")
exitID := insertNode("update-exit", "10.30.0.3", "42000-42010")
tunnelRes, err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "update-port-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0)
if err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID, err := tunnelRes.LastInsertId()
if err != nil {
t.Fatalf("get tunnel id: %v", err)
}
payload := `{"id":` + jsonInt(tunnelID) + `,"name":"update-port-tunnel","type":2,"flow":99999,"trafficRatio":1.0,"status":1,"inNodeId":[{"nodeId":` + jsonInt(entryID) + `,"protocol":"tls"}],"chainNodes":[[{"nodeId":` + jsonInt(chainID) + `,"protocol":"tls","strategy":"round"}]],"outNodeId":[{"nodeId":` + jsonInt(exitID) + `,"protocol":"tls"}]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewBufferString(payload))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
var chainPort int
if err := repo.DB().QueryRow(`SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 2 LIMIT 1`, tunnelID).Scan(&chainPort); err != nil {
t.Fatalf("query chain port: %v", err)
}
if chainPort <= 0 {
t.Fatalf("expected chain node port to be assigned, got %d", chainPort)
}
var outPort int
if err := repo.DB().QueryRow(`SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 3 LIMIT 1`, tunnelID).Scan(&outPort); err != nil {
t.Fatalf("query out port: %v", err)
}
if outPort <= 0 {
t.Fatalf("expected out node port to be assigned, got %d", outPort)
}
}
func jsonInt(v int64) string {
return strconv.FormatInt(v, 10)
}
@@ -0,0 +1,138 @@
package contract
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
func TestUserTunnelVisibleListContracts(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupDiagnosisContractRouter(t, secret)
now := time.Now().UnixMilli()
if _, err := repo.DB().Exec(`
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(2, 'normal_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now); err != nil {
t.Fatalf("insert user: %v", err)
}
insertTunnel := func(name string, status int, inx int64) int64 {
res, err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, 1.0, 1, "tls", 99999, now, now, status, nil, inx)
if err != nil {
t.Fatalf("insert tunnel %s: %v", name, err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("get tunnel id %s: %v", name, err)
}
return id
}
enabledA := insertTunnel("enabled-A", 1, 1)
enabledB := insertTunnel("enabled-B", 1, 2)
disabledC := insertTunnel("disabled-C", 0, 3)
if _, err := repo.DB().Exec(`
INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(?, ?, NULL, ?, ?, 0, 0, ?, ?, ?)
`, 2, enabledA, 100, 1000, 1, 2727251700000, 0); err != nil {
t.Fatalf("insert user_tunnel enabledA: %v", err)
}
if _, err := repo.DB().Exec(`
INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(?, ?, NULL, ?, ?, 0, 0, ?, ?, ?)
`, 2, enabledB, 100, 1000, 1, 2727251700000, 1); err != nil {
t.Fatalf("insert user_tunnel enabledB: %v", err)
}
if _, err := repo.DB().Exec(`
INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(?, ?, NULL, ?, ?, 0, 0, ?, ?, ?)
`, 2, disabledC, 100, 1000, 1, 2727251700000, 1); err != nil {
t.Fatalf("insert user_tunnel disabledC: %v", err)
}
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
if err != nil {
t.Fatalf("generate user token: %v", err)
}
t.Run("admin sees all enabled tunnels without user_tunnel rows", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/tunnel", nil)
req.Header.Set("Authorization", adminToken)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
}
ids := collectTunnelIDs(t, out.Data)
if !ids[enabledA] || !ids[enabledB] {
t.Fatalf("expected enabled tunnels for admin, got %v", ids)
}
if ids[disabledC] {
t.Fatalf("did not expect disabled tunnel for admin")
}
})
t.Run("normal user sees enabled assigned tunnels regardless of user_tunnel status", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/tunnel", nil)
req.Header.Set("Authorization", userToken)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
}
ids := collectTunnelIDs(t, out.Data)
if !ids[enabledA] || !ids[enabledB] {
t.Fatalf("expected enabled assigned tunnels for user, got %v", ids)
}
if ids[disabledC] {
t.Fatalf("did not expect disabled tunnel for user")
}
})
}
func collectTunnelIDs(t *testing.T, data interface{}) map[int64]bool {
t.Helper()
arr, ok := data.([]interface{})
if !ok {
t.Fatalf("expected array data, got %T", data)
}
ids := make(map[int64]bool, len(arr))
for _, item := range arr {
obj, ok := item.(map[string]interface{})
if !ok {
t.Fatalf("expected object item, got %T", item)
}
id := int64(obj["id"].(float64))
ids[id] = true
}
return ids
}
+21 -15
View File
@@ -1,31 +1,37 @@
# GO-GOST SERVICE KNOWLEDGE BASE
**Generated:** Mon Feb 02 2026
## OVERVIEW
Core forwarding service based on GOST v3.
**Stack:** Go 1.23, GOST Core v0.3.1, GOST x (Extensions).
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 point
├── x/ # Local extensions (REPLACES github.com/go-gost/x)
│ ├── api/ # Management API
│ ├── registry/ # Service registry
│ ├── handler/ # Protocol handlers (socks, tunnel, relay)
│ └── listener/ # Network listeners (tcp, udp, tun/tap)
└── go.mod # Defines local replacement
├── 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
- **Local Replace**: `go.mod` uses `replace github.com/go-gost/x => ./x`.
- **Extensions**: Custom logic lives in `x/`. This is the primary place for modifications.
- **Handlers**: Implements SOCKS5, Tunnel, Relay, etc.
- 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
# Run
cd go-gost
go run .
# Build
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, "2.0.2")
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 ./...
```
+83 -36
View File
@@ -176,51 +176,98 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand
}
}
target := &chain.Node{}
if h.hop != nil {
target = h.hop.Select(ctx,
hop.ProtocolSelectOption(proto),
)
}
if target == nil {
err := errors.New("node not available")
return err
// Determine max retry attempts
maxRetries := h.md.maxRetries
if maxRetries <= 0 {
// Default: try all available nodes
if nl, ok := h.hop.(hop.NodeList); ok {
maxRetries = len(nl.Nodes())
}
if maxRetries <= 0 {
maxRetries = 1
}
}
addr := target.Addr
if opts := target.Options(); opts != nil {
switch opts.Network {
case "unix":
network = opts.Network
default:
if _, _, err := net.SplitHostPort(addr); err != nil {
addr += ":0"
var triedNodes []string
var lastErr error
var cc net.Conn
h.options.Logger.Debugf("[handler.retry] starting retry loop: maxRetries=%d", maxRetries)
for attempt := 0; attempt < maxRetries; attempt++ {
// Select a target node, excluding previously tried nodes
selectCtx := ctxvalue.ContextWithExcludeNodes(ctx, triedNodes)
var target *chain.Node
if h.hop != nil {
target = h.hop.Select(selectCtx,
hop.ProtocolSelectOption(proto),
)
}
if target == nil {
h.options.Logger.Debugf("[handler.retry] attempt=%d target=nil, triedNodes=%v", attempt, triedNodes)
if lastErr != nil {
return lastErr
}
return errors.New("node not available")
}
h.options.Logger.Debugf("[handler.retry] attempt=%d selected node=%s addr=%s", attempt, target.Name, target.Addr)
// Track this node as tried
triedNodes = append(triedNodes, target.Addr)
addr := target.Addr
if opts := target.Options(); opts != nil {
switch opts.Network {
case "unix":
network = opts.Network
default:
if _, _, err := net.SplitHostPort(addr); err != nil {
addr += ":0"
}
}
}
}
ro.Network = network
ro.Host = addr
ro.Network = network
ro.Host = addr
var buf bytes.Buffer
cc, err := h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, addr)
ro.Route = buf.String()
if err != nil {
// TODO: the router itself may be failed due to the failed node in the router,
// the dead marker may be a wrong operation.
if marker := target.Marker(); marker != nil {
marker.Mark()
var buf bytes.Buffer
cc, err = h.options.Router.Dial(ctxvalue.ContextWithBuffer(ctx, &buf), network, addr)
ro.Route = buf.String()
if err != nil {
// Mark node as failed for future selections
if marker := target.Marker(); marker != nil {
marker.Mark()
h.options.Logger.Debugf("[handler.retry] attempt=%d dial failed, marked node=%s count=%d err=%v",
attempt, target.Addr, marker.Count(), err)
}
lastErr = err
// Try next node
continue
}
return err
}
if marker := target.Marker(); marker != nil {
marker.Reset()
}
defer cc.Close()
xnet.Transport(conn, cc)
// Success - reset marker and proceed
if marker := target.Marker(); marker != nil {
marker.Reset()
}
defer cc.Close()
return nil
if err := xnet.Transport(conn, cc); err != nil {
if marker := target.Marker(); marker != nil {
marker.Mark()
h.options.Logger.Debugf("[handler.transport] transport failed, marked node=%s count=%d err=%v",
target.Addr, marker.Count(), err)
}
return err
}
return nil
}
// All retries exhausted
if lastErr != nil {
return lastErr
}
return errors.New("all nodes failed")
}
func (h *forwardHandler) checkRateLimit(addr net.Addr) bool {
@@ -25,6 +25,12 @@ type metadata struct {
privateKey crypto.PrivateKey
alpn string
mitmBypass bypass.Bypass
// maxRetries specifies the maximum number of failover retry attempts.
// When a target node fails, the handler will try the next available node.
// 0 means use the total number of available nodes (try all nodes once).
// Default: 0 (try all available nodes)
maxRetries int
}
func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) {
@@ -56,5 +62,8 @@ func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) {
h.md.alpn = mdutil.GetString(md, "mitm.alpn")
h.md.mitmBypass = registry.BypassRegistry().Get(mdutil.GetString(md, "mitm.bypass"))
// maxRetries: 0 means try all available nodes (default behavior)
h.md.maxRetries = mdutil.GetInt(md, "maxRetries", "retry.max")
return
}
+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
+6
View File
@@ -2,11 +2,17 @@ package socket
import (
"os"
"sync"
"github.com/go-gost/x/config"
)
// configMutex 保护配置文件的并发写入
var configMutex sync.Mutex
func saveConfig() {
configMutex.Lock()
defer configMutex.Unlock()
file := "gost.json"
+34 -5
View File
@@ -466,7 +466,13 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
}
if cmdMsg.Type != "call" {
w.routeCommand(cmdMsg)
// TcpPing 诊断命令异步执行,避免阻塞其他命令
// 其他状态变更命令保持同步,确保顺序执行
if cmdMsg.Type == "TcpPing" {
go w.routeCommand(cmdMsg)
} else {
w.routeCommand(cmdMsg)
}
}
} else {
// 处理普通消息
@@ -477,7 +483,13 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
return
}
if cmdMsg.Type != "call" {
w.routeCommand(cmdMsg)
// TcpPing 诊断命令异步执行,避免阻塞其他命令
// 其他状态变更命令保持同步,确保顺序执行
if cmdMsg.Type == "TcpPing" {
go w.routeCommand(cmdMsg)
} else {
w.routeCommand(cmdMsg)
}
}
}
@@ -497,6 +509,7 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
fmt.Println("🔔 收到命令: ", string(jsonBytes))
var err error
var response CommandResponse
var needSaveConfig bool // 标记是否需要保存配置(只有状态变更命令才需要)
// 传递 requestId
response.RequestId = cmd.RequestId
@@ -506,65 +519,81 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
case "AddService":
err = w.handleAddService(cmd.Data)
response.Type = "AddServiceResponse"
needSaveConfig = true
case "UpdateService":
err = w.handleUpdateService(cmd.Data)
response.Type = "UpdateServiceResponse"
needSaveConfig = true
case "DeleteService":
err = w.handleDeleteService(cmd.Data)
response.Type = "DeleteServiceResponse"
needSaveConfig = true
case "PauseService":
err = w.handlePauseService(cmd.Data)
response.Type = "PauseServiceResponse"
needSaveConfig = true
case "ResumeService":
err = w.handleResumeService(cmd.Data)
response.Type = "ResumeServiceResponse"
needSaveConfig = true
// Chain 相关命令
case "AddChains":
err = w.handleAddChain(cmd.Data)
response.Type = "AddChainsResponse"
needSaveConfig = true
case "UpdateChains":
err = w.handleUpdateChain(cmd.Data)
response.Type = "UpdateChainsResponse"
needSaveConfig = true
case "DeleteChains":
err = w.handleDeleteChain(cmd.Data)
response.Type = "DeleteChainsResponse"
needSaveConfig = true
// Limiter 相关命令
case "AddLimiters":
err = w.handleAddLimiter(cmd.Data)
response.Type = "AddLimitersResponse"
needSaveConfig = true
case "UpdateLimiters":
err = w.handleUpdateLimiter(cmd.Data)
response.Type = "UpdateLimitersResponse"
needSaveConfig = true
case "DeleteLimiters":
err = w.handleDeleteLimiter(cmd.Data)
response.Type = "DeleteLimitersResponse"
needSaveConfig = true
// TCP Ping 诊断命令
// TCP Ping 诊断命令(只读,不需要保存配置)
case "TcpPing":
var tcpPingResult TcpPingResponse
tcpPingResult, err = w.handleTcpPing(cmd.Data)
response.Type = "TcpPingResponse"
response.Data = tcpPingResult
// needSaveConfig = false (默认值)
// Protocol blocking switches
case "SetProtocol":
err = w.handleSetProtocol(cmd.Data)
response.Type = "SetProtocolResponse"
needSaveConfig = true
default:
err = fmt.Errorf("未知命令类型: %s", cmd.Type)
response.Type = "UnknownCommandResponse"
}
// 只有状态变更命令才保存配置
if needSaveConfig {
saveConfig()
}
// 发送响应
if err != nil {
saveConfig()
response.Success = false
response.Message = err.Error()
} else {
saveConfig()
response.Success = true
response.Message = "OK"
}
+89
View File
@@ -0,0 +1,89 @@
package middleware
import (
"errors"
"strconv"
"time"
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/utils"
"github.com/golang-jwt/jwt/v5"
"github.com/flipped-aurora/gin-vue-admin/server/model/common/response"
"github.com/gin-gonic/gin"
)
func JWTAuth() gin.HandlerFunc {
return func(c *gin.Context) {
// 我们这里jwt鉴权取头部信息 x-token 登录时回返回token信息 这里前端需要把token存储到cookie或者本地localStorage中 不过需要跟后端协商过期时间 可以约定刷新令牌或者重新登录
token := utils.GetToken(c)
if token == "" {
response.NoAuth("未登录或非法访问,请登录", c)
c.Abort()
return
}
if isBlacklist(token) {
response.NoAuth("您的帐户异地登陆或令牌失效", c)
utils.ClearToken(c)
c.Abort()
return
}
j := utils.NewJWT()
// parseToken 解析token包含的信息
claims, err := j.ParseToken(token)
if err != nil {
if errors.Is(err, utils.TokenExpired) {
response.NoAuth("登录已过期,请重新登录", c)
utils.ClearToken(c)
c.Abort()
return
}
response.NoAuth(err.Error(), c)
utils.ClearToken(c)
c.Abort()
return
}
// 已登录用户被管理员禁用 需要使该用户的jwt失效 此处比较消耗性能 如果需要 请自行打开
// 用户被删除的逻辑 需要优化 此处比较消耗性能 如果需要 请自行打开
//if user, err := userService.FindUserByUuid(claims.UUID.String()); err != nil || user.Enable == 2 {
// _ = jwtService.JsonInBlacklist(system.JwtBlacklist{Jwt: token})
// response.FailWithDetailed(gin.H{"reload": true}, err.Error(), c)
// c.Abort()
//}
c.Set("claims", claims)
if claims.ExpiresAt.Unix()-time.Now().Unix() < claims.BufferTime {
dr, _ := utils.ParseDuration(global.GVA_CONFIG.JWT.ExpiresTime)
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(dr))
newToken, _ := j.CreateTokenByOldToken(token, *claims)
newClaims, _ := j.ParseToken(newToken)
c.Header("new-token", newToken)
c.Header("new-expires-at", strconv.FormatInt(newClaims.ExpiresAt.Unix(), 10))
utils.SetToken(c, newToken, int(dr.Seconds()/60))
if global.GVA_CONFIG.System.UseMultipoint {
// 记录新的活跃jwt
_ = utils.SetRedisJWT(newToken, newClaims.Username)
}
}
c.Next()
if newToken, exists := c.Get("new-token"); exists {
c.Header("new-token", newToken.(string))
}
if newExpiresAt, exists := c.Get("new-expires-at"); exists {
c.Header("new-expires-at", newExpiresAt.(string))
}
}
}
//@author: [piexlmax](https://github.com/piexlmax)
//@function: IsBlacklist
//@description: 判断JWT是否在黑名单内部
//@param: jwt string
//@return: bool
func isBlacklist(jwt string) bool {
_, ok := global.BlackCache.Get(jwt)
return ok
}
+62
View File
@@ -0,0 +1,62 @@
package response
import (
"net/http"
"github.com/gin-gonic/gin"
)
type Response struct {
Code int `json:"code"`
Data interface{} `json:"data"`
Msg string `json:"msg"`
}
const (
ERROR = 7
SUCCESS = 0
)
func Result(code int, data interface{}, msg string, c *gin.Context) {
c.JSON(http.StatusOK, Response{
code,
data,
msg,
})
}
func Ok(c *gin.Context) {
Result(SUCCESS, map[string]interface{}{}, "操作成功", c)
}
func OkWithMessage(message string, c *gin.Context) {
Result(SUCCESS, map[string]interface{}{}, message, c)
}
func OkWithData(data interface{}, c *gin.Context) {
Result(SUCCESS, data, "成功", c)
}
func OkWithDetailed(data interface{}, message string, c *gin.Context) {
Result(SUCCESS, data, message, c)
}
func Fail(c *gin.Context) {
Result(ERROR, map[string]interface{}{}, "操作失败", c)
}
func FailWithMessage(message string, c *gin.Context) {
Result(ERROR, map[string]interface{}{}, message, c)
}
func NoAuth(message string, c *gin.Context) {
c.JSON(http.StatusUnauthorized, Response{
7,
nil,
message,
})
}
func FailWithDetailed(data interface{}, message string, c *gin.Context) {
Result(ERROR, data, message, c)
}
+28
View File
@@ -0,0 +1,28 @@
package system
import (
"github.com/flipped-aurora/gin-vue-admin/server/middleware"
"github.com/gin-gonic/gin"
)
type UserRouter struct{}
func (s *UserRouter) InitUserRouter(Router *gin.RouterGroup) {
userRouter := Router.Group("user").Use(middleware.OperationRecord())
userRouterWithoutRecord := Router.Group("user")
{
userRouter.POST("admin_register", baseApi.Register) // 管理员注册账号
userRouter.POST("changePassword", baseApi.ChangePassword) // 用户修改密码
userRouter.POST("setUserAuthority", baseApi.SetUserAuthority) // 设置用户权限
userRouter.DELETE("deleteUser", baseApi.DeleteUser) // 删除用户
userRouter.PUT("setUserInfo", baseApi.SetUserInfo) // 设置用户信息
userRouter.PUT("setSelfInfo", baseApi.SetSelfInfo) // 设置自身信息
userRouter.POST("setUserAuthorities", baseApi.SetUserAuthorities) // 设置用户权限组
userRouter.POST("resetPassword", baseApi.ResetPassword) // 重置用户密码
userRouter.PUT("setSelfSetting", baseApi.SetSelfSetting) // 用户界面配置
}
{
userRouterWithoutRecord.POST("getUserList", baseApi.GetUserList) // 分页获取用户列表
userRouterWithoutRecord.GET("getUserInfo", baseApi.GetUserInfo) // 获取自身信息
}
}
+4 -2
View File
@@ -295,6 +295,8 @@ After=network.target
WorkingDirectory=$INSTALL_DIR
ExecStart=$INSTALL_DIR/flux_agent
Restart=on-failure
StandardOutput=null
StandardError=null
[Install]
WantedBy=multi-user.target
@@ -312,8 +314,8 @@ EOF
echo "📁 配置目录: $INSTALL_DIR"
echo "🔧 服务状态: $(systemctl is-active flux_agent)"
else
echo "❌ flux_agent服务启动失败,请执行以下命令查看日志:"
echo "journalctl -u flux_agent -f"
echo "❌ flux_agent服务启动失败,请执行以下命令查看状态:"
echo "systemctl status flux_agent --no-pager"
fi
}
+43 -7
View File
@@ -78,11 +78,16 @@ resolve_version() {
return 1
}
# 根据版本号设置 compose 下载地址
set_compose_urls_by_version() {
local version="$1"
DOCKER_COMPOSEV4_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${version}/docker-compose-v4.yml")
DOCKER_COMPOSEV6_URL=$(maybe_proxy_url "https://github.com/${REPO}/releases/download/${version}/docker-compose-v6.yml")
}
# 全局下载地址配置(默认获取最新版本;也可用 VERSION=... 覆盖)
RESOLVED_VERSION=$(resolve_version) || exit 1
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")
set_compose_urls_by_version "$RESOLVED_VERSION"
@@ -221,6 +226,27 @@ generate_random() {
LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c16
}
upsert_env_var() {
local file="$1"
local key="$2"
local value="$3"
local tmp_file
tmp_file=$(mktemp)
if [ -f "$file" ]; then
awk -v k="$key" -v v="$value" '
BEGIN { found=0 }
$0 ~ ("^" k "=") { print k "=" v; found=1; next }
{ print }
END { if (!found) print k "=" v }
' "$file" > "$tmp_file"
else
printf '%s=%s\n' "$key" "$value" > "$tmp_file"
fi
mv "$tmp_file" "$file"
}
# 删除脚本自身
delete_self() {
echo ""
@@ -268,6 +294,7 @@ install_panel() {
JWT_SECRET=$JWT_SECRET
FRONTEND_PORT=$FRONTEND_PORT
BACKEND_PORT=$BACKEND_PORT
FLUX_VERSION=$RESOLVED_VERSION
EOF
echo "🚀 启动 docker 服务..."
@@ -288,6 +315,15 @@ update_panel() {
echo "🔄 开始更新面板..."
check_docker
echo "🔍 获取最新版本号..."
LATEST_VERSION=$(resolve_latest_release_tag) || {
echo "❌ 无法获取最新版本号,更新终止"
return 1
}
echo "🆕 最新版本:$LATEST_VERSION"
set_compose_urls_by_version "$LATEST_VERSION"
upsert_env_var ".env" "FLUX_VERSION" "$LATEST_VERSION"
echo "🔽 下载最新配置文件..."
DOCKER_COMPOSE_URL=$(get_docker_compose_url)
echo "📡 选择配置文件:$(basename "$DOCKER_COMPOSE_URL")"
@@ -301,7 +337,7 @@ update_panel() {
fi
# 先发送 SIGTERM 信号,让应用优雅关闭
docker stop -t 30 springboot-backend 2>/dev/null || true
docker stop -t 30 flux-panel-backend 2>/dev/null || true
docker stop -t 10 vite-frontend 2>/dev/null || true
# 等待 WAL 文件同步
@@ -323,8 +359,8 @@ update_panel() {
# 检查后端容器健康状态
echo "🔍 检查后端服务状态..."
for i in {1..90}; do
if docker ps --format "{{.Names}}" | grep -q "^springboot-backend$"; then
BACKEND_HEALTH=$(docker inspect -f '{{.State.Health.Status}}' springboot-backend 2>/dev/null || echo "unknown")
if docker ps --format "{{.Names}}" | grep -q "^flux-panel-backend$"; then
BACKEND_HEALTH=$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo "unknown")
if [[ "$BACKEND_HEALTH" == "healthy" ]]; then
echo "✅ 后端服务健康检查通过"
break
@@ -340,7 +376,7 @@ update_panel() {
fi
if [ $i -eq 90 ]; then
echo "❌ 后端服务启动超时(90秒)"
echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' springboot-backend 2>/dev/null || echo '容器不存在')"
echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo '容器不存在')"
echo "🛑 更新终止"
return 1
fi
+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>
+21 -19
View File
@@ -1,37 +1,39 @@
# SPRINGBOOT BACKEND KNOWLEDGE BASE
**Generated:** Mon Feb 02 2026
## OVERVIEW
Admin API for Flux Panel. Manages users, licenses, and traffic rules.
**Stack:** Java 21, Spring Boot 2.7.18, SQLite, MyBatis Plus.
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 Endpoints
│ ├── entity/ # DB Models (MyBatis Plus)
│ ├── mapper/ # Data Access
│ ├── service/ # Business Logic
│ └── common/ # Utils, DTOs
│ ├── 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
├── mapper/ # XML Mappers
├── data.sql # Init data
└── bgimages/ # Static resources
├── application.yml # Config (DB_PATH/JWT_SECRET/LOG_DIR)
├── mapper/ # XML mappers
├── schema.sql # Schema
└── data.sql # Seed data
```
## CONVENTIONS
- **DB**: SQLite used via `sqlite-jdbc`.
- **ORM**: MyBatis Plus + MyBatis Plus Join.
- **JSON**: FastJSON2 used for serialization.
- **Utils**: Hutool used extensively.
- **Auth**: Likely custom or token-based (see `controller` logic).
- **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
# Build
cd springboot-backend
mvn clean package
# Run
mvn test
java -jar target/admin-0.0.1-SNAPSHOT.jar
```
@@ -0,0 +1,32 @@
# SPRINGBOOT BACKEND (com.admin) KNOWLEDGE BASE
## OVERVIEW
Primary Java code for the admin API. Controllers expose `/api/v1/*` endpoints and return `R` response envelopes.
## STRUCTURE
```
springboot-backend/src/main/java/com/admin/
├── controller/ # REST controllers (e.g., /api/v1/user)
├── service/ # Business logic interfaces + impl/
├── mapper/ # MyBatis Plus mappers
├── entity/ # DB entities
├── config/ # WebMvc/JWT/CORS/WebSocket config
└── common/ # DTOs, auth, exception handling, utilities
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| User/login endpoints | `springboot-backend/src/main/java/com/admin/controller/UserController.java` | `/api/v1/user/*` |
| Auth enforcement | `springboot-backend/src/main/java/com/admin/config/WebMvcConfig.java` | Intercepts `/api/**`, excludes login/config/captcha |
| JWT validation | `springboot-backend/src/main/java/com/admin/common/interceptor/JwtInterceptor.java` | Requires `Authorization` header |
| Admin-only ops | `springboot-backend/src/main/java/com/admin/common/annotation/RequireRole.java` | Enforced by `RoleAspect` |
| Response envelope | `springboot-backend/src/main/java/com/admin/common/lang/R.java` | `code == 0` success |
| Global error handling | `springboot-backend/src/main/java/com/admin/common/exception/GlobalExceptionHandler.java` | Maps exceptions -> `R.err(...)` |
## CONVENTIONS
- Controllers are mostly `@PostMapping` (even for list/get/delete) and use `/api/v1/*` prefixes.
- JWT is custom (no 3p lib) and includes `role_id` in payload (`springboot-backend/src/main/java/com/admin/common/utils/JwtUtil.java`).
## ANTI-PATTERNS
- Do not change auth header format lightly: frontend expects `Authorization: <token>` (no `Bearer`).
@@ -13,6 +13,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableAsync
@EnableScheduling
@MapperScan("com.admin.mapper")
public class AdminApplication {
public static void main(String[] args) {
@@ -0,0 +1,16 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.util.List;
@Data
public class BatchChangeTunnelDto {
@NotEmpty(message = "转发ID列表不能为空")
private List<Long> forwardIds;
@NotNull(message = "目标隧道ID不能为空")
private Long targetTunnelId;
}
@@ -0,0 +1,12 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import java.util.List;
@Data
public class BatchDeleteDto {
@NotEmpty(message = "ID列表不能为空")
private List<Long> ids;
}
@@ -0,0 +1,39 @@
package com.admin.common.dto;
import lombok.Data;
import java.util.List;
import java.util.ArrayList;
@Data
public class BatchOperationResultDto {
private int successCount;
private int failCount;
private List<FailedItem> failedItems = new ArrayList<>();
@Data
public static class FailedItem {
private Long id;
private String reason;
public FailedItem() {}
public FailedItem(Long id, String reason) {
this.id = id;
this.reason = reason;
}
}
public void addFailedItem(Long id, String reason) {
this.failedItems.add(new FailedItem(id, reason));
this.failCount++;
}
public void incrementSuccess() {
this.successCount++;
}
public boolean isAllSuccess() {
return failCount == 0;
}
}
@@ -0,0 +1,12 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import java.util.List;
@Data
public class BatchRedeployDto {
@NotEmpty(message = "ID列表不能为空")
private List<Long> ids;
}
@@ -0,0 +1,14 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
public class GroupCreateDto {
@NotBlank(message = "分组名称不能为空")
private String name;
private Integer status;
}
@@ -0,0 +1,15 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotNull;
@Data
public class GroupPermissionAssignDto {
@NotNull(message = "用户分组ID不能为空")
private Long userGroupId;
@NotNull(message = "隧道分组ID不能为空")
private Long tunnelGroupId;
}
@@ -0,0 +1,13 @@
package com.admin.common.dto;
import lombok.Data;
@Data
public class GroupPermissionDetailDto {
private Long id;
private Long userGroupId;
private String userGroupName;
private Long tunnelGroupId;
private String tunnelGroupName;
private Long createdTime;
}
@@ -0,0 +1,18 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class GroupUpdateDto {
@NotNull(message = "分组ID不能为空")
private Long id;
@NotBlank(message = "分组名称不能为空")
private String name;
private Integer status;
}
@@ -16,6 +16,10 @@ public class NodeDto {
@NotBlank(message = "服务器ip不能为空")
private String serverIp;
private String serverIpV4;
private String serverIpV6;
@NotBlank(message = "可用端口不能为空")
private String port;
@@ -25,4 +29,4 @@ public class NodeDto {
private String udpListenAddr = "0.0.0.0";
}
}
@@ -17,6 +17,10 @@ public class NodeUpdateDto {
@NotBlank(message = "服务器ip不能为空")
private String serverIp;
private String serverIpV4;
private String serverIpV6;
@NotBlank(message = "可用port不能为空")
private String port;
@@ -28,4 +32,4 @@ public class NodeUpdateDto {
private String tcpListenAddr = "0.0.0.0";
private String udpListenAddr = "0.0.0.0";
}
}
@@ -0,0 +1,16 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotNull;
import java.util.List;
@Data
public class TunnelGroupAssignTunnelsDto {
@NotNull(message = "隧道分组ID不能为空")
private Long groupId;
@NotNull(message = "隧道列表不能为空")
private List<Long> tunnelIds;
}
@@ -0,0 +1,17 @@
package com.admin.common.dto;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class TunnelGroupDetailDto {
private Long id;
private String name;
private Integer status;
private Long createdTime;
private Long updatedTime;
private List<Long> tunnelIds = new ArrayList<>();
private List<String> tunnelNames = new ArrayList<>();
}
@@ -0,0 +1,16 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotNull;
import java.util.List;
@Data
public class UserGroupAssignUsersDto {
@NotNull(message = "用户分组ID不能为空")
private Long groupId;
@NotNull(message = "用户列表不能为空")
private List<Long> userIds;
}
@@ -0,0 +1,17 @@
package com.admin.common.dto;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class UserGroupDetailDto {
private Long id;
private String name;
private Integer status;
private Long createdTime;
private Long updatedTime;
private List<Long> userIds = new ArrayList<>();
private List<String> userNames = new ArrayList<>();
}
@@ -0,0 +1,26 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.NotEmpty;
import java.util.List;
@Data
public class UserTunnelBatchAssignDto {
@NotNull(message = "用户ID不能为空")
private Integer userId;
@Valid
@NotEmpty(message = "隧道列表不能为空")
private List<TunnelAssignItem> tunnels;
@Data
public static class TunnelAssignItem {
@NotNull(message = "隧道ID不能为空")
private Integer tunnelId;
private Integer speedId;
}
}
@@ -13,28 +13,15 @@ public class UserTunnelDto {
@NotNull(message = "隧道ID不能为空")
private Integer tunnelId;
@NotNull(message = "流量限制不能为空")
@Min(value = 0, message = "流量限制不能小于0")
private Long flow;
@NotNull(message = "转发数量不能为空")
@Min(value = 0, message = "转发数量不能小于0")
private Integer num;
/**
* 流量重置时间(时间戳)
*/
@NotNull(message = "流量重置时间不能为空")
private Long flowResetTime;
/**
* 到期时间(时间戳)
*/
@NotNull(message = "到期时间不能为空")
private Long expTime;
/**
* 限速规则ID(可选,null表示不限速)
*/
private Integer speedId;
}
}
@@ -32,6 +32,19 @@ public class SqliteSchemaMigration implements ApplicationRunner {
public void run(ApplicationArguments args) {
ensureColumn("node", "inx", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("tunnel", "inx", "INTEGER NOT NULL DEFAULT 0");
ensureTable("CREATE TABLE IF NOT EXISTS tunnel_group (id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(100) NOT NULL, created_time INTEGER NOT NULL, updated_time INTEGER NOT NULL, status INTEGER NOT NULL)");
ensureTable("CREATE TABLE IF NOT EXISTS user_group (id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(100) NOT NULL, created_time INTEGER NOT NULL, updated_time INTEGER NOT NULL, status INTEGER NOT NULL)");
ensureTable("CREATE TABLE IF NOT EXISTS tunnel_group_tunnel (id INTEGER PRIMARY KEY AUTOINCREMENT, tunnel_group_id INTEGER NOT NULL, tunnel_id INTEGER NOT NULL, created_time INTEGER NOT NULL)");
ensureTable("CREATE TABLE IF NOT EXISTS user_group_user (id INTEGER PRIMARY KEY AUTOINCREMENT, user_group_id INTEGER NOT NULL, user_id INTEGER NOT NULL, created_time INTEGER NOT NULL)");
ensureTable("CREATE TABLE IF NOT EXISTS group_permission (id INTEGER PRIMARY KEY AUTOINCREMENT, user_group_id INTEGER NOT NULL, tunnel_group_id INTEGER NOT NULL, created_time INTEGER NOT NULL)");
ensureTable("CREATE TABLE IF NOT EXISTS group_permission_grant (id INTEGER PRIMARY KEY AUTOINCREMENT, user_group_id INTEGER NOT NULL, tunnel_group_id INTEGER NOT NULL, user_tunnel_id INTEGER NOT NULL, created_by_group INTEGER NOT NULL DEFAULT 0, created_time INTEGER NOT NULL)");
ensureColumn("group_permission_grant", "created_by_group", "INTEGER NOT NULL DEFAULT 0");
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_name ON tunnel_group(name)");
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_name ON user_group(name)");
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_tunnel_unique ON tunnel_group_tunnel(tunnel_group_id, tunnel_id)");
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_user_unique ON user_group_user(user_group_id, user_id)");
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_unique ON group_permission(user_group_id, tunnel_group_id)");
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_grant_unique ON group_permission_grant(user_group_id, tunnel_group_id, user_tunnel_id)");
}
private void ensureColumn(String table, String column, String columnDefinition) {
@@ -51,4 +64,8 @@ public class SqliteSchemaMigration implements ApplicationRunner {
"ALTER TABLE " + table + " ADD COLUMN " + column + " " + columnDefinition
);
}
private void ensureTable(String ddl) {
jdbcTemplate.execute(ddl);
}
}
@@ -7,6 +7,7 @@ import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import org.apache.commons.lang3.StringUtils;
import java.util.regex.Pattern;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -43,6 +44,7 @@ public class GostUtil {
public static GostDto AddChains(Long node_id, List<ChainTunnel> chainTunnels, Map<Long, Node> node_s) {
JSONArray nodes = new JSONArray();
Node fromNode = node_s.get(node_id);
for (ChainTunnel chainTunnel : chainTunnels) {
JSONObject dialer = new JSONObject();
dialer.put("type", chainTunnel.getProtocol());
@@ -53,7 +55,11 @@ public class GostUtil {
Node node_info = node_s.get(chainTunnel.getNodeId());
JSONObject node = new JSONObject();
node.put("name", "node_" + chainTunnel.getInx());
node.put("addr", processServerAddress(node_info.getServerIp() + ":" + chainTunnel.getPort()));
String dialHost = (fromNode != null && node_info != null)
? selectDialHost(fromNode, node_info)
: (node_info != null ? node_info.getServerIp() : null);
node.put("addr", processServerAddress(dialHost + ":" + chainTunnel.getPort()));
node.put("connector", connector);
node.put("dialer", dialer);
@@ -291,4 +297,115 @@ public class GostUtil {
long colonCount = address.chars().filter(ch -> ch == ':').count();
return colonCount >= 2;
}
/**
* v4 优先:当两端都有 v4 时选择 v4,否则尝试 v6。
* 用于节点之间建立链路(A -> B 需要选择 B 的地址族,且 A 需要支持该地址族)。
*/
public static String selectDialHost(Node fromNode, Node toNode) {
if (fromNode == null || toNode == null) {
throw new IllegalArgumentException("node is null");
}
boolean fromV4 = supportsV4(fromNode);
boolean fromV6 = supportsV6(fromNode);
boolean toV4 = supportsV4(toNode);
boolean toV6 = supportsV6(toNode);
if (fromV4 && toV4) {
return pickToAddressV4(toNode);
}
if (fromV6 && toV6) {
return pickToAddressV6(toNode);
}
throw new RuntimeException(
"节点链路不兼容:" + safeName(fromNode) + "(v4=" + fromV4 + ",v6=" + fromV6 + ") -> "
+ safeName(toNode) + "(v4=" + toV4 + ",v6=" + toV6 + ")"
);
}
private static String safeName(Node node) {
if (node.getName() == null || node.getName().isBlank()) {
return "node_" + node.getId();
}
return node.getName();
}
private static boolean supportsV4(Node node) {
// New dual-stack fields take precedence over legacy serverIp.
// If user explicitly provided only v6, treat as v6-only.
if (StrUtil.isNotBlank(node.getServerIpV4())) {
return true;
}
if (StrUtil.isNotBlank(node.getServerIpV6())) {
return false;
}
String legacy = node.getServerIp();
if (StrUtil.isBlank(legacy)) {
return false;
}
legacy = legacy.trim();
if (looksLikeIpv4(legacy)) {
return true;
}
if (isIPv6Address(legacy)) {
return false;
}
// 域名/其它:无法判断,按双栈处理以保持兼容
return true;
}
private static boolean supportsV6(Node node) {
// New dual-stack fields take precedence over legacy serverIp.
// If user explicitly provided only v4, treat as v4-only.
if (StrUtil.isNotBlank(node.getServerIpV6())) {
return true;
}
if (StrUtil.isNotBlank(node.getServerIpV4())) {
return false;
}
String legacy = node.getServerIp();
if (StrUtil.isBlank(legacy)) {
return false;
}
legacy = legacy.trim();
if (isIPv6Address(legacy)) {
return true;
}
if (looksLikeIpv4(legacy)) {
return false;
}
// 域名/其它:无法判断,按双栈处理以保持兼容
return true;
}
private static String pickToAddressV4(Node toNode) {
if (StrUtil.isNotBlank(toNode.getServerIpV4())) {
return toNode.getServerIpV4().trim();
}
String legacy = toNode.getServerIp();
return legacy != null ? legacy.trim() : null;
}
private static String pickToAddressV6(Node toNode) {
if (StrUtil.isNotBlank(toNode.getServerIpV6())) {
return toNode.getServerIpV6().trim();
}
String legacy = toNode.getServerIp();
return legacy != null ? legacy.trim() : null;
}
private static boolean looksLikeIpv4(String value) {
Pattern ipv4 = Pattern.compile("^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
return ipv4.matcher(value).matches();
}
}
@@ -10,7 +10,12 @@ import org.springframework.stereotype.Component;
import javax.annotation.PreDestroy;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Pattern;
/**
* SQLite 数据库配置
@@ -39,13 +44,98 @@ public class SQLiteConfig implements ApplicationRunner {
statement.execute("PRAGMA temp_store=MEMORY;");
statement.execute("PRAGMA busy_timeout=5000;"); // 5秒超时
statement.execute("PRAGMA wal_autocheckpoint=1000;"); // 每1000页自动checkpoint
ensureNodeDualStackColumns(connection);
log.info("SQLite WAL mode configured successfully");
} catch (Exception e) {
log.error("Failed to configure SQLite database", e);
throw e;
}
}
private void ensureNodeDualStackColumns(Connection connection) throws Exception {
Set<String> cols = getTableColumns(connection, "node");
if (cols.isEmpty()) {
return;
}
ensureColumnIfMissing(connection, cols, "node", "server_ip_v4", "VARCHAR(100)");
ensureColumnIfMissing(connection, cols, "node", "server_ip_v6", "VARCHAR(100)");
backfillNodeDualStackColumns(connection);
}
private void backfillNodeDualStackColumns(Connection connection) throws Exception {
Pattern ipv4 = Pattern.compile("^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
try (Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery("SELECT id, server_ip, server_ip_v4, server_ip_v6 FROM node;");
PreparedStatement updV4 = connection.prepareStatement("UPDATE node SET server_ip_v4 = ? WHERE id = ?;");
PreparedStatement updV6 = connection.prepareStatement("UPDATE node SET server_ip_v6 = ? WHERE id = ?;")
) {
while (rs.next()) {
long id = rs.getLong("id");
String serverIp = rs.getString("server_ip");
String v4 = rs.getString("server_ip_v4");
String v6 = rs.getString("server_ip_v6");
if (serverIp == null || serverIp.isBlank()) {
continue;
}
if ((v4 != null && !v4.isBlank()) || (v6 != null && !v6.isBlank())) {
continue;
}
String trimmed = serverIp.trim();
if (ipv4.matcher(trimmed).matches()) {
updV4.setString(1, trimmed);
updV4.setLong(2, id);
updV4.executeUpdate();
} else {
long colonCount = trimmed.chars().filter(ch -> ch == ':').count();
if (colonCount >= 2) {
updV6.setString(1, trimmed);
updV6.setLong(2, id);
updV6.executeUpdate();
}
}
}
}
}
private Set<String> getTableColumns(Connection connection, String table) throws Exception {
Set<String> cols = new HashSet<>();
try (Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery("PRAGMA table_info(" + table + ");")) {
while (rs.next()) {
String name = rs.getString("name");
if (name != null && !name.isBlank()) {
cols.add(name);
}
}
}
return cols;
}
private void ensureColumnIfMissing(
Connection connection,
Set<String> existingColumns,
String table,
String column,
String type
) throws Exception {
if (existingColumns.contains(column)) {
return;
}
try (Statement statement = connection.createStatement()) {
statement.execute("ALTER TABLE " + table + " ADD COLUMN " + column + " " + type + ";");
}
log.info("SQLite schema updated: added {}.{}", table, column);
}
/**
* 定期执行 checkpoint,确保 WAL 文件内容写入主数据库
@@ -80,4 +170,3 @@ public class SQLiteConfig implements ApplicationRunner {
}
}
}
@@ -1,10 +1,13 @@
package com.admin.controller;
import com.admin.common.aop.LogAnnotation;
import com.admin.common.annotation.RequireRole;
import com.admin.common.dto.ForwardDto;
import com.admin.common.dto.ForwardUpdateDto;
import com.admin.common.lang.R;
import com.admin.common.aop.LogAnnotation;
import com.admin.common.annotation.RequireRole;
import com.admin.common.dto.BatchDeleteDto;
import com.admin.common.dto.BatchRedeployDto;
import com.admin.common.dto.BatchChangeTunnelDto;
import com.admin.common.dto.ForwardDto;
import com.admin.common.dto.ForwardUpdateDto;
import com.admin.common.lang.R;
import com.admin.service.ForwardService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
@@ -91,10 +94,40 @@ public class ForwardController extends BaseController {
* @param params 包含forwards数组的参数,每个元素包含id和inx
* @return 更新结果
*/
@LogAnnotation
@PostMapping("/update-order")
public R updateForwardOrder(@RequestBody Map<String, Object> params) {
return forwardService.updateForwardOrder(params);
}
}
@LogAnnotation
@PostMapping("/update-order")
public R updateForwardOrder(@RequestBody Map<String, Object> params) {
return forwardService.updateForwardOrder(params);
}
@LogAnnotation
@PostMapping("/batch-delete")
public R batchDelete(@Validated @RequestBody BatchDeleteDto batchDeleteDto) {
return forwardService.batchDeleteForwards(batchDeleteDto);
}
@LogAnnotation
@PostMapping("/batch-pause")
public R batchPause(@Validated @RequestBody BatchDeleteDto batchDeleteDto) {
return forwardService.batchPauseForwards(batchDeleteDto);
}
@LogAnnotation
@PostMapping("/batch-resume")
public R batchResume(@Validated @RequestBody BatchDeleteDto batchDeleteDto) {
return forwardService.batchResumeForwards(batchDeleteDto);
}
@LogAnnotation
@PostMapping("/batch-redeploy")
public R batchRedeploy(@Validated @RequestBody BatchRedeployDto batchRedeployDto) {
return forwardService.batchRedeployForwards(batchRedeployDto);
}
@LogAnnotation
@PostMapping("/batch-change-tunnel")
public R batchChangeTunnel(@Validated @RequestBody BatchChangeTunnelDto batchChangeTunnelDto) {
return forwardService.batchChangeTunnel(batchChangeTunnelDto);
}
}
@@ -0,0 +1,123 @@
package com.admin.controller;
import com.admin.common.aop.LogAnnotation;
import com.admin.common.annotation.RequireRole;
import com.admin.common.dto.GroupCreateDto;
import com.admin.common.dto.GroupPermissionAssignDto;
import com.admin.common.dto.GroupUpdateDto;
import com.admin.common.dto.TunnelGroupAssignTunnelsDto;
import com.admin.common.dto.UserGroupAssignUsersDto;
import com.admin.common.lang.R;
import com.admin.service.GroupService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.Map;
@RestController
@CrossOrigin
@RequestMapping("/api/v1/group")
public class GroupController {
@Resource
private GroupService groupService;
@LogAnnotation
@RequireRole
@PostMapping("/tunnel/list")
public R tunnelGroupList() {
return groupService.getTunnelGroups();
}
@LogAnnotation
@RequireRole
@PostMapping("/tunnel/create")
public R createTunnelGroup(@Validated @RequestBody GroupCreateDto dto) {
return groupService.createTunnelGroup(dto);
}
@LogAnnotation
@RequireRole
@PostMapping("/tunnel/update")
public R updateTunnelGroup(@Validated @RequestBody GroupUpdateDto dto) {
return groupService.updateTunnelGroup(dto);
}
@LogAnnotation
@RequireRole
@PostMapping("/tunnel/delete")
public R deleteTunnelGroup(@RequestBody Map<String, Object> params) {
Long id = Long.valueOf(params.get("id").toString());
return groupService.deleteTunnelGroup(id);
}
@LogAnnotation
@RequireRole
@PostMapping("/tunnel/assign")
public R assignTunnels(@Validated @RequestBody TunnelGroupAssignTunnelsDto dto) {
return groupService.assignTunnelsToGroup(dto);
}
@LogAnnotation
@RequireRole
@PostMapping("/user/list")
public R userGroupList() {
return groupService.getUserGroups();
}
@LogAnnotation
@RequireRole
@PostMapping("/user/create")
public R createUserGroup(@Validated @RequestBody GroupCreateDto dto) {
return groupService.createUserGroup(dto);
}
@LogAnnotation
@RequireRole
@PostMapping("/user/update")
public R updateUserGroup(@Validated @RequestBody GroupUpdateDto dto) {
return groupService.updateUserGroup(dto);
}
@LogAnnotation
@RequireRole
@PostMapping("/user/delete")
public R deleteUserGroup(@RequestBody Map<String, Object> params) {
Long id = Long.valueOf(params.get("id").toString());
return groupService.deleteUserGroup(id);
}
@LogAnnotation
@RequireRole
@PostMapping("/user/assign")
public R assignUsers(@Validated @RequestBody UserGroupAssignUsersDto dto) {
return groupService.assignUsersToGroup(dto);
}
@LogAnnotation
@RequireRole
@PostMapping("/permission/list")
public R listPermissions() {
return groupService.getGroupPermissions();
}
@LogAnnotation
@RequireRole
@PostMapping("/permission/assign")
public R assignPermission(@Validated @RequestBody GroupPermissionAssignDto dto) {
return groupService.assignGroupPermission(dto);
}
@LogAnnotation
@RequireRole
@PostMapping("/permission/remove")
public R removePermission(@RequestBody Map<String, Object> params) {
Long id = Long.valueOf(params.get("id").toString());
return groupService.removeGroupPermission(id);
}
}
@@ -1,13 +1,14 @@
package com.admin.controller;
import com.admin.common.annotation.RequireRole;
import com.admin.common.aop.LogAnnotation;
import com.admin.common.dto.NodeDto;
import com.admin.common.dto.NodeUpdateDto;
import com.admin.common.lang.R;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
package com.admin.controller;
import com.admin.common.annotation.RequireRole;
import com.admin.common.aop.LogAnnotation;
import com.admin.common.dto.BatchDeleteDto;
import com.admin.common.dto.NodeDto;
import com.admin.common.dto.NodeUpdateDto;
import com.admin.common.lang.R;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@@ -74,4 +75,11 @@ public class NodeController extends BaseController {
return nodeService.updateNodeOrder(params);
}
@LogAnnotation
@RequireRole
@PostMapping("/batch-delete")
public R batchDelete(@Validated @RequestBody BatchDeleteDto batchDeleteDto) {
return nodeService.batchDeleteNodes(batchDeleteDto);
}
}
@@ -2,9 +2,12 @@ package com.admin.controller;
import com.admin.common.aop.LogAnnotation;
import com.admin.common.annotation.RequireRole;
import com.admin.common.dto.BatchDeleteDto;
import com.admin.common.dto.BatchRedeployDto;
import com.admin.common.dto.TunnelDto;
import com.admin.common.dto.TunnelUpdateDto;
import com.admin.common.dto.UserTunnelBatchAssignDto;
import com.admin.common.dto.UserTunnelDto;
import com.admin.common.dto.UserTunnelQueryDto;
import com.admin.common.dto.UserTunnelUpdateDto;
@@ -78,6 +81,13 @@ public class TunnelController extends BaseController {
public R assignUserTunnel(@Validated @RequestBody UserTunnelDto userTunnelDto) {
return userTunnelService.assignUserTunnel(userTunnelDto);
}
@LogAnnotation
@RequireRole
@PostMapping("/user/batch-assign")
public R batchAssignUserTunnel(@Validated @RequestBody UserTunnelBatchAssignDto batchAssignDto) {
return userTunnelService.batchAssignUserTunnel(batchAssignDto);
}
/**
* 查询用户隧道权限列表
@@ -149,4 +159,18 @@ public class TunnelController extends BaseController {
return tunnelService.updateTunnelOrder(params);
}
@LogAnnotation
@RequireRole
@PostMapping("/batch-delete")
public R batchDelete(@Validated @RequestBody BatchDeleteDto batchDeleteDto) {
return tunnelService.batchDeleteTunnels(batchDeleteDto);
}
@LogAnnotation
@RequireRole
@PostMapping("/batch-redeploy")
public R batchRedeploy(@Validated @RequestBody BatchRedeployDto batchRedeployDto) {
return tunnelService.batchRedeployTunnels(batchRedeployDto);
}
}
@@ -0,0 +1,22 @@
package com.admin.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import java.io.Serializable;
@Data
public class GroupPermission implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long userGroupId;
private Long tunnelGroupId;
private Long createdTime;
}
@@ -0,0 +1,26 @@
package com.admin.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import java.io.Serializable;
@Data
public class GroupPermissionGrant implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long userGroupId;
private Long tunnelGroupId;
private Long userTunnelId;
private Integer createdByGroup;
private Long createdTime;
}
@@ -20,9 +20,13 @@ public class Node extends BaseEntity {
private String name;
private String secret;
private String serverIp;
private String secret;
private String serverIp;
private String serverIpV4;
private String serverIpV6;
private String version;
@@ -45,5 +49,4 @@ public class Node extends BaseEntity {
* Lower value appears first.
*/
private Integer inx;
}

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