Compare commits

...

21 Commits

Author SHA1 Message Date
sagit bbffe5872c Merge pull request #54 from Sagit-chu/opencode/clever-eagle
feat(backend): ensure all tables are created at startup
2026-02-09 09:42:48 +08:00
sagit 98db0e6a5e Merge branch 'main' into opencode/clever-eagle 2026-02-09 09:41:29 +08:00
sagit 30591a008a feat(backend): ensure all tables are created at startup 2026-02-09 01:29:42 +00:00
sagit 36ba2f95ef Merge pull request #53 from Sagit-chu/opencode/proud-knight
fix(docker): fix go-backend docker build syntax error
2026-02-08 20:12:44 +08:00
sagit 858f9ef1d5 fix(docker): fix go-backend docker build syntax error 2026-02-08 12:11:02 +00:00
sagit 218084b542 Merge pull request #51 from Sagit-chu/opencode/brave-meadow
feat: update doc
2026-02-08 19:57:42 +08:00
sagit a9c304546e Merge branch 'main' into opencode/brave-meadow 2026-02-08 19:55:57 +08:00
sagit 85250aa2d3 chore: fix remaining rebranding and frontend updates 2026-02-08 11:50:06 +00:00
sagit aeeb57b89d chore: rebrand to FLVX and fix docker multi-arch build 2026-02-08 11:50:06 +00:00
sagit 4e163163cb Merge pull request #50 from Sagit-chu/opencode/brave-meadow
docs: add usage documentation and github pages workflow
2026-02-08 18:54:50 +08:00
sagit eb5464511a Merge branch 'main' into opencode/brave-meadow 2026-02-08 18:53:05 +08:00
sagit efa6c0e322 docs: add usage documentation and github pages workflow 2026-02-08 10:51:18 +00:00
sagit 576654fc5a Merge pull request #48 from Sagit-chu/opencode/silent-orchid
fix(agent): handle config save errors and propagate to reporter
2026-02-08 16:27:48 +08:00
sagit a1454a3549 fix(agent): handle config save errors and propagate to reporter 2026-02-08 08:23:58 +00:00
sagit 1c72fb233a Merge pull request #46 from Sagit-chu/opencode/jolly-circuit
fix: fix some bugs
2026-02-08 15:28:28 +08:00
sagit ac1c2fa6bf Merge branch 'main' into opencode/jolly-circuit 2026-02-08 15:27:09 +08:00
sagit 4af2186e35 fix: 修复修改隧道负载策略不生效的问题 2026-02-08 07:25:35 +00:00
sagit 9fe9798677 fix: sync forward rules when updating user tunnel to preserve ports 2026-02-08 07:21:10 +00:00
sagit cfee6092c7 chore: delete springboot-backend directory 2026-02-08 07:15:47 +00:00
sagit 7ab6545594 fix: make chain and limiter updates idempotent
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-08 07:04:06 +00:00
sagit 2e2c182a0d fix: make service update idempotent (upsert)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-08 07:04:05 +00:00
235 changed files with 467 additions and 13035 deletions
+43
View File
@@ -0,0 +1,43 @@
name: Deploy Docs
on:
push:
branches:
- main
- master
- beta
paths:
- 'doc/**'
- 'mkdocs.yml'
permissions:
contents: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure Git Credentials
run: |
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
- uses: actions/setup-python@v5
with:
python-version: 3.x
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@v4
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
restore-keys: |
mkdocs-material-
- name: Install MkDocs and Material Theme
run: pip install mkdocs-material
- name: Build and Deploy
run: mkdocs gh-deploy --force
+15 -11
View File
@@ -5,17 +5,15 @@
**Branch:** beta
## OVERVIEW
Flux Panel is a traffic forwarding management system built on a forked GOST v3 stack. It ships as Dockerized Spring Boot (admin API) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
## STRUCTURE
```
./
├── go-gost/ # Go forwarding agent (forked gost + local x/)
│ └── x/ # Local fork of github.com/go-gost/x (replace => ./x)
├── springboot-backend/ # Java/Spring Boot admin API (SQLite/MyBatis)
├── go-backend/ # Go Admin API (SQLite, net/http)
├── 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)
@@ -30,13 +28,19 @@ Flux Panel is a traffic forwarding management system built on a forked GOST v3 s
| **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 |
| **Admin API** | `go-backend/` | Go Admin API (SQLite) |
| **Web UI** | `vite-frontend/` | React/Vite dashboard (HeroUI + Tailwind) |
| **Go Agent** | `go-gost/` | Forwarding agent (forked gost + local x/) |
| **Go Core** | `go-gost/x/` | Handlers/listeners/dialers + management API |
## CODE MAP
| Symbol | Type | Location | Role |
|--------|------|----------|------|
| `flvx` | Project | `.` | Root directory |
| `main` | Func | `go-backend/cmd/paneld/main.go` | Backend Entry |
| `App` | Component | `vite-frontend/src/App.tsx` | Frontend Entry |
| `main` | Func | `go-gost/main.go` | Agent Entry |
## CONVENTIONS
- `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `springboot-backend/`.
+3 -1
View File
@@ -1,4 +1,6 @@
# flux-panel转发面板 哆啦A梦转发面板
# FLVX
> 📞 **联系我们**: [Telegram群组](https://t.me/flvxpanel)
本项目基于 [go-gost/gost](https://github.com/go-gost/gost) 和 [go-gost/x](https://github.com/go-gost/x) 两个开源库,实现了转发面板。
---
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

+24
View File
@@ -0,0 +1,24 @@
# 常见问题 (FAQ)
### Q1: 安装脚本提示 "Docker command not found"?
**A**: 请确保您的系统已安装 Docker 和 Docker Compose。
- Ubuntu/Debian 安装 Docker: `curl -fsSL https://get.docker.com | bash`
### Q2: 面板无法访问 (Connection Refused)?
**A**:
1. 检查防火墙是否放行了前端端口(默认 `6366`)。
2. 检查容器是否正常运行: `docker ps`。
3. 查看容器日志: `docker logs flux-panel-backend` 或 `docker logs vite-frontend`。
### Q3: 节点显示离线?
**A**:
1. 检查节点服务器与面板服务器之间的网络连通性。
2. 确认在节点端安装时输入的 **面板地址** 和 **密钥** 是否正确。
3. 检查节点端服务状态: `systemctl status flux_agent`。
4. 查看节点端日志: `journalctl -u flux_agent -f`。
### Q4: 只有 TCP 能通,UDP 不通?
**A**: 请检查服务器防火墙和安全组(AWS/阿里云/腾讯云等)是否同时放行了对应端口的 **TCP 和 UDP** 协议。
### Q5: IPv6 无法使用?
**A**: 面板安装脚本会自动尝试配置 Docker 的 IPv6。如果失败,请手动检查 `/etc/docker/daemon.json` 配置,确保 `ipv6: true` 且分配了正确的 `fixed-cidr-v6` 子网。
+37
View File
@@ -0,0 +1,37 @@
# FLVX 官方文档
**FLVX** 是一个基于 [go-gost/gost](https://github.com/go-gost/gost) 和 [go-gost/x](https://github.com/go-gost/x) 开发的高性能流量转发管理系统。
> 📞 **联系我们**: [Telegram群组](https://t.me/flvxpanel)
## 核心特性
- **多协议支持**: 完美支持 TCP 和 UDP 协议转发。
- **灵活转发**: 支持 **端口转发** 与 **隧道转发** 两种模式。
- **流量控制**: 支持按 **隧道账号级别** 管理流量转发数量,用于用户/隧道配额控制。
- **限速管理**: 可针对 **指定用户的指定隧道进行限速** 设置。
- **计费策略**: 支持配置 **单向或双向流量计费方式**,灵活适配不同计费模型。
- **策略配置**: 提供灵活的转发策略配置,适用于多种网络场景。
## 快速开始
- [安装部署](./install.md)
- [使用指南](./usage.md)
- [常见问题](./faq.md)
## 免责声明
本项目仅供个人学习与研究使用,基于开源项目进行二次开发。
使用本项目所带来的任何风险均由使用者自行承担。本项目为开源的流量转发工具,仅限合法、合规用途。
**禁止将本项目用于任何违法或未经授权的行为,包括但不限于网络攻击、数据窃取、非法访问等。**
## 捐赠支持
如果您觉得本项目对您有帮助,欢迎请作者喝杯咖啡!
| 网络 | 地址 |
|------------|----------------------------------------------------------------------|
| BNB(BEP20) | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
| TRC20 | `TM8VYdU3s3gSX5PC8swjAJrAzZFCHKqG2k` |
| Aptos | `0x49427bfcba1006a346447430689b2307ac156316bb34850d1d3029ff9d118da5` |
| Polygon | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
+79
View File
@@ -0,0 +1,79 @@
# 安装部署指南
本文档介绍如何部署 FLVX 面板端及节点端。
## 一、面板端部署
面板端负责管理用户、节点和转发规则。
### 1. 环境要求
- 操作系统:Linux (推荐 Debian 10+ / Ubuntu 20.04+)
- 必须安装 Docker 和 Docker Compose
### 2. 一键安装脚本
使用以下命令即可快速安装面板:
```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
```
**安装过程中会提示输入以下信息:**
- **前端端口**: 默认为 `6366`
- **后端端口**: 默认为 `6365`
脚本会自动检测系统是否支持 IPv6,并自动配置 Docker 的 IPv6 支持。
### 3. 访问面板
安装完成后,访问:
`http://<服务器IP>:<前端端口>` (默认: `http://<服务器IP>:6366`)
**默认管理员账号:**
- 用户名: `admin_user`
- 密码: `admin_user`
> ⚠️ **注意**: 首次登录后,请务必在“个人中心”或“设置”中修改默认密码!
### 4. 维护命令
再次运行 `./panel_install.sh` 脚本可以看到管理菜单:
1. 安装面板
2. 更新面板
3. 卸载面板
---
## 二、节点端部署
节点端运行在实际进行流量转发的服务器上,需要连接到面板端进行管理。
### 1. 获取接入密钥
1. 登录面板端。
2. 进入 **节点管理 (Node)** 页面。
3. 点击 **添加节点**。
4. 获取该节点的 **接入密钥 (Secret)**。
### 2. 一键安装脚本
在节点服务器上运行:
```bash
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
```
**安装过程中会提示输入:**
- **服务器地址**: 面板端的通信地址(通常是 `http://<面板IP>:<后端端口>`,例如 `http://1.2.3.4:6365`)。
- **密钥**: 刚才在面板中获取的节点密钥。
或者直接使用带参数的命令(适用于自动化部署):
```bash
# 替换 <面板地址> 和 <密钥>
./install.sh -a "http://1.2.3.4:6365" -s "your_node_secret"
```
### 3. 验证安装
安装完成后,服务会自动启动。
- 查看状态: `systemctl status flux_agent`
- 回到面板 **节点管理** 页面,该节点状态应显示为 **在线**。
+35
View File
@@ -0,0 +1,35 @@
# 使用指南
## 1. 仪表盘 (Dashboard)
登录系统后首先看到的是仪表盘,这里展示了系统的整体运行状态,包括:
- 在线节点数量
- 用户总数
- 流量统计信息
## 2. 节点管理 (Node)
节点是实际承载流量转发的服务器。
- **添加节点**: 点击“添加”,获取密钥用于节点端安装。
- **管理**: 可以查看节点在线状态、版本信息,以及对节点进行编辑或删除。
## 3. 用户管理 (User)
管理员可以创建和管理普通用户。
- **创建用户**: 设置用户名、密码、流量配额等。
- **用户组**: 可以将用户分配到不同的组 (Group),便于统一管理权限或策略。
## 4. 转发管理 (Forward)
这是核心功能区,用于设置端口转发规则。
- **端口转发**: 将节点服务器的某个端口流量转发到目标地址。
- **协议**: 支持 TCP / UDP。
- **入口**: 选择入口节点和监听端口。
- **出口**: 设置目标 IP 和端口。
- **隧道转发**: 用于更复杂的网络穿透场景(具体配置视业务需求而定)。
## 5. 限制与策略 (Limit)
- **限速**: 可以对指定用户或指定隧道进行带宽限制,防止资源滥用。
- **计费模式**: 支持配置流量计算方式(单向或双向),适合运营场景。
## 6. 系统配置 (Config)
在此页面进行系统的全局设置。
## 7. 个人设置 (Profile)
- **修改密码**: 为了安全,建议定期修改管理员密码。
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

+47
View File
@@ -0,0 +1,47 @@
# GO BACKEND KNOWLEDGE BASE
## OVERVIEW
Go-based Admin API for FLVX (formerly Flux Panel). Replaces the legacy Spring Boot backend.
**Stack:** Go 1.23, net/http (std lib), SQLite (modernc.org/sqlite).
## STRUCTURE
```
go-backend/
├── cmd/paneld/main.go # Entry point; starts HTTP server + WebSocket
├── internal/
│ ├── http/ # HTTP layer
│ │ ├── router.go # Routes (NewServeMux) + Middleware chain
│ │ ├── handler/ # API Handlers (User, Tunnel, Node, etc.)
│ │ ├── middleware/ # JWT, CORS, Logging, Recover
│ │ └── response/ # JSON response helpers
│ ├── store/sqlite/ # Data Access Layer (Repository pattern)
│ │ ├── repository.go # SQL queries & Struct definitions
│ │ └── sql/ # Embedded schema.sql & data.sql
│ └── auth/ # Auth logic
├── tests/ # Integration/Contract tests
├── Dockerfile # Multi-stage build (alpine)
└── Makefile # Build commands
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| **API Routes** | `go-backend/internal/http/router.go` | Registers handlers to `http.ServeMux` |
| **DB Schema** | `go-backend/internal/store/sqlite/sql/schema.sql` | Embedded in binary |
| **SQL Queries** | `go-backend/internal/store/sqlite/repository.go` | Raw SQL, no ORM |
| **Auth Middleware** | `go-backend/internal/http/middleware/jwt.go` | Extracts `Authorization` header |
| **WebSocket** | `go-backend/internal/ws/` | Real-time updates (traffic, status) |
## CONVENTIONS
- **No ORM**: Uses raw SQL with `database/sql` and `modernc.org/sqlite`.
- **Standard Lib**: Uses `net/http` for routing (Go 1.22+ patterns).
- **Auth**: Expects raw JWT in `Authorization` header (no `Bearer` prefix).
- **Config**: Loaded from environment variables (see `cmd/paneld/main.go`).
## COMMANDS
```bash
cd go-backend
go run ./cmd/paneld
go test ./...
make build
```
+3 -1
View File
@@ -5,7 +5,9 @@ COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/paneld ./cmd/paneld
ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} env ${TARGETARCH:+GOARCH=${TARGETARCH}} go build -o /out/paneld ./cmd/paneld
FROM debian:bookworm-slim
WORKDIR /app
+26 -2
View File
@@ -841,6 +841,13 @@ func (h *Handler) userTunnelUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
// Fetch details to sync forwards
var userID, tunnelID int64
if err := h.repo.DB().QueryRow("SELECT user_id, tunnel_id FROM user_tunnel WHERE id = ?", id).Scan(&userID, &tunnelID); err == nil {
h.syncUserTunnelForwards(userID, tunnelID)
}
response.WriteJSON(w, response.OKEmpty())
}
@@ -2279,8 +2286,8 @@ func replaceTunnelChainsTx(tx *sql.Tx, tunnelID int64, req map[string]interface{
return pickErr
}
}
_, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 3, ?, ?, NULL, 0, ?)`,
tunnelID, nodeID, port, defaultString(asString(n["protocol"]), "tls"))
_, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 3, ?, ?, ?, 0, ?)`,
tunnelID, nodeID, port, defaultString(asString(n["strategy"]), "round"), defaultString(asString(n["protocol"]), "tls"))
if err != nil {
return err
}
@@ -2683,9 +2690,26 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
_, err = db.Exec(`UPDATE user_tunnel SET speed_id = ?, flow = ?, num = ?, exp_time = ?, flow_reset_time = ?, status = ? WHERE id = ?`,
newSpeedID, newFlow, newNum, newExpTime, newFlowReset, newStatus, existingID)
if err == nil {
h.syncUserTunnelForwards(userID, tunnelID)
}
return err
}
func (h *Handler) syncUserTunnelForwards(userID, tunnelID int64) {
forwards, err := h.listForwardsByTunnel(tunnelID)
if err != nil {
return
}
for i := range forwards {
f := &forwards[i]
if f.UserID == userID {
_ = h.syncForwardServices(f, "UpdateService", true)
}
}
}
func asAnySlice(v interface{}) []interface{} {
if v == nil {
return nil
+1 -11
View File
@@ -5,7 +5,6 @@ import (
_ "embed"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"sort"
@@ -1167,19 +1166,10 @@ func bootstrapSchema(db *sql.DB) error {
return errors.New("nil db")
}
var exists int
err := db.QueryRow(`SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name='user'`).Scan(&exists)
if err != nil {
return fmt.Errorf("check schema: %w", err)
}
if exists > 0 {
return nil
}
log.Printf("sqlite schema not found, bootstrapping embedded schema")
if _, err := db.Exec(embeddedSchema); err != nil {
return fmt.Errorf("apply schema.sql: %w", err)
}
if _, err := db.Exec(embeddedSeedData); err != nil {
return fmt.Errorf("apply data.sql: %w", err)
}
+8 -3
View File
@@ -4,9 +4,7 @@ go 1.22.0
toolchain go1.23.4
require (
github.com/shirou/gopsutil/v3 v3.24.5
)
require github.com/shirou/gopsutil/v3 v3.24.5
require (
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137
@@ -76,6 +74,7 @@ require (
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
@@ -91,6 +90,7 @@ require (
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/klauspost/reedsolomon v1.11.8 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
@@ -103,6 +103,7 @@ require (
github.com/pion/udp/v2 v2.0.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/prometheus/client_model v0.6.0 // indirect
github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
@@ -110,6 +111,7 @@ require (
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
@@ -118,8 +120,11 @@ require (
github.com/templexxx/cpu v0.1.0 // indirect
github.com/templexxx/xorsimd v0.4.2 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.8.0 // indirect
+22
View File
@@ -65,6 +65,8 @@ github.com/go-gost/tls-dissector v0.1.1 h1:2zUOTPzCQAUQ54Rpy0UEi3JPMQSYsIFSeFeKr
github.com/go-gost/tls-dissector v0.1.1/go.mod h1:/9QfdewqmHdaE362Hv5nDaSWLx3pCmtD870d6GaquXs=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -102,6 +104,7 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -133,6 +136,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -175,6 +180,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@@ -204,7 +211,12 @@ github.com/shadowsocks/go-shadowsocks2 v0.1.5 h1:PDSQv9y2S85Fl7VBeOMF9StzeXZyK1H
github.com/shadowsocks/go-shadowsocks2 v0.1.5/go.mod h1:AGGpIoek4HRno4xzyFiAtLHkOpcoznZEkAccaI/rplM=
github.com/shadowsocks/shadowsocks-go v0.0.0-20200409064450-3e585ff90601 h1:XU9hik0exChEmY92ALW4l9WnDodxLVS9yOSNh2SizaQ=
github.com/shadowsocks/shadowsocks-go v0.0.0-20200409064450-3e585ff90601/go.mod h1:mttDPaeLm87u74HMrP+n2tugXvIKWcwff/cqSX0lehY=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
@@ -244,6 +256,10 @@ github.com/templexxx/xorsimd v0.4.2 h1:ocZZ+Nvu65LGHmCLZ7OoCtg8Fx8jnHKK37SjvngUo
github.com/templexxx/xorsimd v0.4.2/go.mod h1:HgwaPoDREdi6OnULpSfxhzaiiSUY4Fi3JPn1wpt28NI=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
@@ -266,6 +282,8 @@ github.com/xtaci/tcpraw v1.2.25/go.mod h1:dKyZ2V75s0cZ7cbgJYdxPvms7af0joIeOyx1Gg
github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU=
github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zalando/go-keyring v0.2.4 h1:wi2xxTqdiwMKbM6TWwi+uJCG/Tum2UV0jqaQhCa9/68=
github.com/zalando/go-keyring v0.2.4/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
@@ -321,15 +339,19 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
+9 -7
View File
@@ -43,8 +43,8 @@ func updateChain(req updateChainRequest) error {
name := strings.TrimSpace(req.Chain)
if !registry.ChainRegistry().IsRegistered(name) {
return errors.New("chain " + name + " not found")
if registry.ChainRegistry().IsRegistered(name) {
registry.ChainRegistry().Unregister(name)
}
req.Data.Name = name
@@ -54,19 +54,22 @@ func updateChain(req updateChainRequest) error {
return errors.New("create chain " + name + " failed: " + err.Error())
}
registry.ChainRegistry().Unregister(name)
if err := registry.ChainRegistry().Register(name, v); err != nil {
return errors.New("chain " + name + " already exists")
}
config.OnUpdate(func(c *config.Config) error {
found := false
for i := range c.Chains {
if c.Chains[i].Name == name {
c.Chains[i] = &req.Data
found = true
break
}
}
if !found {
c.Chains = append(c.Chains, &req.Data)
}
return nil
})
@@ -77,10 +80,9 @@ func deleteChain(req deleteChainRequest) error {
name := strings.TrimSpace(req.Chain)
if !registry.ChainRegistry().IsRegistered(name) {
return errors.New("chain " + name + " not found")
if registry.ChainRegistry().IsRegistered(name) {
registry.ChainRegistry().Unregister(name)
}
registry.ChainRegistry().Unregister(name)
config.OnUpdate(func(c *config.Config) error {
chains := c.Chains
+4 -5
View File
@@ -10,7 +10,7 @@ import (
// configMutex 保护配置文件的并发写入
var configMutex sync.Mutex
func saveConfig() {
func saveConfig() error {
configMutex.Lock()
defer configMutex.Unlock()
@@ -18,14 +18,13 @@ func saveConfig() {
f, err := os.Create(file)
if err != nil {
return
return err
}
defer f.Close()
if err := config.Global().Write(f, "json"); err != nil {
return
return err
}
return
return nil
}
+9 -7
View File
@@ -37,27 +37,30 @@ func updateLimiter(req updateLimiterRequest) error {
name := strings.TrimSpace(req.Limiter)
if !registry.TrafficLimiterRegistry().IsRegistered(name) {
return errors.New("limiter " + name + " not found")
if registry.TrafficLimiterRegistry().IsRegistered(name) {
registry.TrafficLimiterRegistry().Unregister(name)
}
req.Data.Name = name
v := parser.ParseTrafficLimiter(&req.Data)
registry.TrafficLimiterRegistry().Unregister(name)
if err := registry.TrafficLimiterRegistry().Register(name, v); err != nil {
return errors.New("limiter " + name + " already exists")
}
config.OnUpdate(func(c *config.Config) error {
found := false
for i := range c.Limiters {
if c.Limiters[i].Name == name {
c.Limiters[i] = &req.Data
found = true
break
}
}
if !found {
c.Limiters = append(c.Limiters, &req.Data)
}
return nil
})
@@ -68,10 +71,9 @@ func deleteLimiter(req deleteLimiterRequest) error {
name := strings.TrimSpace(req.Limiter)
if !registry.TrafficLimiterRegistry().IsRegistered(name) {
return errors.New("limiter " + name + " not found")
if registry.TrafficLimiterRegistry().IsRegistered(name) {
registry.TrafficLimiterRegistry().Unregister(name)
}
registry.TrafficLimiterRegistry().Unregister(name)
config.OnUpdate(func(c *config.Config) error {
limiteres := c.Limiters
+41 -32
View File
@@ -87,36 +87,32 @@ func updateServices(req updateServicesRequest) error {
return errors.New("services list cannot be empty")
}
// 第一阶段:验证所有服务存在
for _, serviceConfig := range req.Data {
name := strings.TrimSpace(serviceConfig.Name)
// 第一阶段:验证所有服务名称有效性
for i := range req.Data {
name := strings.TrimSpace(req.Data[i].Name)
if name == "" {
return errors.New("service name is required")
}
serviceConfig.Name = name
old := registry.ServiceRegistry().Get(name)
if old == nil {
return errors.New("service " + name + " not found")
}
req.Data[i].Name = name
}
// 第二阶段:按照原来的updateService逻辑,逐个更新服务
for _, serviceConfig := range req.Data {
name := strings.TrimSpace(serviceConfig.Name)
serviceConfig.Name = name
// 第二阶段:逐个更新服务(Upsert模式:存在则更新,不存在则创建)
for i := range req.Data {
serviceConfig := &req.Data[i]
name := serviceConfig.Name
// 1. 获取旧服务
old := registry.ServiceRegistry().Get(name)
// 2. 关闭旧服务
old.Close()
// 3. 从注册表移除旧服务
registry.ServiceRegistry().Unregister(name)
// 2. 关闭旧服务 (如果存在)
if old != nil {
old.Close()
// 3. 从注册表移除旧服务
registry.ServiceRegistry().Unregister(name)
}
// 4. 解析新服务配置
svc, err := parser.ParseService(&serviceConfig)
svc, err := parser.ParseService(serviceConfig)
if err != nil {
return errors.New("create service " + name + " failed: " + err.Error())
}
@@ -133,13 +129,20 @@ func updateServices(req updateServicesRequest) error {
// 第三阶段:更新配置
config.OnUpdate(func(c *config.Config) error {
for _, serviceConfig := range req.Data {
for i := range c.Services {
if c.Services[i].Name == serviceConfig.Name {
c.Services[i] = &serviceConfig
for i := range req.Data {
// 创建副本以确保指针安全
cfgCopy := req.Data[i]
found := false
for j := range c.Services {
if c.Services[j].Name == cfgCopy.Name {
c.Services[j] = &cfgCopy
found = true
break
}
}
if !found {
c.Services = append(c.Services, &cfgCopy)
}
}
return nil
})
@@ -158,22 +161,22 @@ func deleteServices(req deleteServicesRequest) error {
name string
service service.Service
}
var namesToRemove []string
for _, serviceName := range req.Services {
name := strings.TrimSpace(serviceName)
if name == "" {
return errors.New("service name is required")
}
namesToRemove = append(namesToRemove, name)
svc := registry.ServiceRegistry().Get(name)
if svc == nil {
return errors.New("service " + name + " not found")
if svc != nil {
servicesToDelete = append(servicesToDelete, struct {
name string
service service.Service
}{name, svc})
}
servicesToDelete = append(servicesToDelete, struct {
name string
service service.Service
}{name, svc})
}
// 第二阶段:删除所有服务
@@ -181,6 +184,12 @@ func deleteServices(req deleteServicesRequest) error {
registry.ServiceRegistry().Unregister(std.name)
std.service.Close()
}
// 确保所有请求删除的服务都从注册表中移除(即使之前未找到实例)
for _, name := range namesToRemove {
if registry.ServiceRegistry().IsRegistered(name) {
registry.ServiceRegistry().Unregister(name)
}
}
// 第三阶段:更新配置
config.OnUpdate(func(c *config.Config) error {
@@ -188,8 +197,8 @@ func deleteServices(req deleteServicesRequest) error {
c.Services = nil
for _, s := range services {
shouldDelete := false
for _, std := range servicesToDelete {
if s.Name == std.name {
for _, name := range namesToRemove {
if s.Name == name {
shouldDelete = true
break
}
+10 -1
View File
@@ -586,7 +586,16 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
// 只有状态变更命令才保存配置
if needSaveConfig {
saveConfig()
if saveErr := saveConfig(); saveErr != nil {
fmt.Printf("❌ 保存配置失败: %v\n", saveErr)
if err == nil {
err = fmt.Errorf("保存配置失败: %v", saveErr)
} else {
err = fmt.Errorf("%v; 保存配置失败: %v", err, saveErr)
}
} else {
fmt.Println("✅ 配置已保存到 gost.json")
}
}
// 发送响应
+40
View File
@@ -0,0 +1,40 @@
site_name: FLVX 文档
site_url: https://sagit-chu.github.io/flux-panel/
site_description: FLVX官方文档
site_author: Sagit-chu
theme:
name: material
language: zh
palette:
- scheme: default
primary: indigo
accent: indigo
- scheme: slate
primary: indigo
accent: indigo
toggle:
icon: material/brightness-4
name: Switch to dark mode
features:
- navigation.tabs
- navigation.sections
- navigation.top
- search.suggest
- search.highlight
- content.code.copy
docs_dir: doc
nav:
- 首页: index.md
- 安装部署: install.md
- 使用指南: usage.md
- 常见问题: faq.md
markdown_extensions:
- admonition
- pymdownx.details
- pymdownx.superfences
- pymdownx.tabbed:
alternate_style: true
-3
View File
@@ -1,3 +0,0 @@
<factorypath>
<factorypathentry kind="VARJAR" id="M2_REPO/org/projectlombok/lombok/1.18.30/lombok-1.18.30.jar" enabled="true" runInBatchMode="false"/>
</factorypath>
-39
View File
@@ -1,39 +0,0 @@
# SPRINGBOOT BACKEND KNOWLEDGE BASE
**Generated:** Mon Feb 02 2026
## OVERVIEW
Admin API for Flux Panel. Manages users, tunnels, nodes, forwards, quotas, and speed limits.
**Stack:** Java 21, Spring Boot 2.7.18, SQLite, MyBatis Plus (+ join), FastJSON2.
## STRUCTURE
```
springboot-backend/
├── src/main/java/com/admin/
│ ├── controller/ # /api/v1/* endpoints
│ ├── entity/ # DB models
│ ├── mapper/ # MyBatis Plus mappers
│ ├── service/ # Business logic
│ ├── config/ # WebMvc/JWT/CORS/WebSocket config
│ └── common/ # DTOs, auth, exception handling, utilities
└── src/main/resources/
├── application.yml # Config (DB_PATH/JWT_SECRET/LOG_DIR)
├── mapper/ # XML mappers
├── schema.sql # Schema
└── data.sql # Seed data
```
## CONVENTIONS
- **DB**: SQLite URL is `jdbc:sqlite:${DB_PATH:/app/data/gost.db}` (`springboot-backend/src/main/resources/application.yml`).
- **Auth**: JWT in `Authorization` header; enforced by `com.admin.common.interceptor.JwtInterceptor` for `/api/**` (with explicit excludes in `com.admin.config.WebMvcConfig`).
- **Roles**: `@RequireRole` means admin-only (`role_id == 0`) via `com.admin.common.aop.RoleAspect`.
- **Responses**: Controllers return `com.admin.common.lang.R` (`code == 0` success).
- **CORS**: Allow-all origins; `Authorization` is exposed (`com.admin.config.WebMvcConfig`).
## COMMANDS
```bash
cd springboot-backend
mvn clean package
mvn test
java -jar target/admin-0.0.1-SNAPSHOT.jar
```
-27
View File
@@ -1,27 +0,0 @@
# 构建阶段
FROM maven:3.9.6-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests
# 运行阶段
FROM eclipse-temurin:21-jre
WORKDIR /app
ENV JAVA_OPTS="-Xmx512m -Xms256m -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
RUN sed -i 's/archive.ubuntu.com/mirrors.ustc.edu.cn/g' /etc/apt/sources.list \
&& sed -i 's/security.ubuntu.com/mirrors.ustc.edu.cn/g' /etc/apt/sources.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends fontconfig fonts-dejavu sqlite3 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/target/*.jar app.jar
EXPOSE 6365
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
-180
View File
@@ -1,180 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.admin</groupId>
<artifactId>admin</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>admin</name>
<description>admin</description>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-boot.version>2.7.18</spring-boot.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.43</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.github.yulichang</groupId>
<artifactId>mybatis-plus-join</artifactId>
<version>1.3.0</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.70</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.1</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.4.1</version>
</dependency>
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.45.0.0</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.3.3</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.11</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.14</version>
</dependency>
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.31</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- tianai-captcha Spring Boot Starter -->
<dependency>
<groupId>cloud.tianai.captcha</groupId>
<artifactId>tianai-captcha-springboot-starter</artifactId>
<version>1.5.2</version>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>21</source>
<target>21</target>
<encoding>UTF-8</encoding>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<configuration>
<mainClass>com.admin.AdminApplication</mainClass>
</configuration>
<executions>
<execution>
<id>repackage</id>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -1,32 +0,0 @@
# SPRINGBOOT BACKEND (com.admin) KNOWLEDGE BASE
## OVERVIEW
Primary Java code for the admin API. Controllers expose `/api/v1/*` endpoints and return `R` response envelopes.
## STRUCTURE
```
springboot-backend/src/main/java/com/admin/
├── controller/ # REST controllers (e.g., /api/v1/user)
├── service/ # Business logic interfaces + impl/
├── mapper/ # MyBatis Plus mappers
├── entity/ # DB entities
├── config/ # WebMvc/JWT/CORS/WebSocket config
└── common/ # DTOs, auth, exception handling, utilities
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| User/login endpoints | `springboot-backend/src/main/java/com/admin/controller/UserController.java` | `/api/v1/user/*` |
| Auth enforcement | `springboot-backend/src/main/java/com/admin/config/WebMvcConfig.java` | Intercepts `/api/**`, excludes login/config/captcha |
| JWT validation | `springboot-backend/src/main/java/com/admin/common/interceptor/JwtInterceptor.java` | Requires `Authorization` header |
| Admin-only ops | `springboot-backend/src/main/java/com/admin/common/annotation/RequireRole.java` | Enforced by `RoleAspect` |
| Response envelope | `springboot-backend/src/main/java/com/admin/common/lang/R.java` | `code == 0` success |
| Global error handling | `springboot-backend/src/main/java/com/admin/common/exception/GlobalExceptionHandler.java` | Maps exceptions -> `R.err(...)` |
## CONVENTIONS
- Controllers are mostly `@PostMapping` (even for list/get/delete) and use `/api/v1/*` prefixes.
- JWT is custom (no 3p lib) and includes `role_id` in payload (`springboot-backend/src/main/java/com/admin/common/utils/JwtUtil.java`).
## ANTI-PATTERNS
- Do not change auth header format lightly: frontend expects `Authorization: <token>` (no `Bearer`).
@@ -1,24 +0,0 @@
package com.admin;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableAsync
@EnableScheduling
@MapperScan("com.admin.mapper")
public class AdminApplication {
public static void main(String[] args) {
SpringApplication.run(AdminApplication.class, args);
}
}
@@ -1,15 +0,0 @@
package com.admin.common.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 权限控制注解
* 用于标记需要管理员权限的方法(role_id = 0)
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
}
@@ -1,9 +0,0 @@
package com.admin.common.aop;
import java.lang.annotation.*;
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LogAnnotation {}
@@ -1,193 +0,0 @@
package com.admin.common.aop;
import cn.hutool.core.util.ArrayUtil;
import com.admin.common.utils.JwtUtil;
import com.alibaba.fastjson.JSON;
import com.admin.common.utils.HttpContextUtils;
import com.admin.common.utils.IpUtils;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.*;
import org.aspectj.lang.reflect.CodeSignature;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
@Component
@Aspect
@Slf4j
public class LogAspect {
@Pointcut("@annotation(com.admin.common.aop.LogAnnotation)")
public void pt() {
}
/**
* 返回后通知(@AfterReturning):在某连接点(joinpoint)
* 正常完成后执行的通知:例如,一个方法没有抛出任何异常,正常返回
* 方法执行完毕之后
* 注意在这里不能使用ProceedingJoinPoint
* 不然会报错ProceedingJoinPoint is only supported for around advice
* crmAspect()指向需要控制的方法
* returning 注解返回值
*
* @param joinPoint
* @param returnValue 返回值
* @throws Exception
*/
@AfterReturning(value = "pt()", returning = "returnValue")
public void log(JoinPoint joinPoint, Object returnValue) throws Throwable {
// 获取请求信息
HttpServletRequest request = HttpContextUtils.getHttpServletRequest();
// 获取请求方法类型(POST/GET等)
String requestMethod = request.getMethod();
// 获取用户ID
String authorization = request.getHeader("Authorization") + "";
Object user_id = "未登录"; // 请求用户的id
if (!authorization.equals("null")) {
user_id = JwtUtil.getUserIdFromToken(authorization);
}
// 获取请求IP
String ipAddr = IpUtils.getIpAddr(request);
// 获取方法签名信息
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
// 获取控制器方法名
String className = joinPoint.getTarget().getClass().getName();
String methodName = signature.getName();
String controllerMethod = className + "." + methodName;
// 获取请求参数
String requestParams = getRequestParams(joinPoint);
// 获取返回参数
String responseParams = returnValue != null ? JSON.toJSONString(returnValue) : "无返回值";
// 合并为一条完整的日志信息
String logMessage = String.format(
"【请求日志】用户ID:[%s], IP地址:[%s], 请求方式:[%s], 控制器方法:[%s], 请求参数:[%s], 返回参数:[%s]", user_id, ipAddr, requestMethod, controllerMethod, requestParams, responseParams
);
// 打印单条完整日志
log.info(logMessage);
}
/**
* 抛出异常后通知(@AfterThrowing):方法抛出异常退出时执行的通知
* 注意在这里不能使用ProceedingJoinPoint
* 不然会报错ProceedingJoinPoint is only supported for around advice
* throwing注解为错误信息
*
* @param joinPoint
* @param ex
*/
@AfterThrowing(value = "pt()", throwing = "ex")
public void recordLog(JoinPoint joinPoint, Exception ex) {
try {
// 获取请求信息
HttpServletRequest request = HttpContextUtils.getHttpServletRequest();
// 获取请求方法类型(POST/GET等)
String requestMethod = request.getMethod();
// 获取用户ID
String authorization = request.getHeader("Authorization") + "";
Object user_id = "未登录"; // 请求用户的id
if (!authorization.equals("null")) {
user_id = JwtUtil.getUserIdFromToken(authorization);
}
// 获取请求IP
String ipAddr = IpUtils.getIpAddr(request);
// 获取方法签名信息
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
// 获取控制器方法名
String className = joinPoint.getTarget().getClass().getName();
String methodName = signature.getName();
String controllerMethod = className + "." + methodName;
// 获取请求参数
String requestParams = getRequestParams(joinPoint);
// 获取异常信息
String exceptionMsg = ex != null ? ex.getMessage() : "未知异常";
// 合并为一条完整的异常日志信息
String errorMessage = String.format(
"【异常日志】用户ID:[%s], IP地址:[%s], 请求方式:[%s], 控制器方法:[%s], 请求参数:[%s], 异常信息:[%s]", user_id, ipAddr, requestMethod, controllerMethod, requestParams, exceptionMsg
);
// 打印单条完整异常日志
log.info(errorMessage, ex);
} catch (Exception e) {
log.info("记录异常日志时出错: {}", e.getMessage());
}
}
/**
* 获取请求参数
*/
private String getRequestParams(JoinPoint joinPoint) {
try {
Object[] args = joinPoint.getArgs();
if (args.length == 0) {
return "无参数";
} else if (args[0] != null && args[0].toString().contains("SecurityContextHolderAwareRequestWrapper")) {
return JSON.toJSONString(Arrays.toString(ArrayUtil.remove(args, 0)));
} else {
// 检查是否只有一个参数且已经是JSON字符串格式
if (args.length == 1 && args[0] != null) {
// 如果参数本身就是字符串且是JSON格式,直接返回
if (args[0] instanceof String && ((String) args[0]).startsWith("{") && ((String) args[0]).endsWith("}")) {
return (String) args[0];
}
// 如果参数是普通对象,直接序列化
try {
return JSON.toJSONString(args[0]);
} catch (Exception e) {
// 如果序列化失败,再尝试使用参数名映射
Map<String, Object> map = new HashMap<>();
String[] names = ((CodeSignature) joinPoint.getSignature()).getParameterNames();
if (names != null) {
map.put(names[0], args[0]);
return JSON.toJSONString(map);
}
return JSON.toJSONString(args[0]);
}
} else {
// 多个参数时,使用参数名映射
Map<String, Object> map = new HashMap<>();
String[] names = ((CodeSignature) joinPoint.getSignature()).getParameterNames();
if (names != null) {
for (int i = 0; i < names.length; i++) {
map.put(names[i], args[i]);
}
}
return JSON.toJSONString(map);
}
}
} catch (Exception e) {
return "获取参数失败: " + e.getMessage();
}
}
}
@@ -1,49 +0,0 @@
package com.admin.common.aop;
import com.admin.common.annotation.RequireRole;
import com.admin.common.lang.R;
import com.admin.common.utils.JwtUtil;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
/**
* 权限控制切面
* 处理 @RequireRole 注解,检查管理员权限(role_id = 0)
* 注意:JWT拦截器已经验证了token的有效性,这里只需要检查权限
*/
@Aspect
@Component
public class RoleAspect {
@Around("@annotation(requireRole)")
public Object checkRole(ProceedingJoinPoint joinPoint, RequireRole requireRole) throws Throwable {
// 获取当前请求
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return R.err(500, "无法获取请求信息");
}
HttpServletRequest request = attributes.getRequest();
String token = request.getHeader("Authorization");
// JWT拦截器已经验证过token存在且有效,这里直接获取role_id
Integer roleId = JwtUtil.getRoleIdFromToken(token);
if (roleId == null) {
return R.err(401, "无法获取用户权限信息");
}
// 检查是否为管理员(role_id = 0)
if (roleId != 0) {
return R.err(403, "权限不足,仅管理员可操作");
}
// 权限检查通过,执行原方法
return joinPoint.proceed();
}
}
@@ -1,16 +0,0 @@
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;
}
@@ -1,12 +0,0 @@
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;
}
@@ -1,39 +0,0 @@
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;
}
}
@@ -1,12 +0,0 @@
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;
}
@@ -1,18 +0,0 @@
package com.admin.common.dto;
import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 验证码验证请求DTO
*/
@Data
public class CaptchaVerifyDto {
private String id;
private ImageCaptchaTrack data;
}
@@ -1,15 +0,0 @@
package com.admin.common.dto;
import lombok.Data;
import java.util.List;
@Data
public class ChainNodesItems {
private String mode;
private List<Integer> nodeIds;
}
@@ -1,21 +0,0 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
public class ChangePasswordDto {
@NotBlank(message = "新用户名不能为空")
private String newUsername;
@NotBlank(message = "当前密码不能为空")
private String currentPassword;
@NotBlank(message = "新密码不能为空")
private String newPassword;
@NotBlank(message = "确认密码不能为空")
private String confirmPassword;
}
@@ -1,11 +0,0 @@
package com.admin.common.dto;
import lombok.Data;
@Data
public class ConfigItem {
private String name;
}
@@ -1,21 +0,0 @@
package com.admin.common.dto;
import lombok.Data;
@Data
public class DiagnosisResult {
private Long nodeId;
private String nodeName;
private String targetIp;
private Integer targetPort;
private String description;
private boolean success;
private String message;
private double averageTime;
private double packetLoss;
private long timestamp;
private Integer fromChainType;
private Integer fromInx;
private Integer toChainType;
private Integer toInx;
}
@@ -1,16 +0,0 @@
package com.admin.common.dto;
import lombok.Data;
@Data
public class FlowDto {
// 转发id_类型
private String n;
// 上传流量
private Long u;
// 下载流量
private Long d;
}
@@ -1,26 +0,0 @@
package com.admin.common.dto;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Min;
import javax.validation.constraints.Max;
@Data
public class ForwardDto {
@NotBlank(message = "转发名称不能为空")
private String name;
@NotNull(message = "隧道ID不能为空")
private Integer tunnelId;
@NotBlank(message = "远程地址不能为空")
private String remoteAddr;
private String strategy;
private Integer inPort;
}
@@ -1,10 +0,0 @@
package com.admin.common.dto;
import lombok.Data;
@Data
public class ForwardPortDto {
private Integer node_id;
private Integer port;
}
@@ -1,32 +0,0 @@
package com.admin.common.dto;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Min;
import javax.validation.constraints.Max;
@Data
public class ForwardUpdateDto {
@NotNull(message = "ID不能为空")
private Long id;
@NotNull(message = "用户ID不能为空")
private Integer userId;
@NotBlank(message = "转发名称不能为空")
private String name;
private Integer tunnelId;
@NotBlank(message = "远程地址不能为空")
private String remoteAddr;
private String strategy;
private Integer inPort;
}
@@ -1,90 +0,0 @@
package com.admin.common.dto;
import lombok.Data;
/**
* <p>
* 转发信息及关联隧道信息DTO
* </p>
*
* @author QAQ
* @since 2025-06-03
*/
@Data
public class ForwardWithTunnelDto {
/**
* 转发记录ID
*/
private Long id;
/**
* 转发名称
*/
private String name;
/**
* 入口IP
*/
private String inIp;
/**
* 入口端口
*/
private Integer inPort;
/**
* 远程地址
*/
private String remoteAddr;
/**
* 转发状态
*/
private Integer status;
/**
* 创建时间
*/
private Long createdTime;
/**
* 更新时间
*/
private Long updatedTime;
// 以下为隧道相关字段
/**
* 隧道名称
*/
private String tunnelName;
private String userName;
/**
* 用户ID
*/
private Integer userId;
/**
* 隧道ID
*/
private Integer tunnelId;
/**
* 入站流量(字节)
*/
private Long inFlow;
/**
* 出站流量(字节)
*/
private Long outFlow;
private String strategy;
private Integer inx;
}
@@ -1,19 +0,0 @@
package com.admin.common.dto;
import lombok.Data;
import java.util.List;
@Data
public class GostConfigDto {
private List<ConfigItem> limiters;
private List<ConfigItem> chains;
private List<ConfigItem> services;
}
@@ -1,12 +0,0 @@
package com.admin.common.dto;
import lombok.Data;
@Data
public class GostDto {
private Integer code;
private String msg;
private Object data; // 添加数据字段,用于存储响应的详细数据
}
@@ -1,14 +0,0 @@
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;
}
@@ -1,15 +0,0 @@
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;
}
@@ -1,13 +0,0 @@
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;
}
@@ -1,18 +0,0 @@
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;
}
@@ -1,20 +0,0 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
public class LoginDto {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
private String captchaId;
}
@@ -1,32 +0,0 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
@Data
public class NodeDto {
@NotBlank(message = "节点名称不能为空")
private String name;
@NotBlank(message = "服务器ip不能为空")
private String serverIp;
private String serverIpV4;
private String serverIpV6;
@NotBlank(message = "可用端口不能为空")
private String port;
private String interfaceName;
private String tcpListenAddr = "0.0.0.0";
private String udpListenAddr = "0.0.0.0";
}
@@ -1,35 +0,0 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class NodeUpdateDto {
@NotNull(message = "节点ID不能为空")
private Long id;
@NotBlank(message = "节点名称不能为空")
private String name;
@NotBlank(message = "服务器ip不能为空")
private String serverIp;
private String serverIpV4;
private String serverIpV6;
@NotBlank(message = "可用port不能为空")
private String port;
private String interfaceName;
private Integer http;
private Integer tls;
private Integer socks;
private String tcpListenAddr = "0.0.0.0";
private String udpListenAddr = "0.0.0.0";
}
@@ -1,16 +0,0 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotNull;
@Data
public class ResetFlowDto {
@NotNull(message = "重置账号id不能为空")
private Integer id;
@NotNull(message = "重置类型不能为空")
private Integer type;
}
@@ -1,23 +0,0 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Min;
@Data
public class SpeedLimitDto {
@NotBlank(message = "限速规则名称不能为空")
private String name;
@NotNull(message = "速度限制不能为空")
@Min(value = 1, message = "速度限制必须大于0")
private Integer speed;
@NotNull(message = "隧道ID不能为空")
private Long tunnelId;
@NotBlank(message = "隧道名称不能为空")
private String tunnelName;
}
@@ -1,20 +0,0 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Min;
@Data
public class SpeedLimitUpdateDto {
@NotNull(message = "ID不能为空")
private Long id;
@NotBlank(message = "限速规则名称不能为空")
private String name;
@NotNull(message = "速度限制不能为空")
@Min(value = 1, message = "速度限制必须大于0")
private Integer speed;
}
@@ -1,57 +0,0 @@
package com.admin.common.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* 系统信息DTO
* 对应Go客户端上报的系统信息结构
*/
@Data
public class SystemInfoDto {
/**
* 主机IP地址
*/
@JsonProperty("host_ip")
private String hostIp;
/**
* 开机时间(秒)
*/
@JsonProperty("uptime")
private Long uptime;
/**
* 接收字节数
*/
@JsonProperty("bytes_received")
private Long bytesReceived;
/**
* 发送字节数
*/
@JsonProperty("bytes_transmitted")
private Long bytesTransmitted;
/**
* CPU使用率(百分比)
*/
@JsonProperty("cpu_usage")
private Double cpuUsage;
/**
* 内存使用率(百分比)
*/
@JsonProperty("memory_usage")
private Double memoryUsage;
/**
* 上报时间戳
*/
private Long timestamp;
public SystemInfoDto() {
this.timestamp = System.currentTimeMillis();
}
}
@@ -1,48 +0,0 @@
package com.admin.common.dto;
import com.admin.entity.ChainTunnel;
import lombok.Data;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/**
* 隧道详情DTO - 包含节点配置信息
*/
@Data
public class TunnelDetailDto {
private Long id;
/**
* Display/order index for drag-sorting in admin UI.
* Lower value appears first.
*/
private Integer inx;
private String name;
private Integer type; // 1: 端口转发, 2: 隧道转发
private Integer flow; // 1: 单向, 2: 双向
private BigDecimal trafficRatio;
private Integer status;
private Long createdTime;
private Long updatedTime;
private String inIp;
// 入口节点列表
private List<ChainTunnel> inNodeId = new ArrayList<>();
// 转发链节点列表(二维数组结构)
private List<List<ChainTunnel>> chainNodes = new ArrayList<>();
// 出口节点列表
private List<ChainTunnel> outNodeId = new ArrayList<>();
}
@@ -1,36 +0,0 @@
package com.admin.common.dto;
import com.admin.entity.ChainTunnel;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.DecimalMin;
import javax.validation.constraints.DecimalMax;
import java.math.BigDecimal;
import java.util.List;
@Data
public class TunnelDto {
@NotBlank(message = "隧道名称不能为空")
private String name;
@NotNull(message = "入口节点不能为空")
private List<ChainTunnel> inNodeId;
private List<List<ChainTunnel>> chainNodes;
private List<ChainTunnel> outNodeId;
private String inIp;
@NotNull(message = "隧道类型不能为空")
private Integer type;
@NotNull(message = "流量计算类型不能为空")
private Integer flow;
@DecimalMin(value = "0.0", inclusive = false, message = "流量倍率必须大于0.0")
@DecimalMax(value = "100.0", message = "流量倍率不能大于100.0")
private BigDecimal trafficRatio;
}
@@ -1,16 +0,0 @@
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;
}
@@ -1,17 +0,0 @@
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<>();
}
@@ -1,42 +0,0 @@
package com.admin.common.dto;
import com.admin.entity.ChainTunnel;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Min;
import javax.validation.constraints.Max;
import javax.validation.constraints.DecimalMin;
import javax.validation.constraints.DecimalMax;
import java.math.BigDecimal;
import java.util.List;
@Data
public class TunnelUpdateDto {
@NotNull(message = "隧道ID不能为空")
private Long id;
@NotBlank(message = "隧道名称不能为空")
private String name;
@NotNull(message = "流量计算类型不能为空")
private Integer flow;
private String inIp;
@DecimalMin(value = "0.0", inclusive = false, message = "流量倍率必须大于0.0")
@DecimalMax(value = "100.0", message = "流量倍率不能大于100.0")
private BigDecimal trafficRatio;
// 入口节点配置(可选,为空时不更新节点配置)
private List<ChainTunnel> inNodeId;
// 转发链节点配置(二维数组,每一跳可有多个节点)
private List<List<ChainTunnel>> chainNodes;
// 出口节点配置
private List<ChainTunnel> outNodeId;
}
@@ -1,33 +0,0 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Min;
@Data
public class UserDto {
@NotBlank(message = "用户名不能为空")
private String user;
@NotBlank(message = "密码不能为空")
private String pwd;
@NotNull(message = "流量不能为空")
@Min(value = 0, message = "流量不能小于0")
private Long flow;
@NotNull(message = "转发数量不能为空")
@Min(value = 0, message = "转发数量不能小于0")
private Integer num;
@NotNull(message = "过期时间不能为空")
private Long expTime;
@NotNull(message = "流量重置时间不能为空")
private Long flowResetTime;
private Integer status;
}
@@ -1,16 +0,0 @@
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;
}
@@ -1,17 +0,0 @@
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<>();
}
@@ -1,10 +0,0 @@
package com.admin.common.dto;
import lombok.Data;
@Data
public class UserInfo {
private final Integer userId;
private final Integer roleId;
private final String userName;
}
@@ -1,91 +0,0 @@
package com.admin.common.dto;
import com.admin.entity.StatisticsFlow;
import lombok.Data;
import java.util.List;
/**
* 用户套餐信息DTO
*/
@Data
public class UserPackageDto {
/**
* 用户基本信息
*/
private UserInfoDto userInfo;
/**
* 用户隧道权限列表
*/
private List<UserTunnelDetailDto> tunnelPermissions;
/**
* 用户转发列表
*/
private List<UserForwardDetailDto> forwards;
/**
* 用户转发列表
*/
private List<StatisticsFlow> statisticsFlows;
/**
* 用户基本信息
*/
@Data
public static class UserInfoDto {
private Long id;
private String name;
private String user;
private Integer status;
private Long flow; // 总流量配额(GB)
private Long inFlow; // 已用入站流量(字节)
private Long outFlow; // 已用出站流量(字节)
private Integer num; // 转发数量配额
private Long expTime; // 过期时间
private Long flowResetTime; // 流量重置时间
private Long createdTime;
private Long updatedTime;
}
/**
* 用户隧道权限详情
*/
@Data
public static class UserTunnelDetailDto {
private Integer id;
private Integer userId;
private Integer tunnelId;
private String tunnelName;
private Integer tunnelFlow; // 隧道流量计算类型(1-单向,2-双向)
private Long flow; // 隧道流量配额(GB)
private Long inFlow; // 隧道已用入站流量(字节)
private Long outFlow; // 隧道已用出站流量(字节)
private Integer num; // 隧道转发数量配额
private Long flowResetTime; // 流量重置时间
private Long expTime; // 隧道权限过期时间
private Integer speedId;
private String speedLimitName;
private Integer speed;
}
/**
* 用户转发详情
*/
@Data
public static class UserForwardDetailDto {
private Long id;
private String name;
private Integer tunnelId;
private String tunnelName;
private String inIp;
private Integer inPort;
private String remoteAddr;
private Long inFlow; // 转发入站流量(字节)
private Long outFlow; // 转发出站流量(字节)
private Integer status;
private Long createdTime;
}
}
@@ -1,26 +0,0 @@
package com.admin.common.dto;
import com.admin.entity.UserTunnel;
import com.admin.service.impl.ForwardServiceImpl;
import lombok.Data;
@Data
public class UserPermissionResult {
public boolean hasError;
private String errorMessage;
private Integer limiter;
private UserTunnel userTunnel;
public static UserPermissionResult success(Integer limiter, UserTunnel userTunnel) {
UserPermissionResult result = new UserPermissionResult();
result.setLimiter(limiter);
result.setUserTunnel(userTunnel);
return result;
}
public static UserPermissionResult error(String errorMessage) {
UserPermissionResult result = new UserPermissionResult();
result.setErrorMessage(errorMessage);
return result;
}
}
@@ -1,26 +0,0 @@
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;
}
}
@@ -1,27 +0,0 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Min;
@Data
public class UserTunnelDto {
@NotNull(message = "用户ID不能为空")
private Integer userId;
@NotNull(message = "隧道ID不能为空")
private Integer tunnelId;
@Min(value = 0, message = "流量限制不能小于0")
private Long flow;
@Min(value = 0, message = "转发数量不能小于0")
private Integer num;
private Long flowResetTime;
private Long expTime;
private Integer speedId;
}
@@ -1,13 +0,0 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotNull;
@Data
public class UserTunnelQueryDto {
@NotNull
private Integer userId;
}
@@ -1,40 +0,0 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Min;
@Data
public class UserTunnelUpdateDto {
@NotNull(message = "用户隧道权限ID不能为空")
private Integer id;
@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;
@NotNull(message = "状态必选")
private Integer status;
/**
* 限速规则ID(可选,null表示不限速)
*/
private Integer speedId;
}
@@ -1,88 +0,0 @@
package com.admin.common.dto;
import lombok.Data;
/**
* <p>
* 用户隧道权限及隧道详细信息DTO
* </p>
*
* @author QAQ
* @since 2025-06-03
*/
@Data
public class UserTunnelWithDetailDto {
/**
* 用户隧道权限记录ID
*/
private Integer id;
/**
* 用户ID
*/
private Integer userId;
/**
* 隧道ID
*/
private Integer tunnelId;
/**
* 流量限制
*/
private Integer flow;
/**
* 转发数量限制
*/
private Integer num;
/**
* 流量重置时间(时间戳)
*/
private Long flowResetTime;
/**
* 到期时间(时间戳)
*/
private Long expTime;
/**
* 限速规则ID
*/
private Integer speedId;
/**
* 限速规则名称
*/
private String speedLimitName;
/**
* 限速值
*/
private Integer speed;
/**
* 隧道名称
*/
private String tunnelName;
/**
* 隧道流量计算类型(1-单向,2-双向)
*/
private Integer tunnelFlow;
/**
* 入站流量(字节)
*/
private Long inFlow;
/**
* 出站流量(字节)
*/
private Long outFlow;
private Integer status;
}
@@ -1,35 +0,0 @@
package com.admin.common.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Min;
@Data
public class UserUpdateDto {
@NotNull(message = "用户ID不能为空")
private Long id;
@NotBlank(message = "用户名不能为空")
private String user;
private String pwd; // 更新时密码可选
@NotNull(message = "流量不能为空")
@Min(value = 0, message = "流量不能小于0")
private Long flow;
@NotNull(message = "转发数量不能为空")
@Min(value = 0, message = "转发数量不能小于0")
private Integer num;
@NotNull(message = "过期时间不能为空")
private Long expTime;
@NotNull(message = "流量重置时间不能为空")
private Long flowResetTime;
private Integer status;
}
@@ -1,41 +0,0 @@
package com.admin.common.exception;
import com.admin.common.lang.R;
import lombok.extern.slf4j.Slf4j;
import org.apache.catalina.connector.ClientAbortException;
import org.springframework.validation.BindingResult;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
//
// 实体校验异常捕获
//@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public R MethodArgumentNotValidException(MethodArgumentNotValidException e) {
BindingResult result = e.getBindingResult();
ObjectError objectError = result.getAllErrors().stream().findFirst().get();
log.info("实体校验异常:----------------{}", objectError.getDefaultMessage());
return R.err(500, objectError.getDefaultMessage());
}
// 未授权异常捕获
@ExceptionHandler(value = UnauthorizedException.class)
public R handleUnauthorizedException(UnauthorizedException e) {
log.info("未授权异常:----------------{}", e.getMessage());
return R.err(401, e.getMessage());
}
@ExceptionHandler(value = Exception.class)
public R Exception(Exception e){
log.info("异常:----------------{}", e.getMessage());
return R.err(-2, e.getMessage());
}
}
@@ -1,18 +0,0 @@
package com.admin.common.exception;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.web.client.ResponseErrorHandler;
import java.io.IOException;
public class HttpErrorHandler implements ResponseErrorHandler {
@Override
public boolean hasError(ClientHttpResponse response) throws IOException {
return false;
}
@Override
public void handleError(ClientHttpResponse clientHttpResponse) throws IOException {
}
}
@@ -1,15 +0,0 @@
package com.admin.common.exception;
/**
* 未授权异常类
*/
public class UnauthorizedException extends RuntimeException {
public UnauthorizedException(String message) {
super(message);
}
public UnauthorizedException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -1,34 +0,0 @@
package com.admin.common.interceptor;
import com.admin.common.exception.UnauthorizedException;
import com.admin.common.utils.JwtUtil;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* JWT拦截器,验证用户是否登录
*/
public class JwtInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String token = request.getHeader("Authorization");
if (!StringUtils.hasText(token)) {
throw new UnauthorizedException("未登录或token已过期");
}
if (!JwtUtil.validateToken(token)) {
throw new UnauthorizedException("无效的token或token已过期");
}
return true;
}
}
@@ -1,49 +0,0 @@
package com.admin.common.lang;
import lombok.Data;
@Data
public class R {
private int code = 0;
private String msg = "操作成功";
private long ts = System.currentTimeMillis();
private Object data;
public static R ok(Object data){
R m = new R();
m.setData(data);
return m;
}
public static R ok(){
return new R();
}
public static R err(int code, String msg){
R m = new R();
m.setCode(code);
m.setMsg(msg);
return m;
}
public static R err(String msg){
R m = new R();
m.setCode(-1);
m.setMsg(msg);
return m;
}
public static R err(){
R m = new R();
m.setCode(-1);
m.setMsg("请求失败");
return m;
}
}
@@ -1,71 +0,0 @@
package com.admin.common.migration;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.util.HashSet;
import java.util.Set;
/**
* Lightweight SQLite schema migration.
*
* Spring Boot SQL init uses CREATE TABLE IF NOT EXISTS, so existing installations
* won't automatically receive new columns. This runner adds missing columns in-place.
*/
@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class SqliteSchemaMigration implements ApplicationRunner {
private final JdbcTemplate jdbcTemplate;
public SqliteSchemaMigration(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public void run(ApplicationArguments args) {
ensureColumn("node", "inx", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("tunnel", "inx", "INTEGER NOT NULL DEFAULT 0");
ensureTable("CREATE TABLE IF NOT EXISTS tunnel_group (id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(100) NOT NULL, created_time INTEGER NOT NULL, updated_time INTEGER NOT NULL, status INTEGER NOT NULL)");
ensureTable("CREATE TABLE IF NOT EXISTS user_group (id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(100) NOT NULL, created_time INTEGER NOT NULL, updated_time INTEGER NOT NULL, status INTEGER NOT NULL)");
ensureTable("CREATE TABLE IF NOT EXISTS tunnel_group_tunnel (id INTEGER PRIMARY KEY AUTOINCREMENT, tunnel_group_id INTEGER NOT NULL, tunnel_id INTEGER NOT NULL, created_time INTEGER NOT NULL)");
ensureTable("CREATE TABLE IF NOT EXISTS user_group_user (id INTEGER PRIMARY KEY AUTOINCREMENT, user_group_id INTEGER NOT NULL, user_id INTEGER NOT NULL, created_time INTEGER NOT NULL)");
ensureTable("CREATE TABLE IF NOT EXISTS group_permission (id INTEGER PRIMARY KEY AUTOINCREMENT, user_group_id INTEGER NOT NULL, tunnel_group_id INTEGER NOT NULL, created_time INTEGER NOT NULL)");
ensureTable("CREATE TABLE IF NOT EXISTS group_permission_grant (id INTEGER PRIMARY KEY AUTOINCREMENT, user_group_id INTEGER NOT NULL, tunnel_group_id INTEGER NOT NULL, user_tunnel_id INTEGER NOT NULL, created_by_group INTEGER NOT NULL DEFAULT 0, created_time INTEGER NOT NULL)");
ensureColumn("group_permission_grant", "created_by_group", "INTEGER NOT NULL DEFAULT 0");
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_name ON tunnel_group(name)");
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_name ON user_group(name)");
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_tunnel_unique ON tunnel_group_tunnel(tunnel_group_id, tunnel_id)");
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_user_unique ON user_group_user(user_group_id, user_id)");
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_unique ON group_permission(user_group_id, tunnel_group_id)");
ensureTable("CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_grant_unique ON group_permission_grant(user_group_id, tunnel_group_id, user_tunnel_id)");
}
private void ensureColumn(String table, String column, String columnDefinition) {
Set<String> columns = new HashSet<>(
jdbcTemplate.query(
"PRAGMA table_info(" + table + ")",
(rs, rowNum) -> rs.getString("name")
)
);
if (columns.contains(column)) {
return;
}
log.info("Adding missing column {}.{}", table, column);
jdbcTemplate.execute(
"ALTER TABLE " + table + " ADD COLUMN " + column + " " + columnDefinition
);
}
private void ensureTable(String ddl) {
jdbcTemplate.execute(ddl);
}
}
@@ -1,150 +0,0 @@
package com.admin.common.task;
import com.admin.common.dto.*;
import com.admin.common.lang.R;
import com.admin.common.utils.GostUtil;
import com.admin.entity.*;
import com.admin.service.*;
import com.alibaba.fastjson.JSONArray;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
@Slf4j
@Service
public class CheckGostConfigAsync {
@Resource
private NodeService nodeService;
@Resource
@Lazy
private ForwardService forwardService;
@Resource
@Lazy
private SpeedLimitService speedLimitService;
@Resource
TunnelService tunnelService;
/**
* 清理孤立的Gost配置项
*/
@Async
public void cleanNodeConfigs(String node_id, GostConfigDto gostConfig) {
Node node = nodeService.getById(node_id);
if (node != null) {
cleanOrphanedServices(gostConfig.getServices(), node);
cleanOrphanedChains(gostConfig.getChains(), node);
cleanOrphanedLimiters(gostConfig.getLimiters(), node);
}
}
/**
* 清理孤立的服务
*/
private void cleanOrphanedServices(List<ConfigItem> configItems, Node node) {
if (configItems == null) return;
for (ConfigItem service : configItems) {
safeExecute(() -> {
if (!Objects.equals(service.getName(), "web_api")){
List<String> serviceIds = parseServiceName(service.getName());
JSONArray services = new JSONArray();
if (Objects.equals(serviceIds.getLast(), "tls")){
String forward_id = serviceIds.getFirst();
services.add(forward_id + "_tls");
Tunnel tunnel = tunnelService.getById(forward_id);
if (tunnel == null) {
GostUtil.DeleteService(node.getId(), services);
log.info("删除孤立的服务: {} (节点: {})", service.getName(), node.getId());
}
}
if (Objects.equals(serviceIds.getLast(), "tcp")){
String forward_id = serviceIds.getFirst();
services.add(forward_id + "_" + serviceIds.get(1) + "_" + serviceIds.get(2) + "_tcp");
services.add(forward_id + "_" + serviceIds.get(1) + "_" + serviceIds.get(2) + "_udp");
Forward forward = forwardService.getById(forward_id);
if (forward == null) {
GostUtil.DeleteService(node.getId(), services);
log.info("删除孤立的服务: {} (节点: {})", service.getName(), node.getId());
}
}
}
}, "清理服务 " + service.getName());
}
}
/**
* 清理孤立的链
*/
private void cleanOrphanedChains(List<ConfigItem> configItems, Node node) {
if (configItems == null) return;
for (ConfigItem chain : configItems) {
safeExecute(() -> {
List<String> serviceIds = parseServiceName(chain.getName());
Tunnel tunnel = tunnelService.getById(serviceIds.getLast());
if (tunnel == null) {
GostUtil.DeleteChains(node.getId(), chain.getName());
log.info("删除孤立的链: {} (节点: {})", chain.getName(), node.getId());
}
}, "清理链 " + chain.getName());
}
}
/**
* 清理孤立的限流器
*/
private void cleanOrphanedLimiters(List<ConfigItem> configItems, Node node) {
if (configItems == null) return;
for (ConfigItem limiter : configItems) {
safeExecute(() -> {
SpeedLimit speedLimit = speedLimitService.getById(limiter.getName());
if (speedLimit == null) {
GostUtil.DeleteLimiters(node.getId(), Long.parseLong(limiter.getName()));
log.info("删除孤立的限流器: {} (节点: {})", limiter.getName(), node.getId());
}
}, "清理限流器 " + limiter.getName());
}
}
/**
* 安全执行操作,捕获异常
*/
private void safeExecute(Runnable operation, String operationDesc) {
try {
operation.run();
} catch (Exception e) {
log.info("执行操作失败: {}", operationDesc, e);
}
}
/**
* 解析服务名称
*/
private List<String> parseServiceName(String serviceName) {
String[] split = serviceName.split("_");
return new ArrayList<>(Arrays.asList(split));
}
}
@@ -1,246 +0,0 @@
package com.admin.common.task;
import com.admin.common.dto.GostDto;
import com.admin.common.utils.GostUtil;
import com.admin.entity.*;
import com.admin.service.*;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import javax.annotation.Resource;
import java.time.LocalDate;
import java.util.Date;
import java.util.List;
@Slf4j
@Configuration
@EnableScheduling
public class ResetFlowAsync {
@Resource
UserService userService;
@Resource
UserTunnelService userTunnelService;
@Resource
ForwardService forwardService;
@Resource
TunnelService tunnelService;
@Resource
ChainTunnelService chainTunnelService;
/**
* 每天0点执行流量重置任务
* 查询出用户和隧道的重置流量日期是今天的数据,将上下流量重置为0
* 考虑当月是29、30天,但是选择是31的这种边界情况
*
* 并发安全说明:
* - 使用setSql()进行原子SQL更新,只更新流量字段(in_flow, out_flow)
* - 不会影响DelayQueueManager的到期任务对status等其他字段的更新
* - 避免了并发修改导致的数据覆盖问题
*/
@Scheduled(cron = "5 0 0 * * ?")
public void reset_flow(){
log.info("开始执行流量重置任务");
try {
// 获取当前日期信息
LocalDate today = LocalDate.now();
int currentDay = today.getDayOfMonth(); // 当前是几号
int lastDayOfMonth = today.lengthOfMonth(); // 当月最后一天
log.info("当前日期: {}, 当月第{}天, 当月最后一天: {}", today, currentDay, lastDayOfMonth);
// 重置用户流量
resetUserFlow(currentDay, lastDayOfMonth);
// 重置用户隧道流量
resetUserTunnelFlow(currentDay, lastDayOfMonth);
log.info("流量重置任务执行完成");
// 处理过期账号
user();
// 处理过期隧道
userTunnel();
log.info("到期任务执行完成");
} catch (Exception e) {
log.info("定时任务执行失败", e);
}
}
/**
* 重置用户流量
* @param currentDay 当前日期(几号)
* @param lastDayOfMonth 当月最后一天
*/
private void resetUserFlow(int currentDay, int lastDayOfMonth) {
try {
// flowResetTime字段存储的是0-31的数字,0表示不重置,1-31表示每月第几号重置
// 构建查询条件:重置日期等于今天,或者重置日期大于当月最大天数且今天是月末
// 排除flowResetTime为0的记录(不重置)
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.ne("flow_reset_time", 0); // 排除不重置的用户
if (currentDay == lastDayOfMonth) {
// 如果今天是月末,查询重置日期等于今天或者大于当月最大天数的记录
// 例如:当月30天,但用户设置31号重置,则在30号执行重置
queryWrapper.and(wrapper -> wrapper.eq("flow_reset_time", currentDay)
.or().gt("flow_reset_time", lastDayOfMonth));
} else {
// 否则只查询重置日期等于今天的记录
queryWrapper.eq("flow_reset_time", currentDay);
}
// 查询需要重置的用户
List<User> usersToReset = userService.list(queryWrapper);
if (usersToReset.isEmpty()) {
log.info("没有需要重置流量的用户");
return;
}
log.info("找到{}个需要重置流量的用户", usersToReset.size());
// 批量重置用户流量 - 使用SQL原子操作避免与到期任务的并发冲突
for (User user : usersToReset) {
UpdateWrapper<User> updateWrapper = new UpdateWrapper<>();
updateWrapper.eq("id", user.getId())
.setSql("in_flow = 0, out_flow = 0"); // 使用SQL原子操作,只更新流量字段
boolean success = userService.update(null, updateWrapper);
if (success) {
log.info("用户[ID: {}, 用户名: {}]流量重置成功,重置日期: 每月{}号",
user.getId(), user.getUser(), user.getFlowResetTime());
} else {
log.info("用户[ID: {}, 用户名: {}]流量重置失败", user.getId(), user.getUser());
}
}
} catch (Exception e) {
log.info("重置用户流量失败", e);
}
}
/**
* 重置用户隧道流量
* @param currentDay 当前日期(几号)
* @param lastDayOfMonth 当月最后一天
*/
private void resetUserTunnelFlow(int currentDay, int lastDayOfMonth) {
try {
// flowResetTime字段存储的是0-31的数字,0表示不重置,1-31表示每月第几号重置
// 构建查询条件:重置日期等于今天,或者重置日期大于当月最大天数且今天是月末
// 排除flowResetTime为0的记录(不重置)
QueryWrapper<UserTunnel> queryWrapper = new QueryWrapper<>();
queryWrapper.ne("flow_reset_time", 0); // 排除不重置的用户隧道
if (currentDay == lastDayOfMonth) {
// 如果今天是月末,查询重置日期等于今天或者大于当月最大天数的记录
// 例如:当月30天,但用户设置31号重置,则在30号执行重置
queryWrapper.and(wrapper -> wrapper.eq("flow_reset_time", currentDay)
.or().gt("flow_reset_time", lastDayOfMonth));
} else {
// 否则只查询重置日期等于今天的记录
queryWrapper.eq("flow_reset_time", currentDay);
}
// 查询需要重置的用户隧道
List<UserTunnel> userTunnelsToReset = userTunnelService.list(queryWrapper);
if (userTunnelsToReset.isEmpty()) {
log.info("没有需要重置流量的用户隧道");
return;
}
log.info("找到{}个需要重置流量的用户隧道", userTunnelsToReset.size());
// 批量重置用户隧道流量 - 使用SQL原子操作避免与到期任务的并发冲突
for (UserTunnel userTunnel : userTunnelsToReset) {
UpdateWrapper<UserTunnel> updateWrapper = new UpdateWrapper<>();
updateWrapper.eq("id", userTunnel.getId())
.setSql("in_flow = 0, out_flow = 0"); // 使用SQL原子操作,只更新流量字段
boolean success = userTunnelService.update(null, updateWrapper);
if (success) {
log.info("用户隧道[ID: {}, 用户ID: {}, 隧道ID: {}]流量重置成功,重置日期: 每月{}号",
userTunnel.getId(), userTunnel.getUserId(), userTunnel.getTunnelId(), userTunnel.getFlowResetTime());
} else {
log.info("用户隧道[ID: {}, 用户ID: {}, 隧道ID: {}]流量重置失败",
userTunnel.getId(), userTunnel.getUserId(), userTunnel.getTunnelId());
}
}
} catch (Exception e) {
log.info("重置用户隧道流量失败", e);
}
}
public void user(){
// 查询过期用户
List<User> user_list = userService.list(new QueryWrapper<User>().ne("role_id", 0).eq("status", 1).isNotNull("exp_time").lt("exp_time", new Date().getTime()));
for (User user : user_list) {
// 查询对应转发
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>().eq("user_id", user.getId()).eq("status", 1));
for (Forward forward : forwardList) {
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", forward.getTunnelId()).eq("chain_type", 1));
for (ChainTunnel chainTunnel : chainTunnels) {
UserTunnel userTunnel = userTunnelService.getOne(new QueryWrapper<UserTunnel>().eq("user_id", forward.getUserId()).eq("tunnel_id", forward.getTunnelId()));
if (userTunnel != null) {
String name = buildServiceName(forward.getId(), forward.getUserId(), userTunnel.getId());
GostUtil.PauseAndResumeService(chainTunnel.getNodeId(), name, "PauseService");
}
}
forward.setStatus(0);
forwardService.updateById(forward);
}
user.setStatus(0);
userService.updateById(user);
}
}
public void userTunnel(){
// 查询过期隧道
List<UserTunnel> user_tunnel_list = userTunnelService.list(new QueryWrapper<UserTunnel>().eq("status", 1).isNotNull("exp_time").lt("exp_time", new Date().getTime()));
// 查询对应转发
for (UserTunnel userTunnel : user_tunnel_list) {
List<Forward> forwardList = forwardService.list(new QueryWrapper<Forward>().eq("tunnel_id", userTunnel.getTunnelId()).eq("user_id", userTunnel.getUserId()).eq("status", 1));
for (Forward forward : forwardList) {
List<ChainTunnel> chainTunnels = chainTunnelService.list(new QueryWrapper<ChainTunnel>().eq("tunnel_id", forward.getTunnelId()).eq("chain_type", 1));
for (ChainTunnel chainTunnel : chainTunnels) {
String name = buildServiceName(forward.getId(), forward.getUserId(), userTunnel.getId());
GostUtil.PauseAndResumeService(chainTunnel.getNodeId(), name, "PauseService");
}
forward.setStatus(0);
forwardService.updateById(forward);
}
userTunnel.setStatus(0);
userTunnelService.updateById(userTunnel);
}
}
private String buildServiceName(Long forwardId, Integer userId, Integer userTunnelId) {
return forwardId + "_" + userId + "_" + userTunnelId;
}
}
@@ -1,90 +0,0 @@
package com.admin.common.task;
import com.admin.entity.StatisticsFlow;
import com.admin.entity.User;
import com.admin.service.StatisticsFlowService;
import com.admin.service.UserService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
@Slf4j
@Configuration
@EnableScheduling
public class StatisticsFlowAsync {
@Resource
UserService userService;
@Resource
StatisticsFlowService statisticsFlowService;
@Scheduled(cron = "0 0 * * * ?")
public void statistics_flow() {
LocalDateTime currentHour = LocalDateTime.now().withMinute(0).withSecond(0).withNano(0);
String hourString = currentHour.format(DateTimeFormatter.ofPattern("HH:mm"));
long time = new Date().getTime();
// 删除48小时前的数据
long nowMs = new Date().getTime();
long cutoffMs = nowMs - 48L * 60 * 60 * 1000;
statisticsFlowService.remove(
new LambdaQueryWrapper<StatisticsFlow>()
.lt(StatisticsFlow::getCreatedTime, cutoffMs)
);
List<User> list = userService.list();
List<StatisticsFlow> statisticsFlowList = new ArrayList<>();
for (User user : list) {
long currentFlow = user.getInFlow() + user.getOutFlow();
// 从数据库获取上一次记录
StatisticsFlow lastFlowRecord = statisticsFlowService.getOne(
new LambdaQueryWrapper<StatisticsFlow>()
.eq(StatisticsFlow::getUserId, user.getId())
.orderByDesc(StatisticsFlow::getId)
.last("LIMIT 1")
);
long currentTotalFlow = currentFlow;
long incrementFlow = currentTotalFlow;
if (lastFlowRecord != null) {
long lastTotalFlow = lastFlowRecord.getTotalFlow();
incrementFlow = currentTotalFlow - lastTotalFlow;
if (incrementFlow < 0) {
incrementFlow = currentTotalFlow;
}
}
StatisticsFlow statisticsFlow = new StatisticsFlow();
statisticsFlow.setUserId(user.getId());
statisticsFlow.setFlow(incrementFlow);
statisticsFlow.setTotalFlow(currentTotalFlow);
statisticsFlow.setTime(hourString);
statisticsFlow.setCreatedTime(time);
statisticsFlowList.add(statisticsFlow);
}
statisticsFlowService.saveBatch(statisticsFlowList);
}
}
@@ -1,167 +0,0 @@
package com.admin.common.utils;
import lombok.extern.slf4j.Slf4j;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
/**
* AES加密工具类
* 使用AES-256-GCM模式,与Go端保持兼容
*/
@Slf4j
public class AESCrypto {
private static final String ALGORITHM = "AES";
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final int GCM_IV_LENGTH = 12; // GCM推荐的IV长度
private static final int GCM_TAG_LENGTH = 16; // GCM认证标签长度
private final SecretKeySpec secretKey;
private final SecureRandom secureRandom;
/**
* 构造函数
* @param secret 密钥字符串,将使用SHA-256转换为32字节密钥
*/
public AESCrypto(String secret) {
if (secret == null || secret.isEmpty()) {
throw new IllegalArgumentException("密钥不能为空");
}
try {
// 使用SHA-256将密码转换为32字节密钥,与Go端保持一致
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
byte[] keyBytes = sha256.digest(secret.getBytes(StandardCharsets.UTF_8));
this.secretKey = new SecretKeySpec(keyBytes, ALGORITHM);
this.secureRandom = new SecureRandom();
} catch (Exception e) {
log.info("AES加密器初始化失败", e);
throw new RuntimeException("AES加密器初始化失败: " + e.getMessage(), e);
}
}
/**
* 加密数据
* @param data 要加密的原始数据
* @return Base64编码的加密数据,格式为: nonce + ciphertext
*/
public String encrypt(byte[] data) {
if (data == null || data.length == 0) {
throw new IllegalArgumentException("待加密数据不能为空");
}
try {
// 生成随机IV(nonce)
byte[] iv = new byte[GCM_IV_LENGTH];
secureRandom.nextBytes(iv);
// 创建GCM参数规范
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv);
// 初始化Cipher
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmSpec);
// 加密数据
byte[] ciphertext = cipher.doFinal(data);
// 组合IV + ciphertext,与Go端格式保持一致
ByteBuffer buffer = ByteBuffer.allocate(iv.length + ciphertext.length);
buffer.put(iv);
buffer.put(ciphertext);
// 返回Base64编码结果
return Base64.getEncoder().encodeToString(buffer.array());
} catch (Exception e) {
log.info("数据加密失败", e);
throw new RuntimeException("数据加密失败: " + e.getMessage(), e);
}
}
/**
* 加密字符串
* @param data 要加密的字符串
* @return Base64编码的加密数据
*/
public String encrypt(String data) {
if (data == null) {
throw new IllegalArgumentException("待加密字符串不能为空");
}
return encrypt(data.getBytes(StandardCharsets.UTF_8));
}
/**
* 解密数据
* @param encryptedData Base64编码的加密数据
* @return 解密后的原始数据
*/
public byte[] decrypt(String encryptedData) {
if (encryptedData == null || encryptedData.isEmpty()) {
throw new IllegalArgumentException("加密数据不能为空");
}
try {
// Base64解码
byte[] encrypted = Base64.getDecoder().decode(encryptedData);
if (encrypted.length < GCM_IV_LENGTH) {
throw new IllegalArgumentException("加密数据长度不足");
}
// 分离IV和密文
ByteBuffer buffer = ByteBuffer.wrap(encrypted);
byte[] iv = new byte[GCM_IV_LENGTH];
buffer.get(iv);
byte[] ciphertext = new byte[buffer.remaining()];
buffer.get(ciphertext);
// 创建GCM参数规范
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv);
// 初始化Cipher
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmSpec);
// 解密数据
return cipher.doFinal(ciphertext);
} catch (Exception e) {
log.info("数据解密失败", e);
throw new RuntimeException("数据解密失败: " + e.getMessage(), e);
}
}
/**
* 解密字符串
* @param encryptedData Base64编码的加密数据
* @return 解密后的字符串
*/
public String decryptString(String encryptedData) {
byte[] decrypted = decrypt(encryptedData);
return new String(decrypted, StandardCharsets.UTF_8);
}
/**
* 创建AES加密器实例
* @param secret 密钥字符串
* @return AES加密器实例,如果创建失败返回null
*/
public static AESCrypto create(String secret) {
try {
return new AESCrypto(secret);
} catch (Exception e) {
log.info("创建AES加密器失败: {}", e.getMessage());
return null;
}
}
}
@@ -1,411 +0,0 @@
package com.admin.common.utils;
import cn.hutool.core.util.StrUtil;
import com.admin.common.dto.GostDto;
import com.admin.entity.*;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import org.apache.commons.lang3.StringUtils;
import java.util.regex.Pattern;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public class GostUtil {
public static GostDto AddLimiters(Long node_id, Long name, String speed) {
JSONObject data = createLimiterData(name, speed);
GostDto gostDto = WebSocketServer.send_msg(node_id, data, "AddLimiters");
if (gostDto.getMsg().contains("exists")){
gostDto.setMsg("OK");
}
return gostDto;
}
public static GostDto UpdateLimiters(Long node_id, Long name, String speed) {
JSONObject data = createLimiterData(name, speed);
JSONObject req = new JSONObject();
req.put("limiter", name + "");
req.put("data", data);
return WebSocketServer.send_msg(node_id, req, "UpdateLimiters");
}
public static GostDto DeleteLimiters(Long node_id, Long name) {
JSONObject req = new JSONObject();
req.put("limiter", name + "");
GostDto gostDto = WebSocketServer.send_msg(node_id, req, "DeleteLimiters");
if (gostDto.getMsg().contains("not found")){
gostDto.setMsg("OK");
}
return gostDto;
}
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());
JSONObject connector = new JSONObject();
connector.put("type", "relay");
Node node_info = node_s.get(chainTunnel.getNodeId());
JSONObject node = new JSONObject();
node.put("name", "node_" + chainTunnel.getInx());
String dialHost = (fromNode != null && node_info != null)
? selectDialHost(fromNode, node_info)
: (node_info != null ? node_info.getServerIp() : null);
node.put("addr", processServerAddress(dialHost + ":" + chainTunnel.getPort()));
node.put("connector", connector);
node.put("dialer", dialer);
nodes.add(node);
}
JSONObject hop = new JSONObject();
hop.put("name", "hop_" + chainTunnels.getFirst().getTunnelId());
// interface设置在转发链
if (StringUtils.isNotBlank(node_s.get(node_id).getInterfaceName())) {
hop.put("interface", node_s.get(node_id).getInterfaceName());
}
JSONObject selector = new JSONObject();
selector.put("strategy", chainTunnels.getFirst().getStrategy());
selector.put("maxFails", 1);
selector.put("failTimeout", 600000000000L); // 600 秒(纳秒单位)
hop.put("selector", selector);
hop.put("nodes", nodes);
JSONArray hops = new JSONArray();
hops.add(hop);
JSONObject data = new JSONObject();
data.put("name", "chains_" + chainTunnels.getFirst().getTunnelId());
data.put("hops", hops);
GostDto gostDto = WebSocketServer.send_msg(node_id, data, "AddChains");
if (gostDto.getMsg().contains("exists")){
gostDto.setMsg("OK");
}
return gostDto;
}
public static GostDto DeleteChains(Long node_id, String name) {
JSONObject data = new JSONObject();
data.put("chain", name);
GostDto gostDto = WebSocketServer.send_msg(node_id, data, "DeleteChains");
if (gostDto.getMsg().contains("not found")){
gostDto.setMsg("OK");
}
return gostDto;
}
public static GostDto AddChainService(Long node_id, ChainTunnel chainTunnel, Map<Long, Node> node_s) {
JSONArray services = new JSONArray();
Node node_info = node_s.get(chainTunnel.getNodeId());
JSONObject service_item = new JSONObject();
service_item.put("name", chainTunnel.getTunnelId() + "_tls");
service_item.put("addr", node_info.getTcpListenAddr() + ":" + chainTunnel.getPort());
// 只为出口节点(chainType=3)设置 interface
if (chainTunnel.getChainType() == 3 && StringUtils.isNotBlank(node_s.get(node_id).getInterfaceName())) {
JSONObject metadata = new JSONObject();
metadata.put("interface", node_s.get(node_id).getInterfaceName());
service_item.put("metadata", metadata);
}
JSONObject handler = new JSONObject();
handler.put("type", "relay");
if (chainTunnel.getChainType() == 2){
handler.put("chain","chains_" + chainTunnel.getTunnelId());
}
service_item.put("handler", handler);
JSONObject listener = new JSONObject();
listener.put("type", chainTunnel.getProtocol());
service_item.put("listener", listener);
services.add(service_item);
GostDto gostDto = WebSocketServer.send_msg(node_id, services, "AddService");
if (gostDto.getMsg().contains("exists")){
gostDto.setMsg("OK");
}
return gostDto;
}
public static GostDto AddAndUpdateService(String name, Integer limiter, Node node, Forward forward, ForwardPort forwardPort, Tunnel tunnel, String meth) {
JSONArray services = new JSONArray();
String[] protocols = {"tcp", "udp"};
for (String protocol : protocols) {
JSONObject service = new JSONObject();
service.put("name", name + "_" + protocol);
if (Objects.equals(protocol, "tcp")){
service.put("addr", node.getTcpListenAddr() + ":" + forwardPort.getPort());
}else {
service.put("addr", node.getUdpListenAddr() + ":" + forwardPort.getPort());
}
// 只在端口转发时设置 interface(隧道转发时 interface 在转发链的节点上设置)
if (tunnel.getType() == 1 && StringUtils.isNotBlank(node.getInterfaceName())) {
JSONObject metadata = new JSONObject();
metadata.put("interface", node.getInterfaceName());
service.put("metadata", metadata);
}
// 添加限流器配置
if (limiter != null) {
service.put("limiter", limiter.toString());
}
// 配置处理器
JSONObject handler = new JSONObject();
handler.put("type", protocol);
if (tunnel.getType() == 2){
handler.put("chain", "chains_" + forward.getTunnelId());
}
service.put("handler", handler);
// 配置监听器
JSONObject listener = createListener(protocol);
service.put("listener", listener);
JSONObject forwarder = createForwarder(forward.getRemoteAddr(), forward.getStrategy());
service.put("forwarder", forwarder);
services.add(service);
}
GostDto gostDto = WebSocketServer.send_msg(node.getId(), services, meth);
if (gostDto.getMsg().contains("exists")){
gostDto.setMsg("OK");
}
return gostDto;
}
public static GostDto DeleteService(Long node_id, JSONArray services) {
JSONObject data = new JSONObject();
data.put("services", services);
GostDto gostDto = WebSocketServer.send_msg(node_id, data, "DeleteService");
if (gostDto.getMsg().contains("not found")){
gostDto.setMsg("OK");
}
return gostDto;
}
public static GostDto PauseAndResumeService(Long node_id, String name, String meth) {
JSONObject data = new JSONObject();
JSONArray services = new JSONArray();
services.add(name + "_tcp");
services.add(name + "_udp");
data.put("services", services);
return WebSocketServer.send_msg(node_id, data, meth);
}
private static JSONObject createLimiterData(Long name, String speed) {
JSONObject data = new JSONObject();
data.put("name", name.toString());
JSONArray limits = new JSONArray();
limits.add("$ " + speed + "MB " + speed + "MB");
data.put("limits", limits);
return data;
}
private static JSONObject createListener(String protocol) {
JSONObject listener = new JSONObject();
listener.put("type", protocol);
if (Objects.equals(protocol, "udp")) {
JSONObject metadata = new JSONObject();
metadata.put("keepAlive", true);
listener.put("metadata", metadata);
}
return listener;
}
private static JSONObject createForwarder(String remoteAddr, String strategy) {
JSONObject forwarder = new JSONObject();
JSONArray nodes = new JSONArray();
String[] split = remoteAddr.split(",");
int num = 1;
for (String addr : split) {
JSONObject node = new JSONObject();
node.put("name", "node_" + num);
node.put("addr", addr);
nodes.add(node);
num++;
}
if (strategy == null || strategy.isEmpty()) {
strategy = "fifo";
}
forwarder.put("nodes", nodes);
JSONObject selector = new JSONObject();
selector.put("strategy", strategy);
selector.put("maxFails", 1);
selector.put("failTimeout", "600s");
forwarder.put("selector", selector);
return forwarder;
}
public static String processServerAddress(String serverAddr) {
if (StrUtil.isBlank(serverAddr)) {
return serverAddr;
}
// 如果已经被方括号包裹,直接返回
if (serverAddr.startsWith("[")) {
return serverAddr;
}
// 查找最后一个冒号,分离主机和端口
int lastColonIndex = serverAddr.lastIndexOf(':');
if (lastColonIndex == -1) {
// 没有端口号,直接检查是否需要包裹
return isIPv6Address(serverAddr) ? "[" + serverAddr + "]" : serverAddr;
}
String host = serverAddr.substring(0, lastColonIndex);
String port = serverAddr.substring(lastColonIndex);
// 检查主机部分是否为IPv6地址
if (isIPv6Address(host)) {
return "[" + host + "]" + port;
}
return serverAddr;
}
private static boolean isIPv6Address(String address) {
// IPv6地址包含多个冒号,至少2个
if (!address.contains(":")) {
return false;
}
// 计算冒号数量,IPv6地址至少有2个冒号
long colonCount = address.chars().filter(ch -> ch == ':').count();
return colonCount >= 2;
}
/**
* 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();
}
}
@@ -1,14 +0,0 @@
package com.admin.common.utils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
public class HttpContextUtils {
public static HttpServletRequest getHttpServletRequest(){
return ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
}
}
@@ -1,31 +0,0 @@
package com.admin.common.utils;
import com.admin.common.dto.GostConfigDto;
import com.admin.common.dto.GostDto;
import com.admin.config.RestTemplateConfig;
import com.alibaba.fastjson.JSONObject;
import lombok.SneakyThrows;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.http.*;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResponseErrorHandler;
import org.springframework.web.client.RestTemplate;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.*;
/**
* HTTP请求工具类
* 支持GET和POST请求,支持表单和JSON格式的请求体
*/
@Component
public class HttpUtils{
}
@@ -1,47 +0,0 @@
package com.admin.common.utils;
import javax.servlet.http.HttpServletRequest;
import java.net.InetAddress;
import java.net.UnknownHostException;
public class IpUtils {
public static String getIpAddr(HttpServletRequest request) {
String ipAddress = null;
try {
ipAddress = request.getHeader("x-forwarded-for");
if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
ipAddress = request.getHeader("Proxy-Client-IP");
}
if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
ipAddress = request.getHeader("WL-Proxy-Client-IP");
}
if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {
ipAddress = request.getRemoteAddr();
if (ipAddress.equals("127.0.0.1")) {
// 根据网卡取本机配置的IP
InetAddress inet = null;
try {
inet = InetAddress.getLocalHost();
} catch (UnknownHostException e) {
e.printStackTrace();
}
ipAddress = inet.getHostAddress();
}
}
// 对于通过多个代理的情况,第一个IP为客户端真实IP,多个IP按照','分割
if (ipAddress != null && ipAddress.length() > 15) {
// "***.***.***.***".length()
// = 15
if (ipAddress.indexOf(",") > 0) {
ipAddress = ipAddress.substring(0, ipAddress.indexOf(","));
}
}
} catch (Exception e) {
ipAddress="";
}
return ipAddress;
}
}
@@ -1,194 +0,0 @@
package com.admin.common.utils;
import com.admin.entity.User;
import com.alibaba.fastjson2.JSON;
import lombok.SneakyThrows;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* JWT工具类,不使用第三方库实现
*/
@Component
public class JwtUtil {
@Value("${jwt-secret}")
private String secretKey;
private static String SECRET_KEY;
// token有效期,7天
private static final long EXPIRE_TIME = 90L * 24 * 60 * 60 * 1000;
// 算法
private static final String ALGORITHM = "HmacSHA256";
@PostConstruct
public void init() {
SECRET_KEY = this.secretKey;
}
/**
* 生成JWT Token
*
* @param user 用户信息
* @return 生成的JWT Token
*/
public static String generateToken(User user) {
try {
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
Date expireDate = new Date(nowMillis + EXPIRE_TIME);
// Header
Map<String, Object> header = new HashMap<>();
header.put("alg", ALGORITHM);
header.put("typ", "JWT");
String headerJson = JSON.toJSONString(header);
String encodedHeader = Base64.getUrlEncoder().withoutPadding()
.encodeToString(headerJson.getBytes(StandardCharsets.UTF_8));
// Payload
Map<String, Object> payload = new HashMap<>();
payload.put("sub", user.getId().toString());
payload.put("iat", now.getTime() / 1000); // 发布时间
payload.put("exp", expireDate.getTime() / 1000); // 过期时间
payload.put("user", user.getUser());
payload.put("name", user.getUser());
payload.put("role_id", user.getRoleId());
String payloadJson = JSON.toJSONString(payload);
String encodedPayload = Base64.getUrlEncoder().withoutPadding()
.encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8));
// Signature
String signature = calculateSignature(encodedHeader, encodedPayload);
// Token
return encodedHeader + "." + encodedPayload + "." + signature;
} catch (Exception e) {
throw new RuntimeException("JWT token generation failed", e);
}
}
/**
* 验证JWT Token
*
* @param token JWT Token
* @return 验证是否通过
*/
public static boolean validateToken(String token) {
try {
if (token == null || token.isEmpty()) {
return false;
}
String[] parts = token.split("\\.");
if (parts.length != 3) {
return false;
}
String encodedHeader = parts[0];
String encodedPayload = parts[1];
String signature = parts[2];
// 验证签名
String expectedSignature = calculateSignature(encodedHeader, encodedPayload);
if (!expectedSignature.equals(signature)) {
return false;
}
// 验证过期时间
String decodedPayload = new String(Base64.getUrlDecoder().decode(encodedPayload), StandardCharsets.UTF_8);
Map<String, Object> payload = JSON.parseObject(decodedPayload, Map.class);
long exp = Long.parseLong(payload.get("exp").toString());
long now = System.currentTimeMillis() / 1000;
return exp > now;
} catch (Exception e) {
return false;
}
}
/**
* 从JWT Token中获取用户ID
*
* @param token JWT Token
* @return 用户ID
*/
public static Long getUserIdFromToken(String token) {
String[] parts = token.split("\\.");
String encodedPayload = parts[1];
String decodedPayload = new String(Base64.getUrlDecoder().decode(encodedPayload), StandardCharsets.UTF_8);
Map<String, Object> payload = JSON.parseObject(decodedPayload, Map.class);
return Long.parseLong(payload.get("sub").toString());
}
public static Integer getUserIdFromToken() {
String token = HttpContextUtils.getHttpServletRequest().getHeader("Authorization");
String[] parts = token.split("\\.");
String encodedPayload = parts[1];
String decodedPayload = new String(Base64.getUrlDecoder().decode(encodedPayload), StandardCharsets.UTF_8);
Map<String, Object> payload = JSON.parseObject(decodedPayload, Map.class);
return Integer.parseInt(payload.get("sub").toString());
}
public static String getNameFromToken() {
String token = HttpContextUtils.getHttpServletRequest().getHeader("Authorization");
String[] parts = token.split("\\.");
String encodedPayload = parts[1];
String decodedPayload = new String(Base64.getUrlDecoder().decode(encodedPayload), StandardCharsets.UTF_8);
Map<String, Object> payload = JSON.parseObject(decodedPayload, Map.class);
return payload.get("name").toString();
}
/**
* 从JWT Token中获取用户角色ID
*
* @param token JWT Token
* @return 角色ID
*/
public static Integer getRoleIdFromToken(String token) {
String[] parts = token.split("\\.");
String encodedPayload = parts[1];
String decodedPayload = new String(Base64.getUrlDecoder().decode(encodedPayload), StandardCharsets.UTF_8);
Map<String, Object> payload = JSON.parseObject(decodedPayload, Map.class);
return Integer.parseInt(payload.get("role_id").toString());
}
@SneakyThrows
public static Integer getRoleIdFromToken() {
String token = HttpContextUtils.getHttpServletRequest().getHeader("Authorization");
if (token == null || token.isEmpty()) throw new Exception();
String[] parts = token.split("\\.");
String encodedPayload = parts[1];
String decodedPayload = new String(Base64.getUrlDecoder().decode(encodedPayload), StandardCharsets.UTF_8);
Map<String, Object> payload = JSON.parseObject(decodedPayload, Map.class);
return Integer.parseInt(payload.get("role_id").toString());
}
/**
* 计算签名
*
* @param encodedHeader 编码后的头部
* @param encodedPayload 编码后的负载
* @return 签名
* @throws Exception 签名计算异常
*/
private static String calculateSignature(String encodedHeader, String encodedPayload) throws Exception {
String content = encodedHeader + "." + encodedPayload;
Mac hmac = Mac.getInstance(ALGORITHM);
SecretKeySpec secretKeySpec = new SecretKeySpec(SECRET_KEY.getBytes(StandardCharsets.UTF_8), ALGORITHM);
hmac.init(secretKeySpec);
byte[] signatureBytes = hmac.doFinal(content.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(signatureBytes);
}
}
@@ -1,172 +0,0 @@
package com.admin.common.utils;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Base64;
/**
* MD5工具类
*/
public class Md5Util {
private static final String MD5_ALGORITHM = "MD5";
private static final String DEFAULT_SALT = "admin_salt_2024";
/**
* 基础MD5加密
*
* @param input 待加密字符串
* @return MD5加密后的字符串(32位小写)
*/
public static String md5(String input) {
if (input == null || input.isEmpty()) {
return null;
}
try {
MessageDigest md = MessageDigest.getInstance(MD5_ALGORITHM);
byte[] digest = md.digest(input.getBytes());
return bytesToHex(digest);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5算法不可用", e);
}
}
/**
* MD5加密(使用默认盐值)
*
* @param input 待加密字符串
* @return MD5加密后的字符串
*/
public static String md5WithSalt(String input) {
return md5WithSalt(input, DEFAULT_SALT);
}
/**
* MD5加密(使用自定义盐值)
*
* @param input 待加密字符串
* @param salt 盐值
* @return MD5加密后的字符串
*/
public static String md5WithSalt(String input, String salt) {
if (input == null || input.isEmpty()) {
return null;
}
if (salt == null) {
salt = DEFAULT_SALT;
}
return md5(input + salt);
}
/**
* 生成随机盐值
*
* @param length 盐值长度
* @return 随机盐值
*/
public static String generateSalt(int length) {
SecureRandom random = new SecureRandom();
byte[] salt = new byte[length];
random.nextBytes(salt);
return Base64.getEncoder().encodeToString(salt);
}
/**
* 生成默认长度(16字节)的随机盐值
*
* @return 随机盐值
*/
public static String generateSalt() {
return generateSalt(16);
}
/**
* 验证密码
*
* @param password 原始密码
* @param hashedPassword 已加密的密码
* @return 是否匹配
*/
public static boolean verify(String password, String hashedPassword) {
if (password == null || hashedPassword == null) {
return false;
}
String encrypted = md5WithSalt(password);
return encrypted.equals(hashedPassword);
}
/**
* 验证密码(使用自定义盐值)
*
* @param password 原始密码
* @param salt 盐值
* @param hashedPassword 已加密的密码
* @return 是否匹配
*/
public static boolean verify(String password, String salt, String hashedPassword) {
if (password == null || hashedPassword == null) {
return false;
}
String encrypted = md5WithSalt(password, salt);
return encrypted.equals(hashedPassword);
}
/**
* 多次MD5加密
*
* @param input 待加密字符串
* @param times 加密次数
* @return 加密后的字符串
*/
public static String md5Multiple(String input, int times) {
if (input == null || input.isEmpty() || times <= 0) {
return input;
}
String result = input;
for (int i = 0; i < times; i++) {
result = md5(result);
}
return result;
}
/**
* 字节数组转十六进制字符串
*
* @param bytes 字节数组
* @return 十六进制字符串
*/
private static String bytesToHex(byte[] bytes) {
StringBuilder result = new StringBuilder();
for (byte b : bytes) {
result.append(String.format("%02x", b));
}
return result.toString();
}
/**
* 获取文件的MD5值
*
* @param bytes 文件字节数组
* @return MD5值
*/
public static String getFileMd5(byte[] bytes) {
if (bytes == null || bytes.length == 0) {
return null;
}
try {
MessageDigest md = MessageDigest.getInstance(MD5_ALGORITHM);
byte[] digest = md.digest(bytes);
return bytesToHex(digest);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5算法不可用", e);
}
}
}
@@ -1,496 +0,0 @@
package com.admin.common.utils;
import com.admin.common.dto.GostConfigDto;
import com.admin.common.dto.GostDto;
import com.admin.common.task.CheckGostConfigAsync;
import com.admin.entity.Node;
import com.admin.service.NodeService;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import javax.annotation.Resource;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.UUID;
@Slf4j
public class WebSocketServer extends TextWebSocketHandler {
@Resource
NodeService nodeService;
// 存储所有活跃的 WebSocket 连接(
private static final CopyOnWriteArraySet<WebSocketSession> activeSessions = new CopyOnWriteArraySet<>();
// 存储节点ID和对应的WebSocket session映射
private static final ConcurrentHashMap<Long, WebSocketSession> nodeSessions = new ConcurrentHashMap<>();
// 为每个session提供锁对象,防止并发发送消息
private static final ConcurrentHashMap<String, Object> sessionLocks = new ConcurrentHashMap<>();
// 存储等待响应的请求,key为requestId,value为CompletableFuture
private static final ConcurrentHashMap<String, CompletableFuture<GostDto>> pendingRequests = new ConcurrentHashMap<>();
// 缓存加密器实例,避免重复创建
private static final ConcurrentHashMap<String, AESCrypto> cryptoCache = new ConcurrentHashMap<>();
/**
* 加密消息包装器
*/
public static class EncryptedMessage {
private boolean encrypted;
private String data;
private Long timestamp;
// getters and setters
public boolean isEncrypted() { return encrypted; }
public void setEncrypted(boolean encrypted) { this.encrypted = encrypted; }
public String getData() { return data; }
public void setData(String data) { this.data = data; }
public Long getTimestamp() { return timestamp; }
public void setTimestamp(Long timestamp) { this.timestamp = timestamp; }
}
//接受客户端消息
@Override
public void handleTextMessage(WebSocketSession session, TextMessage message) {
try {
if (StringUtils.isNoneBlank(message.getPayload())) {
String id = session.getAttributes().get("id").toString();
String type = session.getAttributes().get("type").toString();
String nodeSecret = (String) session.getAttributes().get("nodeSecret");
// 尝试解密消息
String decryptedPayload = decryptMessageIfNeeded(message.getPayload(), nodeSecret);
if (decryptedPayload.contains("memory_usage")){
// 先发送确认消息
sendToUser(session, "{\"type\":\"call\"}", nodeSecret);
}else if (decryptedPayload.contains("requestId")) {
log.info("收到消息: {}", decryptedPayload);
// 处理命令响应消息
try {
JSONObject responseJson = JSONObject.parseObject(decryptedPayload);
String requestId = responseJson.getString("requestId");
String responseMessage = responseJson.getString("message");
String responseType = responseJson.getString("type");
JSONObject responseData = responseJson.getJSONObject("data");
if (requestId != null) {
CompletableFuture<GostDto> future = pendingRequests.remove(requestId);
if (future != null) {
GostDto result = new GostDto();
// 根据响应类型处理不同的数据
if ("PingResponse".equals(responseType) && responseData != null) {
// 特殊处理ping响应,将完整的响应数据返回
result.setMsg(responseMessage != null ? responseMessage : "OK");
result.setData(responseData); // 保存ping详细结果
} else {
// 其他类型的响应
result.setMsg(responseMessage != null ? responseMessage : "无响应消息");
if (responseData != null) {
result.setData(responseData);
}
}
future.complete(result);
}
}
} catch (Exception e) {
log.info("处理响应消息失败: {}", e.getMessage(), e);
}
} else {
log.info("收到消息: {}", decryptedPayload);
}
// 如果是节点类型,转发消息给其他会话
if (Objects.equals(type, "1")) {
JSONObject jsonObject = new JSONObject();
jsonObject.put("id", id);
jsonObject.put("type", "info");
jsonObject.put("data", decryptedPayload);
String broadcastMessage = jsonObject.toJSONString();
// 异步处理广播消息,避免阻塞当前线程
for (WebSocketSession targetSession : activeSessions) {
if (targetSession != null && targetSession.isOpen() && !targetSession.equals(session)) {
sendToUser(targetSession, broadcastMessage, null);
}
}
}
}
} catch (Exception e) {
log.info("处理WebSocket消息时发生异常: {}", e.getMessage(), e);
}
}
/**
* 尝试解密消息(如果需要)
*/
private String decryptMessageIfNeeded(String payload, String nodeSecret) {
if (payload == null || payload.trim().isEmpty()) {
return payload;
}
try {
// 尝试解析为加密消息格式
EncryptedMessage encryptedMessage = JSON.parseObject(payload, EncryptedMessage.class);
if (encryptedMessage.isEncrypted() && encryptedMessage.getData() != null) {
// 获取或创建加密器
AESCrypto crypto = getOrCreateCrypto(nodeSecret);
if (crypto == null) {
log.info("⚠️ 收到加密消息但无法创建解密器,使用原始数据");
return payload;
}
// 解密数据
String decryptedData = crypto.decryptString(encryptedMessage.getData());
return decryptedData;
}
} catch (Exception e) {
// 解析失败,可能是非加密格式,直接返回原始数据
log.info("WebSocket消息未加密或解密失败,使用原始数据: {}", e.getMessage());
}
return payload;
}
/**
* 加密消息(如果可能)
*/
private static String encryptMessageIfPossible(String message, String nodeSecret) {
if (message == null || nodeSecret == null) {
return message;
}
try {
AESCrypto crypto = getOrCreateCrypto(nodeSecret);
if (crypto != null) {
String encryptedData = crypto.encrypt(message);
// 创建加密消息包装器
JSONObject encryptedMessage = new JSONObject();
encryptedMessage.put("encrypted", true);
encryptedMessage.put("data", encryptedData);
encryptedMessage.put("timestamp", System.currentTimeMillis());
return encryptedMessage.toJSONString();
}
} catch (Exception e) {
log.info("⚠️ WebSocket消息加密失败,发送原始数据: {}", e.getMessage());
}
return message;
}
/**
* 获取或创建加密器实例
*/
private static AESCrypto getOrCreateCrypto(String secret) {
if (secret == null || secret.isEmpty()) {
return null;
}
return cryptoCache.computeIfAbsent(secret, AESCrypto::create);
}
// 建立连接
@Override
public void afterConnectionEstablished(WebSocketSession session) {
try {
String id = session.getAttributes().get("id").toString();
String type = session.getAttributes().get("type").toString();
if (!Objects.equals(type, "1")) {
// 网页管理员连接
activeSessions.add(session);
log.info("管理员连接建立,sessionId: {}", session.getId());
} else {
// 客户端节点连接
Long nodeId = Long.valueOf(id);
String version = (String) session.getAttributes().get("nodeVersion");
String http = (String) session.getAttributes().get("http");
String tls = (String) session.getAttributes().get("tls");
String socks = (String) session.getAttributes().get("socks");
log.info("节点 {} 尝试连接,开始处理连接逻辑", nodeId);
log.info("屏蔽协议数据:http->{}, tls->{}, socks->{}", http, tls, socks);
// 检查是否已有该节点的连接,如果有则记录日志但直接覆盖
WebSocketSession existingSession = nodeSessions.get(nodeId);
if (existingSession != null && existingSession.isOpen()) {
log.info("节点 {} 已有连接存在: {},新连接将覆盖旧连接", nodeId, existingSession.getId());
// 清理旧连接的锁对象
sessionLocks.remove(existingSession.getId());
}
// 直接覆盖会话映射(不主动关闭旧连接,让它自然断开)
nodeSessions.put(nodeId, session);
// 如果有旧连接,在覆盖映射后主动关闭它
if (existingSession != null && existingSession.isOpen()) {
try {
log.info("主动关闭节点 {} 的旧连接: {}", nodeId, existingSession.getId());
existingSession.close();
} catch (Exception e) {
log.info("关闭节点 {} 旧连接失败: {}", nodeId, e.getMessage());
}
}
// 更新节点状态为在线
Node node = nodeService.getById(nodeId);
if (node != null) {
// 更新状态和版本信息
node.setStatus(1);
if (version != null) {
node.setVersion(version);
}
if (http != null) {
node.setHttp(Integer.parseInt(http));
}
if (tls != null) {
node.setTls(Integer.parseInt(tls));
}
if (socks != null) {
node.setSocks(Integer.parseInt(socks));
}
boolean updateResult = nodeService.updateById(node);
if (updateResult) {
log.info("节点 {} 连接建立成功,状态更新为在线,版本: {}", nodeId, version);
// 广播节点上线状态给所有管理员
JSONObject res = new JSONObject();
res.put("id", id);
res.put("type", "status");
res.put("data", 1);
broadcastMessage(res.toJSONString());
} else {
log.info("节点 {} 状态更新失败", nodeId);
}
} else {
log.info("节点 {} 不存在,无法更新状态", nodeId);
// 移除无效的会话
nodeSessions.remove(nodeId);
}
}
} catch (Exception e) {
log.info("建立连接时发生异常: {}", e.getMessage(), e);
// 异常情况下,确保清理会话
try {
String id = session.getAttributes().get("id").toString();
String type = session.getAttributes().get("type").toString();
if (Objects.equals(type, "1")) {
Long nodeId = Long.valueOf(id);
nodeSessions.remove(nodeId);
log.info("由于异常,移除节点 {} 的会话", nodeId);
}
} catch (Exception cleanupException) {
log.info("清理异常会话时出错: {}", cleanupException.getMessage());
}
}
}
// 连接关闭后
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
try {
String id = session.getAttributes().get("id").toString();
String type = session.getAttributes().get("type").toString();
String sessionId = session.getId();
log.info("连接关闭,ID: {}, 类型: {}, 状态: {}", id, type, status);
if (!Objects.equals(type, "1")) {
// 管理员连接关闭
boolean removed = activeSessions.remove(session);
log.info("管理员连接关闭,sessionId: {}, 移除结果: {}", sessionId, removed);
} else {
// 客户端节点连接关闭
Long nodeId = Long.valueOf(id);
// 验证当前会话是否还是活跃会话(关键:这里会自动过滤掉被覆盖的旧连接)
WebSocketSession currentSession = nodeSessions.get(nodeId);
if (currentSession == null || !currentSession.equals(session)) {
log.info("节点 {} 连接关闭,但已有新连接或会话不匹配,跳过状态更新", nodeId);
sessionLocks.remove(sessionId);
return;
}
log.info("节点 {} 当前活跃连接关闭,开始验证并更新状态", nodeId);
nodeSessions.remove(nodeId);
// 更新节点状态为离线
Node node = nodeService.getById(nodeId);
if (node != null) {
node.setStatus(0);
boolean updateResult = nodeService.updateById(node);
if (updateResult) {
log.info("节点 {} 状态更新为离线成功", nodeId);
JSONObject res = new JSONObject();
res.put("id", id);
res.put("type", "status");
res.put("data", 0);
broadcastMessage(res.toJSONString());
} else {
log.info("节点 {} 状态更新为离线失败", nodeId);
}
} else {
log.info("节点 {} 不存在,无法更新离线状态", nodeId);
}
}
// 清理session锁对象
sessionLocks.remove(sessionId);
} catch (Exception e) {
log.info("关闭连接时发生异常: {}", e.getMessage(), e);
}
}
// 点对点发送消息
@SneakyThrows
public static void sendToUser(WebSocketSession socketSession, String message) {
sendToUser(socketSession, message, null);
}
// 点对点发送消息(支持加密)
@SneakyThrows
public static void sendToUser(WebSocketSession socketSession, String message, String nodeSecret) {
if (socketSession != null && socketSession.isOpen()) {
String sessionId = socketSession.getId();
Object lock = sessionLocks.computeIfAbsent(sessionId, k -> new Object());
synchronized (lock) {
try {
if (socketSession.isOpen()) {
// 如果是节点连接且有密钥,尝试加密消息
String finalMessage = message;
if (nodeSecret != null && !nodeSecret.isEmpty()) {
String type = (String) socketSession.getAttributes().get("type");
if ("1".equals(type)) { // 节点连接
finalMessage = encryptMessageIfPossible(message, nodeSecret);
}
}
socketSession.sendMessage(new TextMessage(finalMessage));
}
} catch (Exception e) {
log.info("发送WebSocket消息失败 [sessionId={}]: {}", sessionId, e.getMessage());
cleanupSession(socketSession);
}
}
} else {
cleanupSession(socketSession);
}
}
/**
* 清理失效的session,自动识别是节点session还是管理员session
*/
private static void cleanupSession(WebSocketSession session) {
if (session == null) return;
String sessionId = session.getId();
// 清理session锁
sessionLocks.remove(sessionId);
boolean removedFromAdmin = activeSessions.remove(session);
if (!removedFromAdmin) {
nodeSessions.entrySet().removeIf(entry -> {
if (entry.getValue() == session) {
return true;
}
return false;
});
}
}
// 广播消息
public static void broadcastMessage(String message) {
for (WebSocketSession session : activeSessions) {
sendToUser(session, message);
}
}
public static GostDto send_msg(Long node_id, Object msg, String type) {
WebSocketSession nodeSession = nodeSessions.get(node_id);
if (nodeSession == null) {
log.info("发送消息失败:节点 {} 不在线或会话不存在", node_id);
GostDto result = new GostDto();
result.setMsg("节点不在线");
return result;
}
if (!nodeSession.isOpen()) {
log.info("发送消息失败:节点 {} 连接已断开,清理会话", node_id);
nodeSessions.remove(node_id);
sessionLocks.remove(nodeSession.getId());
GostDto result = new GostDto();
result.setMsg("节点连接已断开");
return result;
}
// 生成唯一的请求ID
String requestId = UUID.randomUUID().toString();
// 创建CompletableFuture用于等待响应
CompletableFuture<GostDto> future = new CompletableFuture<>();
pendingRequests.put(requestId, future);
// 获取节点密钥用于加密
String nodeSecret = (String) nodeSession.getAttributes().get("nodeSecret");
try {
JSONObject data = new JSONObject();
data.put("type", type);
data.put("data", msg);
data.put("requestId", requestId);
sendToUser(nodeSession, data.toJSONString(), nodeSecret);
GostDto result = future.get(10, TimeUnit.SECONDS);
log.info("成功发送消息到节点 {} 并收到响应: {}", node_id, result.getMsg());
return result;
} catch (Exception e) {
// 清理请求和映射关系
pendingRequests.remove(requestId);
GostDto result = new GostDto();
if (e instanceof java.util.concurrent.TimeoutException) {
result.setMsg("等待响应超时");
log.info("节点 {} 响应超时,可能存在连接问题", node_id);
} else {
result.setMsg("发送消息失败: " + e.getMessage());
log.info("发送消息到节点 {} 失败: {}", node_id, e.getMessage(), e);
}
return result;
}
}
}
@@ -1,49 +0,0 @@
package com.admin.config;
import cloud.tianai.captcha.common.constant.CaptchaTypeConstant;
import cloud.tianai.captcha.resource.FontCache;
import cloud.tianai.captcha.resource.ResourceStore;
import cloud.tianai.captcha.resource.common.model.dto.Resource;
import cloud.tianai.captcha.resource.common.model.dto.ResourceMap;
import cloud.tianai.captcha.resource.impl.provider.ClassPathResourceProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import static cloud.tianai.captcha.common.constant.CommonConstant.DEFAULT_SLIDER_IMAGE_TEMPLATE_PATH;
@Component
@RequiredArgsConstructor
public class CaptchaResourceConfiguration {
private final ResourceStore resourceStore;
private static final String[] OPTIONS = {
"SLIDER", "WORD_IMAGE_CLICK", "ROTATE", "CONCAT"
};
@PostConstruct
public void init() {
// 添加自定义背景图片
for (int i = 1; i <= 10; i++) {
ResourceMap template = new ResourceMap("default", 4);
template.put("active.png", new Resource(ClassPathResourceProvider.NAME, "slide/" + i + "/1.png"));
template.put("fixed.png", new Resource(ClassPathResourceProvider.NAME, "slide/" + i + "/2.png"));
resourceStore.addTemplate(CaptchaTypeConstant.SLIDER, template);
}
// 添加自定义背景图片
for (String option : OPTIONS) {
for (int i = 1; i <= 26; i++) {
resourceStore.addResource(option, new Resource("classpath", "bgimages/" + i + ".jpg", "default"));
}
}
//添加自定义字体
resourceStore.addResource(FontCache.FONT_TYPE, new Resource("classpath", "fonts/SIMSUN.TTC", "default"));
}
}
@@ -1,81 +0,0 @@
package com.admin.config;
import com.admin.common.utils.AESCrypto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap;
/**
* 加密配置管理类
* 统一管理AES加密功能,为HTTP上报和WebSocket通信提供加密支持
*/
@Component
@Slf4j
public class EncryptionConfig {
// 缓存加密器实例,避免重复创建
private static final ConcurrentHashMap<String, AESCrypto> CRYPTO_CACHE = new ConcurrentHashMap<>();
/**
* 获取或创建AES加密器实例
*
* @param secret 密钥字符串
* @return AES加密器实例,如果创建失败返回null
*/
public static AESCrypto getOrCreateCrypto(String secret) {
if (secret == null || secret.isEmpty()) {
return null;
}
return CRYPTO_CACHE.computeIfAbsent(secret, AESCrypto::create);
}
/**
* 检测消息是否为加密格式
*
* @param message 消息内容
* @return 如果是加密格式返回true,否则返回false
*/
public static boolean isEncryptedMessage(String message) {
if (message == null || message.trim().isEmpty()) {
return false;
}
try {
// 简单检查是否包含加密标识
return message.contains("\"encrypted\":true") || message.contains("\"encrypted\": true");
} catch (Exception e) {
return false;
}
}
/**
* 清理缓存的加密器实例
*
* @param secret 要清理的密钥
*/
public static void clearCrypto(String secret) {
if (secret != null) {
CRYPTO_CACHE.remove(secret);
log.info("已清理密钥对应的加密器实例");
}
}
/**
* 清理所有缓存的加密器实例
*/
public static void clearAllCrypto() {
int size = CRYPTO_CACHE.size();
CRYPTO_CACHE.clear();
log.info("已清理所有加密器实例缓存,共清理 {} 个实例", size);
}
/**
* 获取当前缓存的加密器数量
*
* @return 缓存的加密器数量
*/
public static int getCacheSize() {
return CRYPTO_CACHE.size();
}
}
@@ -1,30 +0,0 @@
package com.admin.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.autoconfigure.ConfigurationCustomizer;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.BlockAttackInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.admin.mapper")
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 配置分页插件,指定数据库类型为 SQLite
PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.SQLITE);
interceptor.addInnerInterceptor(paginationInterceptor); // 分页插件
interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); // 防止全表更新插件
return interceptor;
}
@Bean
public ConfigurationCustomizer configurationCustomizer() {
return configuration -> configuration.setUseDeprecatedExecutor(false);
}
}
@@ -1,78 +0,0 @@
package com.admin.config;
import org.apache.http.HttpHost;
import org.apache.http.conn.ssl.NoopHostnameVerifier;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.ssl.SSLContexts;
import org.apache.http.ssl.TrustStrategy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
import javax.net.ssl.SSLContext;
import java.security.KeyManagementException;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate(ClientHttpRequestFactory factory){
return new RestTemplate(factory);
}
// 10秒超时配置
private static final int TIMEOUT_SECONDS = 10;
private static final int TIMEOUT_MILLISECONDS = TIMEOUT_SECONDS * 1000;
@Bean
public ClientHttpRequestFactory simpleClientHttpRequestFactory(){
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
factory.setConnectTimeout(TIMEOUT_MILLISECONDS);
factory.setReadTimeout(TIMEOUT_MILLISECONDS);
return factory;
}
// 禁用ssl证书校验
public static HttpComponentsClientHttpRequestFactory generateHttpRequestFactory() throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException{
TrustStrategy acceptingTrustStrategy = (x509Certificates, authType) -> true;
SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(null, acceptingTrustStrategy).build();
SSLConnectionSocketFactory connectionSocketFactory = new SSLConnectionSocketFactory(sslContext, new NoopHostnameVerifier());
HttpClientBuilder httpClientBuilder = HttpClients.custom();
httpClientBuilder.setSSLSocketFactory(connectionSocketFactory);
CloseableHttpClient httpClient = httpClientBuilder.build();
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
factory.setHttpClient(httpClient);
// 添加超时配置
factory.setConnectTimeout(TIMEOUT_MILLISECONDS);
factory.setReadTimeout(TIMEOUT_MILLISECONDS);
return factory;
}
// 禁用ssl证书校验 添加代理ip post请求
public static HttpComponentsClientHttpRequestFactory post(String url, Integer port) throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException{
TrustStrategy acceptingTrustStrategy = (x509Certificates, authType) -> true;
SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(null, acceptingTrustStrategy).build();
SSLConnectionSocketFactory connectionSocketFactory = new SSLConnectionSocketFactory(sslContext, new NoopHostnameVerifier());
HttpClientBuilder httpClientBuilder = HttpClients.custom();
httpClientBuilder.setProxy(new HttpHost(url, port, "http"));
httpClientBuilder.setSSLSocketFactory(connectionSocketFactory);
CloseableHttpClient httpClient = httpClientBuilder.build();
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
factory.setHttpClient(httpClient);
// 添加超时配置
factory.setConnectTimeout(TIMEOUT_MILLISECONDS);
factory.setReadTimeout(TIMEOUT_MILLISECONDS);
return factory;
}
}
@@ -1,172 +0,0 @@
package com.admin.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import javax.annotation.PreDestroy;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Pattern;
/**
* SQLite 数据库配置
* 启用 WAL (Write-Ahead Logging) 模式以提高并发性能
* 添加定期 checkpoint 和优雅关闭处理
*/
@Slf4j
@Component
@EnableScheduling
public class SQLiteConfig implements ApplicationRunner {
private final DataSource dataSource;
public SQLiteConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public void run(ApplicationArguments args) throws Exception {
try (Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement()) {
statement.execute("PRAGMA journal_mode=WAL;");
statement.execute("PRAGMA synchronous=NORMAL;");
statement.execute("PRAGMA cache_size=-64000;"); // 64MB 缓存
statement.execute("PRAGMA temp_store=MEMORY;");
statement.execute("PRAGMA busy_timeout=5000;"); // 5秒超时
statement.execute("PRAGMA wal_autocheckpoint=1000;"); // 每1000页自动checkpoint
ensureNodeDualStackColumns(connection);
log.info("SQLite WAL mode configured successfully");
} catch (Exception e) {
log.error("Failed to configure SQLite database", e);
throw e;
}
}
private void ensureNodeDualStackColumns(Connection connection) throws Exception {
Set<String> cols = getTableColumns(connection, "node");
if (cols.isEmpty()) {
return;
}
ensureColumnIfMissing(connection, cols, "node", "server_ip_v4", "VARCHAR(100)");
ensureColumnIfMissing(connection, cols, "node", "server_ip_v6", "VARCHAR(100)");
backfillNodeDualStackColumns(connection);
}
private void backfillNodeDualStackColumns(Connection connection) throws Exception {
Pattern ipv4 = Pattern.compile("^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
try (Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery("SELECT id, server_ip, server_ip_v4, server_ip_v6 FROM node;");
PreparedStatement updV4 = connection.prepareStatement("UPDATE node SET server_ip_v4 = ? WHERE id = ?;");
PreparedStatement updV6 = connection.prepareStatement("UPDATE node SET server_ip_v6 = ? WHERE id = ?;")
) {
while (rs.next()) {
long id = rs.getLong("id");
String serverIp = rs.getString("server_ip");
String v4 = rs.getString("server_ip_v4");
String v6 = rs.getString("server_ip_v6");
if (serverIp == null || serverIp.isBlank()) {
continue;
}
if ((v4 != null && !v4.isBlank()) || (v6 != null && !v6.isBlank())) {
continue;
}
String trimmed = serverIp.trim();
if (ipv4.matcher(trimmed).matches()) {
updV4.setString(1, trimmed);
updV4.setLong(2, id);
updV4.executeUpdate();
} else {
long colonCount = trimmed.chars().filter(ch -> ch == ':').count();
if (colonCount >= 2) {
updV6.setString(1, trimmed);
updV6.setLong(2, id);
updV6.executeUpdate();
}
}
}
}
}
private Set<String> getTableColumns(Connection connection, String table) throws Exception {
Set<String> cols = new HashSet<>();
try (Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery("PRAGMA table_info(" + table + ");")) {
while (rs.next()) {
String name = rs.getString("name");
if (name != null && !name.isBlank()) {
cols.add(name);
}
}
}
return cols;
}
private void ensureColumnIfMissing(
Connection connection,
Set<String> existingColumns,
String table,
String column,
String type
) throws Exception {
if (existingColumns.contains(column)) {
return;
}
try (Statement statement = connection.createStatement()) {
statement.execute("ALTER TABLE " + table + " ADD COLUMN " + column + " " + type + ";");
}
log.info("SQLite schema updated: added {}.{}", table, column);
}
/**
* 定期执行 checkpoint,确保 WAL 文件内容写入主数据库
* 每5分钟执行一次
*/
@Scheduled(fixedDelay = 300000, initialDelay = 300000)
public void performCheckpoint() {
try (Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement()) {
statement.execute("PRAGMA wal_checkpoint(TRUNCATE);");
log.debug("SQLite WAL checkpoint completed");
} catch (Exception e) {
log.error("Failed to perform SQLite checkpoint", e);
}
}
/**
* 应用关闭前执行最终的 checkpoint,确保所有数据都写入主数据库文件
*/
@PreDestroy
public void onShutdown() {
log.info("Performing final SQLite checkpoint before shutdown...");
try (Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement()) {
// 强制执行 checkpoint,将所有 WAL 内容写入主数据库
statement.execute("PRAGMA wal_checkpoint(TRUNCATE);");
log.info("Final SQLite checkpoint completed successfully");
} catch (Exception e) {
log.error("Failed to perform final SQLite checkpoint", e);
}
}
}
@@ -1,65 +0,0 @@
package com.admin.config;
import com.admin.common.interceptor.JwtInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@EnableWebMvc
public class WebMvcConfig implements WebMvcConfigurer {
private CorsConfiguration buildConfig() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.addAllowedOrigin("*");
corsConfiguration.addAllowedHeader("*");
corsConfiguration.addAllowedMethod("*");
corsConfiguration.addExposedHeader("Authorization");
return corsConfiguration;
}
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", buildConfig());
return new CorsFilter(source);
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("GET", "POST", "DELETE", "PUT")
.maxAge(3600);
}
/**
* JWT拦截器
*/
@Bean
public JwtInterceptor jwtInterceptor() {
return new JwtInterceptor();
}
/**
* 添加JWT拦截器
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 添加JWT拦截器,不拦截登录接口
registry.addInterceptor(jwtInterceptor())
.addPathPatterns("/api/**")
.excludePathPatterns("/flow/**")
.excludePathPatterns("/api/v1/open_api/**")
.excludePathPatterns("/api/v1/config/get")
.excludePathPatterns("/api/v1/user/login")
.excludePathPatterns("/api/v1/captcha/**");
}
}
@@ -1,40 +0,0 @@
package com.admin.config;
import com.admin.common.utils.WebSocketServer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.boot.web.servlet.ServletContextInitializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.annotation.Resource;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Resource
private WebSocketInterceptor webSocketInterceptor;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
webSocketHandlerRegistry
.addHandler(myHandler(), "/system-info")
.setAllowedOrigins("*")
.addInterceptors(webSocketInterceptor);
}
@Bean
public WebSocketHandler myHandler() {
return new WebSocketServer();
}
}
@@ -1,77 +0,0 @@
package com.admin.config;
import com.admin.common.utils.IpUtils;
import com.admin.common.utils.JwtUtil;
import com.admin.entity.Node;
import com.admin.service.NodeService;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;
import javax.annotation.Resource;
import java.net.InetSocketAddress;
import java.util.Map;
import java.util.Objects;
@Configuration
@Slf4j
public class WebSocketInterceptor extends HttpSessionHandshakeInterceptor {
@Resource
NodeService nodeService;
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception ex) {
}
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
ServletServerHttpRequest serverHttpRequest = (ServletServerHttpRequest) request;
String secret = serverHttpRequest.getServletRequest().getParameter("secret");
String type = serverHttpRequest.getServletRequest().getParameter("type");
String version = serverHttpRequest.getServletRequest().getParameter("version");
String http = serverHttpRequest.getServletRequest().getParameter("http");
String tls = serverHttpRequest.getServletRequest().getParameter("tls");
String socks = serverHttpRequest.getServletRequest().getParameter("socks");
if (Objects.equals(type, "1")) {
System.out.println("type: " + type + " - version: " + version + " - secret: " + secret + " - IP: " + getClientIp(request));
Node node = nodeService.getOne(new QueryWrapper<Node>().eq("secret", secret));
if (node == null) {
log.info("节点验证失败:未找到匹配的secret");
return false;
}
attributes.put("id", node.getId());
attributes.put("nodeSecret", secret);
attributes.put("nodeVersion", version);
attributes.put("http",http);
attributes.put("tls",tls);
attributes.put("socks",socks);
log.info("节点 {} 通过验证,版本: {}", node.getId(), version);
// 不在这里更新状态,等到连接建立后再统一更新
}else {
boolean b = JwtUtil.validateToken(secret);
if (!b) return false;
attributes.put("id", JwtUtil.getUserIdFromToken(secret));
}
attributes.put("type", type);
return true;
}
public String getClientIp(ServerHttpRequest request) {
InetSocketAddress remoteAddress = request.getRemoteAddress();
if (remoteAddress != null) {
return remoteAddress.getAddress().getHostAddress();
}
return null;
}
}

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