Compare commits

..

46 Commits

Author SHA1 Message Date
sagit abb8591b11 fix(backend): backfill legacy node dual-stack columns in sqlite migration 2026-02-10 04:06:00 +00:00
sagit 2acee481f0 fix(backend): backfill legacy inx columns during sqlite migration 2026-02-10 03:14:08 +00:00
sagit 76f443f900 Merge pull request #62 from Sagit-chu/update-tz-mirror
feat: add Shanghai timezone to docker-compose and update github mirror
2026-02-09 18:52:59 +08:00
sagit 47b1663938 feat: add Shanghai timezone to docker-compose and update github mirror 2026-02-09 10:47:13 +00:00
sagit 406f5bb380 Merge pull request #61 from Sagit-chu/opencode/calm-orchid
fix: limit speed
2026-02-09 17:13:40 +08:00
sagit 85ea6c17a4 Merge branch 'main' into opencode/calm-orchid 2026-02-09 17:12:38 +08:00
sagit 3420dc5460 fix(backend): sync limiter on association instead of connection
Reverted the full sync on connection hook. Instead, ensureLimiterOnNode is called within syncForwardServices to push limiter configuration immediately before pushing the service configuration that references it.
2026-02-09 09:06:59 +00:00
sagit 3d7a0b697d feat(backend): sync limiters on agent connect
Implemented full sync of speed limit configurations when an Agent connects via WebSocket. This ensures that even fresh or restarted agents receive the necessary limiter configurations.
2026-02-09 08:49:47 +00:00
sagit 065b23d9c3 Merge pull request #59 from Sagit-chu/opencode/calm-orchid
refactor(backend): reimplement speed limit logic
2026-02-09 16:15:59 +08:00
sagit 7919dfde59 Merge branch 'main' into opencode/calm-orchid 2026-02-09 16:13:40 +08:00
sagit 565d732967 refactor(backend): reimplement speed limit logic
1. Refactor speed limit CRUD to sync with agents immediately via WebSocket (AddLimiters/DeleteLimiters).
2. Update unit conversion to match GOST v3 requirements (Mbps -> MB/s).
3. Update service config generation to reference Limiter IDs instead of hardcoded values.
2026-02-09 08:12:10 +00:00
sagit 20dc151aec Merge pull request #58 from Sagit-chu/opencode/calm-orchid
fix(backend): fix tunnel batch redeploy logic for type 2 tunnels and speed limit
2026-02-09 14:28:01 +08:00
sagit 630ed969d3 Merge branch 'main' into opencode/calm-orchid 2026-02-09 14:23:20 +08:00
sagit e94aa01213 fix(gost): append 'B' suffix to speed limit values for correct unit parsing 2026-02-09 06:22:47 +00:00
sagit 67d8f7a381 fix(backend): correct speed limit unit conversion from Mbps to Bytes/s 2026-02-09 06:13:56 +00:00
sagit 0c7b7deaf5 fix(backend): fix tunnel batch redeploy logic for type 2 tunnels 2026-02-09 05:17:58 +00:00
sagit a4def9c5f3 Merge pull request #57 from Sagit-chu/opencode/calm-orchid
fix: prevent nil pointer dereference in listener config parsing
2026-02-09 12:42:16 +08:00
sagit 6582348da2 Merge branch 'main' into opencode/calm-orchid 2026-02-09 12:40:57 +08:00
sagit 3a14b22ebc fix: prevent nil pointer dereference in listener config parsing 2026-02-09 04:39:27 +00:00
sagit d7b44916bf Merge pull request #56 from Sagit-chu/opencode/calm-orchid
fix(limiter): fix traffic limiter ScopeClient behavior to allow per-u…
2026-02-09 11:39:23 +08:00
sagit f8a0bda3fd Merge branch 'main' into opencode/calm-orchid 2026-02-09 11:37:49 +08:00
sagit 634562e56d fix(config): support raw number string for limiter configuration 2026-02-09 03:17:02 +00:00
sagit d06e02998b fix(limiter): fix traffic limiter ScopeClient behavior to allow per-user limits 2026-02-09 03:12:33 +00:00
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
sagit 96eec61a92 Merge pull request #45 from Sagit-chu/opencode/cosmic-sailor
fix(frontend): update turnstile implementation for login page
2026-02-08 14:54:32 +08:00
sagit 6e4bde4053 fix(frontend): update turnstile implementation for login page 2026-02-08 06:50:46 +00:00
243 changed files with 873 additions and 13075 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

+1
View File
@@ -12,6 +12,7 @@ services:
JWT_SECRET: ${JWT_SECRET}
LOG_DIR: /app/logs
SERVER_ADDR: :6365
TZ: Asia/Shanghai
ports:
- "${BACKEND_PORT}:6365"
volumes:
+1
View File
@@ -12,6 +12,7 @@ services:
JWT_SECRET: ${JWT_SECRET}
LOG_DIR: /app/logs
SERVER_ADDR: :6365
TZ: Asia/Shanghai
ports:
- "${BACKEND_PORT}:6365"
volumes:
+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
@@ -59,6 +59,8 @@ type chainNodeRecord struct {
NodeID int64
Port int
NodeName string
Protocol string
Strategy string
}
type diagnosisTarget struct {
@@ -232,9 +234,9 @@ func (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {
return &n, nil
}
func (h *Handler) resolveUserTunnelAndLimiter(userID, tunnelID int64) (int64, *int, error) {
func (h *Handler) resolveUserTunnelAndLimiter(userID, tunnelID int64) (int64, *int64, *int, error) {
row := h.repo.DB().QueryRow(`
SELECT ut.id, sl.speed
SELECT ut.id, sl.id, sl.speed
FROM user_tunnel ut
LEFT JOIN speed_limit sl ON sl.id = ut.speed_id
WHERE ut.user_id = ? AND ut.tunnel_id = ?
@@ -242,19 +244,21 @@ func (h *Handler) resolveUserTunnelAndLimiter(userID, tunnelID int64) (int64, *i
LIMIT 1
`, userID, tunnelID)
var userTunnelID int64
var limiterID sql.NullInt64
var speed sql.NullInt64
err := row.Scan(&userTunnelID, &speed)
err := row.Scan(&userTunnelID, &limiterID, &speed)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, nil, nil
return 0, nil, nil, nil
}
return 0, nil, err
return 0, nil, nil, err
}
if !speed.Valid || speed.Int64 <= 0 {
return userTunnelID, nil, nil
if !limiterID.Valid || limiterID.Int64 <= 0 {
return userTunnelID, nil, nil, nil
}
v := int(speed.Int64)
return userTunnelID, &v, nil
v := limiterID.Int64
s := int(speed.Int64)
return userTunnelID, &v, &s, nil
}
func (h *Handler) listUserTunnelIDs(userID, tunnelID int64) ([]int64, error) {
@@ -326,18 +330,22 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
return errors.New("转发入口端口不存在")
}
userTunnelID, limiter, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
userTunnelID, limiterID, speed, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
if err != nil {
return err
}
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, userTunnelID)
for _, fp := range ports {
if limiterID != nil && speed != nil {
h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed)
}
node, err := h.getNodeRecord(fp.NodeID)
if err != nil {
return err
}
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiter)
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiterID)
_, err = h.sendNodeCommand(node.ID, method, services, true, false)
if err != nil && allowFallbackAdd && method == "UpdateService" {
_, err = h.sendNodeCommand(node.ID, "AddService", services, true, false)
@@ -360,7 +368,7 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
if len(ports) == 0 {
return nil
}
userTunnelID, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
if err != nil {
return err
}
@@ -862,7 +870,7 @@ func firstPortFromRange(portRange string) int {
func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, error) {
rows, err := h.repo.DB().Query(`
SELECT ct.chain_type, COALESCE(ct.inx, 0), ct.node_id, COALESCE(ct.port, 0), n.name
SELECT ct.chain_type, COALESCE(ct.inx, 0), ct.node_id, COALESCE(ct.port, 0), n.name, ct.protocol, ct.strategy
FROM chain_tunnel ct
LEFT JOIN node n ON n.id = ct.node_id
WHERE ct.tunnel_id = ?
@@ -877,7 +885,9 @@ func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, er
for rows.Next() {
var item chainNodeRecord
var name sql.NullString
if err := rows.Scan(&item.ChainType, &item.Inx, &item.NodeID, &item.Port, &name); err != nil {
var protocol sql.NullString
var strategy sql.NullString
if err := rows.Scan(&item.ChainType, &item.Inx, &item.NodeID, &item.Port, &name, &protocol, &strategy); err != nil {
return nil, err
}
if strings.TrimSpace(name.String) == "" {
@@ -885,6 +895,8 @@ func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, er
} else {
item.NodeName = name.String
}
item.Protocol = defaultString(protocol.String, "tls")
item.Strategy = defaultString(strategy.String, "round")
result = append(result, item)
}
if err := rows.Err(); err != nil {
@@ -997,7 +1009,7 @@ func isNotFoundError(err error) bool {
return strings.Contains(msg, "not found") || strings.Contains(msg, "不存在")
}
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiter *int) []map[string]interface{} {
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64) []map[string]interface{} {
protocols := []string{"tcp", "udp"}
services := make([]map[string]interface{}, 0, 2)
targets := splitRemoteTargets(forward.RemoteAddr)
@@ -1038,8 +1050,8 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
if tunnel != nil && tunnel.Type == 1 && strings.TrimSpace(node.InterfaceName) != "" {
service["metadata"] = map[string]interface{}{"interface": node.InterfaceName}
}
if limiter != nil && *limiter > 0 {
service["limiter"] = strconv.Itoa(*limiter)
if limiterID != nil && *limiterID > 0 {
service["limiter"] = strconv.FormatInt(*limiterID, 10)
}
services = append(services, service)
}
@@ -1102,3 +1114,49 @@ func asBool(v interface{}, def bool) bool {
return def
}
}
func (h *Handler) sendLimiterConfig(limiterID int64, speedMbps int, tunnelID int64) error {
rate := float64(speedMbps) / 8.0
limitStr := fmt.Sprintf("$ %.1fMB %.1fMB", rate, rate)
payload := map[string]interface{}{
"name": strconv.FormatInt(limiterID, 10),
"limits": []string{limitStr},
}
nodes, err := h.tunnelEntryNodeIDs(tunnelID)
if err != nil {
return err
}
for _, nodeID := range nodes {
_, _ = h.sendNodeCommand(nodeID, "AddLimiters", payload, false, false)
}
return nil
}
func (h *Handler) sendDeleteLimiterConfig(limiterID int64, tunnelID int64) error {
payload := map[string]interface{}{
"limiter": strconv.FormatInt(limiterID, 10),
}
nodes, err := h.tunnelEntryNodeIDs(tunnelID)
if err != nil {
return err
}
for _, nodeID := range nodes {
_, _ = h.sendNodeCommand(nodeID, "DeleteLimiters", payload, false, true)
}
return nil
}
func (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int) {
rate := float64(speed) / 8.0
limitStr := fmt.Sprintf("$ %.1fMB %.1fMB", rate, rate)
payload := map[string]interface{}{
"name": strconv.FormatInt(limiterID, 10),
"limits": []string{limitStr},
}
_, _ = h.sendNodeCommand(nodeID, "AddLimiters", payload, false, false)
}
+136 -5
View File
@@ -731,6 +731,82 @@ func (h *Handler) tunnelBatchDelete(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": success, "failCount": fail}))
}
func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, error) {
tunnel, err := h.getTunnelRecord(tunnelID)
if err != nil {
return nil, err
}
chainRows, err := h.listChainNodesForTunnel(tunnelID)
if err != nil {
return nil, err
}
state := &tunnelCreateState{
TunnelID: tunnelID,
Type: tunnel.Type,
InNodes: make([]tunnelRuntimeNode, 0),
ChainHops: make([][]tunnelRuntimeNode, 0),
OutNodes: make([]tunnelRuntimeNode, 0),
Nodes: make(map[int64]*nodeRecord),
NodeIDList: make([]int64, 0),
}
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
for _, r := range inNodes {
state.InNodes = append(state.InNodes, tunnelRuntimeNode{
NodeID: r.NodeID,
Protocol: r.Protocol,
Strategy: r.Strategy,
ChainType: 1,
})
state.NodeIDList = append(state.NodeIDList, r.NodeID)
}
for _, r := range outNodes {
state.OutNodes = append(state.OutNodes, tunnelRuntimeNode{
NodeID: r.NodeID,
Protocol: r.Protocol,
Strategy: r.Strategy,
ChainType: 3,
Port: r.Port,
})
state.NodeIDList = append(state.NodeIDList, r.NodeID)
}
for _, hop := range chainHops {
stateHop := make([]tunnelRuntimeNode, 0)
for _, r := range hop {
stateHop = append(stateHop, tunnelRuntimeNode{
NodeID: r.NodeID,
Protocol: r.Protocol,
Strategy: r.Strategy,
ChainType: 2,
Inx: int(r.Inx),
Port: r.Port,
})
state.NodeIDList = append(state.NodeIDList, r.NodeID)
}
state.ChainHops = append(state.ChainHops, stateHop)
}
seen := make(map[int64]struct{})
for _, id := range state.NodeIDList {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
node, err := h.getNodeRecord(id)
if err != nil {
return nil, err
}
state.Nodes[id] = node
}
return state, nil
}
func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
ids := idsFromBody(r, w)
if ids == nil {
@@ -739,6 +815,26 @@ func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
success := 0
fail := 0
for _, tunnelID := range ids {
tunnel, err := h.getTunnelRecord(tunnelID)
if err != nil {
fail++
continue
}
if tunnel.Type == 2 {
h.cleanupTunnelRuntime(tunnelID)
state, err := h.reconstructTunnelState(tunnelID)
if err != nil {
fail++
continue
}
_, _, applyErr := h.applyTunnelRuntime(state)
if applyErr != nil {
fail++
continue
}
}
forwards, err := h.listForwardsByTunnel(tunnelID)
if err != nil {
fail++
@@ -841,6 +937,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())
}
@@ -1366,12 +1469,15 @@ func (h *Handler) speedLimitCreate(w http.ResponseWriter, r *http.Request) {
return
}
now := time.Now().UnixMilli()
_, err := h.repo.DB().Exec(`INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status) VALUES(?, ?, ?, ?, ?, ?, ?)`,
name, asInt(req["speed"], 100), tunnelID, tunnelName, now, now, asInt(req["status"], 1))
speed := asInt(req["speed"], 100)
res, err := h.repo.DB().Exec(`INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status) VALUES(?, ?, ?, ?, ?, ?, ?)`,
name, speed, tunnelID, tunnelName, now, now, asInt(req["status"], 1))
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
id, _ := res.LastInsertId()
_ = h.sendLimiterConfig(id, speed, tunnelID)
response.WriteJSON(w, response.OKEmpty())
}
@@ -1393,12 +1499,14 @@ func (h *Handler) speedLimitUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
return
}
speed := asInt(req["speed"], 100)
_, err := h.repo.DB().Exec(`UPDATE speed_limit SET name=?, speed=?, tunnel_id=?, tunnel_name=?, status=?, updated_time=? WHERE id=?`,
asString(req["name"]), asInt(req["speed"], 100), tunnelID, tunnelName, asInt(req["status"], 1), time.Now().UnixMilli(), id)
asString(req["name"]), speed, tunnelID, tunnelName, asInt(req["status"], 1), time.Now().UnixMilli(), id)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
_ = h.sendLimiterConfig(id, speed, tunnelID)
response.WriteJSON(w, response.OKEmpty())
}
@@ -1407,11 +1515,17 @@ func (h *Handler) speedLimitDelete(w http.ResponseWriter, r *http.Request) {
if id <= 0 {
return
}
var tunnelID int64
_ = h.repo.DB().QueryRow(`SELECT tunnel_id FROM speed_limit WHERE id = ?`, id).Scan(&tunnelID)
_, err := h.repo.DB().Exec(`DELETE FROM speed_limit WHERE id = ?`, id)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if tunnelID > 0 {
_ = h.sendDeleteLimiterConfig(id, tunnelID)
}
response.WriteJSON(w, response.OKEmpty())
}
@@ -2279,8 +2393,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 +2797,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
+54 -11
View File
@@ -5,7 +5,6 @@ import (
_ "embed"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"sort"
@@ -124,6 +123,11 @@ func Open(path string) (*Repository, error) {
return nil, err
}
if err := migrateSchema(db); err != nil {
_ = db.Close()
return nil, err
}
return &Repository{db: db}, nil
}
@@ -1167,25 +1171,64 @@ 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)
}
return nil
}
func migrateSchema(db *sql.DB) error {
if db == nil {
return errors.New("nil db")
}
ensureColumn := func(table, col, typ string) error {
var dummy interface{}
err := db.QueryRow(fmt.Sprintf("SELECT %s FROM %s LIMIT 1", col, table)).Scan(&dummy)
if err == nil || errors.Is(err, sql.ErrNoRows) {
return nil
}
if !strings.Contains(err.Error(), "no such column") {
return nil
}
if _, err := db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, col, typ)); err != nil {
return fmt.Errorf("add %s.%s: %w", table, col, err)
}
return nil
}
columnsByTable := map[string]map[string]string{
"node": {
"server_ip_v4": "VARCHAR(100)",
"server_ip_v6": "VARCHAR(100)",
"inx": "INTEGER NOT NULL DEFAULT 0",
},
"tunnel": {
"inx": "INTEGER NOT NULL DEFAULT 0",
},
"forward": {
"inx": "INTEGER NOT NULL DEFAULT 0",
},
"chain_tunnel": {
"inx": "INTEGER",
},
}
for table, cols := range columnsByTable {
for col, typ := range cols {
if err := ensureColumn(table, col, typ); err != nil {
return err
}
}
}
return nil
}
var osMkdirAll = func(path string) error {
return os.MkdirAll(path, 0o755)
}
@@ -2,6 +2,7 @@ package contract_test
import (
"bytes"
"database/sql"
"encoding/json"
"io"
"net/http"
@@ -17,6 +18,8 @@ import (
"go-backend/internal/http/handler"
"go-backend/internal/http/response"
"go-backend/internal/store/sqlite"
_ "modernc.org/sqlite"
)
func TestCaptchaVerifyLoginContract(t *testing.T) {
@@ -213,3 +216,117 @@ func setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *sqlite.
h := handler.New(repo, jwtSecret)
return httpserver.NewRouter(h, jwtSecret), repo
}
func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "legacy-2.0.7-beta.db")
legacyDB, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("open legacy sqlite: %v", err)
}
t.Cleanup(func() {
_ = legacyDB.Close()
})
if _, err := legacyDB.Exec(`
CREATE TABLE IF NOT EXISTS node (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
secret VARCHAR(100) NOT NULL,
server_ip VARCHAR(100) NOT NULL,
port TEXT NOT NULL,
interface_name VARCHAR(200),
version VARCHAR(100),
http INTEGER NOT NULL DEFAULT 0,
tls INTEGER NOT NULL DEFAULT 0,
socks INTEGER NOT NULL DEFAULT 0,
created_time INTEGER NOT NULL,
updated_time INTEGER,
status INTEGER NOT NULL,
tcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
udp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]'
)
`); err != nil {
t.Fatalf("create legacy node table: %v", err)
}
if _, err := legacyDB.Exec(`
CREATE TABLE IF NOT EXISTS tunnel (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
traffic_ratio REAL NOT NULL DEFAULT 1.0,
type INTEGER NOT NULL,
protocol VARCHAR(10) NOT NULL DEFAULT 'tls',
flow INTEGER NOT NULL,
created_time INTEGER NOT NULL,
updated_time INTEGER NOT NULL,
status INTEGER NOT NULL,
in_ip TEXT
)
`); err != nil {
t.Fatalf("create legacy tunnel table: %v", err)
}
now := time.Now().UnixMilli()
if _, err := legacyDB.Exec(`
INSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "legacy-node", "legacy-secret", "10.10.0.1", "10000-10010", "eth0", "v-old", 1, 1, 1, now, now, 1, "[::]", "[::]"); err != nil {
t.Fatalf("seed legacy node row: %v", err)
}
repo, err := sqlite.Open(dbPath)
if err != nil {
t.Fatalf("open migrated sqlite: %v", err)
}
t.Cleanup(func() {
_ = repo.Close()
})
nodes, err := repo.ListNodes()
if err != nil {
t.Fatalf("list nodes after migration: %v", err)
}
if len(nodes) != 1 {
t.Fatalf("expected 1 node after migration, got %d", len(nodes))
}
columns := readTableColumns(t, repo.DB(), "node")
for _, required := range []string{"server_ip_v4", "server_ip_v6", "inx"} {
if !columns[required] {
t.Fatalf("expected node column %q to exist after migration", required)
}
}
tunnelColumns := readTableColumns(t, repo.DB(), "tunnel")
if !tunnelColumns["inx"] {
t.Fatalf("expected tunnel column %q to exist after migration", "inx")
}
}
func readTableColumns(t *testing.T, db *sql.DB, table string) map[string]bool {
t.Helper()
rows, err := db.Query("PRAGMA table_info(" + table + ")")
if err != nil {
t.Fatalf("inspect %s columns: %v", table, err)
}
defer rows.Close()
columns := map[string]bool{}
for rows.Next() {
var cid, notNull, pk int
var name, typ string
var defaultValue sql.NullString
if err := rows.Scan(&cid, &name, &typ, &notNull, &defaultValue, &pk); err != nil {
t.Fatalf("scan %s pragma row: %v", table, err)
}
columns[name] = true
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate %s pragma rows: %v", table, err)
}
return columns
}
+31 -8
View File
@@ -3,6 +3,7 @@ package service
import (
"fmt"
"runtime"
"strconv"
"strings"
"time"
@@ -30,6 +31,7 @@ import (
logger_parser "github.com/go-gost/x/config/parsing/logger"
selector_parser "github.com/go-gost/x/config/parsing/selector"
tls_util "github.com/go-gost/x/internal/util/tls"
xtraffic "github.com/go-gost/x/limiter/traffic"
cache_limiter "github.com/go-gost/x/limiter/traffic/cache"
"github.com/go-gost/x/metadata"
mdutil "github.com/go-gost/x/metadata/util"
@@ -181,6 +183,32 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
)
}
var trafficLimiter listener.Option
if cfg.Limiter != "" {
lim := registry.TrafficLimiterRegistry().Get(cfg.Limiter)
if lim == nil {
// Try to parse as simple number (bandwidth in bytes/sec)
if val, err := strconv.Atoi(cfg.Limiter); err == nil && val > 0 {
lim = xtraffic.NewTrafficLimiter(
xtraffic.LimitsOption(fmt.Sprintf("%s %dB %dB", xtraffic.ServiceLimitKey, val, val)),
)
}
if lim == nil {
lim = xtraffic.NewTrafficLimiter(
xtraffic.LimitsOption(fmt.Sprintf("%s %s %s", xtraffic.ServiceLimitKey, cfg.Limiter, cfg.Limiter)),
)
}
}
trafficLimiter = listener.TrafficLimiterOption(
cache_limiter.NewCachedTrafficLimiter(
lim,
cache_limiter.RefreshIntervalOption(limiterRefreshInterval),
cache_limiter.CleanupIntervalOption(limiterCleanupInterval),
cache_limiter.ScopeOption(limiterScope),
),
)
}
listenOpts := []listener.Option{
listener.AddrOption(cfg.Addr),
listener.RouterOption(xchain.NewRouter(routerOpts...)),
@@ -188,14 +216,6 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
listener.AuthOption(auth_parser.Info(cfg.Listener.Auth)),
listener.TLSConfigOption(tlsConfig),
listener.AdmissionOption(xadmission.AdmissionGroup(admissions...)),
listener.TrafficLimiterOption(
cache_limiter.NewCachedTrafficLimiter(
registry.TrafficLimiterRegistry().Get(cfg.Limiter),
cache_limiter.RefreshIntervalOption(limiterRefreshInterval),
cache_limiter.CleanupIntervalOption(limiterCleanupInterval),
cache_limiter.ScopeOption(limiterScope),
),
),
listener.ConnLimiterOption(registry.ConnLimiterRegistry().Get(cfg.CLimiter)),
listener.ServiceOption(cfg.Name),
listener.ProxyProtocolOption(ppv),
@@ -203,6 +223,9 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
listener.NetnsOption(netnsIn),
listener.LoggerOption(listenerLogger),
}
if trafficLimiter != nil {
listenOpts = append(listenOpts, trafficLimiter)
}
if netnsIn != "" {
runtime.LockOSThread()
+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=
+6
View File
@@ -136,6 +136,9 @@ func (l *trafficLimiter) In(ctx context.Context, key string, opts ...limiter.Opt
return nil
case limiter.ScopeClient:
if lim, ok := l.inLimits.Get(key); ok && lim != nil {
return lim.(traffic.Limiter)
}
return nil
case limiter.ScopeConn:
@@ -215,6 +218,9 @@ func (l *trafficLimiter) Out(ctx context.Context, key string, opts ...limiter.Op
return nil
case limiter.ScopeClient:
if lim, ok := l.outLimits.Get(key); ok && lim != nil {
return lim.(traffic.Limiter)
}
return nil
case limiter.ScopeConn:
+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")
}
}
// 发送响应
+1 -1
View File
@@ -28,7 +28,7 @@ COUNTRY=$(curl -s https://ipinfo.io/country)
maybe_proxy_url() {
local url="$1"
if [ "$COUNTRY" = "CN" ]; then
echo "https://ghfast.top/${url}"
echo "https://gcode.hostcentral.cc/${url}"
else
echo "$url"
fi
+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
+1 -1
View File
@@ -15,7 +15,7 @@ COUNTRY=$(curl -s https://ipinfo.io/country)
maybe_proxy_url() {
local url="$1"
if [ "$COUNTRY" = "CN" ]; then
echo "https://ghfast.top/${url}"
echo "https://gcode.hostcentral.cc/${url}"
else
echo "$url"
fi
-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;
}
}
}

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