mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
Compare commits
93 Commits
2.1.1
...
2.1.3-beta6
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e52c8eace | |||
| 7808d57a79 | |||
| 46bc4ca6e4 | |||
| 28e66ab172 | |||
| f19bccec4c | |||
| e37d6cf666 | |||
| 177c2bc35f | |||
| 76c0978763 | |||
| fd1168d855 | |||
| 92c9590c1a | |||
| 2afb1d275a | |||
| 880cd4cac5 | |||
| a69a0f040b | |||
| cf6294a77d | |||
| 524ee4cd95 | |||
| c049ceaacf | |||
| 3424221176 | |||
| 5a9715eb26 | |||
| 1b79213aed | |||
| c0d71125f4 | |||
| f01c0481cd | |||
| f227ffddc3 | |||
| c4f14f985e | |||
| 3b294c6b9e | |||
| 641aa66afc | |||
| fea1bf52f3 | |||
| a72d84fa76 | |||
| 146821ebba | |||
| 0191f29cf1 | |||
| 149a841a49 | |||
| 229ae9e454 | |||
| ae8a3db3df | |||
| 51660c413e | |||
| 2d2ca389e3 | |||
| 3799729706 | |||
| 8628c35802 | |||
| acea5ea76c | |||
| 8652380da1 | |||
| dd206ced14 | |||
| f720b92f53 | |||
| f879a58bb4 | |||
| b11283d488 | |||
| 70f8dfeac1 | |||
| 37005a1954 | |||
| b55e056316 | |||
| b3b7f5e56d | |||
| d6ff6ea500 | |||
| 9ed875b7ef | |||
| 6387ce1816 | |||
| 219067a27c | |||
| 33678477aa | |||
| e33fbda8f4 | |||
| 62cb75f669 | |||
| f3260637db | |||
| e43c6bf840 | |||
| 025d0a3322 | |||
| 2dcfe15f00 | |||
| ecaae178b6 | |||
| 46a8ceff6a | |||
| 7bfa1251c4 | |||
| cd09305a85 | |||
| b5f8a491b3 | |||
| e4acd855e0 | |||
| 275cfdc65a | |||
| 9d323ce930 | |||
| 1733948a1b | |||
| cedcaebd1f | |||
| b32133f81a | |||
| ff57bca505 | |||
| cdb2914dbf | |||
| b56d0a28e7 | |||
| bdfc704f95 | |||
| 9223892ca5 | |||
| 6f205df37c | |||
| 04266165df | |||
| dbd5773717 | |||
| 6c4d44e7a7 | |||
| 07b8d73956 | |||
| 71a6a60077 | |||
| fe33028934 | |||
| 01da4bd283 | |||
| a0b975b62a | |||
| f4e56d091e | |||
| 0a5335c1ca | |||
| d40e97d73b | |||
| 73bf672e62 | |||
| 297f526a92 | |||
| 2b854d3172 | |||
| c184a75f22 | |||
| 8a5bfa5aa8 | |||
| 421f18d4da | |||
| 5133e6f039 | |||
| 311840b29b |
@@ -112,6 +112,12 @@ jobs:
|
||||
upx --best --lzma gost-amd64
|
||||
upx --best --lzma gost-arm64
|
||||
|
||||
- name: Generate SHA256 checksums
|
||||
working-directory: ./go-gost
|
||||
run: |
|
||||
sha256sum gost-amd64 > gost-amd64.sha256
|
||||
sha256sum gost-arm64 > gost-arm64.sha256
|
||||
|
||||
- name: Upload GOST AMD64 artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -124,6 +130,18 @@ jobs:
|
||||
name: gost-binary-arm64
|
||||
path: ./go-gost/gost-arm64
|
||||
|
||||
- name: Upload GOST AMD64 checksum artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gost-checksum-amd64
|
||||
path: ./go-gost/gost-amd64.sha256
|
||||
|
||||
- name: Upload GOST ARM64 checksum artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gost-checksum-arm64
|
||||
path: ./go-gost/gost-arm64.sha256
|
||||
|
||||
build-vite:
|
||||
name: Build & Push Vite Frontend
|
||||
needs: check-version
|
||||
@@ -238,7 +256,20 @@ jobs:
|
||||
name: gost-binary-arm64
|
||||
path: ./artifacts/arm64
|
||||
|
||||
- name: Download GOST AMD64 checksum
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: gost-checksum-amd64
|
||||
path: ./artifacts/
|
||||
|
||||
- name: Download GOST ARM64 checksum
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: gost-checksum-arm64
|
||||
path: ./artifacts/
|
||||
|
||||
- name: Prepare release files
|
||||
|
||||
run: |
|
||||
VERSION="${{ needs.check-version.outputs.version }}"
|
||||
OWNER="${{ needs.check-version.outputs.image_owner }}"
|
||||
@@ -331,6 +362,10 @@ jobs:
|
||||
gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber
|
||||
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
|
||||
|
||||
echo "📤 上传 GOST 校验文件..."
|
||||
gh release upload "${VERSION}" ./artifacts/gost-amd64.sha256 --clobber
|
||||
gh release upload "${VERSION}" ./artifacts/gost-arm64.sha256 --clobber
|
||||
|
||||
echo "📤 上传安装脚本..."
|
||||
gh release upload "${VERSION}" ./artifacts/install.sh --clobber
|
||||
gh release upload "${VERSION}" ./artifacts/panel_install.sh --clobber
|
||||
@@ -363,6 +398,18 @@ jobs:
|
||||
name: gost-binary-arm64
|
||||
path: ./artifacts/arm64
|
||||
|
||||
- name: Download GOST AMD64 checksum
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: gost-checksum-amd64
|
||||
path: ./artifacts/
|
||||
|
||||
- name: Download GOST ARM64 checksum
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: gost-checksum-arm64
|
||||
path: ./artifacts/
|
||||
|
||||
- name: Rename binaries
|
||||
run: |
|
||||
mv ./artifacts/amd64/gost-amd64 ./artifacts/gost-amd64
|
||||
@@ -379,4 +426,9 @@ jobs:
|
||||
gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber
|
||||
gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber
|
||||
|
||||
echo "📤 上传 GOST 校验文件..."
|
||||
gh release upload "${VERSION}" ./artifacts/gost-amd64.sha256 --clobber
|
||||
gh release upload "${VERSION}" ./artifacts/gost-arm64.sha256 --clobber
|
||||
|
||||
echo "✅ GOST 二进制文件更新完成"
|
||||
|
||||
|
||||
@@ -177,6 +177,7 @@ build/
|
||||
*.dylib
|
||||
your_app.exe
|
||||
go-backend/paneld
|
||||
go-gost/gost
|
||||
|
||||
# Go 测试二进制文件
|
||||
*.test
|
||||
@@ -262,3 +263,6 @@ sql/
|
||||
!go-backend/internal/store/sqlite/sql/
|
||||
!go-backend/internal/store/sqlite/sql/schema.sql
|
||||
!go-backend/internal/store/sqlite/sql/data.sql
|
||||
!go-backend/internal/store/postgres/sql/
|
||||
!go-backend/internal/store/postgres/sql/schema.sql
|
||||
!go-backend/internal/store/postgres/sql/data.sql
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Mon Feb 02 2026
|
||||
**Commit:** 7ca01ab
|
||||
**Branch:** beta
|
||||
**Generated:** Fri Feb 13 2026
|
||||
**Commit:** 3799729
|
||||
**Branch:** (detached)
|
||||
|
||||
## OVERVIEW
|
||||
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.
|
||||
@@ -47,7 +47,11 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
|
||||
- `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
|
||||
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
- Do not edit generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
|
||||
- **DO NOT EDIT** generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
|
||||
- **DO NOT ADD** `Bearer` prefix to Authorization header - expects raw JWT token.
|
||||
- **DO NOT MODIFY** `install.sh` or `panel_install.sh` locally - CI overwrites these on release.
|
||||
- **DO NOT USE** ORM in backend - uses raw SQL with `database/sql`.
|
||||
- **DO NOT ADD** frontend tests - project has no test infrastructure (Vitest/Jest not configured).
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
@@ -68,3 +72,6 @@ docker compose -f docker-compose-v6.yml up -d
|
||||
## NOTES
|
||||
- LSP servers are not installed in this environment (gopls/jdtls/typescript-language-server); rely on grep-based navigation.
|
||||
- `vite-frontend/vite.config.ts` sets `minify: false` and disables treeshake; expect larger bundles.
|
||||
- Install scripts (`install.sh`, `panel_install.sh`) self-delete after execution - common pattern in one-liner installs.
|
||||
- CI uses UPX compression on Go binaries before release.
|
||||
- Backend has contract tests in `go-backend/tests/contract/` - frontend has no test infrastructure.
|
||||
|
||||
@@ -42,6 +42,79 @@ curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/panel_in
|
||||
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
||||
```
|
||||
|
||||
#### PostgreSQL 部署(Docker Compose)
|
||||
|
||||
安装脚本会根据环境自动下载对应的 Compose 配置并保存为 `docker-compose.yml`。默认仍使用 SQLite,切换到 PostgreSQL 只需要配置环境变量。
|
||||
|
||||
1) 在 `docker-compose` 同目录创建或修改 `.env`:
|
||||
|
||||
```bash
|
||||
JWT_SECRET=replace_with_your_secret
|
||||
BACKEND_PORT=6365
|
||||
FRONTEND_PORT=6366
|
||||
|
||||
DB_TYPE=postgres
|
||||
DATABASE_URL=postgres://flux_panel:replace_with_strong_password@postgres:5432/flux_panel?sslmode=disable
|
||||
|
||||
POSTGRES_DB=flux_panel
|
||||
POSTGRES_USER=flux_panel
|
||||
POSTGRES_PASSWORD=replace_with_strong_password
|
||||
```
|
||||
|
||||
> 📌 使用安装脚本部署时,`POSTGRES_PASSWORD` 会自动随机生成并写入 `.env`。
|
||||
|
||||
2) 启动服务:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
3) 如果你想继续使用 SQLite,保留 `DB_TYPE=sqlite`(或不设置 `DB_TYPE`)即可。
|
||||
|
||||
#### 从 SQLite 迁移到 PostgreSQL
|
||||
|
||||
如果你是通过 `panel_install.sh` 安装面板,推荐直接使用脚本菜单一键迁移:
|
||||
|
||||
```bash
|
||||
./panel_install.sh
|
||||
# 选择 4. 迁移到 PostgreSQL
|
||||
```
|
||||
|
||||
脚本会自动完成 SQLite 备份、PostgreSQL 启动、`pgloader` 导入、`.env` 中 `DB_TYPE`/`DATABASE_URL` 更新,并重启服务。
|
||||
|
||||
如果你希望手动迁移,以下示例基于 Docker Volume `sqlite_data`(项目默认配置)与 `pgloader`:
|
||||
|
||||
1) 停止服务并备份 SQLite 数据:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker run --rm -v sqlite_data:/data -v "$(pwd)":/backup alpine sh -c "cp /data/gost.db /backup/gost.db.bak"
|
||||
```
|
||||
|
||||
2) 仅启动 PostgreSQL:
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres
|
||||
```
|
||||
|
||||
3) 使用 `pgloader` 迁移:
|
||||
|
||||
```bash
|
||||
source .env
|
||||
docker run --rm --network gost-network -v sqlite_data:/sqlite dimitri/pgloader:latest pgloader /sqlite/gost.db "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}"
|
||||
```
|
||||
|
||||
4) 切换后端到 PostgreSQL 并启动:
|
||||
|
||||
```bash
|
||||
source .env
|
||||
export DB_TYPE=postgres
|
||||
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable"
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
5) 迁移完成后,登录面板检查用户、隧道、转发、节点数据是否正确。
|
||||
|
||||
#### 默认管理员账号
|
||||
|
||||
- **账号**: admin_user
|
||||
@@ -74,7 +147,7 @@ The following major changes and additions have been made in this fork (FLVX):
|
||||
- **Removed**: `ios-app/` - Source code for the iOS client.
|
||||
|
||||
### 5. Infrastructure & Scripts
|
||||
- **Modified**: `docker-compose-v4.yml`, `docker-compose-v6.yml` (Updated for Go backend).
|
||||
- **Modified**: `docker-compose.yml` (installer output name, auto-selects IPv4/IPv6 template, updated for Go backend).
|
||||
- **Modified**: `install.sh`, `panel_install.sh` (Updated installation logic).
|
||||
- **Added**: `AGENTS.md` (Project documentation).
|
||||
|
||||
|
||||
+16
@@ -22,3 +22,19 @@
|
||||
|
||||
### Q5: IPv6 无法使用?
|
||||
**A**: 面板安装脚本会自动尝试配置 Docker 的 IPv6。如果失败,请手动检查 `/etc/docker/daemon.json` 配置,确保 `ipv6: true` 且分配了正确的 `fixed-cidr-v6` 子网。
|
||||
|
||||
### Q6: 如何切换到 PostgreSQL?
|
||||
**A**: 在 `.env` 文件中设置 `DB_TYPE=postgres`,并让 `DATABASE_URL` 与 `POSTGRES_*` 保持一致,然后执行 `docker compose up -d` 重启服务即可。使用安装脚本部署时,`POSTGRES_PASSWORD` 会自动随机生成并写入 `.env`。详见 [PostgreSQL 数据库指南](./postgresql.md)。
|
||||
|
||||
### Q7: 从 SQLite 迁移到 PostgreSQL 后数据丢失?
|
||||
**A**:
|
||||
1. 确认迁移前已备份 SQLite 文件(`gost.db.bak`)。
|
||||
2. 确认 `pgloader` 命令执行成功,检查其输出是否有报错。
|
||||
3. 确认 `.env` 中 `DATABASE_URL` 的密码与 `POSTGRES_PASSWORD` 一致。
|
||||
4. 详细迁移步骤参考 [PostgreSQL 数据库指南 - 从 SQLite 迁移](./postgresql.md)。
|
||||
|
||||
### Q8: PostgreSQL 容器启动失败?
|
||||
**A**:
|
||||
1. 检查 `POSTGRES_PASSWORD` 是否已设置(不能为空)。
|
||||
2. 查看容器日志:`docker logs flux-panel-postgres`。
|
||||
3. 如果是首次启动后修改了密码,需要删除旧的数据卷重新初始化:`docker volume rm postgres_data`。
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
- [安装部署](./install.md)
|
||||
- [使用指南](./usage.md)
|
||||
- [PostgreSQL 数据库指南](./postgresql.md)
|
||||
- [常见问题](./faq.md)
|
||||
|
||||
## 免责声明
|
||||
|
||||
@@ -41,6 +41,8 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_instal
|
||||
1. 安装面板
|
||||
2. 更新面板
|
||||
3. 卸载面板
|
||||
4. 迁移到 PostgreSQL
|
||||
5. 退出
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
# PostgreSQL 数据库指南
|
||||
|
||||
FLVX 默认使用 SQLite 作为数据库,同时也内置了对 PostgreSQL 的完整支持。本文档介绍如何使用 PostgreSQL 部署面板、从 SQLite 迁移以及日常维护。
|
||||
|
||||
## 一、SQLite 与 PostgreSQL 对比
|
||||
|
||||
| 特性 | SQLite | PostgreSQL |
|
||||
|------|--------|------------|
|
||||
| **部署复杂度** | 零配置,开箱即用 | 需要额外的数据库服务 |
|
||||
| **并发性能** | 适合小规模单机使用 | 支持高并发读写 |
|
||||
| **数据规模** | 适合中小规模数据 | 适合大规模数据 |
|
||||
| **备份与恢复** | 直接复制文件 | 支持逻辑备份与物理备份 |
|
||||
| **高可用** | 不支持 | 支持主从复制、流复制 |
|
||||
|
||||
**建议**:如果你只是个人使用或小团队使用,SQLite 完全够用。如果节点多,推荐使用 PostgreSQL。
|
||||
|
||||
---
|
||||
|
||||
## 二、环境变量说明
|
||||
|
||||
以下环境变量用于配置数据库连接,在 `.env` 文件或 Docker Compose `environment` 中设置。
|
||||
|
||||
### 后端服务 (backend) 使用
|
||||
|
||||
| 变量名 | 说明 | 默认值 | 示例 |
|
||||
|--------|------|--------|------|
|
||||
| `DB_TYPE` | 数据库类型,`sqlite` 或 `postgres` | `sqlite` | `postgres` |
|
||||
| `DATABASE_URL` | PostgreSQL 连接字符串(仅 `DB_TYPE=postgres` 时必填) | 空 | `postgres://flux_panel:密码@postgres:5432/flux_panel?sslmode=disable` |
|
||||
| `DB_PATH` | SQLite 数据库文件路径(仅 `DB_TYPE=sqlite` 时使用) | `/app/data/gost.db` | `/app/data/gost.db` |
|
||||
|
||||
### PostgreSQL 容器使用
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `POSTGRES_DB` | 数据库名称 | `flux_panel` |
|
||||
| `POSTGRES_USER` | 数据库用户名 | `flux_panel` |
|
||||
| `POSTGRES_PASSWORD` | 数据库密码 | `flux_panel_change_me` |
|
||||
|
||||
> ⚠️ **安全提示**:生产环境中请务必修改 `POSTGRES_PASSWORD` 为强密码,不要使用默认值!
|
||||
|
||||
---
|
||||
|
||||
## 三、全新部署(Docker Compose + PostgreSQL)
|
||||
|
||||
安装脚本会根据环境自动下载对应的 Compose 配置并保存为 `docker-compose.yml`。默认使用 SQLite,只需配置环境变量即可切换到 PostgreSQL。
|
||||
|
||||
### 1. 创建 `.env` 文件
|
||||
|
||||
在 `docker-compose` 同目录创建 `.env` 文件:
|
||||
|
||||
```bash
|
||||
# 基础配置
|
||||
JWT_SECRET=替换为你的密钥
|
||||
BACKEND_PORT=6365
|
||||
FRONTEND_PORT=6366
|
||||
|
||||
# PostgreSQL 配置
|
||||
DB_TYPE=postgres
|
||||
DATABASE_URL=postgres://flux_panel:替换为强密码@postgres:5432/flux_panel?sslmode=disable
|
||||
|
||||
POSTGRES_DB=flux_panel
|
||||
POSTGRES_USER=flux_panel
|
||||
POSTGRES_PASSWORD=替换为强密码
|
||||
```
|
||||
|
||||
> ⚠️ `DATABASE_URL` 中的密码必须与 `POSTGRES_PASSWORD` 保持一致。
|
||||
|
||||
### 2. 启动服务
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 3. 验证
|
||||
|
||||
```bash
|
||||
# 检查所有容器是否正常运行
|
||||
docker ps
|
||||
|
||||
# 查看后端日志,确认连接 PostgreSQL 成功
|
||||
docker logs flux-panel-backend
|
||||
|
||||
# 查看 PostgreSQL 容器状态
|
||||
docker logs flux-panel-postgres
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、从 SQLite 迁移到 PostgreSQL
|
||||
|
||||
如果你已经在使用 SQLite 并且希望迁移到 PostgreSQL,请按照以下步骤操作。
|
||||
|
||||
### 快速方式:脚本菜单一键迁移(推荐)
|
||||
|
||||
如果你是通过安装脚本部署面板,可直接执行:
|
||||
|
||||
```bash
|
||||
./panel_install.sh
|
||||
# 选择 4. 迁移到 PostgreSQL
|
||||
```
|
||||
|
||||
脚本会自动完成以下操作:
|
||||
- 备份 SQLite 数据到当前目录(`gost.db.bak`)
|
||||
- 启动并等待 PostgreSQL 健康检查通过
|
||||
- 使用 `pgloader` 导入 SQLite 数据
|
||||
- 自动写入 `.env` 的 `DB_TYPE=postgres` 与 `DATABASE_URL`
|
||||
- 重启服务并等待后端健康检查
|
||||
|
||||
### 手动方式:按步骤迁移
|
||||
|
||||
### 1. 备份 SQLite 数据
|
||||
|
||||
```bash
|
||||
# 停止所有服务
|
||||
docker compose down
|
||||
|
||||
# 备份 SQLite 数据文件到当前目录
|
||||
docker run --rm -v sqlite_data:/data -v "$(pwd)":/backup alpine sh -c "cp /data/gost.db /backup/gost.db.bak"
|
||||
```
|
||||
|
||||
### 2. 配置 PostgreSQL 环境变量
|
||||
|
||||
在 `.env` 文件中添加 PostgreSQL 配置(参考上方"环境变量说明")。
|
||||
|
||||
### 3. 仅启动 PostgreSQL
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres
|
||||
```
|
||||
|
||||
等待 PostgreSQL 完全就绪:
|
||||
|
||||
```bash
|
||||
# 检查 PostgreSQL 健康状态
|
||||
docker inspect --format='{{.State.Health.Status}}' flux-panel-postgres
|
||||
# 输出 "healthy" 表示就绪
|
||||
```
|
||||
|
||||
### 4. 使用 pgloader 迁移数据
|
||||
|
||||
```bash
|
||||
source .env
|
||||
docker run --rm \
|
||||
--network gost-network \
|
||||
-v sqlite_data:/sqlite \
|
||||
dimitri/pgloader:latest \
|
||||
pgloader /sqlite/gost.db "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}"
|
||||
```
|
||||
|
||||
> 📌 建议直接从 `.env` 读取 `POSTGRES_USER`、`POSTGRES_PASSWORD`、`POSTGRES_DB`,避免手填密码导致认证失败。
|
||||
|
||||
### 5. 启动全部服务
|
||||
|
||||
```bash
|
||||
source .env
|
||||
export DB_TYPE=postgres
|
||||
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable"
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 6. 验证迁移
|
||||
|
||||
登录面板后,检查以下数据是否完整:
|
||||
- 用户列表和权限
|
||||
- 节点信息和状态
|
||||
- 隧道配置
|
||||
- 转发规则
|
||||
- 流量统计数据
|
||||
|
||||
---
|
||||
|
||||
## 五、独立 PostgreSQL(非 Docker)
|
||||
|
||||
如果你不想使用 Docker Compose 中自带的 PostgreSQL 容器,也可以连接外部的 PostgreSQL 实例。
|
||||
|
||||
### 1. 准备 PostgreSQL
|
||||
|
||||
在目标 PostgreSQL 服务器上创建数据库和用户:
|
||||
|
||||
```sql
|
||||
CREATE USER flux_panel WITH PASSWORD '你的强密码';
|
||||
CREATE DATABASE flux_panel OWNER flux_panel;
|
||||
```
|
||||
|
||||
### 2. 配置连接
|
||||
|
||||
修改 `.env` 文件,将 `DATABASE_URL` 指向外部 PostgreSQL:
|
||||
|
||||
```bash
|
||||
DB_TYPE=postgres
|
||||
DATABASE_URL=postgres://flux_panel:你的强密码@数据库地址:5432/flux_panel?sslmode=disable
|
||||
```
|
||||
|
||||
> 📌 如果 PostgreSQL 在远程服务器且启用了 SSL,请将 `sslmode=disable` 改为 `sslmode=require` 或 `sslmode=verify-full`。
|
||||
|
||||
### 3. 停用内置 PostgreSQL 容器(可选)
|
||||
|
||||
如果使用外部 PostgreSQL,可以在启动时不启动内置的 postgres 服务:
|
||||
|
||||
```bash
|
||||
docker compose up -d backend frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、数据备份与恢复
|
||||
|
||||
### 逻辑备份(pg_dump)
|
||||
|
||||
```bash
|
||||
# 备份(在 Docker 环境下)
|
||||
docker exec flux-panel-postgres pg_dump -U flux_panel flux_panel > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||
|
||||
# 恢复
|
||||
docker exec -i flux-panel-postgres psql -U flux_panel flux_panel < backup_20260101_120000.sql
|
||||
```
|
||||
|
||||
### 定时备份(cron)
|
||||
|
||||
创建备份脚本 `/opt/flvx/backup.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
BACKUP_DIR="/opt/flvx/backups"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
docker exec flux-panel-postgres pg_dump -U flux_panel flux_panel | gzip > "$BACKUP_DIR/flvx_$(date +%Y%m%d_%H%M%S).sql.gz"
|
||||
# 清理 30 天前的备份
|
||||
find "$BACKUP_DIR" -name "flvx_*.sql.gz" -mtime +30 -delete
|
||||
```
|
||||
|
||||
添加 cron 任务(每天凌晨 3 点执行):
|
||||
|
||||
```bash
|
||||
chmod +x /opt/flvx/backup.sh
|
||||
echo "0 3 * * * /opt/flvx/backup.sh" | crontab -
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、常见问题
|
||||
|
||||
### Q: 切换到 PostgreSQL 后启动失败,提示连接被拒绝?
|
||||
|
||||
**A**:
|
||||
1. 确认 PostgreSQL 容器已启动并处于 `healthy` 状态:`docker ps`。
|
||||
2. 确认 `DATABASE_URL` 中的主机名、端口、用户名、密码正确。
|
||||
3. 在 Docker Compose 环境下,主机名应为 `postgres`(服务名),而非 `localhost`。
|
||||
|
||||
### Q: pgloader 迁移时报错?
|
||||
|
||||
**A**:
|
||||
1. 确认 PostgreSQL 容器已完全就绪(状态为 `healthy`)。
|
||||
2. 确认 `--network gost-network` 参数正确,使 pgloader 容器与 PostgreSQL 在同一网络中。
|
||||
3. 如果数据库已有表结构,pgloader 可能会报冲突。可以先清空目标数据库后重试。
|
||||
|
||||
### Q: 如何查看当前使用的数据库类型?
|
||||
|
||||
**A**: 查看后端容器的 `DB_TYPE` 环境变量:
|
||||
|
||||
```bash
|
||||
docker exec flux-panel-backend printenv DB_TYPE
|
||||
```
|
||||
|
||||
### Q: 可以同时使用 SQLite 和 PostgreSQL 吗?
|
||||
|
||||
**A**: 不可以。`DB_TYPE` 只能设置为 `sqlite` 或 `postgres` 之一。后端启动时根据此配置连接对应的数据库。
|
||||
|
||||
### Q: PostgreSQL 数据存储在哪里?
|
||||
|
||||
**A**: 在 Docker Compose 部署中,PostgreSQL 数据存储在名为 `postgres_data` 的 Docker Volume 中。可以通过以下命令查看:
|
||||
|
||||
```bash
|
||||
docker volume inspect postgres_data
|
||||
```
|
||||
+31
-5
@@ -7,16 +7,17 @@ services:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
environment:
|
||||
DB_TYPE: ${DB_TYPE:-sqlite}
|
||||
DB_PATH: /app/data/gost.db
|
||||
DATABASE_URL: ${DATABASE_URL:-}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
LOG_DIR: /app/logs
|
||||
SERVER_ADDR: :6365
|
||||
TZ: Asia/Shanghai
|
||||
ports:
|
||||
- "${BACKEND_PORT}:6365"
|
||||
volumes:
|
||||
- backend_logs:/app/logs
|
||||
- sqlite_data:/app/data
|
||||
networks:
|
||||
- gost-network
|
||||
@@ -29,6 +30,30 @@ services:
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: flux-panel-postgres
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-flux_panel}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-flux_panel}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flux_panel_change_me}
|
||||
TZ: Asia/Shanghai
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- gost-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-flux_panel} -d ${POSTGRES_DB:-flux_panel}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
|
||||
frontend:
|
||||
image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}
|
||||
container_name: vite-frontend
|
||||
@@ -37,6 +62,7 @@ services:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
ports:
|
||||
- "${FRONTEND_PORT}:80"
|
||||
depends_on:
|
||||
@@ -50,8 +76,8 @@ volumes:
|
||||
sqlite_data:
|
||||
name: sqlite_data
|
||||
driver: local
|
||||
backend_logs:
|
||||
name: backend_logs
|
||||
postgres_data:
|
||||
name: postgres_data
|
||||
driver: local
|
||||
|
||||
|
||||
@@ -61,4 +87,4 @@ networks:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
- subnet: 172.80.0.0/16
|
||||
|
||||
+31
-5
@@ -7,16 +7,17 @@ services:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
environment:
|
||||
DB_TYPE: ${DB_TYPE:-sqlite}
|
||||
DB_PATH: /app/data/gost.db
|
||||
DATABASE_URL: ${DATABASE_URL:-}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
LOG_DIR: /app/logs
|
||||
SERVER_ADDR: :6365
|
||||
TZ: Asia/Shanghai
|
||||
ports:
|
||||
- "${BACKEND_PORT}:6365"
|
||||
volumes:
|
||||
- backend_logs:/app/logs
|
||||
- sqlite_data:/app/data
|
||||
networks:
|
||||
- gost-network
|
||||
@@ -29,6 +30,30 @@ services:
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: flux-panel-postgres
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-flux_panel}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-flux_panel}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flux_panel_change_me}
|
||||
TZ: Asia/Shanghai
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- gost-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-flux_panel} -d ${POSTGRES_DB:-flux_panel}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
|
||||
frontend:
|
||||
image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}
|
||||
container_name: vite-frontend
|
||||
@@ -37,6 +62,7 @@ services:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
ports:
|
||||
- "${FRONTEND_PORT}:80"
|
||||
depends_on:
|
||||
@@ -50,8 +76,8 @@ volumes:
|
||||
sqlite_data:
|
||||
name: sqlite_data
|
||||
driver: local
|
||||
backend_logs:
|
||||
name: backend_logs
|
||||
postgres_data:
|
||||
name: postgres_data
|
||||
driver: local
|
||||
|
||||
|
||||
@@ -62,5 +88,5 @@ networks:
|
||||
enable_ipv6: true
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
- subnet: 172.80.0.0/16
|
||||
- subnet: fd00:dead:beef::/48
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
FROM golang:1.23-bookworm AS builder
|
||||
FROM golang:1.24-bookworm AS builder
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod ./
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
+8
-1
@@ -1,22 +1,29 @@
|
||||
module go-backend
|
||||
|
||||
go 1.23.0
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.4
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.7.3
|
||||
modernc.org/sqlite v1.37.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
modernc.org/libc v1.65.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
+32
-6
@@ -1,3 +1,6 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
@@ -6,23 +9,46 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.3 h1:PO1wNKj/bTAwxSJnO1Z4Ai8j4magtqg2SLNjEDzcXQo=
|
||||
github.com/jackc/pgx/v5 v5.7.3/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
|
||||
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/config"
|
||||
@@ -20,9 +21,24 @@ type App struct {
|
||||
}
|
||||
|
||||
func New(cfg config.Config) (*App, error) {
|
||||
repo, err := sqlite.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
var (
|
||||
repo *sqlite.Repository
|
||||
err error
|
||||
)
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(cfg.DBType)) {
|
||||
case "", "sqlite":
|
||||
repo, err = sqlite.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
case "postgres", "postgresql":
|
||||
repo, err = sqlite.OpenPostgres(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open postgres: %w", err)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported DB_TYPE %q", cfg.DBType)
|
||||
}
|
||||
|
||||
h := handler.New(repo, cfg.JWTSecret)
|
||||
|
||||
@@ -3,18 +3,22 @@ package config
|
||||
import "os"
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
DBPath string
|
||||
JWTSecret string
|
||||
LogDir string
|
||||
Addr string
|
||||
DBType string
|
||||
DBPath string
|
||||
DatabaseURL string
|
||||
JWTSecret string
|
||||
LogDir string
|
||||
}
|
||||
|
||||
func FromEnv() Config {
|
||||
cfg := Config{
|
||||
Addr: getEnv("SERVER_ADDR", ":6365"),
|
||||
DBPath: getEnv("DB_PATH", "/app/data/gost.db"),
|
||||
JWTSecret: getEnv("JWT_SECRET", ""),
|
||||
LogDir: getEnv("LOG_DIR", "/app/logs"),
|
||||
Addr: getEnv("SERVER_ADDR", ":6365"),
|
||||
DBType: getEnv("DB_TYPE", "sqlite"),
|
||||
DBPath: getEnv("DB_PATH", "/app/data/gost.db"),
|
||||
DatabaseURL: getEnv("DATABASE_URL", ""),
|
||||
JWTSecret: getEnv("JWT_SECRET", ""),
|
||||
LogDir: getEnv("LOG_DIR", "/app/logs"),
|
||||
}
|
||||
|
||||
return cfg
|
||||
|
||||
@@ -77,6 +77,18 @@ type RuntimeDiagnoseRequest struct {
|
||||
Timeout int `json:"timeout"`
|
||||
}
|
||||
|
||||
type RuntimeNodeCommandRequest struct {
|
||||
CommandType string `json:"commandType"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
type RuntimeNodeCommandResponse struct {
|
||||
Type string `json:"type"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func NewFederationClient() *FederationClient {
|
||||
return &FederationClient{
|
||||
client: &http.Client{
|
||||
@@ -333,3 +345,42 @@ func (c *FederationClient) Diagnose(url, token, localDomain string, reqData Runt
|
||||
|
||||
return res.Data, nil
|
||||
}
|
||||
|
||||
func (c *FederationClient) Command(url, token, localDomain string, reqData RuntimeNodeCommandRequest) (*RuntimeNodeCommandResponse, error) {
|
||||
url = strings.TrimSuffix(url, "/")
|
||||
bodyBytes, _ := json.Marshal(reqData)
|
||||
req, err := http.NewRequest("POST", url+"/api/v1/federation/runtime/command", strings.NewReader(string(bodyBytes)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if localDomain != "" {
|
||||
req.Header.Set("X-Panel-Domain", localDomain)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var res struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data RuntimeNodeCommandResponse `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Code != 0 {
|
||||
return nil, fmt.Errorf("remote api error: %s", res.Msg)
|
||||
}
|
||||
|
||||
return &res.Data, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# BACKEND HTTP HANDLER KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Feb 13 2026
|
||||
|
||||
## OVERVIEW
|
||||
HTTP request handlers for FLVX Admin API. Core business logic layer.
|
||||
**Stack:** Go 1.23, net/http, raw SQL (no ORM).
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
handler/
|
||||
├── handler.go # Main Handler struct, login/captcha, job scheduling
|
||||
├── control_plane.go # Node control plane API (add/delete/list)
|
||||
├── federation.go # Federation/cluster sync API
|
||||
├── flow_policy.go # Traffic policy API
|
||||
├── jobs.go # Background job management (sync, cleanup)
|
||||
├── mutations.go # CRUD for users, tunnels, forwards (largest: 100k+ LOC)
|
||||
└── upgrade.go # System upgrade API
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| **User/Tunnel CRUD** | `mutations.go` | Largest file; all create/update/delete ops |
|
||||
| **Login/Captcha** | `handler.go` | Login flow, captcha verification |
|
||||
| **Federation Sync** | `federation.go` | Panel-to-panel sync |
|
||||
| **Traffic Policies** | `flow_policy.go` | Flow limiting, quota management |
|
||||
| **Background Jobs** | `jobs.go` | Scheduled sync/cleanup tasks |
|
||||
|
||||
## CONVENTIONS
|
||||
- Inherits from parent: raw SQL, no ORM, JWT in Authorization header.
|
||||
- Large files expected (`mutations.go` >100k LOC).
|
||||
- Uses `sqlite.Repository` for DB access via `repo.XXX()` methods.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- Do NOT add ORM here - uses raw SQL throughout.
|
||||
- Do NOT change handler signatures without updating router.go.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-backend
|
||||
go test ./internal/http/handler/...
|
||||
```
|
||||
@@ -114,7 +114,7 @@ func (h *Handler) ensureTunnelPermission(userID int64, roleID int, tunnelID int6
|
||||
|
||||
func (h *Handler) getForwardRecord(forwardID int64) (*forwardRecord, error) {
|
||||
row := h.repo.DB().QueryRow(`
|
||||
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, strategy, status
|
||||
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, COALESCE(strategy, 'fifo'), status
|
||||
FROM forward WHERE id = ? LIMIT 1
|
||||
`, forwardID)
|
||||
var fr forwardRecord
|
||||
@@ -152,7 +152,7 @@ func (h *Handler) getTunnelRecord(tunnelID int64) (*tunnelRecord, error) {
|
||||
|
||||
func (h *Handler) listForwardsByTunnel(tunnelID int64) ([]forwardRecord, error) {
|
||||
rows, err := h.repo.DB().Query(`
|
||||
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, strategy, status
|
||||
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, COALESCE(strategy, 'fifo'), status
|
||||
FROM forward
|
||||
WHERE tunnel_id = ?
|
||||
ORDER BY id ASC
|
||||
@@ -200,6 +200,26 @@ func (h *Handler) listForwardPorts(forwardID int64) ([]forwardPortRecord, error)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (h *Handler) isTunnelSelectedTLSProtocol(tunnelID int64) (bool, error) {
|
||||
row := h.repo.DB().QueryRow(`
|
||||
SELECT protocol
|
||||
FROM chain_tunnel
|
||||
WHERE tunnel_id = ? AND chain_type = '3'
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
`, tunnelID)
|
||||
|
||||
var protocol sql.NullString
|
||||
if err := row.Scan(&protocol); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
return isTLSTunnelProtocol(protocol.String), nil
|
||||
}
|
||||
|
||||
func (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {
|
||||
row := h.repo.DB().QueryRow(`
|
||||
SELECT id, name, server_ip, server_ip_v4, server_ip_v6, status, port, tcp_listen_addr, udp_listen_addr, interface_name, is_remote, remote_url, remote_token, remote_config
|
||||
@@ -346,6 +366,10 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
|
||||
return err
|
||||
}
|
||||
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, userTunnelID)
|
||||
tunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, fp := range ports {
|
||||
if limiterID != nil && speed != nil {
|
||||
@@ -356,7 +380,7 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiterID)
|
||||
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiterID, tunnelTLSProtocol)
|
||||
_, err = h.sendNodeCommand(node.ID, method, services, true, false)
|
||||
if err != nil && allowFallbackAdd && method == "UpdateService" {
|
||||
_, err = h.sendNodeCommand(node.ID, "AddService", services, true, false)
|
||||
@@ -457,7 +481,17 @@ func (h *Handler) applyNodeProtocolChange(nodeID int64, httpVal, tlsVal, socksVa
|
||||
}
|
||||
|
||||
func (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interface{}, tolerateExists bool, tolerateNotFound bool) (ws.CommandResult, error) {
|
||||
result, err := h.wsServer.SendCommand(nodeID, commandType, data, 12*time.Second)
|
||||
var (
|
||||
result ws.CommandResult
|
||||
err error
|
||||
)
|
||||
|
||||
node, nodeErr := h.getNodeRecord(nodeID)
|
||||
if nodeErr == nil && node != nil && node.IsRemote == 1 {
|
||||
result, err = h.sendRemoteNodeCommand(node, commandType, data)
|
||||
} else {
|
||||
result, err = h.wsServer.SendCommand(nodeID, commandType, data, 12*time.Second)
|
||||
}
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
@@ -475,6 +509,44 @@ func (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interfa
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (h *Handler) sendRemoteNodeCommand(node *nodeRecord, commandType string, data interface{}) (ws.CommandResult, error) {
|
||||
if node == nil {
|
||||
return ws.CommandResult{}, errors.New("节点不存在")
|
||||
}
|
||||
remoteURL := strings.TrimSpace(node.RemoteURL)
|
||||
remoteToken := strings.TrimSpace(node.RemoteToken)
|
||||
if remoteURL == "" || remoteToken == "" {
|
||||
return ws.CommandResult{}, errors.New("远程节点缺少共享配置")
|
||||
}
|
||||
|
||||
fc := client.NewFederationClient()
|
||||
res, err := fc.Command(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeNodeCommandRequest{
|
||||
CommandType: commandType,
|
||||
Data: data,
|
||||
})
|
||||
if err != nil {
|
||||
return ws.CommandResult{}, err
|
||||
}
|
||||
if res == nil {
|
||||
return ws.CommandResult{}, errors.New("远程节点未返回命令结果")
|
||||
}
|
||||
|
||||
result := ws.CommandResult{
|
||||
Type: res.Type,
|
||||
Success: res.Success,
|
||||
Message: res.Message,
|
||||
Data: res.Data,
|
||||
}
|
||||
if !result.Success {
|
||||
msg := strings.TrimSpace(result.Message)
|
||||
if msg == "" {
|
||||
msg = "命令执行失败"
|
||||
}
|
||||
return result, errors.New(msg)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]interface{}, error) {
|
||||
if forward == nil {
|
||||
return nil, errForwardNotFound
|
||||
@@ -889,11 +961,11 @@ 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, ct.protocol, ct.strategy
|
||||
SELECT CAST(ct.chain_type AS INTEGER), 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 = ?
|
||||
ORDER BY ct.chain_type ASC, COALESCE(ct.inx, 0) ASC, ct.id ASC
|
||||
ORDER BY CAST(ct.chain_type AS INTEGER) ASC, COALESCE(ct.inx, 0) ASC, ct.id ASC
|
||||
`, tunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1047,7 +1119,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, limiterID *int64) []map[string]interface{} {
|
||||
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {
|
||||
protocols := []string{"tcp", "udp"}
|
||||
services := make([]map[string]interface{}, 0, 2)
|
||||
targets := splitRemoteTargets(forward.RemoteAddr)
|
||||
@@ -1080,7 +1152,11 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
|
||||
},
|
||||
}
|
||||
if protocol == "udp" {
|
||||
service["listener"].(map[string]interface{})["metadata"] = map[string]interface{}{"keepAlive": true}
|
||||
listenerMetadata := map[string]interface{}{"keepAlive": true}
|
||||
if tunnelTLSProtocol {
|
||||
listenerMetadata["ttl"] = "10s"
|
||||
}
|
||||
service["listener"].(map[string]interface{})["metadata"] = listenerMetadata
|
||||
}
|
||||
if tunnel != nil && tunnel.Type == 2 {
|
||||
service["handler"].(map[string]interface{})["chain"] = fmt.Sprintf("chains_%d", forward.TunnelID)
|
||||
|
||||
@@ -91,6 +91,11 @@ type federationRuntimeDiagnoseRequest struct {
|
||||
Timeout int `json:"timeout"`
|
||||
}
|
||||
|
||||
type federationRuntimeCommandRequest struct {
|
||||
CommandType string `json:"commandType"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
type peerShareUsedPort struct {
|
||||
RuntimeID int64 `json:"runtimeId"`
|
||||
Port int `json:"port"`
|
||||
@@ -786,7 +791,7 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
|
||||
defer tx.Rollback()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
res, err := tx.Exec(`INSERT INTO tunnel (name, type, protocol, flow, created_time, updated_time, status, in_ip) VALUES (?, ?, ?, 0, ?, ?, 1, ?)`,
|
||||
tunnelID, err := tx.ExecReturningID(`INSERT INTO tunnel (name, type, protocol, flow, created_time, updated_time, status, in_ip) VALUES (?, ?, ?, 0, ?, ?, 1, ?)`,
|
||||
fmt.Sprintf("Share-%d-Port-%d", share.ID, req.RemotePort),
|
||||
tunnelType,
|
||||
req.Protocol,
|
||||
@@ -799,9 +804,7 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
tunnelID, _ := res.LastInsertId()
|
||||
|
||||
_, err = tx.Exec(`INSERT INTO chain_tunnel (tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES (?, 1, ?, ?, 'fifo', 0, ?)`,
|
||||
_, err = tx.Exec(`INSERT INTO chain_tunnel (tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES (?, '1', ?, ?, 'fifo', 0, ?)`,
|
||||
tunnelID,
|
||||
share.NodeID,
|
||||
req.RemotePort,
|
||||
@@ -1002,14 +1005,19 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid target"))
|
||||
return
|
||||
}
|
||||
targetProtocol := defaultString(target.Protocol, protocol)
|
||||
connector := map[string]interface{}{
|
||||
"type": "relay",
|
||||
}
|
||||
if isTLSTunnelProtocol(targetProtocol) {
|
||||
connector["metadata"] = map[string]interface{}{"nodelay": true}
|
||||
}
|
||||
nodeItems = append(nodeItems, map[string]interface{}{
|
||||
"name": fmt.Sprintf("node_%d", i+1),
|
||||
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, target.Port)),
|
||||
"connector": map[string]interface{}{
|
||||
"type": "relay",
|
||||
},
|
||||
"name": fmt.Sprintf("node_%d", i+1),
|
||||
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, target.Port)),
|
||||
"connector": connector,
|
||||
"dialer": map[string]interface{}{
|
||||
"type": defaultString(target.Protocol, protocol),
|
||||
"type": targetProtocol,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1048,6 +1056,9 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
|
||||
"type": protocol,
|
||||
},
|
||||
}
|
||||
if isTLSTunnelProtocol(protocol) {
|
||||
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{"nodelay": true}
|
||||
}
|
||||
if req.Role == "middle" {
|
||||
service["handler"].(map[string]interface{})["chain"] = chainName
|
||||
}
|
||||
@@ -1193,6 +1204,51 @@ func (h *Handler) federationRuntimeDiagnose(w http.ResponseWriter, r *http.Reque
|
||||
response.WriteJSON(w, response.OK(res.Data))
|
||||
}
|
||||
|
||||
func (h *Handler) federationRuntimeCommand(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||
return
|
||||
}
|
||||
|
||||
token := extractBearerToken(r)
|
||||
share, err := h.repo.GetPeerShareByToken(token)
|
||||
if err != nil || share == nil {
|
||||
response.WriteJSON(w, response.Err(401, "Unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
var req federationRuntimeCommandRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
|
||||
return
|
||||
}
|
||||
cmd := strings.TrimSpace(req.CommandType)
|
||||
if cmd == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("commandType is required"))
|
||||
return
|
||||
}
|
||||
if !isFederationRuntimeCommandAllowed(cmd) {
|
||||
response.WriteJSON(w, response.ErrDefault("command not allowed"))
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.sendNodeCommand(share.NodeID, cmd, req.Data, false, false)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(res))
|
||||
}
|
||||
|
||||
func isFederationRuntimeCommandAllowed(commandType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(commandType)) {
|
||||
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "deletechains", "addlimiters", "deletelimiters", "tcpping", "reload":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) pickPeerSharePort(share *sqlite.PeerShare, requestedPort int) (int, error) {
|
||||
if share == nil {
|
||||
return 0, fmt.Errorf("share not found")
|
||||
@@ -1350,7 +1406,7 @@ func parseIPLiteral(raw string) net.IP {
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(value); ip != nil {
|
||||
return ip
|
||||
return normalizeIPAddress(ip)
|
||||
}
|
||||
|
||||
host, _, err := net.SplitHostPort(value)
|
||||
@@ -1362,7 +1418,17 @@ func parseIPLiteral(raw string) net.IP {
|
||||
if host == "" {
|
||||
return nil
|
||||
}
|
||||
return net.ParseIP(host)
|
||||
return normalizeIPAddress(net.ParseIP(host))
|
||||
}
|
||||
|
||||
func normalizeIPAddress(ip net.IP) net.IP {
|
||||
if ip == nil {
|
||||
return nil
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
return v4
|
||||
}
|
||||
return ip.To16()
|
||||
}
|
||||
|
||||
func isTrustedProxyIP(ip net.IP) bool {
|
||||
|
||||
@@ -152,6 +152,71 @@ func TestPrepareTunnelCreateStateRemoteAutoPortDefersToFederation(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareTunnelCreateStateAllowsOfflineRemoteMiddleNode(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer repo.Close()
|
||||
|
||||
h := &Handler{repo: repo}
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
insertNode := func(name string, status int, portRange string, isRemote int) int64 {
|
||||
res, execErr := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", "10.0.0.1", "10.0.0.1", "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0, isRemote, "http://peer", "peer-token", `{"shareId":2}`)
|
||||
if execErr != nil {
|
||||
t.Fatalf("insert node %s: %v", name, execErr)
|
||||
}
|
||||
id, idErr := res.LastInsertId()
|
||||
if idErr != nil {
|
||||
t.Fatalf("node id %s: %v", name, idErr)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
entryID := insertNode("entry-local", 1, "32000-32010", 0)
|
||||
remoteMiddleID := insertNode("middle-remote", 0, "33000-33010", 1)
|
||||
outID := insertNode("out-local", 1, "34000-34010", 0)
|
||||
|
||||
tx, err := repo.DB().Begin()
|
||||
if err != nil {
|
||||
t.Fatalf("begin tx: %v", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
req := map[string]interface{}{
|
||||
"name": "remote-middle-offline-status",
|
||||
"inNodeId": []interface{}{
|
||||
map[string]interface{}{"nodeId": float64(entryID), "protocol": "tls", "strategy": "round"},
|
||||
},
|
||||
"chainNodes": []interface{}{
|
||||
[]interface{}{
|
||||
map[string]interface{}{"nodeId": float64(remoteMiddleID), "protocol": "tls", "strategy": "round", "port": float64(0)},
|
||||
},
|
||||
},
|
||||
"outNodeId": []interface{}{
|
||||
map[string]interface{}{"nodeId": float64(outID), "protocol": "tls", "strategy": "round", "port": float64(0)},
|
||||
},
|
||||
}
|
||||
|
||||
state, err := h.prepareTunnelCreateState(tx, req, 2, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare state should allow offline remote middle node: %v", err)
|
||||
}
|
||||
if len(state.ChainHops) != 1 || len(state.ChainHops[0]) != 1 {
|
||||
t.Fatalf("expected one middle hop node, got %+v", state.ChainHops)
|
||||
}
|
||||
if state.ChainHops[0][0].NodeID != remoteMiddleID {
|
||||
t.Fatalf("expected remote middle node id %d, got %d", remoteMiddleID, state.ChainHops[0][0].NodeID)
|
||||
}
|
||||
if state.Nodes[remoteMiddleID] == nil || state.Nodes[remoteMiddleID].IsRemote != 1 {
|
||||
t.Fatalf("expected remote middle node metadata in state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRuntimeReservePortRejectsWhenShareFlowExceeded(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
|
||||
@@ -567,6 +567,13 @@ func TestAuthPeerAllowedIPs(t *testing.T) {
|
||||
xff: "198.51.100.20, 172.20.0.3",
|
||||
wantAllowed: true,
|
||||
},
|
||||
{
|
||||
name: "ipv4-mapped proxy xff allowed",
|
||||
allowedIPs: "198.51.100.20",
|
||||
remoteAddr: "[::ffff:172.20.0.3]:34567",
|
||||
xff: "198.51.100.20, 172.20.0.3",
|
||||
wantAllowed: true,
|
||||
},
|
||||
{
|
||||
name: "non whitelisted ip denied",
|
||||
allowedIPs: "203.0.113.10",
|
||||
|
||||
@@ -251,7 +251,7 @@ func (h *Handler) pauseForwardRecords(forwards []forwardRecord, now int64) {
|
||||
|
||||
func (h *Handler) listActiveForwardsByUser(userID int64) ([]forwardRecord, error) {
|
||||
rows, err := h.repo.DB().Query(`
|
||||
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, strategy, status
|
||||
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, COALESCE(strategy, 'fifo'), status
|
||||
FROM forward
|
||||
WHERE user_id = ? AND status = 1
|
||||
ORDER BY id ASC
|
||||
@@ -266,7 +266,7 @@ func (h *Handler) listActiveForwardsByUser(userID int64) ([]forwardRecord, error
|
||||
|
||||
func (h *Handler) listActiveForwardsByUserTunnel(userID int64, tunnelID int64) ([]forwardRecord, error) {
|
||||
rows, err := h.repo.DB().Query(`
|
||||
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, strategy, status
|
||||
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, COALESCE(strategy, 'fifo'), status
|
||||
FROM forward
|
||||
WHERE user_id = ? AND tunnel_id = ? AND status = 1
|
||||
ORDER BY id ASC
|
||||
|
||||
@@ -93,6 +93,12 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/config/list", h.getConfigs)
|
||||
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
|
||||
mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig)
|
||||
mux.HandleFunc("/api/v1/backup/export", h.backupExport)
|
||||
mux.HandleFunc("/api/v1/backup/import", h.backupImport)
|
||||
mux.HandleFunc("/api/v1/backup/restore", h.backupImport)
|
||||
mux.HandleFunc("/api/v1/api/v1/backup/export", h.backupExport)
|
||||
mux.HandleFunc("/api/v1/api/v1/backup/import", h.backupImport)
|
||||
mux.HandleFunc("/api/v1/api/v1/backup/restore", h.backupImport)
|
||||
mux.HandleFunc("/api/v1/captcha/check", h.checkCaptcha)
|
||||
mux.HandleFunc("/api/v1/captcha/verify", h.captchaVerify)
|
||||
mux.HandleFunc("/api/v1/user/package", h.userPackage)
|
||||
@@ -105,6 +111,10 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/node/update-order", h.nodeUpdateOrder)
|
||||
mux.HandleFunc("/api/v1/node/batch-delete", h.nodeBatchDelete)
|
||||
mux.HandleFunc("/api/v1/node/check-status", h.nodeCheckStatus)
|
||||
mux.HandleFunc("/api/v1/node/upgrade", h.nodeUpgrade)
|
||||
mux.HandleFunc("/api/v1/node/batch-upgrade", h.nodeBatchUpgrade)
|
||||
mux.HandleFunc("/api/v1/node/rollback", h.nodeRollback)
|
||||
mux.HandleFunc("/api/v1/node/releases", h.listReleases)
|
||||
mux.HandleFunc("/api/v1/tunnel/list", h.tunnelList)
|
||||
mux.HandleFunc("/api/v1/tunnel/create", h.tunnelCreate)
|
||||
mux.HandleFunc("/api/v1/tunnel/get", h.tunnelGet)
|
||||
@@ -165,6 +175,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/federation/runtime/apply-role", h.authPeer(h.federationRuntimeApplyRole))
|
||||
mux.HandleFunc("/api/v1/federation/runtime/release-role", h.authPeer(h.federationRuntimeReleaseRole))
|
||||
mux.HandleFunc("/api/v1/federation/runtime/diagnose", h.authPeer(h.federationRuntimeDiagnose))
|
||||
mux.HandleFunc("/api/v1/federation/runtime/command", h.authPeer(h.federationRuntimeCommand))
|
||||
mux.HandleFunc("/api/v1/federation/node/import", h.nodeImport)
|
||||
|
||||
mux.HandleFunc("/flow/test", h.flowTest)
|
||||
@@ -302,11 +313,35 @@ func (h *Handler) userList(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Current int `json:"current"`
|
||||
Size int `json:"size"`
|
||||
Keyword string `json:"keyword"`
|
||||
}
|
||||
if err := decodeJSON(r.Body, &req); err != nil && err != io.EOF {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
users, err := h.repo.ListUsers()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
keyword := strings.ToLower(strings.TrimSpace(req.Keyword))
|
||||
if keyword != "" {
|
||||
filtered := make([]map[string]interface{}, 0, len(users))
|
||||
for _, item := range users {
|
||||
username := strings.ToLower(strings.TrimSpace(fmt.Sprint(item["user"])))
|
||||
displayName := strings.ToLower(strings.TrimSpace(fmt.Sprint(item["name"])))
|
||||
if strings.Contains(username, keyword) || strings.Contains(displayName, keyword) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
users = filtered
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(users))
|
||||
}
|
||||
|
||||
@@ -1112,3 +1147,84 @@ func (h *Handler) verifyCloudflareTurnstile(token, secretKey string) bool {
|
||||
}
|
||||
return body.Success
|
||||
}
|
||||
|
||||
type backupExportRequest struct {
|
||||
Types []string `json:"types"`
|
||||
}
|
||||
|
||||
func (h *Handler) backupExport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req backupExportRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.Err(500, "请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
var backup interface{}
|
||||
var err error
|
||||
|
||||
if len(req.Types) == 0 {
|
||||
backup, err = h.repo.ExportAll()
|
||||
} else {
|
||||
backup, err = h.repo.ExportPartial(req.Types)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=backup.json")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(backup); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type backupImportRequest struct {
|
||||
Types []string `json:"types"`
|
||||
sqlite.BackupData
|
||||
}
|
||||
|
||||
func (h *Handler) backupImport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req backupImportRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.Err(500, "请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Types) == 0 {
|
||||
response.WriteJSON(w, response.Err(500, "请选择要导入的数据类型"))
|
||||
return
|
||||
}
|
||||
|
||||
autoBackup, err := h.repo.ExportAll()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("导入前自动备份失败: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
if req.BackupData.Version == "" {
|
||||
response.WriteJSON(w, response.Err(500, "备份数据格式错误"))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.repo.Import(&req.BackupData, req.Types)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("导入失败: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
result.AutoBackup = autoBackup
|
||||
response.WriteJSON(w, response.OK(result))
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"go-backend/internal/http/client"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store"
|
||||
"go-backend/internal/store/sqlite"
|
||||
)
|
||||
|
||||
@@ -413,7 +414,7 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flux-panel/releases/latest/download/install.sh -o ./install.sh && chmod +x ./install.sh && ./install.sh -a %s -s %s", processServerAddress(panelAddr), secret)
|
||||
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/latest/download/install.sh -o ./install.sh && chmod +x ./install.sh && ./install.sh -a %s -s %s", processServerAddress(panelAddr), secret)
|
||||
response.WriteJSON(w, response.OK(cmd))
|
||||
}
|
||||
|
||||
@@ -559,13 +560,12 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
res, err := tx.Exec(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
tunnelID, err := tx.ExecReturningID(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
name, trafficRatio, typeVal, "tls", flow, now, now, status, nullableText(inIP), inx)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
tunnelID, _ := res.LastInsertId()
|
||||
runtimeState.TunnelID = tunnelID
|
||||
var federationBindings []sqlite.FederationTunnelBinding
|
||||
var federationReleaseRefs []federationRuntimeReleaseRef
|
||||
@@ -688,6 +688,9 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
runtimeState.TunnelID = id
|
||||
|
||||
inIp := buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes)
|
||||
|
||||
var federationBindings []sqlite.FederationTunnelBinding
|
||||
var federationReleaseRefs []federationRuntimeReleaseRef
|
||||
if typeVal == 2 {
|
||||
@@ -700,7 +703,7 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
applyTunnelPortsToRequest(req, runtimeState)
|
||||
|
||||
_, err = tx.Exec(`UPDATE tunnel SET name=?, type=?, flow=?, traffic_ratio=?, status=?, in_ip=?, updated_time=? WHERE id=?`,
|
||||
asString(req["name"]), typeVal, asInt64(req["flow"], 1), asFloat(req["trafficRatio"], 1.0), asInt(req["status"], 1), nullableText(asString(req["inIp"])), now, id)
|
||||
asString(req["name"]), typeVal, asInt64(req["flow"], 1), asFloat(req["trafficRatio"], 1.0), asInt(req["status"], 1), nullableText(inIp), now, id)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
@@ -1119,7 +1122,7 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
res, err := tx.Exec(`
|
||||
forwardID, err := tx.ExecReturningID(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
|
||||
`, userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, now, inx)
|
||||
@@ -1127,7 +1130,6 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
forwardID, _ := res.LastInsertId()
|
||||
entryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)
|
||||
for _, nodeID := range entryNodes {
|
||||
_, _ = tx.Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port)
|
||||
@@ -1587,13 +1589,12 @@ func (h *Handler) speedLimitCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
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(?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, err := h.repo.DB().ExecReturningID(`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())
|
||||
}
|
||||
@@ -1687,7 +1688,7 @@ func (h *Handler) groupTunnelAssign(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
_, _ = tx.Exec(`DELETE FROM tunnel_group_tunnel WHERE tunnel_group_id = ?`, req.GroupID)
|
||||
for _, tid := range req.TunnelIDs {
|
||||
_, _ = tx.Exec(`INSERT OR IGNORE INTO tunnel_group_tunnel(tunnel_group_id, tunnel_id, created_time) VALUES(?, ?, ?)`, req.GroupID, tid, time.Now().UnixMilli())
|
||||
_, _ = tx.Exec(`INSERT INTO tunnel_group_tunnel(tunnel_group_id, tunnel_id, created_time) VALUES(?, ?, ?) ON CONFLICT DO NOTHING`, req.GroupID, tid, time.Now().UnixMilli())
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
@@ -1712,9 +1713,18 @@ func (h *Handler) groupUserAssign(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
previousUserIDs, err := queryInt64ListTx(tx, `SELECT user_id FROM user_group_user WHERE user_group_id = ?`, req.GroupID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
_, _ = tx.Exec(`DELETE FROM user_group_user WHERE user_group_id = ?`, req.GroupID)
|
||||
for _, uid := range req.UserIDs {
|
||||
_, _ = tx.Exec(`INSERT OR IGNORE INTO user_group_user(user_group_id, user_id, created_time) VALUES(?, ?, ?)`, req.GroupID, uid, time.Now().UnixMilli())
|
||||
_, _ = tx.Exec(`INSERT INTO user_group_user(user_group_id, user_id, created_time) VALUES(?, ?, ?) ON CONFLICT DO NOTHING`, req.GroupID, uid, time.Now().UnixMilli())
|
||||
}
|
||||
if err := revokeGroupGrantsForRemovedUsersTx(tx, req.GroupID, previousUserIDs, req.UserIDs); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
@@ -1733,7 +1743,7 @@ func (h *Handler) groupPermissionAssign(w http.ResponseWriter, r *http.Request)
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
_, err := h.repo.DB().Exec(`INSERT OR IGNORE INTO group_permission(user_group_id, tunnel_group_id, created_time) VALUES(?, ?, ?)`, req.UserGroupID, req.TunnelGroupID, time.Now().UnixMilli())
|
||||
_, err := h.repo.DB().Exec(`INSERT INTO group_permission(user_group_id, tunnel_group_id, created_time) VALUES(?, ?, ?) ON CONFLICT DO NOTHING`, req.UserGroupID, req.TunnelGroupID, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
@@ -1747,10 +1757,35 @@ func (h *Handler) groupPermissionRemove(w http.ResponseWriter, r *http.Request)
|
||||
if id <= 0 {
|
||||
return
|
||||
}
|
||||
tx, err := h.repo.DB().Begin()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var ug, tg int64
|
||||
_ = h.repo.DB().QueryRow(`SELECT user_group_id, tunnel_group_id FROM group_permission WHERE id = ?`, id).Scan(&ug, &tg)
|
||||
_, _ = h.repo.DB().Exec(`DELETE FROM group_permission WHERE id = ?`, id)
|
||||
_, _ = h.repo.DB().Exec(`DELETE FROM group_permission_grant WHERE user_group_id = ? AND tunnel_group_id = ?`, ug, tg)
|
||||
err = tx.QueryRow(`SELECT user_group_id, tunnel_group_id FROM group_permission WHERE id = ?`, id).Scan(&ug, &tg)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM group_permission WHERE id = ?`, id); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
if err := revokeGroupPermissionPairTx(tx, ug, tg); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
@@ -1835,7 +1870,7 @@ func (h *Handler) applyGroupPermission(userGroupID, tunnelGroupID int64) error {
|
||||
if created {
|
||||
createdByGroup = 1
|
||||
}
|
||||
_, _ = db.Exec(`INSERT OR IGNORE INTO group_permission_grant(user_group_id, tunnel_group_id, user_tunnel_id, created_by_group, created_time) VALUES(?, ?, ?, ?, ?)`,
|
||||
_, _ = db.Exec(`INSERT INTO group_permission_grant(user_group_id, tunnel_group_id, user_tunnel_id, created_by_group, created_time) VALUES(?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`,
|
||||
userGroupID, tunnelGroupID, utID, createdByGroup, time.Now().UnixMilli())
|
||||
}
|
||||
}
|
||||
@@ -1866,7 +1901,7 @@ func (h *Handler) syncPermissionsByTunnelGroup(tunnelGroupID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureUserTunnelGrant(db *sql.DB, userID, tunnelID int64) (int64, bool, error) {
|
||||
func ensureUserTunnelGrant(db *store.DB, userID, tunnelID int64) (int64, bool, error) {
|
||||
var id int64
|
||||
err := db.QueryRow(`SELECT id FROM user_tunnel WHERE user_id = ? AND tunnel_id = ? LIMIT 1`, userID, tunnelID).Scan(&id)
|
||||
if err == nil {
|
||||
@@ -1882,16 +1917,15 @@ func ensureUserTunnelGrant(db *sql.DB, userID, tunnelID int64) (int64, bool, err
|
||||
if err := db.QueryRow(`SELECT flow, num, exp_time, flow_reset_time FROM user WHERE id = ?`, userID).Scan(&flow, &num, &expTime, &flowReset); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
res, err := db.Exec(`INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(?, ?, NULL, ?, ?, 0, 0, ?, ?, 1)`,
|
||||
id, err = db.ExecReturningID(`INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(?, ?, NULL, ?, ?, 0, 0, ?, ?, 1)`,
|
||||
userID, tunnelID, num, flow, flowReset, expTime)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
id, _ = res.LastInsertId()
|
||||
return id, true, nil
|
||||
}
|
||||
|
||||
func queryInt64List(db *sql.DB, q string, args ...interface{}) ([]int64, error) {
|
||||
func queryInt64List(db *store.DB, q string, args ...interface{}) ([]int64, error) {
|
||||
rows, err := db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1908,7 +1942,145 @@ func queryInt64List(db *sql.DB, q string, args ...interface{}) ([]int64, error)
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func queryPairs(db *sql.DB, q string, args ...interface{}) ([][2]int64, error) {
|
||||
func queryInt64ListTx(tx *store.Tx, q string, args ...interface{}) ([]int64, error) {
|
||||
rows, err := tx.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]int64, 0)
|
||||
for rows.Next() {
|
||||
var v int64
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func revokeGroupGrantsForRemovedUsersTx(tx *store.Tx, userGroupID int64, previousUserIDs, currentUserIDs []int64) error {
|
||||
currentSet := make(map[int64]struct{}, len(currentUserIDs))
|
||||
for _, uid := range currentUserIDs {
|
||||
if uid > 0 {
|
||||
currentSet[uid] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
removedUserIDs := make([]int64, 0)
|
||||
for _, uid := range previousUserIDs {
|
||||
if uid <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := currentSet[uid]; !ok {
|
||||
removedUserIDs = append(removedUserIDs, uid)
|
||||
}
|
||||
}
|
||||
if len(removedUserIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, userID := range removedUserIDs {
|
||||
rows, err := tx.Query(`
|
||||
SELECT g.user_tunnel_id, g.created_by_group
|
||||
FROM group_permission_grant g
|
||||
JOIN user_tunnel ut ON ut.id = g.user_tunnel_id
|
||||
WHERE g.user_group_id = ? AND ut.user_id = ?
|
||||
`, userGroupID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
groupCreatedTunnelIDs := make(map[int64]struct{})
|
||||
for rows.Next() {
|
||||
var userTunnelID int64
|
||||
var createdByGroup int
|
||||
if err := rows.Scan(&userTunnelID, &createdByGroup); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if createdByGroup == 1 && userTunnelID > 0 {
|
||||
groupCreatedTunnelIDs[userTunnelID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
if _, err := tx.Exec(`
|
||||
DELETE FROM group_permission_grant
|
||||
WHERE user_group_id = ?
|
||||
AND user_tunnel_id IN (SELECT id FROM user_tunnel WHERE user_id = ?)
|
||||
`, userGroupID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for userTunnelID := range groupCreatedTunnelIDs {
|
||||
var remaining int
|
||||
if err := tx.QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&remaining); err != nil {
|
||||
return err
|
||||
}
|
||||
if remaining == 0 {
|
||||
if _, err := tx.Exec(`DELETE FROM user_tunnel WHERE id = ?`, userTunnelID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func revokeGroupPermissionPairTx(tx *store.Tx, userGroupID, tunnelGroupID int64) error {
|
||||
rows, err := tx.Query(`
|
||||
SELECT user_tunnel_id, created_by_group
|
||||
FROM group_permission_grant
|
||||
WHERE user_group_id = ? AND tunnel_group_id = ?
|
||||
`, userGroupID, tunnelGroupID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
groupCreatedTunnelIDs := make(map[int64]struct{})
|
||||
for rows.Next() {
|
||||
var userTunnelID int64
|
||||
var createdByGroup int
|
||||
if err := rows.Scan(&userTunnelID, &createdByGroup); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if createdByGroup == 1 && userTunnelID > 0 {
|
||||
groupCreatedTunnelIDs[userTunnelID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM group_permission_grant WHERE user_group_id = ? AND tunnel_group_id = ?`, userGroupID, tunnelGroupID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for userTunnelID := range groupCreatedTunnelIDs {
|
||||
var remaining int
|
||||
if err := tx.QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&remaining); err != nil {
|
||||
return err
|
||||
}
|
||||
if remaining == 0 {
|
||||
if _, err := tx.Exec(`DELETE FROM user_tunnel WHERE id = ?`, userTunnelID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func queryPairs(db *store.DB, q string, args ...interface{}) ([][2]int64, error) {
|
||||
rows, err := db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1944,7 +2116,7 @@ type tunnelCreateState struct {
|
||||
NodeIDList []int64
|
||||
}
|
||||
|
||||
func (h *Handler) prepareTunnelCreateState(tx *sql.Tx, req map[string]interface{}, tunnelType int, excludeTunnelID int64) (*tunnelCreateState, error) {
|
||||
func (h *Handler) prepareTunnelCreateState(tx *store.Tx, req map[string]interface{}, tunnelType int, excludeTunnelID int64) (*tunnelCreateState, error) {
|
||||
state := &tunnelCreateState{
|
||||
Type: tunnelType,
|
||||
InNodes: make([]tunnelRuntimeNode, 0),
|
||||
@@ -2061,7 +2233,7 @@ func (h *Handler) prepareTunnelCreateState(tx *sql.Tx, req map[string]interface{
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if node.Status != 1 {
|
||||
if node.IsRemote != 1 && node.Status != 1 {
|
||||
return nil, errors.New("部分节点不在线")
|
||||
}
|
||||
state.Nodes[nodeID] = node
|
||||
@@ -2403,7 +2575,7 @@ func (h *Handler) cleanupFederationRuntime(tunnelID int64) {
|
||||
_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)
|
||||
}
|
||||
|
||||
func replaceFederationTunnelBindingsTx(tx *sql.Tx, tunnelID int64, bindings []sqlite.FederationTunnelBinding) error {
|
||||
func replaceFederationTunnelBindingsTx(tx *store.Tx, tunnelID int64, bindings []sqlite.FederationTunnelBinding) error {
|
||||
if tx == nil {
|
||||
return errors.New("database unavailable")
|
||||
}
|
||||
@@ -2441,9 +2613,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
}
|
||||
|
||||
for _, inNode := range state.InNodes {
|
||||
if node := state.Nodes[inNode.NodeID]; node != nil && node.IsRemote == 1 {
|
||||
continue
|
||||
}
|
||||
node := state.Nodes[inNode.NodeID]
|
||||
targets := state.OutNodes
|
||||
if len(state.ChainHops) > 0 {
|
||||
targets = state.ChainHops[0]
|
||||
@@ -2453,6 +2623,9 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
||||
return createdChains, createdServices, err
|
||||
}
|
||||
if _, err := h.sendNodeCommand(inNode.NodeID, "AddChains", chainData, true, false); err != nil {
|
||||
if node != nil && node.IsRemote == 1 && shouldDeferTunnelRuntimeApplyError(err) {
|
||||
continue
|
||||
}
|
||||
return createdChains, createdServices, fmt.Errorf("入口节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[inNode.NodeID]), err)
|
||||
}
|
||||
createdChains = append(createdChains, inNode.NodeID)
|
||||
@@ -2564,14 +2737,19 @@ func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRu
|
||||
if port <= 0 {
|
||||
return nil, errors.New("节点端口不能为空")
|
||||
}
|
||||
protocol := defaultString(target.Protocol, "tls")
|
||||
connector := map[string]interface{}{
|
||||
"type": "relay",
|
||||
}
|
||||
if isTLSTunnelProtocol(protocol) {
|
||||
connector["metadata"] = map[string]interface{}{"nodelay": true}
|
||||
}
|
||||
nodeItems = append(nodeItems, map[string]interface{}{
|
||||
"name": fmt.Sprintf("node_%d", idx+1),
|
||||
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, port)),
|
||||
"connector": map[string]interface{}{
|
||||
"type": "relay",
|
||||
},
|
||||
"name": fmt.Sprintf("node_%d", idx+1),
|
||||
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, port)),
|
||||
"connector": connector,
|
||||
"dialer": map[string]interface{}{
|
||||
"type": defaultString(target.Protocol, "tls"),
|
||||
"type": protocol,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -2600,14 +2778,19 @@ func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode,
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
protocol := defaultString(chainNode.Protocol, "tls")
|
||||
handlerCfg := map[string]interface{}{
|
||||
"type": "relay",
|
||||
}
|
||||
if isTLSTunnelProtocol(protocol) {
|
||||
handlerCfg["metadata"] = map[string]interface{}{"nodelay": true}
|
||||
}
|
||||
service := map[string]interface{}{
|
||||
"name": fmt.Sprintf("%d_tls", tunnelID),
|
||||
"addr": fmt.Sprintf("%s:%d", node.TCPListenAddr, chainNode.Port),
|
||||
"handler": map[string]interface{}{
|
||||
"type": "relay",
|
||||
},
|
||||
"name": fmt.Sprintf("%d_tls", tunnelID),
|
||||
"addr": fmt.Sprintf("%s:%d", node.TCPListenAddr, chainNode.Port),
|
||||
"handler": handlerCfg,
|
||||
"listener": map[string]interface{}{
|
||||
"type": defaultString(chainNode.Protocol, "tls"),
|
||||
"type": protocol,
|
||||
},
|
||||
}
|
||||
if chainNode.ChainType == 2 {
|
||||
@@ -2653,6 +2836,10 @@ func nodeDisplayName(node *nodeRecord) string {
|
||||
return fmt.Sprintf("node_%d", node.ID)
|
||||
}
|
||||
|
||||
func isTLSTunnelProtocol(protocol string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(defaultString(protocol, "tls")), "tls")
|
||||
}
|
||||
|
||||
func nodeSupportsV4(node *nodeRecord) bool {
|
||||
if node == nil {
|
||||
return false
|
||||
@@ -2713,7 +2900,7 @@ func pickNodeAddressV6(node *nodeRecord) string {
|
||||
return strings.TrimSpace(node.ServerIP)
|
||||
}
|
||||
|
||||
func isRemoteNodeTx(tx *sql.Tx, nodeID int64) (bool, error) {
|
||||
func isRemoteNodeTx(tx *store.Tx, nodeID int64) (bool, error) {
|
||||
if tx == nil {
|
||||
return false, errors.New("database unavailable")
|
||||
}
|
||||
@@ -2730,7 +2917,7 @@ func isRemoteNodeTx(tx *sql.Tx, nodeID int64) (bool, error) {
|
||||
return isRemote == 1, nil
|
||||
}
|
||||
|
||||
func pickNodePortTx(tx *sql.Tx, nodeID int64, allocated map[int64]int, excludeTunnelID int64) (int, error) {
|
||||
func pickNodePortTx(tx *store.Tx, nodeID int64, allocated map[int64]int, excludeTunnelID int64) (int, error) {
|
||||
if tx == nil {
|
||||
return 0, errors.New("database unavailable")
|
||||
}
|
||||
@@ -2841,7 +3028,7 @@ func parsePortRangeSpec(input string) []int {
|
||||
return out
|
||||
}
|
||||
|
||||
func replaceTunnelChainsTx(tx *sql.Tx, tunnelID int64, req map[string]interface{}) error {
|
||||
func replaceTunnelChainsTx(tx *store.Tx, tunnelID int64, req map[string]interface{}) error {
|
||||
allocated := map[int64]int{}
|
||||
inNodes := asMapSlice(req["inNodeId"])
|
||||
for _, n := range inNodes {
|
||||
@@ -2849,8 +3036,8 @@ func replaceTunnelChainsTx(tx *sql.Tx, tunnelID int64, req map[string]interface{
|
||||
if nodeID <= 0 {
|
||||
continue
|
||||
}
|
||||
_, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 1, ?, NULL, NULL, 0, ?)`,
|
||||
tunnelID, nodeID, defaultString(asString(n["protocol"]), "tls"))
|
||||
_, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, '1', ?, NULL, ?, 0, ?)`,
|
||||
tunnelID, nodeID, defaultString(asString(n["strategy"]), "round"), defaultString(asString(n["protocol"]), "tls"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -2868,7 +3055,7 @@ 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, ?, ?, ?, 0, ?)`,
|
||||
_, 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
|
||||
@@ -2889,7 +3076,7 @@ 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(?, 2, ?, ?, ?, ?, ?)`,
|
||||
_, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, '2', ?, ?, ?, ?, ?)`,
|
||||
tunnelID, nodeID, port, defaultString(asString(n["strategy"]), "round"), i+1, defaultString(asString(n["protocol"]), "tls"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -2975,7 +3162,7 @@ func (h *Handler) batchForwardStatus(ids []int64, status int) (int, int) {
|
||||
}
|
||||
|
||||
func (h *Handler) tunnelEntryNodeIDs(tunnelID int64) ([]int64, error) {
|
||||
rows, err := h.repo.DB().Query(`SELECT node_id FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 1 ORDER BY inx ASC, id ASC`, tunnelID)
|
||||
rows, err := h.repo.DB().Query(`SELECT node_id FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = '1' ORDER BY inx ASC, id ASC`, tunnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -3462,7 +3649,7 @@ func randomToken(n int) string {
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func nextIndex(db *sql.DB, table string) int {
|
||||
func nextIndex(db *store.DB, table string) int {
|
||||
if db == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
const (
|
||||
githubRepo = "Sagit-chu/flvx"
|
||||
githubProxy = "https://gcode.hostcentral.cc"
|
||||
githubAPIBase = "https://api.github.com"
|
||||
githubHTMLBase = "https://github.com"
|
||||
upgradeTimeout = 5 * time.Minute
|
||||
batchWorkers = 5
|
||||
)
|
||||
|
||||
func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ID int64 `json:"id"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if req.ID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("节点ID无效"))
|
||||
return
|
||||
}
|
||||
|
||||
version := strings.TrimSpace(req.Version)
|
||||
if version == "" {
|
||||
var err error
|
||||
version, err = resolveLatestRelease()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err)))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
downloadURL := fmt.Sprintf(
|
||||
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}",
|
||||
githubHTMLBase, githubRepo, version,
|
||||
)
|
||||
checksumURL := fmt.Sprintf(
|
||||
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256",
|
||||
githubHTMLBase, githubRepo, version,
|
||||
)
|
||||
|
||||
result, err := h.wsServer.SendCommand(req.ID, "UpgradeAgent", map[string]interface{}{
|
||||
"downloadUrl": downloadURL,
|
||||
"checksumUrl": checksumURL,
|
||||
}, upgradeTimeout)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("升级失败: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"version": version,
|
||||
"message": result.Message,
|
||||
}))
|
||||
}
|
||||
|
||||
func resolveLatestRelease() (string, error) {
|
||||
client := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Get(githubProxy + "/" + githubHTMLBase + "/" + githubRepo + "/releases/latest")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求GitHub失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusFound && resp.StatusCode != http.StatusMovedPermanently {
|
||||
return resolveLatestReleaseAPI()
|
||||
}
|
||||
|
||||
location := resp.Header.Get("Location")
|
||||
if location == "" {
|
||||
return resolveLatestReleaseAPI()
|
||||
}
|
||||
|
||||
parts := strings.Split(location, "/")
|
||||
tag := parts[len(parts)-1]
|
||||
if tag == "" || tag == "latest" {
|
||||
return resolveLatestReleaseAPI()
|
||||
}
|
||||
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
func resolveLatestReleaseAPI() (string, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases/latest")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求GitHub API失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return "", fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var release struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return "", fmt.Errorf("解析GitHub API响应失败: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(release.TagName) == "" {
|
||||
return "", fmt.Errorf("无法从GitHub获取最新版本号")
|
||||
}
|
||||
|
||||
return release.TagName, nil
|
||||
}
|
||||
|
||||
func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if len(req.IDs) == 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("ids不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
version := strings.TrimSpace(req.Version)
|
||||
if version == "" {
|
||||
var err error
|
||||
version, err = resolveLatestRelease()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err)))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
downloadURL := fmt.Sprintf(
|
||||
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}",
|
||||
githubHTMLBase, githubRepo, version,
|
||||
)
|
||||
checksumURL := fmt.Sprintf(
|
||||
githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256",
|
||||
githubHTMLBase, githubRepo, version,
|
||||
)
|
||||
|
||||
type upgradeResult struct {
|
||||
ID int64 `json:"id"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
results := make([]upgradeResult, len(req.IDs))
|
||||
sem := make(chan struct{}, batchWorkers)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, id := range req.IDs {
|
||||
wg.Add(1)
|
||||
go func(index int, nodeID int64) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
result, err := h.wsServer.SendCommand(nodeID, "UpgradeAgent", map[string]interface{}{
|
||||
"downloadUrl": downloadURL,
|
||||
"checksumUrl": checksumURL,
|
||||
}, upgradeTimeout)
|
||||
if err != nil {
|
||||
results[index] = upgradeResult{ID: nodeID, Success: false, Message: err.Error()}
|
||||
return
|
||||
}
|
||||
results[index] = upgradeResult{ID: nodeID, Success: true, Message: result.Message}
|
||||
}(i, id)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"version": version,
|
||||
"results": results,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases?per_page=20")
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err)))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: GitHub API返回 %d: %s", resp.StatusCode, string(body))))
|
||||
return
|
||||
}
|
||||
|
||||
var releases []struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Draft bool `json:"draft"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("解析版本列表失败: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
type releaseItem struct {
|
||||
Version string `json:"version"`
|
||||
Name string `json:"name"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
}
|
||||
|
||||
items := make([]releaseItem, 0, len(releases))
|
||||
for _, r := range releases {
|
||||
if r.Draft {
|
||||
continue
|
||||
}
|
||||
items = append(items, releaseItem{
|
||||
Version: r.TagName,
|
||||
Name: r.Name,
|
||||
PublishedAt: r.PublishedAt,
|
||||
Prerelease: r.Prerelease,
|
||||
})
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
}
|
||||
|
||||
func (h *Handler) nodeRollback(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if req.ID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("节点ID无效"))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.wsServer.SendCommand(req.ID, "RollbackAgent", map[string]interface{}{}, 30*time.Second)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("回退失败: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
"message": result.Message,
|
||||
}))
|
||||
}
|
||||
@@ -93,6 +93,8 @@ func shouldSkip(path string) bool {
|
||||
return true
|
||||
case path == "/api/v1/federation/runtime/diagnose":
|
||||
return true
|
||||
case path == "/api/v1/federation/runtime/command":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -115,6 +117,14 @@ func requiresAdmin(path string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/v1/backup/") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/v1/api/v1/backup/") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/v1/tunnel/") {
|
||||
if strings.HasPrefix(path, "/api/v1/tunnel/user/tunnel") {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
// Package store provides a thin dialect-aware wrapper around database/sql,
|
||||
// enabling transparent use of both SQLite and PostgreSQL.
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Dialect identifies the underlying database engine.
|
||||
type Dialect int
|
||||
|
||||
const (
|
||||
DialectSQLite Dialect = iota
|
||||
DialectPostgres
|
||||
)
|
||||
|
||||
// String returns a human-readable dialect name.
|
||||
func (d Dialect) String() string {
|
||||
switch d {
|
||||
case DialectSQLite:
|
||||
return "sqlite"
|
||||
case DialectPostgres:
|
||||
return "postgres"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// DB wraps *sql.DB with dialect awareness.
|
||||
type DB struct {
|
||||
raw *sql.DB
|
||||
dialect Dialect
|
||||
}
|
||||
|
||||
// Wrap creates a new dialect-aware DB from an existing *sql.DB.
|
||||
func Wrap(raw *sql.DB, dialect Dialect) *DB {
|
||||
return &DB{raw: raw, dialect: dialect}
|
||||
}
|
||||
|
||||
// Dialect returns the database dialect.
|
||||
func (db *DB) Dialect() Dialect {
|
||||
if db == nil {
|
||||
return DialectSQLite
|
||||
}
|
||||
return db.dialect
|
||||
}
|
||||
|
||||
// RawDB returns the underlying *sql.DB.
|
||||
func (db *DB) RawDB() *sql.DB {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
return db.raw
|
||||
}
|
||||
|
||||
// Close closes the underlying connection.
|
||||
func (db *DB) Close() error {
|
||||
if db == nil || db.raw == nil {
|
||||
return nil
|
||||
}
|
||||
return db.raw.Close()
|
||||
}
|
||||
|
||||
// Ping verifies the connection is alive.
|
||||
func (db *DB) Ping() error {
|
||||
return db.raw.Ping()
|
||||
}
|
||||
|
||||
// Exec executes a query with transparent placeholder and syntax rewriting.
|
||||
func (db *DB) Exec(query string, args ...any) (sql.Result, error) {
|
||||
return db.raw.Exec(db.rewrite(query), args...)
|
||||
}
|
||||
|
||||
// Query executes a query that returns rows, with transparent rewriting.
|
||||
func (db *DB) Query(query string, args ...any) (*sql.Rows, error) {
|
||||
return db.raw.Query(db.rewrite(query), args...)
|
||||
}
|
||||
|
||||
// QueryRow executes a query that returns at most one row, with transparent rewriting.
|
||||
func (db *DB) QueryRow(query string, args ...any) *sql.Row {
|
||||
return db.raw.QueryRow(db.rewrite(query), args...)
|
||||
}
|
||||
|
||||
// Begin starts a transaction, returning a dialect-aware Tx.
|
||||
func (db *DB) Begin() (*Tx, error) {
|
||||
tx, err := db.raw.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Tx{raw: tx, dialect: db.dialect}, nil
|
||||
}
|
||||
|
||||
// ExecReturningID executes an INSERT and returns the auto-generated id.
|
||||
// - SQLite: uses LastInsertId()
|
||||
// - PostgreSQL: appends RETURNING id and uses QueryRow().Scan()
|
||||
func (db *DB) ExecReturningID(query string, args ...any) (int64, error) {
|
||||
q := db.rewrite(query)
|
||||
if db.dialect == DialectPostgres {
|
||||
q = ensureReturningID(q)
|
||||
var id int64
|
||||
if err := db.raw.QueryRow(q, args...).Scan(&id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
res, err := db.raw.Exec(q, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// Tx wraps *sql.Tx with dialect awareness.
|
||||
type Tx struct {
|
||||
raw *sql.Tx
|
||||
dialect Dialect
|
||||
}
|
||||
|
||||
// Exec executes a query inside the transaction with transparent rewriting.
|
||||
func (tx *Tx) Exec(query string, args ...any) (sql.Result, error) {
|
||||
return tx.raw.Exec(rewriteQuery(tx.dialect, query), args...)
|
||||
}
|
||||
|
||||
// Query executes a query that returns rows inside the transaction.
|
||||
func (tx *Tx) Query(query string, args ...any) (*sql.Rows, error) {
|
||||
return tx.raw.Query(rewriteQuery(tx.dialect, query), args...)
|
||||
}
|
||||
|
||||
// QueryRow executes a query that returns at most one row inside the transaction.
|
||||
func (tx *Tx) QueryRow(query string, args ...any) *sql.Row {
|
||||
return tx.raw.QueryRow(rewriteQuery(tx.dialect, query), args...)
|
||||
}
|
||||
|
||||
// Commit commits the transaction.
|
||||
func (tx *Tx) Commit() error { return tx.raw.Commit() }
|
||||
|
||||
// Rollback aborts the transaction.
|
||||
func (tx *Tx) Rollback() error { return tx.raw.Rollback() }
|
||||
|
||||
// ExecReturningID executes an INSERT inside the transaction and returns the id.
|
||||
func (tx *Tx) ExecReturningID(query string, args ...any) (int64, error) {
|
||||
q := rewriteQuery(tx.dialect, query)
|
||||
if tx.dialect == DialectPostgres {
|
||||
q = ensureReturningID(q)
|
||||
var id int64
|
||||
if err := tx.raw.QueryRow(q, args...).Scan(&id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
res, err := tx.raw.Exec(q, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (db *DB) rewrite(query string) string {
|
||||
return rewriteQuery(db.dialect, query)
|
||||
}
|
||||
|
||||
func rewriteQuery(dialect Dialect, query string) string {
|
||||
if dialect != DialectPostgres {
|
||||
return query
|
||||
}
|
||||
query = rewriteUserIdentifier(query)
|
||||
query = rewriteInsertOrIgnore(query)
|
||||
query = rewritePlaceholders(query)
|
||||
return query
|
||||
}
|
||||
|
||||
func rewriteUserIdentifier(query string) string {
|
||||
var buf strings.Builder
|
||||
buf.Grow(len(query) + 16)
|
||||
i := 0
|
||||
for i < len(query) {
|
||||
if end, ok := skipSQLProtectedSegment(query, i); ok {
|
||||
buf.WriteString(query[i:end])
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
|
||||
ch := query[i]
|
||||
if isIdentifierChar(ch) {
|
||||
j := i + 1
|
||||
for j < len(query) && isIdentifierChar(query[j]) {
|
||||
j++
|
||||
}
|
||||
tok := query[i:j]
|
||||
if strings.EqualFold(tok, "user") {
|
||||
buf.WriteString(`"user"`)
|
||||
} else {
|
||||
buf.WriteString(tok)
|
||||
}
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
buf.WriteByte(ch)
|
||||
i++
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func isIdentifierChar(ch byte) bool {
|
||||
if ch >= 'a' && ch <= 'z' {
|
||||
return true
|
||||
}
|
||||
if ch >= 'A' && ch <= 'Z' {
|
||||
return true
|
||||
}
|
||||
if ch >= '0' && ch <= '9' {
|
||||
return true
|
||||
}
|
||||
return ch == '_'
|
||||
}
|
||||
|
||||
func rewriteInsertOrIgnore(query string) string {
|
||||
start, end, ok := findKeywordSequenceOutside(query, []string{"INSERT", "OR", "IGNORE", "INTO"}, 0)
|
||||
if !ok {
|
||||
return query
|
||||
}
|
||||
|
||||
rewritten := query[:start] + "INSERT INTO" + query[end:]
|
||||
rewritten = strings.TrimRight(rewritten, "; \t\n")
|
||||
|
||||
insertIntoEnd := start + len("INSERT INTO")
|
||||
if _, _, hasOnConflict := findKeywordSequenceOutside(rewritten, []string{"ON", "CONFLICT"}, insertIntoEnd); hasOnConflict {
|
||||
return rewritten
|
||||
}
|
||||
|
||||
if retStart, _, hasReturning := findKeywordSequenceOutside(rewritten, []string{"RETURNING"}, insertIntoEnd); hasReturning {
|
||||
prefix := strings.TrimRight(rewritten[:retStart], " \t\n")
|
||||
suffix := strings.TrimLeft(rewritten[retStart:], " \t\n")
|
||||
return prefix + " ON CONFLICT DO NOTHING " + suffix
|
||||
}
|
||||
|
||||
return rewritten + " ON CONFLICT DO NOTHING"
|
||||
}
|
||||
|
||||
func rewritePlaceholders(query string) string {
|
||||
var buf strings.Builder
|
||||
buf.Grow(len(query) + 16)
|
||||
n := 1
|
||||
for i := 0; i < len(query); i++ {
|
||||
if end, ok := skipSQLProtectedSegment(query, i); ok {
|
||||
buf.WriteString(query[i:end])
|
||||
i = end - 1
|
||||
continue
|
||||
}
|
||||
|
||||
ch := query[i]
|
||||
if ch == '?' {
|
||||
buf.WriteByte('$')
|
||||
buf.WriteString(strconv.Itoa(n))
|
||||
n++
|
||||
continue
|
||||
}
|
||||
buf.WriteByte(ch)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func ensureReturningID(query string) string {
|
||||
trimmed := strings.TrimRight(query, "; \t\n")
|
||||
if _, _, ok := findKeywordSequenceOutside(trimmed, []string{"RETURNING"}, 0); ok {
|
||||
return trimmed
|
||||
}
|
||||
return trimmed + " RETURNING id"
|
||||
}
|
||||
|
||||
func findKeywordSequenceOutside(query string, keywords []string, from int) (int, int, bool) {
|
||||
if len(keywords) == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
if from >= len(query) {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
matched := 0
|
||||
seqStart := -1
|
||||
|
||||
for i := from; i < len(query); {
|
||||
if end, ok := skipSQLProtectedSegment(query, i); ok {
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
|
||||
ch := query[i]
|
||||
if isIdentifierChar(ch) {
|
||||
j := i + 1
|
||||
for j < len(query) && isIdentifierChar(query[j]) {
|
||||
j++
|
||||
}
|
||||
tok := query[i:j]
|
||||
|
||||
if strings.EqualFold(tok, keywords[matched]) {
|
||||
if matched == 0 {
|
||||
seqStart = i
|
||||
}
|
||||
matched++
|
||||
if matched == len(keywords) {
|
||||
return seqStart, j, true
|
||||
}
|
||||
} else if strings.EqualFold(tok, keywords[0]) {
|
||||
seqStart = i
|
||||
matched = 1
|
||||
} else {
|
||||
matched = 0
|
||||
seqStart = -1
|
||||
}
|
||||
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
if !isSQLSpace(ch) {
|
||||
matched = 0
|
||||
seqStart = -1
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func skipSQLProtectedSegment(query string, i int) (int, bool) {
|
||||
if i < 0 || i >= len(query) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
switch query[i] {
|
||||
case '\'':
|
||||
return skipSingleQuotedLiteral(query, i), true
|
||||
case '"':
|
||||
return skipDoubleQuotedIdentifier(query, i), true
|
||||
case '-':
|
||||
if i+1 < len(query) && query[i+1] == '-' {
|
||||
return skipLineComment(query, i), true
|
||||
}
|
||||
case '/':
|
||||
if i+1 < len(query) && query[i+1] == '*' {
|
||||
return skipBlockComment(query, i), true
|
||||
}
|
||||
case '$':
|
||||
if end, ok := skipDollarQuotedLiteral(query, i); ok {
|
||||
return end, true
|
||||
}
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func skipSingleQuotedLiteral(query string, i int) int {
|
||||
for j := i + 1; j < len(query); j++ {
|
||||
if query[j] != '\'' {
|
||||
continue
|
||||
}
|
||||
if j+1 < len(query) && query[j+1] == '\'' {
|
||||
j++
|
||||
continue
|
||||
}
|
||||
return j + 1
|
||||
}
|
||||
return len(query)
|
||||
}
|
||||
|
||||
func skipDoubleQuotedIdentifier(query string, i int) int {
|
||||
for j := i + 1; j < len(query); j++ {
|
||||
if query[j] != '"' {
|
||||
continue
|
||||
}
|
||||
if j+1 < len(query) && query[j+1] == '"' {
|
||||
j++
|
||||
continue
|
||||
}
|
||||
return j + 1
|
||||
}
|
||||
return len(query)
|
||||
}
|
||||
|
||||
func skipLineComment(query string, i int) int {
|
||||
for j := i + 2; j < len(query); j++ {
|
||||
if query[j] == '\n' {
|
||||
return j
|
||||
}
|
||||
}
|
||||
return len(query)
|
||||
}
|
||||
|
||||
func skipBlockComment(query string, i int) int {
|
||||
depth := 1
|
||||
for j := i + 2; j < len(query)-1; j++ {
|
||||
if query[j] == '/' && query[j+1] == '*' {
|
||||
depth++
|
||||
j++
|
||||
continue
|
||||
}
|
||||
if query[j] == '*' && query[j+1] == '/' {
|
||||
depth--
|
||||
j++
|
||||
if depth == 0 {
|
||||
return j + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return len(query)
|
||||
}
|
||||
|
||||
func skipDollarQuotedLiteral(query string, i int) (int, bool) {
|
||||
if i < 0 || i >= len(query) || query[i] != '$' {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if i+1 >= len(query) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var endTag int
|
||||
if query[i+1] == '$' {
|
||||
endTag = i + 1
|
||||
} else {
|
||||
if !isDollarTagStart(query[i+1]) {
|
||||
return 0, false
|
||||
}
|
||||
j := i + 2
|
||||
for j < len(query) && isDollarTagChar(query[j]) {
|
||||
j++
|
||||
}
|
||||
if j >= len(query) || query[j] != '$' {
|
||||
return 0, false
|
||||
}
|
||||
endTag = j
|
||||
}
|
||||
|
||||
tag := query[i : endTag+1]
|
||||
if closeIdx := strings.Index(query[endTag+1:], tag); closeIdx >= 0 {
|
||||
return endTag + 1 + closeIdx + len(tag), true
|
||||
}
|
||||
return len(query), true
|
||||
}
|
||||
|
||||
func isDollarTagStart(ch byte) bool {
|
||||
return ch == '_' || (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')
|
||||
}
|
||||
|
||||
func isDollarTagChar(ch byte) bool {
|
||||
if isDollarTagStart(ch) {
|
||||
return true
|
||||
}
|
||||
return ch >= '0' && ch <= '9'
|
||||
}
|
||||
|
||||
func isSQLSpace(ch byte) bool {
|
||||
switch ch {
|
||||
case ' ', '\t', '\n', '\r', '\f':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRewritePlaceholdersSkipsProtectedSegments(t *testing.T) {
|
||||
q := `SELECT ?, '?', "id?", $$body ? $$, $tag$X?$tag$, col -- comment ?
|
||||
FROM t /* block ? */ WHERE id = ?`
|
||||
got := rewritePlaceholders(q)
|
||||
want := `SELECT $1, '?', "id?", $$body ? $$, $tag$X?$tag$, col -- comment ?
|
||||
FROM t /* block ? */ WHERE id = $2`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteInsertOrIgnoreBasic(t *testing.T) {
|
||||
q := `INSERT OR IGNORE INTO user_group_user(user_group_id, user_id, created_time) VALUES(?, ?, ?)`
|
||||
got := rewriteInsertOrIgnore(q)
|
||||
want := `INSERT INTO user_group_user(user_group_id, user_id, created_time) VALUES(?, ?, ?) ON CONFLICT DO NOTHING`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteInsertOrIgnoreBeforeReturning(t *testing.T) {
|
||||
q := `INSERT OR IGNORE INTO x(a) VALUES(?) RETURNING id`
|
||||
got := rewriteInsertOrIgnore(q)
|
||||
want := `INSERT INTO x(a) VALUES(?) ON CONFLICT DO NOTHING RETURNING id`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteInsertOrIgnoreNotDuplicatingOnConflict(t *testing.T) {
|
||||
q := `INSERT OR IGNORE INTO x(a) VALUES(?) ON CONFLICT(a) DO UPDATE SET a=excluded.a`
|
||||
got := rewriteInsertOrIgnore(q)
|
||||
want := `INSERT INTO x(a) VALUES(?) ON CONFLICT(a) DO UPDATE SET a=excluded.a`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReturningID(t *testing.T) {
|
||||
if got := ensureReturningID(`INSERT INTO x(a) VALUES($1)`); got != `INSERT INTO x(a) VALUES($1) RETURNING id` {
|
||||
t.Fatalf("missing RETURNING append: %s", got)
|
||||
}
|
||||
if got := ensureReturningID(`INSERT INTO x(a) VALUES($1) RETURNING other_id`); got != `INSERT INTO x(a) VALUES($1) RETURNING other_id` {
|
||||
t.Fatalf("RETURNING should not be duplicated: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteUserIdentifierSafety(t *testing.T) {
|
||||
q := `SELECT user, user_id, 'user', "user", note FROM user -- user
|
||||
WHERE owner='user'`
|
||||
got := rewriteUserIdentifier(q)
|
||||
want := `SELECT "user", user_id, 'user', "user", note FROM "user" -- user
|
||||
WHERE owner='user'`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteQueryPostgresPipeline(t *testing.T) {
|
||||
q := `INSERT OR IGNORE INTO user(name, note) VALUES(?, '?')`
|
||||
got := rewriteQuery(DialectPostgres, q)
|
||||
want := `INSERT INTO "user"(name, note) VALUES($1, '?') ON CONFLICT DO NOTHING`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteInsertOrIgnoreSkipsStringLiteral(t *testing.T) {
|
||||
q := `SELECT 'INSERT OR IGNORE INTO t(a) VALUES(?)' AS q`
|
||||
got := rewriteInsertOrIgnore(q)
|
||||
if got != q {
|
||||
t.Fatalf("string literal should stay unchanged\nwant: %s\ngot: %s", q, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteInsertOrIgnoreSkipsCommentedKeyword(t *testing.T) {
|
||||
q := `-- INSERT OR IGNORE INTO ignored(a) VALUES(?)
|
||||
INSERT OR IGNORE INTO real_t(a) VALUES(?)`
|
||||
got := rewriteInsertOrIgnore(q)
|
||||
want := `-- INSERT OR IGNORE INTO ignored(a) VALUES(?)
|
||||
INSERT INTO real_t(a) VALUES(?) ON CONFLICT DO NOTHING`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewritePlaceholdersSkipsNestedBlockComment(t *testing.T) {
|
||||
q := `SELECT ? /* outer ? /* inner ? */ still_outer ? */ FROM t WHERE id = ?`
|
||||
got := rewritePlaceholders(q)
|
||||
want := `SELECT $1 /* outer ? /* inner ? */ still_outer ? */ FROM t WHERE id = $2`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewritePlaceholdersSkipsUnterminatedBlockComment(t *testing.T) {
|
||||
q := `SELECT ? /* unterminated ? comment`
|
||||
got := rewritePlaceholders(q)
|
||||
want := `SELECT $1 /* unterminated ? comment`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteUserIdentifierSkipsDollarQuotedAndComment(t *testing.T) {
|
||||
q := `SELECT user, $$user ?$$ AS body, col FROM user /* user */ -- user`
|
||||
got := rewriteUserIdentifier(q)
|
||||
want := `SELECT "user", $$user ?$$ AS body, col FROM "user" /* user */ -- user`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected rewrite\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package postgres
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed sql/schema.sql
|
||||
var EmbeddedSchema string
|
||||
|
||||
//go:embed sql/data.sql
|
||||
var EmbeddedSeedData string
|
||||
@@ -0,0 +1,18 @@
|
||||
INSERT INTO "user" (id, "user", pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES (1, 'admin_user', '3c85cdebade1c51cf64ca9f3c09d182d', 0, 2727251700000, 99999, 0, 0, 1, 99999, 1748914865000, 1754011744252, 1)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO vite_config (id, name, value, time)
|
||||
VALUES (1, 'app_name', 'flux', 1755147963000)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('public.user_id_seq') IS NOT NULL THEN
|
||||
PERFORM setval('user_id_seq', (SELECT COALESCE(MAX(id), 0) FROM "user"));
|
||||
END IF;
|
||||
IF to_regclass('public.vite_config_id_seq') IS NOT NULL THEN
|
||||
PERFORM setval('vite_config_id_seq', (SELECT COALESCE(MAX(id), 0) FROM vite_config));
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
@@ -0,0 +1,241 @@
|
||||
CREATE TABLE IF NOT EXISTS forward (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
user_name VARCHAR(100) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
remote_addr TEXT NOT NULL,
|
||||
strategy VARCHAR(100) NOT NULL DEFAULT 'fifo',
|
||||
in_flow BIGINT NOT NULL DEFAULT 0,
|
||||
out_flow BIGINT NOT NULL DEFAULT 0,
|
||||
created_time BIGINT NOT NULL,
|
||||
updated_time BIGINT NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
inx INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS forward_port (
|
||||
id SERIAL PRIMARY KEY,
|
||||
forward_id INTEGER NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
port INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS node (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
secret VARCHAR(100) NOT NULL,
|
||||
server_ip VARCHAR(100) NOT NULL,
|
||||
server_ip_v4 VARCHAR(100),
|
||||
server_ip_v6 VARCHAR(100),
|
||||
port TEXT NOT NULL,
|
||||
interface_name VARCHAR(200),
|
||||
version VARCHAR(100),
|
||||
http INTEGER NOT NULL DEFAULT 0,
|
||||
tls INTEGER NOT NULL DEFAULT 0,
|
||||
socks INTEGER NOT NULL DEFAULT 0,
|
||||
created_time BIGINT NOT NULL,
|
||||
updated_time BIGINT,
|
||||
status INTEGER NOT NULL,
|
||||
tcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
|
||||
udp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
|
||||
inx INTEGER NOT NULL DEFAULT 0,
|
||||
is_remote INTEGER DEFAULT 0,
|
||||
remote_url TEXT,
|
||||
remote_token TEXT,
|
||||
remote_config TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS speed_limit (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
speed INTEGER NOT NULL,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
tunnel_name VARCHAR(100) NOT NULL,
|
||||
created_time BIGINT NOT NULL,
|
||||
updated_time BIGINT,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS statistics_flow (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
flow BIGINT NOT NULL,
|
||||
total_flow BIGINT NOT NULL,
|
||||
time VARCHAR(100) NOT NULL,
|
||||
created_time BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tunnel (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
traffic_ratio DOUBLE PRECISION NOT NULL DEFAULT 1.0,
|
||||
type INTEGER NOT NULL,
|
||||
protocol VARCHAR(10) NOT NULL DEFAULT 'tls',
|
||||
flow BIGINT NOT NULL,
|
||||
created_time BIGINT NOT NULL,
|
||||
updated_time BIGINT NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
in_ip TEXT,
|
||||
inx INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chain_tunnel (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
chain_type VARCHAR(10) NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
port INTEGER,
|
||||
strategy VARCHAR(10),
|
||||
inx INTEGER,
|
||||
protocol VARCHAR(10)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "user" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
"user" VARCHAR(100) NOT NULL,
|
||||
pwd VARCHAR(100) NOT NULL,
|
||||
role_id INTEGER NOT NULL,
|
||||
exp_time BIGINT NOT NULL,
|
||||
flow BIGINT NOT NULL,
|
||||
in_flow BIGINT NOT NULL DEFAULT 0,
|
||||
out_flow BIGINT NOT NULL DEFAULT 0,
|
||||
flow_reset_time BIGINT NOT NULL,
|
||||
num INTEGER NOT NULL,
|
||||
created_time BIGINT NOT NULL,
|
||||
updated_time BIGINT,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_tunnel (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
speed_id INTEGER,
|
||||
num INTEGER NOT NULL,
|
||||
flow BIGINT NOT NULL,
|
||||
in_flow BIGINT NOT NULL DEFAULT 0,
|
||||
out_flow BIGINT NOT NULL DEFAULT 0,
|
||||
flow_reset_time BIGINT NOT NULL,
|
||||
exp_time BIGINT NOT NULL,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tunnel_group (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
created_time BIGINT NOT NULL,
|
||||
updated_time BIGINT NOT NULL,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_group (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
created_time BIGINT NOT NULL,
|
||||
updated_time BIGINT NOT NULL,
|
||||
status INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tunnel_group_tunnel (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tunnel_group_id INTEGER NOT NULL,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
created_time BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_group_user (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_group_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_time BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_permission (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_group_id INTEGER NOT NULL,
|
||||
tunnel_group_id INTEGER NOT NULL,
|
||||
created_time BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_permission_grant (
|
||||
id SERIAL PRIMARY KEY,
|
||||
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 BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_name ON tunnel_group(name);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_name ON user_group(name);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tunnel_group_tunnel_unique ON tunnel_group_tunnel(tunnel_group_id, tunnel_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_user_unique ON user_group_user(user_group_id, user_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_unique ON group_permission(user_group_id, tunnel_group_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_group_permission_grant_unique ON group_permission_grant(user_group_id, tunnel_group_id, user_tunnel_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_tunnel_unique ON user_tunnel(user_id, tunnel_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vite_config (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL UNIQUE,
|
||||
value VARCHAR(200) NOT NULL,
|
||||
time BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS peer_share (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
max_bandwidth INTEGER DEFAULT 0,
|
||||
expiry_time BIGINT DEFAULT 0,
|
||||
port_range_start INTEGER DEFAULT 0,
|
||||
port_range_end INTEGER DEFAULT 0,
|
||||
current_flow BIGINT DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_time BIGINT NOT NULL,
|
||||
updated_time BIGINT NOT NULL,
|
||||
allowed_domains TEXT DEFAULT '',
|
||||
allowed_ips TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS peer_share_runtime (
|
||||
id SERIAL PRIMARY KEY,
|
||||
share_id INTEGER NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
reservation_id TEXT NOT NULL UNIQUE,
|
||||
resource_key TEXT NOT NULL UNIQUE,
|
||||
binding_id TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT '',
|
||||
chain_name TEXT NOT NULL DEFAULT '',
|
||||
service_name TEXT NOT NULL DEFAULT '',
|
||||
protocol TEXT NOT NULL DEFAULT 'tls',
|
||||
strategy TEXT NOT NULL DEFAULT 'round',
|
||||
port INTEGER NOT NULL DEFAULT 0,
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
applied INTEGER NOT NULL DEFAULT 0,
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
created_time BIGINT NOT NULL,
|
||||
updated_time BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_peer_share_runtime_share_node_status ON peer_share_runtime(share_id, node_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_peer_share_runtime_binding_id ON peer_share_runtime(binding_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS federation_tunnel_binding (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tunnel_id INTEGER NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
chain_type INTEGER NOT NULL,
|
||||
hop_inx INTEGER NOT NULL DEFAULT 0,
|
||||
remote_url TEXT NOT NULL,
|
||||
resource_key TEXT NOT NULL UNIQUE,
|
||||
remote_binding_id TEXT NOT NULL,
|
||||
allocated_port INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
created_time BIGINT NOT NULL,
|
||||
updated_time BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_federation_tunnel_binding_unique ON federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx);
|
||||
CREATE INDEX IF NOT EXISTS idx_federation_tunnel_binding_tunnel ON federation_tunnel_binding(tunnel_id, status);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -54,6 +54,12 @@ type pendingRequest struct {
|
||||
ch chan CommandResult
|
||||
}
|
||||
|
||||
const (
|
||||
wsPingPeriod = 15 * time.Second
|
||||
wsPongWait = 45 * time.Second
|
||||
wsWriteWait = 5 * time.Second
|
||||
)
|
||||
|
||||
type CommandResult struct {
|
||||
Type string `json:"type"`
|
||||
Success bool `json:"success"`
|
||||
@@ -120,12 +126,19 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
cw := &connWrap{conn: conn}
|
||||
_ = conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
return conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||
})
|
||||
done := make(chan struct{})
|
||||
go startKeepalive(cw, done)
|
||||
|
||||
s.mu.Lock()
|
||||
s.admins[cw] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
close(done)
|
||||
s.mu.Lock()
|
||||
delete(s.admins, cw)
|
||||
s.mu.Unlock()
|
||||
@@ -145,6 +158,12 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
||||
return
|
||||
}
|
||||
cw := &connWrap{conn: conn}
|
||||
_ = conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
return conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||
})
|
||||
done := make(chan struct{})
|
||||
go startKeepalive(cw, done)
|
||||
|
||||
version := r.URL.Query().Get("version")
|
||||
httpVal := parseIntDefault(r.URL.Query().Get("http"), 0)
|
||||
@@ -165,6 +184,7 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
||||
s.broadcastStatus(nodeID, 1)
|
||||
|
||||
defer func() {
|
||||
close(done)
|
||||
needOfflineBroadcast := false
|
||||
s.mu.Lock()
|
||||
current, ok := s.nodes[nodeID]
|
||||
@@ -190,7 +210,15 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
||||
|
||||
msg := decryptIfNeeded(payload, secret)
|
||||
s.tryResolvePending(nodeID, msg)
|
||||
s.broadcastInfo(nodeID, msg)
|
||||
|
||||
var parsed struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if json.Unmarshal([]byte(msg), &parsed) == nil && parsed.Type == "UpgradeProgress" {
|
||||
s.broadcastTyped(nodeID, "upgrade_progress", msg)
|
||||
} else {
|
||||
s.broadcastInfo(nodeID, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +292,9 @@ func (s *Server) SendCommand(nodeID int64, cmdType string, data interface{}, tim
|
||||
}
|
||||
|
||||
ns.conn.mu.Lock()
|
||||
_ = ns.conn.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||
err = ns.conn.conn.WriteMessage(websocket.TextMessage, messageData)
|
||||
_ = ns.conn.conn.SetWriteDeadline(time.Time{})
|
||||
ns.conn.mu.Unlock()
|
||||
if err != nil {
|
||||
cleanup()
|
||||
@@ -385,6 +415,12 @@ func (s *Server) broadcastInfo(nodeID int64, data string) {
|
||||
s.broadcastToAdmins(string(raw))
|
||||
}
|
||||
|
||||
func (s *Server) broadcastTyped(nodeID int64, msgType string, data string) {
|
||||
payload := broadcastMessage{ID: nodeID, Type: msgType, Data: data}
|
||||
raw, _ := json.Marshal(payload)
|
||||
s.broadcastToAdmins(string(raw))
|
||||
}
|
||||
|
||||
func (s *Server) broadcastToAdmins(message string) {
|
||||
s.mu.RLock()
|
||||
admins := make([]*connWrap, 0, len(s.admins))
|
||||
@@ -395,7 +431,9 @@ func (s *Server) broadcastToAdmins(message string) {
|
||||
|
||||
for _, c := range admins {
|
||||
c.mu.Lock()
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||
err := c.conn.WriteMessage(websocket.TextMessage, []byte(message))
|
||||
_ = c.conn.SetWriteDeadline(time.Time{})
|
||||
c.mu.Unlock()
|
||||
if err != nil {
|
||||
log.Printf("websocket broadcast failed: %v", err)
|
||||
@@ -428,3 +466,28 @@ func parseIntDefault(v string, fallback int) int {
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func startKeepalive(cw *connWrap, done <-chan struct{}) {
|
||||
if cw == nil || cw.conn == nil {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(wsPingPeriod)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
cw.mu.Lock()
|
||||
_ = cw.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||
err := cw.conn.WriteMessage(websocket.PingMessage, nil)
|
||||
_ = cw.conn.SetWriteDeadline(time.Time{})
|
||||
cw.mu.Unlock()
|
||||
if err != nil {
|
||||
_ = cw.conn.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
|
||||
middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-token")
|
||||
exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-token")
|
||||
|
||||
stopEntry := startMockNodeSession(t, providerServer.URL, "provider-entry-secret")
|
||||
defer stopEntry()
|
||||
stopMiddle := startMockNodeSession(t, providerServer.URL, "provider-middle-secret")
|
||||
defer stopMiddle()
|
||||
stopExit := startMockNodeSession(t, providerServer.URL, "provider-exit-secret")
|
||||
@@ -148,6 +150,23 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
|
||||
assertTunnelPortInRange(t, consumerRepo, secondTunnelID, 2, middleRemoteNodeID, 44000, 44010)
|
||||
assertTunnelPortInRange(t, consumerRepo, secondTunnelID, 3, exitRemoteNodeID, 45000, 45010)
|
||||
|
||||
forwardPayload := map[string]interface{}{
|
||||
"name": "dual-panel-remote-entry-forward",
|
||||
"tunnelId": secondTunnelID,
|
||||
"remoteAddr": "1.1.1.1:443",
|
||||
"strategy": "fifo",
|
||||
}
|
||||
forwardBody, err := json.Marshal(forwardPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal forward payload: %v", err)
|
||||
}
|
||||
forwardReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(forwardBody))
|
||||
forwardReq.Header.Set("Authorization", consumerAdminToken)
|
||||
forwardReq.Header.Set("Content-Type", "application/json")
|
||||
forwardRes := httptest.NewRecorder()
|
||||
consumerRouter.ServeHTTP(forwardRes, forwardReq)
|
||||
assertCode(t, forwardRes, 0)
|
||||
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, middleShareID, 1)
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, exitShareID, 1)
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ?`, entryShareID, 0)
|
||||
@@ -297,6 +316,136 @@ func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationDualPanelRemoteEntryRuntimeContract(t *testing.T) {
|
||||
providerSecret := "provider-contract-jwt"
|
||||
providerRouter, providerRepo := setupContractRouter(t, providerSecret)
|
||||
providerServer := httptest.NewServer(providerRouter)
|
||||
defer providerServer.Close()
|
||||
|
||||
consumerSecret := "consumer-contract-jwt"
|
||||
consumerRouter, consumerRepo := setupContractRouter(t, consumerSecret)
|
||||
|
||||
consumerAdminToken, err := auth.GenerateToken(1, "consumer-admin", 0, consumerSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate consumer admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
providerEntryNodeID := insertContractNode(t, providerRepo, "provider-entry-rt", "198.51.100.21", "43020-43030", "provider-entry-rt-secret", 1)
|
||||
providerMiddleNodeID := insertContractNode(t, providerRepo, "provider-middle-rt", "198.51.100.22", "44020-44030", "provider-middle-rt-secret", 1)
|
||||
providerExitNodeID := insertContractNode(t, providerRepo, "provider-exit-rt", "198.51.100.23", "45020-45030", "provider-exit-rt-secret", 1)
|
||||
|
||||
insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
Name: "entry-share-rt",
|
||||
NodeID: providerEntryNodeID,
|
||||
Token: "share-entry-rt-token",
|
||||
PortRangeStart: 43020,
|
||||
PortRangeEnd: 43030,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
Name: "middle-share-rt",
|
||||
NodeID: providerMiddleNodeID,
|
||||
Token: "share-middle-rt-token",
|
||||
PortRangeStart: 44020,
|
||||
PortRangeEnd: 44030,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||
Name: "exit-share-rt",
|
||||
NodeID: providerExitNodeID,
|
||||
Token: "share-exit-rt-token",
|
||||
PortRangeStart: 45020,
|
||||
PortRangeEnd: 45030,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
|
||||
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-entry-rt-token")
|
||||
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-middle-rt-token")
|
||||
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-exit-rt-token")
|
||||
|
||||
entryRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-entry-rt-token")
|
||||
middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-rt-token")
|
||||
exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-rt-token")
|
||||
|
||||
var commandMu sync.Mutex
|
||||
entryCommands := make([]string, 0, 8)
|
||||
stopEntry := startMockNodeSessionWithHook(t, providerServer.URL, "provider-entry-rt-secret", func(cmdType string) {
|
||||
commandMu.Lock()
|
||||
entryCommands = append(entryCommands, cmdType)
|
||||
commandMu.Unlock()
|
||||
})
|
||||
defer stopEntry()
|
||||
stopMiddle := startMockNodeSession(t, providerServer.URL, "provider-middle-rt-secret")
|
||||
defer stopMiddle()
|
||||
stopExit := startMockNodeSession(t, providerServer.URL, "provider-exit-rt-secret")
|
||||
defer stopExit()
|
||||
|
||||
createTunnel := func(name string) int64 {
|
||||
payload := map[string]interface{}{
|
||||
"name": name,
|
||||
"type": 2,
|
||||
"flow": 99999,
|
||||
"status": 1,
|
||||
"inNodeId": []map[string]interface{}{
|
||||
{"nodeId": entryRemoteNodeID, "protocol": "tls", "strategy": "round"},
|
||||
},
|
||||
"chainNodes": [][]map[string]interface{}{
|
||||
{{"nodeId": middleRemoteNodeID, "protocol": "tls", "strategy": "round"}},
|
||||
},
|
||||
"outNodeId": []map[string]interface{}{
|
||||
{"nodeId": exitRemoteNodeID, "protocol": "tls", "strategy": "round"},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", consumerAdminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
consumerRouter.ServeHTTP(res, req)
|
||||
assertCode(t, res, 0)
|
||||
|
||||
var tunnelID int64
|
||||
if err := consumerRepo.DB().QueryRow(`SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, name).Scan(&tunnelID); err != nil {
|
||||
t.Fatalf("query tunnel id (%s): %v", name, err)
|
||||
}
|
||||
if tunnelID <= 0 {
|
||||
t.Fatalf("invalid tunnel id for %s", name)
|
||||
}
|
||||
return tunnelID
|
||||
}
|
||||
|
||||
createTunnel("dual-panel-remote-entry-online")
|
||||
|
||||
commandMu.Lock()
|
||||
seenAddChains := false
|
||||
seenCommands := append([]string(nil), entryCommands...)
|
||||
for _, cmdType := range entryCommands {
|
||||
if strings.EqualFold(strings.TrimSpace(cmdType), "AddChains") {
|
||||
seenAddChains = true
|
||||
break
|
||||
}
|
||||
}
|
||||
commandMu.Unlock()
|
||||
if !seenAddChains {
|
||||
t.Fatalf("expected entry remote node to receive AddChains, commands=%v", seenCommands)
|
||||
}
|
||||
|
||||
stopEntry()
|
||||
waitNodeStatus(t, providerRepo, providerEntryNodeID, 0)
|
||||
|
||||
createTunnel("dual-panel-remote-entry-offline")
|
||||
}
|
||||
|
||||
func insertContractNode(t *testing.T, repo *sqlite.Repository, name, ip, portRange, secret string, status int) int64 {
|
||||
t.Helper()
|
||||
now := time.Now().UnixMilli()
|
||||
@@ -390,6 +539,10 @@ func assertCount(t *testing.T, repo *sqlite.Repository, query string, arg interf
|
||||
}
|
||||
|
||||
func startMockNodeSession(t *testing.T, baseURL string, nodeSecret string) func() {
|
||||
return startMockNodeSessionWithHook(t, baseURL, nodeSecret, nil)
|
||||
}
|
||||
|
||||
func startMockNodeSessionWithHook(t *testing.T, baseURL string, nodeSecret string, onCommand func(cmdType string)) func() {
|
||||
t.Helper()
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
@@ -449,6 +602,9 @@ func startMockNodeSession(t *testing.T, baseURL string, nodeSecret string) func(
|
||||
if strings.TrimSpace(cmd.RequestID) == "" {
|
||||
continue
|
||||
}
|
||||
if onCommand != nil {
|
||||
onCommand(strings.TrimSpace(cmd.Type))
|
||||
}
|
||||
|
||||
respType := fmt.Sprintf("%sResponse", cmd.Type)
|
||||
respPayload := map[string]interface{}{
|
||||
@@ -473,9 +629,27 @@ func startMockNodeSession(t *testing.T, baseURL string, nodeSecret string) func(
|
||||
}
|
||||
}()
|
||||
|
||||
var stopOnce sync.Once
|
||||
return func() {
|
||||
_ = conn.Close()
|
||||
wg.Wait()
|
||||
stopOnce.Do(func() {
|
||||
_ = conn.Close()
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func waitNodeStatus(t *testing.T, repo *sqlite.Repository, nodeID int64, expectedStatus int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
var status int
|
||||
if err := repo.DB().QueryRow(`SELECT status FROM node WHERE id = ?`, nodeID).Scan(&status); err == nil && status == expectedStatus {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("node %d status did not reach %d before timeout", nodeID, expectedStatus)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
)
|
||||
|
||||
func TestGroupUserUnbindRevokesInheritedTunnelPermission(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(200, 'group_user_contract', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now); err != nil {
|
||||
t.Fatalf("insert test user: %v", err)
|
||||
}
|
||||
|
||||
tunnelRes, err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES('group-contract-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, now, now)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID, err := tunnelRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("read tunnel id: %v", err)
|
||||
}
|
||||
|
||||
ugRes, err := repo.DB().Exec(`INSERT INTO user_group(name, created_time, updated_time, status) VALUES('ug-contract', ?, ?, 1)`, now, now)
|
||||
if err != nil {
|
||||
t.Fatalf("insert user_group: %v", err)
|
||||
}
|
||||
userGroupID, err := ugRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("read user_group id: %v", err)
|
||||
}
|
||||
|
||||
tgRes, err := repo.DB().Exec(`INSERT INTO tunnel_group(name, created_time, updated_time, status) VALUES('tg-contract', ?, ?, 1)`, now, now)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tunnel_group: %v", err)
|
||||
}
|
||||
tunnelGroupID, err := tgRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("read tunnel_group id: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`INSERT INTO tunnel_group_tunnel(tunnel_group_id, tunnel_id, created_time) VALUES(?, ?, ?)`, tunnelGroupID, tunnelID, now); err != nil {
|
||||
t.Fatalf("insert tunnel_group_tunnel: %v", err)
|
||||
}
|
||||
if _, err := repo.DB().Exec(`INSERT INTO group_permission(user_group_id, tunnel_group_id, created_time) VALUES(?, ?, ?)`, userGroupID, tunnelGroupID, now); err != nil {
|
||||
t.Fatalf("insert group_permission: %v", err)
|
||||
}
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
bindReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/user/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(userGroupID)+`,"userIds":[200]}`))
|
||||
bindReq.Header.Set("Authorization", adminToken)
|
||||
bindRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(bindRes, bindReq)
|
||||
assertCode(t, bindRes, 0)
|
||||
|
||||
var userTunnelID int64
|
||||
if err := repo.DB().QueryRow(`SELECT id FROM user_tunnel WHERE user_id = 200 AND tunnel_id = ?`, tunnelID).Scan(&userTunnelID); err != nil {
|
||||
t.Fatalf("query user_tunnel after bind: %v", err)
|
||||
}
|
||||
|
||||
var grantCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
|
||||
t.Fatalf("query group_permission_grant after bind: %v", err)
|
||||
}
|
||||
if grantCount == 0 {
|
||||
t.Fatalf("expected non-zero grants after bind")
|
||||
}
|
||||
|
||||
unbindReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/user/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(userGroupID)+`,"userIds":[]}`))
|
||||
unbindReq.Header.Set("Authorization", adminToken)
|
||||
unbindRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(unbindRes, unbindReq)
|
||||
assertCode(t, unbindRes, 0)
|
||||
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
|
||||
t.Fatalf("query group_permission_grant after unbind: %v", err)
|
||||
}
|
||||
if grantCount != 0 {
|
||||
t.Fatalf("expected grants revoked after unbind, got %d", grantCount)
|
||||
}
|
||||
|
||||
var userTunnelCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM user_tunnel WHERE id = ?`, userTunnelID).Scan(&userTunnelCount); err != nil {
|
||||
t.Fatalf("query user_tunnel after unbind: %v", err)
|
||||
}
|
||||
if userTunnelCount != 0 {
|
||||
t.Fatalf("expected user_tunnel revoked after unbind, got %d", userTunnelCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupPermissionRemoveRevokesInheritedTunnelPermission(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(201, 'group_user_permission_remove', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now); err != nil {
|
||||
t.Fatalf("insert test user: %v", err)
|
||||
}
|
||||
|
||||
tunnelRes, err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES('group-remove-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, now, now)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID, err := tunnelRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("read tunnel id: %v", err)
|
||||
}
|
||||
|
||||
ugRes, err := repo.DB().Exec(`INSERT INTO user_group(name, created_time, updated_time, status) VALUES('ug-remove-contract', ?, ?, 1)`, now, now)
|
||||
if err != nil {
|
||||
t.Fatalf("insert user_group: %v", err)
|
||||
}
|
||||
userGroupID, err := ugRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("read user_group id: %v", err)
|
||||
}
|
||||
|
||||
tgRes, err := repo.DB().Exec(`INSERT INTO tunnel_group(name, created_time, updated_time, status) VALUES('tg-remove-contract', ?, ?, 1)`, now, now)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tunnel_group: %v", err)
|
||||
}
|
||||
tunnelGroupID, err := tgRes.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("read tunnel_group id: %v", err)
|
||||
}
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
assignTunnelReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/tunnel/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(tunnelGroupID)+`,"tunnelIds":[`+jsonNumber(tunnelID)+`]}`))
|
||||
assignTunnelReq.Header.Set("Authorization", adminToken)
|
||||
assignTunnelRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(assignTunnelRes, assignTunnelReq)
|
||||
assertCode(t, assignTunnelRes, 0)
|
||||
|
||||
assignUserReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/user/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(userGroupID)+`,"userIds":[201]}`))
|
||||
assignUserReq.Header.Set("Authorization", adminToken)
|
||||
assignUserRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(assignUserRes, assignUserReq)
|
||||
assertCode(t, assignUserRes, 0)
|
||||
|
||||
assignPermissionReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/permission/assign", bytes.NewBufferString(`{"userGroupId":`+jsonNumber(userGroupID)+`,"tunnelGroupId":`+jsonNumber(tunnelGroupID)+`}`))
|
||||
assignPermissionReq.Header.Set("Authorization", adminToken)
|
||||
assignPermissionRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(assignPermissionRes, assignPermissionReq)
|
||||
assertCode(t, assignPermissionRes, 0)
|
||||
|
||||
var permissionID int64
|
||||
if err := repo.DB().QueryRow(`SELECT id FROM group_permission WHERE user_group_id = ? AND tunnel_group_id = ?`, userGroupID, tunnelGroupID).Scan(&permissionID); err != nil {
|
||||
t.Fatalf("query group_permission id: %v", err)
|
||||
}
|
||||
|
||||
var userTunnelID int64
|
||||
if err := repo.DB().QueryRow(`SELECT id FROM user_tunnel WHERE user_id = 201 AND tunnel_id = ?`, tunnelID).Scan(&userTunnelID); err != nil {
|
||||
t.Fatalf("query user_tunnel after assign: %v", err)
|
||||
}
|
||||
|
||||
var grantCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
|
||||
t.Fatalf("query group_permission_grant after assign: %v", err)
|
||||
}
|
||||
if grantCount == 0 {
|
||||
t.Fatalf("expected non-zero grants after permission assign")
|
||||
}
|
||||
|
||||
removeReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/permission/remove", bytes.NewBufferString(`{"id":`+jsonNumber(permissionID)+`}`))
|
||||
removeReq.Header.Set("Authorization", adminToken)
|
||||
removeRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(removeRes, removeReq)
|
||||
assertCode(t, removeRes, 0)
|
||||
|
||||
var permissionCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission WHERE id = ?`, permissionID).Scan(&permissionCount); err != nil {
|
||||
t.Fatalf("query group_permission after remove: %v", err)
|
||||
}
|
||||
if permissionCount != 0 {
|
||||
t.Fatalf("expected group_permission removed, got %d", permissionCount)
|
||||
}
|
||||
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
|
||||
t.Fatalf("query group_permission_grant after remove: %v", err)
|
||||
}
|
||||
if grantCount != 0 {
|
||||
t.Fatalf("expected grants removed after permission remove, got %d", grantCount)
|
||||
}
|
||||
|
||||
var userTunnelCount int
|
||||
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM user_tunnel WHERE id = ?`, userTunnelID).Scan(&userTunnelCount); err != nil {
|
||||
t.Fatalf("query user_tunnel after permission remove: %v", err)
|
||||
}
|
||||
if userTunnelCount != 0 {
|
||||
t.Fatalf("expected user_tunnel revoked after permission remove, got %d", userTunnelCount)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
httpserver "go-backend/internal/http"
|
||||
"go-backend/internal/http/handler"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store"
|
||||
"go-backend/internal/store/sqlite"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
@@ -202,6 +203,234 @@ func TestSpeedLimitTunnelsRouteAlias(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestBackupExportImportRestoreContracts(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate user token: %v", err)
|
||||
}
|
||||
|
||||
key := "backup_contract_key"
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO vite_config(name, value, time)
|
||||
VALUES(?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
|
||||
`, key, "v1", time.Now().UnixMilli()); err != nil {
|
||||
t.Fatalf("seed config for backup contract: %v", err)
|
||||
}
|
||||
|
||||
t.Run("non-admin is blocked on backup export", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/export", nil)
|
||||
req.Header.Set("Authorization", userToken)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
assertCodeMsg(t, resp, 403, "权限不足,仅管理员可操作")
|
||||
})
|
||||
|
||||
t.Run("standard and duplicate export routes both work", func(t *testing.T) {
|
||||
payloadA := exportBackupPayload(t, router, "/api/v1/backup/export", adminToken)
|
||||
if len(payloadA.Configs) == 0 {
|
||||
t.Fatalf("expected exported configs, got none")
|
||||
}
|
||||
if _, ok := payloadA.Configs[key]; !ok {
|
||||
t.Fatalf("expected %q in exported configs", key)
|
||||
}
|
||||
|
||||
payloadB := exportBackupPayload(t, router, "/api/v1/api/v1/backup/export", adminToken)
|
||||
if len(payloadB.Configs) == 0 {
|
||||
t.Fatalf("expected exported configs from duplicate-prefix route, got none")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("backup import applies exported data", func(t *testing.T) {
|
||||
payload := exportBackupPayload(t, router, "/api/v1/backup/export", adminToken)
|
||||
payload.Configs[key] = "v2"
|
||||
raw, err := json.Marshal(backupImportPayload{Types: []string{"configs"}, backupExportPayload: payload})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal import payload: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
var out response.R
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode import response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected import code 0, got %d (%s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
cfg, err := repo.GetConfigByName(key)
|
||||
if err != nil {
|
||||
t.Fatalf("query imported config: %v", err)
|
||||
}
|
||||
if cfg == nil || cfg.Value != "v2" {
|
||||
t.Fatalf("expected imported config value v2, got %+v", cfg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("backup restore alias applies exported data", func(t *testing.T) {
|
||||
payload := exportBackupPayload(t, router, "/api/v1/backup/export", adminToken)
|
||||
payload.Configs[key] = "v3"
|
||||
raw, err := json.Marshal(backupImportPayload{Types: []string{"configs"}, backupExportPayload: payload})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal restore payload: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/restore", bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
var out response.R
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode restore response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected restore code 0, got %d (%s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
cfg, err := repo.GetConfigByName(key)
|
||||
if err != nil {
|
||||
t.Fatalf("query restored config: %v", err)
|
||||
}
|
||||
if cfg == nil || cfg.Value != "v3" {
|
||||
t.Fatalf("expected restored config value v3, got %+v", cfg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("backup export tolerates nullable legacy tunnel chain fields", func(t *testing.T) {
|
||||
now := time.Now().UnixMilli()
|
||||
res, err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "legacy-null-chain", 1.0, 1, "tls", 1000, now, now, 1, nil, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("seed tunnel for nullable chain export: %v", err)
|
||||
}
|
||||
tunnelID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("read tunnel id for nullable chain export: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||
`, tunnelID, "1", 1, nil, nil, nil, nil); err != nil {
|
||||
t.Fatalf("seed nullable chain_tunnel row: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/export", bytes.NewBufferString(`{"types":["tunnels"]}`))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", resp.Code)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Version string `json:"version"`
|
||||
Tunnels []struct {
|
||||
ID int64 `json:"id"`
|
||||
ChainTunnels []struct {
|
||||
Inx int `json:"inx"`
|
||||
Strategy string `json:"strategy"`
|
||||
Protocol string `json:"protocol"`
|
||||
} `json:"chainTunnels"`
|
||||
} `json:"tunnels"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode tunnels backup payload: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(payload.Version) == "" {
|
||||
t.Fatalf("expected backup payload version, got empty")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, tunnel := range payload.Tunnels {
|
||||
if tunnel.ID != tunnelID {
|
||||
continue
|
||||
}
|
||||
if len(tunnel.ChainTunnels) != 1 {
|
||||
t.Fatalf("expected one chain tunnel for seeded tunnel %d, got %d", tunnelID, len(tunnel.ChainTunnels))
|
||||
}
|
||||
if tunnel.ChainTunnels[0].Inx != 0 {
|
||||
t.Fatalf("expected nullable chain inx to export as 0, got %d", tunnel.ChainTunnels[0].Inx)
|
||||
}
|
||||
if tunnel.ChainTunnels[0].Strategy != "" {
|
||||
t.Fatalf("expected nullable chain strategy to export as empty string, got %q", tunnel.ChainTunnels[0].Strategy)
|
||||
}
|
||||
if tunnel.ChainTunnels[0].Protocol != "" {
|
||||
t.Fatalf("expected nullable chain protocol to export as empty string, got %q", tunnel.ChainTunnels[0].Protocol)
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected seeded tunnel %d in backup export", tunnelID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type backupExportPayload struct {
|
||||
Version string `json:"version"`
|
||||
ExportedAt int64 `json:"exportedAt"`
|
||||
Configs map[string]string `json:"configs"`
|
||||
}
|
||||
|
||||
type backupImportPayload struct {
|
||||
Types []string `json:"types"`
|
||||
backupExportPayload
|
||||
}
|
||||
|
||||
func exportBackupPayload(t *testing.T, router http.Handler, path, token string) backupExportPayload {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(`{"types":["configs"]}`))
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200 on %s, got %d", path, resp.Code)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read backup payload from %s: %v", path, err)
|
||||
}
|
||||
|
||||
var payload backupExportPayload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("decode backup payload from %s: %v", path, err)
|
||||
}
|
||||
if strings.TrimSpace(payload.Version) == "" {
|
||||
var out response.R
|
||||
if err := json.Unmarshal(body, &out); err == nil {
|
||||
t.Fatalf("expected backup payload on %s, got envelope code=%d msg=%q", path, out.Code, out.Msg)
|
||||
}
|
||||
t.Fatalf("expected non-empty backup payload version on %s, body=%s", path, string(body))
|
||||
}
|
||||
if payload.Configs == nil {
|
||||
t.Fatalf("expected configs map in backup payload on %s", path)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *sqlite.Repository) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "contract.db")
|
||||
@@ -305,7 +534,7 @@ func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func readTableColumns(t *testing.T, db *sql.DB, table string) map[string]bool {
|
||||
func readTableColumns(t *testing.T, db *store.DB, table string) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
rows, err := db.Query("PRAGMA table_info(" + table + ")")
|
||||
|
||||
@@ -2,6 +2,7 @@ package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -144,6 +145,17 @@ func TestTunnelUpdateAssignsChainPortsContract(t *testing.T) {
|
||||
if outPort <= 0 {
|
||||
t.Fatalf("expected out node port to be assigned, got %d", outPort)
|
||||
}
|
||||
|
||||
var entryStrategy sql.NullString
|
||||
if err := repo.DB().QueryRow(`SELECT strategy FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 1 LIMIT 1`, tunnelID).Scan(&entryStrategy); err != nil {
|
||||
t.Fatalf("query entry strategy: %v", err)
|
||||
}
|
||||
if !entryStrategy.Valid || strings.TrimSpace(entryStrategy.String) == "" {
|
||||
t.Fatalf("expected entry strategy to be non-null and non-empty")
|
||||
}
|
||||
if entryStrategy.String != "round" {
|
||||
t.Fatalf("expected entry strategy round, got %q", entryStrategy.String)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonInt(v int64) string {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# GOST CONNECTOR KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Feb 13 2026
|
||||
|
||||
## OVERVIEW
|
||||
Connection initiators (clients) for various protocols in GOST forwarding.
|
||||
**Stack:** Go, GOST core.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
connector/
|
||||
├── direct/ # Direct connection
|
||||
├── forward/ # Forward proxy
|
||||
├── http/ # HTTP connector
|
||||
├── http2/ # HTTP/2 connector
|
||||
├── relay/ # Relay protocol
|
||||
├── router/ # Router connector
|
||||
├── serial/ # Serial port
|
||||
├── sni/ # SNI routing
|
||||
├── socks/ # SOCKS4/5
|
||||
├── ss/ # Shadowsocks
|
||||
├── sshd/ # SSH daemon
|
||||
├── tcp/ # TCP connector
|
||||
├── tunnel/ # Tunnel mode
|
||||
└── unix/ # Unix socket
|
||||
```
|
||||
|
||||
## CONVENTIONS
|
||||
- Inherits from parent `go-gost/x/` conventions.
|
||||
- Each subdir implements `Connector` interface from GOST core.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- DO NOT EDIT generated protobuf in `go-gost/x/internal/util/grpc/proto/`.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost
|
||||
go test ./x/connector/...
|
||||
```
|
||||
@@ -54,10 +54,10 @@ var needWrap = false
|
||||
|
||||
// SetProtocolBlock sets protocol blocking switches and recomputes wrapper need
|
||||
func SetProtocolBlock(httpOn int, tlsOn int, socksOn int) {
|
||||
isHttp = httpOn
|
||||
isTls = tlsOn
|
||||
isSocks = socksOn
|
||||
needWrap = isTls+isSocks+isHttp > 0
|
||||
isHttp = httpOn
|
||||
isTls = tlsOn
|
||||
isSocks = socksOn
|
||||
needWrap = isTls+isSocks+isHttp > 0
|
||||
}
|
||||
|
||||
type Option func(opts *options)
|
||||
@@ -292,7 +292,9 @@ func (s *defaultService) Serve() error {
|
||||
}
|
||||
|
||||
if err := s.handler.Handle(ctx, conn); err != nil {
|
||||
log.Error(err)
|
||||
if !errors.Is(err, net.ErrClosed) {
|
||||
log.Error(err)
|
||||
}
|
||||
if v := xmetrics.GetCounter(xmetrics.MetricServiceHandlerErrorsCounter,
|
||||
metrics.Labels{"service": s.name, "client": clientIP}); v != nil {
|
||||
v.Inc()
|
||||
@@ -403,12 +405,12 @@ func (s *defaultService) observeStats(ctx context.Context) {
|
||||
TotalErrs: st.Get(stats.KindTotalErrs),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
// 将流量累积到全局管理器,而不是立即上报
|
||||
if outputBytes > 0 || inputBytes > 0 {
|
||||
globalManager := GetGlobalTrafficManager()
|
||||
globalManager.AddTraffic(s.name, int64(outputBytes), int64(inputBytes))
|
||||
|
||||
|
||||
// 立即重置流量计数(因为已经记录到全局管理器中)
|
||||
if xstats, ok := st.(*xstats.Stats); ok {
|
||||
xstats.ResetTraffic(st.Get(stats.KindInputBytes)-inputBytes, st.Get(stats.KindOutputBytes)-outputBytes)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# GOST SOCKET KNOWLEDGE BASE
|
||||
|
||||
**Generated:** Fri Feb 13 2026
|
||||
|
||||
## OVERVIEW
|
||||
Socket utilities and wrappers for GOST forwarding.
|
||||
**Stack:** Go, GOST core.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
socket/
|
||||
├── socket.go # Core socket interface
|
||||
├── udp.go # UDP socket handling
|
||||
├── packet.go # Packet framing
|
||||
├── packetconn.go # Packet connection wrapper
|
||||
└── ... # Additional socket utilities
|
||||
```
|
||||
|
||||
## CONVENTIONS
|
||||
- Inherits from parent `go-gost/x/` conventions.
|
||||
- Low-level network primitives.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- DO NOT EDIT generated protobuf.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
cd go-gost
|
||||
go test ./x/socket/...
|
||||
```
|
||||
@@ -4,10 +4,17 @@ import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync" // 新增:用于管理连接状态的互斥锁
|
||||
@@ -21,7 +28,6 @@ import (
|
||||
"github.com/shirou/gopsutil/v3/host"
|
||||
"github.com/shirou/gopsutil/v3/mem"
|
||||
psnet "github.com/shirou/gopsutil/v3/net"
|
||||
"os"
|
||||
)
|
||||
|
||||
// SystemInfo 系统信息结构体
|
||||
@@ -85,6 +91,11 @@ type TcpPingResponse struct {
|
||||
RequestId string `json:"requestId,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
reporterReadWait = 60 * time.Second
|
||||
reporterWriteWait = 5 * time.Second
|
||||
)
|
||||
|
||||
type WebSocketReporter struct {
|
||||
url string
|
||||
addr string // 保存服务器地址
|
||||
@@ -237,6 +248,14 @@ func (w *WebSocketReporter) connect() error {
|
||||
|
||||
w.conn = conn
|
||||
w.connected = true
|
||||
_ = conn.SetReadDeadline(time.Now().Add(reporterReadWait))
|
||||
conn.SetPingHandler(func(appData string) error {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(reporterReadWait))
|
||||
return conn.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(reporterWriteWait))
|
||||
})
|
||||
conn.SetPongHandler(func(string) error {
|
||||
return conn.SetReadDeadline(time.Now().Add(reporterReadWait))
|
||||
})
|
||||
|
||||
// 设置关闭处理器来检测连接状态
|
||||
w.conn.SetCloseHandler(func(code int, text string) error {
|
||||
@@ -377,7 +396,7 @@ func (w *WebSocketReporter) receiveMessages() {
|
||||
}
|
||||
|
||||
// 设置读取超时
|
||||
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
|
||||
conn.SetReadDeadline(time.Now().Add(reporterReadWait))
|
||||
|
||||
messageType, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
@@ -466,9 +485,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
|
||||
}
|
||||
|
||||
if cmdMsg.Type != "call" {
|
||||
// TcpPing 诊断命令异步执行,避免阻塞其他命令
|
||||
// 其他状态变更命令保持同步,确保顺序执行
|
||||
if cmdMsg.Type == "TcpPing" {
|
||||
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
|
||||
go w.routeCommand(cmdMsg)
|
||||
} else {
|
||||
w.routeCommand(cmdMsg)
|
||||
@@ -483,9 +501,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
|
||||
return
|
||||
}
|
||||
if cmdMsg.Type != "call" {
|
||||
// TcpPing 诊断命令异步执行,避免阻塞其他命令
|
||||
// 其他状态变更命令保持同步,确保顺序执行
|
||||
if cmdMsg.Type == "TcpPing" {
|
||||
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
|
||||
go w.routeCommand(cmdMsg)
|
||||
} else {
|
||||
w.routeCommand(cmdMsg)
|
||||
@@ -579,6 +596,18 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
|
||||
response.Type = "SetProtocolResponse"
|
||||
needSaveConfig = true
|
||||
|
||||
// 升级 Agent 命令(异步执行,不需要保存配置)
|
||||
case "UpgradeAgent":
|
||||
err = w.handleUpgradeAgent(cmd.Data)
|
||||
response.Type = "UpgradeAgentResponse"
|
||||
// needSaveConfig = false (默认值)
|
||||
|
||||
// 回退 Agent 到旧版本
|
||||
case "RollbackAgent":
|
||||
err = w.handleRollbackAgent(cmd.Data)
|
||||
response.Type = "RollbackAgentResponse"
|
||||
// needSaveConfig = false (默认值)
|
||||
|
||||
default:
|
||||
err = fmt.Errorf("未知命令类型: %s", cmd.Type)
|
||||
response.Type = "UnknownCommandResponse"
|
||||
@@ -881,6 +910,186 @@ func (w *WebSocketReporter) handleSetProtocol(data interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendUpgradeProgress 通过 WS 发送升级进度消息
|
||||
func (w *WebSocketReporter) sendUpgradeProgress(stage string, percent int, message string) {
|
||||
response := CommandResponse{
|
||||
Type: "UpgradeProgress",
|
||||
Success: true,
|
||||
Message: message,
|
||||
Data: map[string]interface{}{
|
||||
"stage": stage,
|
||||
"percent": percent,
|
||||
},
|
||||
}
|
||||
w.sendResponse(response)
|
||||
}
|
||||
|
||||
func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化数据失败: %v", err)
|
||||
}
|
||||
|
||||
var req struct {
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
ChecksumURL string `json:"checksumUrl"`
|
||||
}
|
||||
if err := json.Unmarshal(jsonData, &req); err != nil {
|
||||
return fmt.Errorf("解析升级参数失败: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(req.DownloadURL) == "" {
|
||||
return fmt.Errorf("下载地址不能为空")
|
||||
}
|
||||
|
||||
// 替换架构占位符
|
||||
downloadURL := strings.ReplaceAll(req.DownloadURL, "{ARCH}", runtime.GOARCH)
|
||||
checksumURL := strings.ReplaceAll(req.ChecksumURL, "{ARCH}", runtime.GOARCH)
|
||||
|
||||
w.sendUpgradeProgress("downloading", 0, "开始下载升级包...")
|
||||
fmt.Printf("📦 开始下载升级包: %s\n", downloadURL)
|
||||
|
||||
// 下载新版本二进制
|
||||
const binaryPath = "/etc/flux_agent/flux_agent"
|
||||
tmpPath := binaryPath + ".new"
|
||||
backupPath := binaryPath + ".old"
|
||||
|
||||
resp, err := http.Get(downloadURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("下载升级包失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("下载升级包失败, HTTP状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
outFile, err := os.Create(tmpPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建临时文件失败: %v", err)
|
||||
}
|
||||
|
||||
// 带进度的下载
|
||||
totalSize := resp.ContentLength
|
||||
var downloaded int64
|
||||
buf := make([]byte, 32*1024)
|
||||
lastPercent := 0
|
||||
hasher := sha256.New()
|
||||
|
||||
for {
|
||||
n, readErr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
if _, wErr := outFile.Write(buf[:n]); wErr != nil {
|
||||
outFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("写入升级包失败: %v", wErr)
|
||||
}
|
||||
hasher.Write(buf[:n])
|
||||
downloaded += int64(n)
|
||||
if totalSize > 0 {
|
||||
percent := int(downloaded * 100 / totalSize)
|
||||
if percent-lastPercent >= 10 {
|
||||
lastPercent = percent
|
||||
w.sendUpgradeProgress("downloading", percent, fmt.Sprintf("下载中... %d%%", percent))
|
||||
}
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
outFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("读取升级包失败: %v", readErr)
|
||||
}
|
||||
}
|
||||
outFile.Close()
|
||||
|
||||
if downloaded == 0 {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("下载的升级包为空")
|
||||
}
|
||||
|
||||
w.sendUpgradeProgress("downloading", 100, fmt.Sprintf("下载完成 (%d bytes)", downloaded))
|
||||
|
||||
// Checksum 校验
|
||||
if checksumURL != "" {
|
||||
w.sendUpgradeProgress("verifying", 0, "校验文件完整性...")
|
||||
checksumResp, err := http.Get(checksumURL)
|
||||
if err == nil {
|
||||
defer checksumResp.Body.Close()
|
||||
if checksumResp.StatusCode == http.StatusOK {
|
||||
checksumBody, err := io.ReadAll(checksumResp.Body)
|
||||
if err == nil {
|
||||
// 格式: "<hash> <filename>" 或 "<hash>"
|
||||
expectedHash := strings.TrimSpace(strings.Split(string(checksumBody), " ")[0])
|
||||
actualHash := hex.EncodeToString(hasher.Sum(nil))
|
||||
if !strings.EqualFold(expectedHash, actualHash) {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("校验失败: 期望 %s, 实际 %s", expectedHash, actualHash)
|
||||
}
|
||||
fmt.Printf("✅ Checksum 校验通过: %s\n", actualHash)
|
||||
}
|
||||
}
|
||||
}
|
||||
w.sendUpgradeProgress("verifying", 100, "校验通过")
|
||||
}
|
||||
|
||||
if err := os.Chmod(tmpPath, 0755); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("设置执行权限失败: %v", err)
|
||||
}
|
||||
|
||||
// 备份旧版本
|
||||
w.sendUpgradeProgress("installing", 50, "备份旧版本...")
|
||||
if _, err := os.Stat(binaryPath); err == nil {
|
||||
// 复制旧文件作为备份(不用 rename,因为可能正在运行)
|
||||
oldData, err := os.ReadFile(binaryPath)
|
||||
if err == nil {
|
||||
_ = os.WriteFile(backupPath, oldData, 0755)
|
||||
fmt.Println("📦 旧版本已备份到", backupPath)
|
||||
}
|
||||
}
|
||||
|
||||
w.sendUpgradeProgress("installing", 80, "准备重启...")
|
||||
fmt.Printf("✅ 升级包下载完成 (%d bytes), 准备重启...\n", downloaded)
|
||||
|
||||
// 执行重启脚本
|
||||
// 使用 systemd-run 在独立的 transient unit 中运行重启脚本,
|
||||
// 避免 systemctl stop 杀死 flux_agent cgroup 内所有进程(包括此脚本自身)导致 mv 未执行。
|
||||
script := fmt.Sprintf("sleep 1 && systemctl stop flux_agent && mv %s %s && systemctl start flux_agent", tmpPath, binaryPath)
|
||||
cmd := exec.Command("systemd-run", "--quiet", "/bin/sh", "-c", script)
|
||||
if err := cmd.Start(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("启动重启脚本失败: %v", err)
|
||||
}
|
||||
|
||||
w.sendUpgradeProgress("installing", 100, "重启中...")
|
||||
fmt.Println("🔄 重启脚本已启动, Agent 将在 1 秒后重启...")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WebSocketReporter) handleRollbackAgent(data interface{}) error {
|
||||
const binaryPath = "/etc/flux_agent/flux_agent"
|
||||
backupPath := binaryPath + ".old"
|
||||
|
||||
// 检查备份文件是否存在
|
||||
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("没有可用的备份文件,无法回退")
|
||||
}
|
||||
|
||||
fmt.Println("🔄 开始回退到旧版本...")
|
||||
|
||||
// 执行回退脚本(同升级逻辑,使用 systemd-run 避免 cgroup 问题)
|
||||
script := fmt.Sprintf("sleep 1 && systemctl stop flux_agent && cp %s %s && systemctl start flux_agent", backupPath, binaryPath)
|
||||
cmd := exec.Command("systemd-run", "--quiet", "/bin/sh", "-c", script)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("启动回退脚本失败: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("🔄 回退脚本已启动, Agent 将在 1 秒后重启...")
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateLocalConfigJSON 将 http/tls/socks 写入工作目录下的 config.json
|
||||
func updateLocalConfigJSON(httpVal int, tlsVal int, socksVal int) error {
|
||||
path := "config.json"
|
||||
|
||||
+236
-34
@@ -203,7 +203,8 @@ show_menu() {
|
||||
echo "1. 安装面板"
|
||||
echo "2. 更新面板"
|
||||
echo "3. 卸载面板"
|
||||
echo "4. 退出"
|
||||
echo "4. 迁移到 PostgreSQL"
|
||||
echo "5. 退出"
|
||||
echo "==============================================="
|
||||
}
|
||||
|
||||
@@ -232,6 +233,92 @@ upsert_env_var() {
|
||||
mv "$tmp_file" "$file"
|
||||
}
|
||||
|
||||
get_env_var() {
|
||||
local key="$1"
|
||||
local file="${2:-.env}"
|
||||
|
||||
if [[ ! -f "$file" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
grep -m1 "^${key}=" "$file" | cut -d= -f2-
|
||||
}
|
||||
|
||||
get_current_db_type() {
|
||||
local db_type database_url
|
||||
|
||||
db_type=$(get_env_var "DB_TYPE")
|
||||
database_url=$(get_env_var "DATABASE_URL")
|
||||
|
||||
if [[ "$db_type" == "sqlite" ]]; then
|
||||
echo "sqlite"
|
||||
elif [[ "$db_type" == "postgres" || "$database_url" == postgres://* || "$database_url" == postgresql://* ]]; then
|
||||
echo "postgres"
|
||||
else
|
||||
echo "sqlite"
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_postgres_healthy() {
|
||||
local pg_health
|
||||
|
||||
echo "🔍 检查 PostgreSQL 服务状态..."
|
||||
for i in {1..90}; do
|
||||
if docker ps --format "{{.Names}}" | grep -q "^flux-panel-postgres$"; then
|
||||
pg_health=$(docker inspect -f '{{.State.Health.Status}}' flux-panel-postgres 2>/dev/null || echo "unknown")
|
||||
if [[ "$pg_health" == "healthy" ]]; then
|
||||
echo "✅ PostgreSQL 服务健康检查通过"
|
||||
return 0
|
||||
elif [[ "$pg_health" == "unhealthy" ]]; then
|
||||
echo "⚠️ PostgreSQL 健康状态:$pg_health"
|
||||
fi
|
||||
else
|
||||
pg_health="not_running"
|
||||
fi
|
||||
|
||||
if [ $i -eq 90 ]; then
|
||||
echo "❌ PostgreSQL 启动超时(90秒)"
|
||||
echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' flux-panel-postgres 2>/dev/null || echo '容器不存在')"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ $((i % 15)) -eq 1 ]; then
|
||||
echo "⏳ 等待 PostgreSQL 启动... ($i/90) 状态:${pg_health:-unknown}"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_backend_healthy() {
|
||||
local backend_health
|
||||
|
||||
echo "🔍 检查后端服务状态..."
|
||||
for i in {1..90}; do
|
||||
if docker ps --format "{{.Names}}" | grep -q "^flux-panel-backend$"; then
|
||||
backend_health=$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo "unknown")
|
||||
if [[ "$backend_health" == "healthy" ]]; then
|
||||
echo "✅ 后端服务健康检查通过"
|
||||
return 0
|
||||
elif [[ "$backend_health" == "unhealthy" ]]; then
|
||||
echo "⚠️ 后端健康状态:$backend_health"
|
||||
fi
|
||||
else
|
||||
backend_health="not_running"
|
||||
fi
|
||||
|
||||
if [ $i -eq 90 ]; then
|
||||
echo "❌ 后端服务启动超时(90秒)"
|
||||
echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo '容器不存在')"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ $((i % 15)) -eq 1 ]; then
|
||||
echo "⏳ 等待后端服务启动... ($i/90) 状态:${backend_health:-unknown}"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
# 删除脚本自身
|
||||
delete_self() {
|
||||
echo ""
|
||||
@@ -253,6 +340,33 @@ get_config_params() {
|
||||
read -p "后端端口(默认 6365): " BACKEND_PORT
|
||||
BACKEND_PORT=${BACKEND_PORT:-6365}
|
||||
|
||||
echo "请选择数据库类型:"
|
||||
echo "1. SQLite(默认)"
|
||||
echo "2. PostgreSQL"
|
||||
read -p "数据库类型(1/2,默认 1): " DB_CHOICE
|
||||
case "$DB_CHOICE" in
|
||||
2)
|
||||
DB_TYPE="postgres"
|
||||
;;
|
||||
""|1)
|
||||
DB_TYPE="sqlite"
|
||||
;;
|
||||
*)
|
||||
echo "⚠️ 输入无效,默认使用 SQLite"
|
||||
DB_TYPE="sqlite"
|
||||
;;
|
||||
esac
|
||||
|
||||
POSTGRES_DB="flux_panel"
|
||||
POSTGRES_USER="flux_panel"
|
||||
POSTGRES_PASSWORD=$(generate_random)
|
||||
|
||||
if [[ "$DB_TYPE" == "postgres" ]]; then
|
||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable"
|
||||
else
|
||||
DATABASE_URL=""
|
||||
fi
|
||||
|
||||
# 生成JWT密钥
|
||||
JWT_SECRET=$(generate_random)
|
||||
}
|
||||
@@ -280,10 +394,23 @@ JWT_SECRET=$JWT_SECRET
|
||||
FRONTEND_PORT=$FRONTEND_PORT
|
||||
BACKEND_PORT=$BACKEND_PORT
|
||||
FLUX_VERSION=$RESOLVED_VERSION
|
||||
|
||||
DB_TYPE=$DB_TYPE
|
||||
DATABASE_URL=$DATABASE_URL
|
||||
|
||||
POSTGRES_DB=$POSTGRES_DB
|
||||
POSTGRES_USER=$POSTGRES_USER
|
||||
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
|
||||
EOF
|
||||
|
||||
echo "🚀 启动 docker 服务..."
|
||||
$DOCKER_CMD up -d
|
||||
if [[ "$DB_TYPE" == "postgres" ]]; then
|
||||
$DOCKER_CMD up -d postgres
|
||||
wait_for_postgres_healthy
|
||||
$DOCKER_CMD up -d backend frontend
|
||||
else
|
||||
$DOCKER_CMD up -d backend frontend
|
||||
fi
|
||||
|
||||
echo "🎉 部署完成"
|
||||
echo "🌐 访问地址: http://服务器IP:$FRONTEND_PORT"
|
||||
@@ -300,6 +427,12 @@ update_panel() {
|
||||
echo "🔄 开始更新面板..."
|
||||
check_docker
|
||||
|
||||
if [[ ! -f ".env" ]]; then
|
||||
echo "⚠️ 未找到 .env,默认按 SQLite 模式更新"
|
||||
fi
|
||||
CURRENT_DB_TYPE=$(get_current_db_type)
|
||||
echo "🗄️ 当前数据库类型:$CURRENT_DB_TYPE"
|
||||
|
||||
echo "🔍 获取最新版本号..."
|
||||
LATEST_VERSION=$(resolve_latest_release_tag) || {
|
||||
echo "❌ 无法获取最新版本号,更新终止"
|
||||
@@ -333,49 +466,113 @@ update_panel() {
|
||||
$DOCKER_CMD down
|
||||
|
||||
echo "⬇️ 拉取最新镜像..."
|
||||
$DOCKER_CMD pull
|
||||
if [[ "$CURRENT_DB_TYPE" == "postgres" ]]; then
|
||||
$DOCKER_CMD pull backend frontend postgres
|
||||
else
|
||||
$DOCKER_CMD pull backend frontend
|
||||
fi
|
||||
|
||||
echo "🚀 启动更新后的服务..."
|
||||
$DOCKER_CMD up -d
|
||||
if [[ "$CURRENT_DB_TYPE" == "postgres" ]]; then
|
||||
$DOCKER_CMD up -d postgres
|
||||
wait_for_postgres_healthy
|
||||
$DOCKER_CMD up -d backend frontend
|
||||
else
|
||||
$DOCKER_CMD up -d backend frontend
|
||||
fi
|
||||
|
||||
# 等待服务启动
|
||||
echo "⏳ 等待服务启动..."
|
||||
|
||||
# 检查后端容器健康状态
|
||||
echo "🔍 检查后端服务状态..."
|
||||
for i in {1..90}; do
|
||||
if docker ps --format "{{.Names}}" | grep -q "^flux-panel-backend$"; then
|
||||
BACKEND_HEALTH=$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo "unknown")
|
||||
if [[ "$BACKEND_HEALTH" == "healthy" ]]; then
|
||||
echo "✅ 后端服务健康检查通过"
|
||||
break
|
||||
elif [[ "$BACKEND_HEALTH" == "starting" ]]; then
|
||||
# 继续等待
|
||||
:
|
||||
elif [[ "$BACKEND_HEALTH" == "unhealthy" ]]; then
|
||||
echo "⚠️ 后端健康状态:$BACKEND_HEALTH"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ 后端容器未找到或未运行"
|
||||
BACKEND_HEALTH="not_running"
|
||||
fi
|
||||
if [ $i -eq 90 ]; then
|
||||
echo "❌ 后端服务启动超时(90秒)"
|
||||
echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo '容器不存在')"
|
||||
echo "🛑 更新终止"
|
||||
return 1
|
||||
fi
|
||||
# 每15秒显示一次进度
|
||||
if [ $((i % 15)) -eq 1 ]; then
|
||||
echo "⏳ 等待后端服务启动... ($i/90) 状态:${BACKEND_HEALTH:-unknown}"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if ! wait_for_backend_healthy; then
|
||||
echo "🛑 更新终止"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "✅ 更新完成"
|
||||
}
|
||||
|
||||
|
||||
migrate_to_postgres() {
|
||||
local current_db_type postgres_db postgres_user postgres_password database_url
|
||||
|
||||
echo "🔄 开始迁移 SQLite -> PostgreSQL..."
|
||||
check_docker
|
||||
|
||||
if [[ ! -f ".env" ]]; then
|
||||
echo "❌ 未找到 .env 文件,请先安装面板"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "docker-compose.yml" ]]; then
|
||||
echo "⚠️ 未找到 docker-compose.yml 文件,正在下载..."
|
||||
DOCKER_COMPOSE_URL=$(get_docker_compose_url)
|
||||
echo "📡 选择配置文件:$(basename "$DOCKER_COMPOSE_URL")"
|
||||
curl -L -o docker-compose.yml "$DOCKER_COMPOSE_URL"
|
||||
echo "✅ docker-compose.yml 下载完成"
|
||||
fi
|
||||
|
||||
current_db_type=$(get_current_db_type)
|
||||
if [[ "$current_db_type" == "postgres" ]]; then
|
||||
echo "ℹ️ 当前已使用 PostgreSQL,无需迁移"
|
||||
return 0
|
||||
fi
|
||||
|
||||
postgres_db=$(get_env_var "POSTGRES_DB")
|
||||
postgres_user=$(get_env_var "POSTGRES_USER")
|
||||
postgres_password=$(get_env_var "POSTGRES_PASSWORD")
|
||||
|
||||
postgres_db=${postgres_db:-flux_panel}
|
||||
postgres_user=${postgres_user:-flux_panel}
|
||||
postgres_password=${postgres_password:-$(generate_random)}
|
||||
|
||||
upsert_env_var ".env" "POSTGRES_DB" "$postgres_db"
|
||||
upsert_env_var ".env" "POSTGRES_USER" "$postgres_user"
|
||||
upsert_env_var ".env" "POSTGRES_PASSWORD" "$postgres_password"
|
||||
|
||||
echo "🛑 停止当前服务..."
|
||||
docker stop -t 30 flux-panel-backend 2>/dev/null || true
|
||||
docker stop -t 10 vite-frontend 2>/dev/null || true
|
||||
echo "⏳ 等待数据同步..."
|
||||
sleep 5
|
||||
$DOCKER_CMD down
|
||||
|
||||
echo "💾 备份 SQLite 数据到当前目录..."
|
||||
if ! docker run --rm -v sqlite_data:/data -v "$(pwd)":/backup alpine sh -c "cp /data/gost.db /backup/gost.db.bak"; then
|
||||
echo "❌ SQLite 备份失败,迁移终止"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "🚀 启动 PostgreSQL..."
|
||||
$DOCKER_CMD up -d postgres
|
||||
if ! wait_for_postgres_healthy; then
|
||||
echo "🛑 PostgreSQL 未就绪,迁移终止"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "🔄 执行 pgloader 迁移..."
|
||||
if ! docker run --rm --network gost-network -v sqlite_data:/sqlite dimitri/pgloader:latest pgloader /sqlite/gost.db "postgresql://${postgres_user}:${postgres_password}@postgres:5432/${postgres_db}"; then
|
||||
echo "❌ pgloader 迁移失败,迁移终止(如报 28P01,可执行 docker volume rm postgres_data 后重试)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
database_url="postgresql://${postgres_user}:${postgres_password}@postgres:5432/${postgres_db}?sslmode=disable"
|
||||
upsert_env_var ".env" "DB_TYPE" "postgres"
|
||||
upsert_env_var ".env" "DATABASE_URL" "$database_url"
|
||||
|
||||
echo "🚀 启动迁移后的服务..."
|
||||
$DOCKER_CMD up -d postgres backend frontend
|
||||
|
||||
echo "⏳ 等待服务启动..."
|
||||
if ! wait_for_backend_healthy; then
|
||||
echo "🛑 迁移后服务启动失败"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "✅ SQLite -> PostgreSQL 迁移完成"
|
||||
}
|
||||
|
||||
|
||||
|
||||
# 卸载功能
|
||||
uninstall_panel() {
|
||||
@@ -428,6 +625,11 @@ main() {
|
||||
exit 0
|
||||
;;
|
||||
4)
|
||||
migrate_to_postgres
|
||||
delete_self
|
||||
exit 0
|
||||
;;
|
||||
5)
|
||||
echo "👋 退出脚本"
|
||||
delete_self
|
||||
exit 0
|
||||
|
||||
@@ -87,6 +87,8 @@ http {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -94,4 +96,4 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"@heroui/system": "2.4.19",
|
||||
"@heroui/table": "^2.2.24",
|
||||
"@heroui/tabs": "^2.2.27",
|
||||
"@heroui/theme": "2.4.19",
|
||||
"@heroui/theme": "2.4.24",
|
||||
"@heroui/use-theme": "2.1.10",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@nextui-org/system": "^2.4.6",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import axios from "axios";
|
||||
|
||||
import Network from "./network";
|
||||
|
||||
// 登陆相关接口
|
||||
@@ -41,6 +43,22 @@ export const checkNodeStatus = (nodeId?: number) => {
|
||||
return Network.post("/node/check-status", params);
|
||||
};
|
||||
|
||||
export const upgradeNode = (id: number, version?: string) =>
|
||||
Network.post(
|
||||
"/node/upgrade",
|
||||
{ id, version: version || "" },
|
||||
{ timeout: 5 * 60 * 1000 },
|
||||
);
|
||||
export const batchUpgradeNodes = (ids: number[], version?: string) =>
|
||||
Network.post(
|
||||
"/node/batch-upgrade",
|
||||
{ ids, version: version || "" },
|
||||
{ timeout: 15 * 60 * 1000 },
|
||||
);
|
||||
export const getNodeReleases = () => Network.post("/node/releases");
|
||||
export const rollbackNode = (id: number) =>
|
||||
Network.post("/node/rollback", { id });
|
||||
|
||||
// 隧道CRUD操作 - 全部使用POST请求
|
||||
export const createTunnel = (data: any) => Network.post("/tunnel/create", data);
|
||||
export const getTunnelList = () => Network.post("/tunnel/list");
|
||||
@@ -122,6 +140,10 @@ export const updateConfigs = (configMap: Record<string, string>) =>
|
||||
export const updateConfig = (name: string, value: string) =>
|
||||
Network.post("/config/update-single", { name, value });
|
||||
|
||||
export const exportBackupData = () => Network.post("/backup/export");
|
||||
export const importBackupData = (data: any) => Network.post("/backup/import", data);
|
||||
export const restoreBackupData = (data: any) => Network.post("/backup/restore", data);
|
||||
|
||||
// 验证码相关接口
|
||||
export const checkCaptcha = () => Network.post("/captcha/check");
|
||||
export const generateCaptcha = () => Network.post(`/captcha/generate`);
|
||||
@@ -216,7 +238,50 @@ export const resetPeerShareFlow = (id: number) =>
|
||||
Network.post("/federation/share/reset-flow", { id });
|
||||
export const getPeerRemoteUsageList = () =>
|
||||
Network.post("/federation/share/remote-usage/list");
|
||||
export const importRemoteNode = (data: {
|
||||
remoteUrl: string;
|
||||
token: string;
|
||||
}) => Network.post("/federation/node/import", data);
|
||||
export const importRemoteNode = (data: { remoteUrl: string; token: string }) =>
|
||||
Network.post("/federation/node/import", data);
|
||||
|
||||
export interface BackupTypes {
|
||||
users?: boolean;
|
||||
nodes?: boolean;
|
||||
tunnels?: boolean;
|
||||
forwards?: boolean;
|
||||
userTunnels?: boolean;
|
||||
speedLimits?: boolean;
|
||||
tunnelGroups?: boolean;
|
||||
userGroups?: boolean;
|
||||
permissions?: boolean;
|
||||
configs?: boolean;
|
||||
}
|
||||
|
||||
export const exportBackup = async (types: string[] = []) => {
|
||||
const token = window.localStorage.getItem("token");
|
||||
const baseURL = axios.defaults.baseURL || "/api/v1/";
|
||||
|
||||
const response = await axios.post(
|
||||
`${baseURL}/backup/export`,
|
||||
{ types },
|
||||
{
|
||||
headers: {
|
||||
Authorization: token,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
responseType: "blob",
|
||||
},
|
||||
);
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement("a");
|
||||
|
||||
link.href = url;
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, "");
|
||||
|
||||
link.setAttribute("download", `backup_${timestamp}.json`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const importBackup = (data: { types: string[]; [key: string]: any }) =>
|
||||
Network.post("/backup/import", data);
|
||||
|
||||
@@ -43,6 +43,10 @@ interface ApiResponse<T = any> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
// 处理token失效的逻辑
|
||||
function handleTokenExpired() {
|
||||
// 清除localStorage中的token
|
||||
@@ -71,6 +75,7 @@ const Network = {
|
||||
get: function <T = any>(
|
||||
path: string = "",
|
||||
data: any = {},
|
||||
options: RequestOptions = {},
|
||||
): Promise<ApiResponse<T>> {
|
||||
return new Promise(function (resolve) {
|
||||
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
|
||||
@@ -83,7 +88,7 @@ const Network = {
|
||||
axios
|
||||
.get(path, {
|
||||
params: data,
|
||||
timeout: 30000,
|
||||
timeout: options.timeout ?? 30000,
|
||||
headers: {
|
||||
Authorization: window.localStorage.getItem("token"),
|
||||
},
|
||||
@@ -117,6 +122,7 @@ const Network = {
|
||||
post: function <T = any>(
|
||||
path: string = "",
|
||||
data: any = {},
|
||||
options: RequestOptions = {},
|
||||
): Promise<ApiResponse<T>> {
|
||||
return new Promise(function (resolve) {
|
||||
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
|
||||
@@ -128,7 +134,7 @@ const Network = {
|
||||
|
||||
axios
|
||||
.post(path, data, {
|
||||
timeout: 30000,
|
||||
timeout: options.timeout ?? 30000,
|
||||
headers: {
|
||||
Authorization: window.localStorage.getItem("token"),
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
@@ -7,9 +7,10 @@ import { Spinner } from "@heroui/spinner";
|
||||
import { Divider } from "@heroui/divider";
|
||||
import { Switch } from "@heroui/switch";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import { Checkbox, CheckboxGroup } from "@heroui/checkbox";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { updateConfigs } from "@/api";
|
||||
import { updateConfigs, exportBackup, importBackup } from "@/api";
|
||||
import { SettingsIcon } from "@/components/icons";
|
||||
import { isAdmin } from "@/utils/auth";
|
||||
import {
|
||||
@@ -130,12 +131,19 @@ export default function ConfigPage() {
|
||||
useState<Record<string, string>>(initialConfigs);
|
||||
const [loading, setLoading] = useState(
|
||||
Object.keys(initialConfigs).length === 0,
|
||||
); // 如果有缓存数据,不显示loading
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [originalConfigs, setOriginalConfigs] =
|
||||
useState<Record<string, string>>(initialConfigs);
|
||||
|
||||
const [exportTypes, setExportTypes] = useState<string[]>([]);
|
||||
const [importTypes, setImportTypes] = useState<string[]>([]);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importFileName, setImportFileName] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 权限检查
|
||||
useEffect(() => {
|
||||
if (!isAdmin()) {
|
||||
@@ -331,6 +339,63 @@ export default function ConfigPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
if (exportTypes.length === 0) {
|
||||
toast.error("请至少选择一种数据类型");
|
||||
|
||||
return;
|
||||
}
|
||||
setExporting(true);
|
||||
try {
|
||||
await exportBackup(exportTypes);
|
||||
toast.success("导出成功");
|
||||
} catch {
|
||||
toast.error("导出失败,请重试");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (!file) return;
|
||||
|
||||
if (importTypes.length === 0) {
|
||||
toast.error("请先选择要导入的数据类型");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setImportFileName(file.name);
|
||||
setImporting(true);
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
|
||||
const response = await importBackup({
|
||||
types: importTypes,
|
||||
...data,
|
||||
});
|
||||
|
||||
if (response.code === 0) {
|
||||
toast.success(`导入成功: ${JSON.stringify(response.data)}`);
|
||||
setImportTypes([]);
|
||||
setImportFileName("");
|
||||
} else {
|
||||
toast.error("导入失败: " + response.msg);
|
||||
}
|
||||
} catch {
|
||||
toast.error("导入失败,请检查文件格式");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -427,6 +492,140 @@ export default function ConfigPage() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 备份与恢复 */}
|
||||
<Card className="mt-6 shadow-md">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">数据备份与恢复</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
导出或导入系统数据,支持选择特定数据类型
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<Divider />
|
||||
|
||||
<CardBody className="space-y-6 pt-6">
|
||||
{/* 导出部分 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">导出数据</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
选择要导出的数据类型,导出为 JSON 格式文件
|
||||
</p>
|
||||
|
||||
<CheckboxGroup
|
||||
classNames={{
|
||||
wrapper: "gap-4",
|
||||
}}
|
||||
label="选择导出内容"
|
||||
orientation="horizontal"
|
||||
value={exportTypes}
|
||||
onValueChange={(values) => setExportTypes(values as string[])}
|
||||
>
|
||||
<Checkbox value="users">用户</Checkbox>
|
||||
<Checkbox value="nodes">节点</Checkbox>
|
||||
<Checkbox value="tunnels">隧道</Checkbox>
|
||||
<Checkbox value="forwards">转发</Checkbox>
|
||||
<Checkbox value="userTunnels">用户隧道权限</Checkbox>
|
||||
<Checkbox value="speedLimits">限速规则</Checkbox>
|
||||
<Checkbox value="tunnelGroups">隧道分组</Checkbox>
|
||||
<Checkbox value="userGroups">用户分组</Checkbox>
|
||||
<Checkbox value="permissions">分组权限</Checkbox>
|
||||
<Checkbox value="configs">系统配置</Checkbox>
|
||||
</CheckboxGroup>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={exporting}
|
||||
onPress={handleExport}
|
||||
>
|
||||
{exporting ? "导出中..." : "导出数据"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="bordered"
|
||||
onPress={() => {
|
||||
setExportTypes([
|
||||
"users",
|
||||
"nodes",
|
||||
"tunnels",
|
||||
"forwards",
|
||||
"userTunnels",
|
||||
"speedLimits",
|
||||
"tunnelGroups",
|
||||
"userGroups",
|
||||
"permissions",
|
||||
"configs",
|
||||
]);
|
||||
}}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button variant="flat" onPress={() => setExportTypes([])}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 导入部分 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">导入数据</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
选择要导入的数据类型,支持从备份文件恢复数据
|
||||
</p>
|
||||
|
||||
<CheckboxGroup
|
||||
classNames={{
|
||||
wrapper: "gap-4",
|
||||
}}
|
||||
label="选择导入内容"
|
||||
orientation="horizontal"
|
||||
value={importTypes}
|
||||
onValueChange={(values) => setImportTypes(values as string[])}
|
||||
>
|
||||
<Checkbox value="users">用户</Checkbox>
|
||||
<Checkbox value="nodes">节点</Checkbox>
|
||||
<Checkbox value="tunnels">隧道</Checkbox>
|
||||
<Checkbox value="forwards">转发</Checkbox>
|
||||
<Checkbox value="userTunnels">用户隧道权限</Checkbox>
|
||||
<Checkbox value="speedLimits">限速规则</Checkbox>
|
||||
<Checkbox value="tunnelGroups">隧道分组</Checkbox>
|
||||
<Checkbox value="userGroups">用户分组</Checkbox>
|
||||
<Checkbox value="permissions">分组权限</Checkbox>
|
||||
<Checkbox value="configs">系统配置</Checkbox>
|
||||
</CheckboxGroup>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
type="file"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={importing}
|
||||
variant="flat"
|
||||
onPress={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{importing ? "导入中..." : "选择文件导入"}
|
||||
</Button>
|
||||
{importFileName && (
|
||||
<span className="self-center text-sm text-gray-600 dark:text-gray-400">
|
||||
已选择: {importFileName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,8 +83,9 @@ export default function IndexPage() {
|
||||
if (response.code !== 0) {
|
||||
toast.error(response.msg || "登录失败");
|
||||
if (showCaptcha) {
|
||||
setForm((prev) => ({ ...prev, captchaId: "" }));
|
||||
setForm((prev) => ({ ...prev, captchaId: "" }));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -138,11 +139,7 @@ export default function IndexPage() {
|
||||
} else {
|
||||
const configResp = await getConfigByName("cloudflare_site_key");
|
||||
|
||||
if (
|
||||
configResp.code === 0 &&
|
||||
configResp.data &&
|
||||
configResp.data.value
|
||||
) {
|
||||
if (configResp.code === 0 && configResp.data && configResp.data.value) {
|
||||
setSiteKey(configResp.data.value);
|
||||
setShowCaptcha(true);
|
||||
} else {
|
||||
@@ -241,8 +238,10 @@ export default function IndexPage() {
|
||||
{showCaptcha && siteKey && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* 背景遮罩层 - 模糊效果,暗黑模式下更深 */}
|
||||
<div
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm captcha-backdrop-enter"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
setShowCaptcha(false);
|
||||
setLoading(false);
|
||||
@@ -253,32 +252,35 @@ export default function IndexPage() {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
/>
|
||||
{/* 验证码容器 */}
|
||||
<div className="mb-4 relative z-50 bg-white dark:bg-zinc-900 p-6 rounded-lg shadow-xl">
|
||||
<div className="mb-4 text-center text-sm font-medium text-gray-700 dark:text-gray-200">请完成安全验证</div>
|
||||
<div className="mb-4 text-center text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
请完成安全验证
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Turnstile
|
||||
siteKey={siteKey}
|
||||
onSuccess={(token) => {
|
||||
setForm((prev) => ({ ...prev, captchaId: token }));
|
||||
void performLogin(token);
|
||||
options={{
|
||||
theme: (document.documentElement.classList.contains(
|
||||
"dark",
|
||||
) ||
|
||||
document.documentElement.getAttribute("data-theme") ===
|
||||
"dark" ||
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light") as "light" | "dark" | "auto",
|
||||
}}
|
||||
siteKey={siteKey}
|
||||
onError={() => {
|
||||
toast.error("验证失败,请刷新重试");
|
||||
setLoading(false);
|
||||
}}
|
||||
onExpire={() => {
|
||||
setForm((prev) => ({ ...prev, captchaId: "" }));
|
||||
setForm((prev) => ({ ...prev, captchaId: "" }));
|
||||
}}
|
||||
options={{
|
||||
theme: (document.documentElement.classList.contains("dark") ||
|
||||
document.documentElement.getAttribute("data-theme") === "dark" ||
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light") as "light" | "dark" | "auto"
|
||||
onSuccess={(token) => {
|
||||
setForm((prev) => ({ ...prev, captchaId: token }));
|
||||
void performLogin(token);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+563
-258
@@ -16,6 +16,7 @@ import { Spinner } from "@heroui/spinner";
|
||||
import { Alert } from "@heroui/alert";
|
||||
import { Progress } from "@heroui/progress";
|
||||
import { Accordion, AccordionItem } from "@heroui/accordion";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import { Checkbox } from "@heroui/checkbox";
|
||||
import toast from "react-hot-toast";
|
||||
import axios from "axios";
|
||||
@@ -45,6 +46,10 @@ import {
|
||||
getNodeInstallCommand,
|
||||
updateNodeOrder,
|
||||
batchDeleteNodes,
|
||||
upgradeNode,
|
||||
batchUpgradeNodes,
|
||||
getNodeReleases,
|
||||
rollbackNode,
|
||||
} from "@/api";
|
||||
|
||||
interface Node {
|
||||
@@ -77,6 +82,8 @@ interface Node {
|
||||
uptime: number;
|
||||
} | null;
|
||||
copyLoading?: boolean;
|
||||
upgradeLoading?: boolean;
|
||||
rollbackLoading?: boolean;
|
||||
}
|
||||
|
||||
interface NodeForm {
|
||||
@@ -118,7 +125,12 @@ const SortableItem = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{children(listeners)}
|
||||
</div>
|
||||
);
|
||||
@@ -165,6 +177,29 @@ export default function NodePage() {
|
||||
const [installCommand, setInstallCommand] = useState("");
|
||||
const [currentNodeName, setCurrentNodeName] = useState("");
|
||||
|
||||
// 升级相关状态
|
||||
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
|
||||
const [upgradeTarget, setUpgradeTarget] = useState<"single" | "batch">(
|
||||
"single",
|
||||
);
|
||||
const [upgradeTargetNodeId, setUpgradeTargetNodeId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [releases, setReleases] = useState<
|
||||
Array<{
|
||||
version: string;
|
||||
name: string;
|
||||
publishedAt: string;
|
||||
prerelease: boolean;
|
||||
}>
|
||||
>([]);
|
||||
const [releasesLoading, setReleasesLoading] = useState(false);
|
||||
const [selectedVersion, setSelectedVersion] = useState("");
|
||||
const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false);
|
||||
const [upgradeProgress, setUpgradeProgress] = useState<
|
||||
Record<number, { stage: string; percent: number; message: string }>
|
||||
>({});
|
||||
|
||||
const websocketRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
@@ -220,7 +255,11 @@ export default function NodePage() {
|
||||
const nodesData: Node[] = (res.data || []).map((node: any) => ({
|
||||
...node,
|
||||
inx: node.inx ?? 0,
|
||||
connectionStatus: node.syncError ? "offline" : node.status === 1 ? "online" : "offline",
|
||||
connectionStatus: node.syncError
|
||||
? "offline"
|
||||
: node.status === 1
|
||||
? "online"
|
||||
: "offline",
|
||||
syncError: node.syncError || undefined,
|
||||
systemInfo: null,
|
||||
copyLoading: false,
|
||||
@@ -425,6 +464,26 @@ export default function NodePage() {
|
||||
return node;
|
||||
}),
|
||||
);
|
||||
} else if (type === "upgrade_progress") {
|
||||
try {
|
||||
const progressData =
|
||||
typeof messageData === "string"
|
||||
? JSON.parse(messageData)
|
||||
: messageData;
|
||||
|
||||
if (progressData?.data) {
|
||||
setUpgradeProgress((prev) => ({
|
||||
...prev,
|
||||
[nodeId]: {
|
||||
stage: progressData.data.stage || "",
|
||||
percent: progressData.data.percent || 0,
|
||||
message: progressData.message || "",
|
||||
},
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -770,6 +829,106 @@ export default function NodePage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 打开版本选择弹窗
|
||||
const openUpgradeModal = async (
|
||||
target: "single" | "batch",
|
||||
nodeId?: number,
|
||||
) => {
|
||||
setUpgradeTarget(target);
|
||||
setUpgradeTargetNodeId(nodeId || null);
|
||||
setSelectedVersion("");
|
||||
setUpgradeModalOpen(true);
|
||||
setReleasesLoading(true);
|
||||
try {
|
||||
const res = await getNodeReleases();
|
||||
|
||||
if (res.code === 0 && Array.isArray(res.data)) {
|
||||
setReleases(res.data);
|
||||
} else {
|
||||
toast.error(res.msg || "获取版本列表失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("获取版本列表失败");
|
||||
} finally {
|
||||
setReleasesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 确认升级(从版本弹窗)
|
||||
const handleConfirmUpgrade = async () => {
|
||||
const version = selectedVersion || undefined;
|
||||
|
||||
if (upgradeTarget === "single" && upgradeTargetNodeId) {
|
||||
setUpgradeModalOpen(false);
|
||||
// Find the node
|
||||
const node = nodeList.find((n) => n.id === upgradeTargetNodeId);
|
||||
|
||||
if (!node) return;
|
||||
setNodeList((prev) =>
|
||||
prev.map((n) =>
|
||||
n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: true } : n,
|
||||
),
|
||||
);
|
||||
try {
|
||||
const res = await upgradeNode(upgradeTargetNodeId, version);
|
||||
|
||||
if (res.code === 0) {
|
||||
toast.success(`节点升级命令已发送,节点将自动重启`);
|
||||
} else {
|
||||
toast.error(res.msg || "升级失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误,请重试");
|
||||
} finally {
|
||||
setNodeList((prev) =>
|
||||
prev.map((n) =>
|
||||
n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: false } : n,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (upgradeTarget === "batch") {
|
||||
setBatchUpgradeLoading(true);
|
||||
setUpgradeModalOpen(false);
|
||||
try {
|
||||
const res = await batchUpgradeNodes(Array.from(selectedIds), version);
|
||||
|
||||
if (res.code === 0) {
|
||||
toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`);
|
||||
} else {
|
||||
toast.error(res.msg || "批量升级失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误,请重试");
|
||||
} finally {
|
||||
setBatchUpgradeLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 回退节点
|
||||
const handleRollbackNode = async (node: Node) => {
|
||||
setNodeList((prev) =>
|
||||
prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: true } : n)),
|
||||
);
|
||||
try {
|
||||
const res = await rollbackNode(node.id);
|
||||
|
||||
if (res.code === 0) {
|
||||
toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`);
|
||||
} else {
|
||||
toast.error(res.msg || "回退失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误,请重试");
|
||||
} finally {
|
||||
setNodeList((prev) =>
|
||||
prev.map((n) =>
|
||||
n.id === node.id ? { ...n, rollbackLoading: false } : n,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) return;
|
||||
@@ -1048,6 +1207,15 @@ export default function NodePage() {
|
||||
<Button size="sm" variant="flat" onPress={deselectAll}>
|
||||
清空
|
||||
</Button>
|
||||
<Button
|
||||
color="warning"
|
||||
isLoading={batchUpgradeLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => openUpgradeModal("batch")}
|
||||
>
|
||||
升级
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
size="sm"
|
||||
@@ -1122,262 +1290,282 @@ export default function NodePage() {
|
||||
return (
|
||||
<SortableItem key={node.id} id={node.id}>
|
||||
{(listeners) => (
|
||||
<Card
|
||||
key={node.id}
|
||||
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200"
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{selectMode && (
|
||||
<Checkbox
|
||||
isSelected={selectedIds.has(node.id)}
|
||||
onValueChange={() => toggleSelect(node.id)}
|
||||
/>
|
||||
)}
|
||||
<h3 className="font-semibold text-foreground truncate text-sm">
|
||||
{node.name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 ml-2">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
|
||||
{...listeners}
|
||||
style={{ touchAction: "none" }}
|
||||
title="拖拽排序"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
|
||||
</svg>
|
||||
<Card
|
||||
key={node.id}
|
||||
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{selectMode && (
|
||||
<Checkbox
|
||||
isSelected={selectedIds.has(node.id)}
|
||||
onValueChange={() => toggleSelect(node.id)}
|
||||
/>
|
||||
)}
|
||||
<h3 className="font-semibold text-foreground truncate text-sm">
|
||||
{node.name}
|
||||
</h3>
|
||||
</div>
|
||||
{isRemoteNode && (
|
||||
<div className="flex items-center gap-1.5 ml-2">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
|
||||
{...listeners}
|
||||
style={{ touchAction: "none" }}
|
||||
title="拖拽排序"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
|
||||
</svg>
|
||||
</div>
|
||||
{isRemoteNode && (
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
远程
|
||||
</Chip>
|
||||
)}
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color="secondary"
|
||||
color={
|
||||
node.connectionStatus === "online"
|
||||
? "success"
|
||||
: "danger"
|
||||
}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
远程
|
||||
{node.connectionStatus === "online"
|
||||
? "在线"
|
||||
: "离线"}
|
||||
</Chip>
|
||||
)}
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color={
|
||||
node.connectionStatus === "online"
|
||||
? "success"
|
||||
: "danger"
|
||||
}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{node.connectionStatus === "online"
|
||||
? "在线"
|
||||
: "离线"}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="pt-0 pb-3">
|
||||
{isRemoteNode && node.syncError && (
|
||||
<div className="mb-3 px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs">
|
||||
{node.syncError === "provider_share_deleted"
|
||||
? "提供方已删除该分享"
|
||||
: node.syncError === "provider_share_disabled"
|
||||
? "提供方已禁用该分享"
|
||||
: node.syncError === "provider_share_expired"
|
||||
? "提供方分享已过期"
|
||||
: `远程同步失败: ${node.syncError}`}
|
||||
</div>
|
||||
)}
|
||||
{/* 基础信息 */}
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between items-center text-sm min-w-0">
|
||||
<span className="text-default-600 flex-shrink-0">
|
||||
IP
|
||||
</span>
|
||||
<div className="text-right text-xs min-w-0 flex-1 ml-2">
|
||||
{node.serverIpV4?.trim() ||
|
||||
node.serverIpV6?.trim() ? (
|
||||
<div className="space-y-0.5">
|
||||
{node.serverIpV4?.trim() && (
|
||||
<span
|
||||
className="font-mono truncate block"
|
||||
title={node.serverIpV4.trim()}
|
||||
>
|
||||
{node.serverIpV4.trim()}
|
||||
</span>
|
||||
)}
|
||||
{node.serverIpV6?.trim() && (
|
||||
<span
|
||||
className="font-mono truncate block"
|
||||
title={node.serverIpV6.trim()}
|
||||
>
|
||||
{node.serverIpV6.trim()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
className="font-mono truncate block"
|
||||
title={node.serverIp.trim()}
|
||||
>
|
||||
{node.serverIp.trim()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="pt-0 pb-3">
|
||||
{isRemoteNode && node.syncError && (
|
||||
<div className="mb-3 px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs">
|
||||
{node.syncError === "provider_share_deleted"
|
||||
? "提供方已删除该分享"
|
||||
: node.syncError === "provider_share_disabled"
|
||||
? "提供方已禁用该分享"
|
||||
: node.syncError === "provider_share_expired"
|
||||
? "提供方分享已过期"
|
||||
: `远程同步失败: ${node.syncError}`}
|
||||
</div>
|
||||
)}
|
||||
{/* 基础信息 */}
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between items-center text-sm min-w-0">
|
||||
<span className="text-default-600 flex-shrink-0">
|
||||
IP
|
||||
</span>
|
||||
<div className="text-right text-xs min-w-0 flex-1 ml-2">
|
||||
{node.serverIpV4?.trim() ||
|
||||
node.serverIpV6?.trim() ? (
|
||||
<div className="space-y-0.5">
|
||||
{node.serverIpV4?.trim() && (
|
||||
<span
|
||||
className="font-mono truncate block"
|
||||
title={node.serverIpV4.trim()}
|
||||
>
|
||||
{node.serverIpV4.trim()}
|
||||
</span>
|
||||
)}
|
||||
{node.serverIpV6?.trim() && (
|
||||
<span
|
||||
className="font-mono truncate block"
|
||||
title={node.serverIpV6.trim()}
|
||||
>
|
||||
{node.serverIpV6.trim()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
className="font-mono truncate block"
|
||||
title={node.serverIp.trim()}
|
||||
>
|
||||
{node.serverIp.trim()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isRemoteNode && (
|
||||
<>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">版本</span>
|
||||
<span className="text-xs">
|
||||
{node.version || "未知"}
|
||||
</span>
|
||||
</div>
|
||||
{upgradeProgress[node.id] &&
|
||||
upgradeProgress[node.id].percent < 100 && (
|
||||
<div className="mt-1">
|
||||
<Progress
|
||||
showValueLabel
|
||||
aria-label="升级进度"
|
||||
color="warning"
|
||||
label={upgradeProgress[node.id].message}
|
||||
size="sm"
|
||||
value={upgradeProgress[node.id].percent}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">
|
||||
开机时间
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatUptime(node.systemInfo.uptime)
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isRemoteNode && (
|
||||
<>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">版本</span>
|
||||
<span className="text-xs">
|
||||
{node.version || "未知"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">开机时间</span>
|
||||
<span className="text-xs">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatUptime(node.systemInfo.uptime)
|
||||
: "-"}
|
||||
</span>
|
||||
{/* 系统监控 */}
|
||||
<div className="space-y-3 mb-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>CPU</span>
|
||||
<span className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label="CPU使用率"
|
||||
color={getProgressColor(
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.cpuUsage
|
||||
: 0,
|
||||
node.connectionStatus !== "online",
|
||||
)}
|
||||
size="sm"
|
||||
value={
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.cpuUsage
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>内存</span>
|
||||
<span className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label="内存使用率"
|
||||
color={getProgressColor(
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.memoryUsage
|
||||
: 0,
|
||||
node.connectionStatus !== "online",
|
||||
)}
|
||||
size="sm"
|
||||
value={
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.memoryUsage
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
|
||||
<div className="text-default-600 mb-0.5">
|
||||
上传
|
||||
</div>
|
||||
<div className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatSpeed(
|
||||
node.systemInfo.uploadSpeed,
|
||||
)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
|
||||
<div className="text-default-600 mb-0.5">
|
||||
下载
|
||||
</div>
|
||||
<div className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatSpeed(
|
||||
node.systemInfo.downloadSpeed,
|
||||
)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 流量统计 */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="text-center p-2 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20">
|
||||
<div className="text-primary-600 dark:text-primary-400 mb-0.5">
|
||||
↑ 上行流量
|
||||
</div>
|
||||
<div className="font-mono text-primary-700 dark:text-primary-300">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatTraffic(
|
||||
node.systemInfo.uploadTraffic,
|
||||
)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20">
|
||||
<div className="text-success-600 dark:text-success-400 mb-0.5">
|
||||
↓ 下行流量
|
||||
</div>
|
||||
<div className="font-mono text-success-700 dark:text-success-300">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatTraffic(
|
||||
node.systemInfo.downloadTraffic,
|
||||
)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isRemoteNode && (
|
||||
<>
|
||||
{/* 系统监控 */}
|
||||
<div className="space-y-3 mb-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>CPU</span>
|
||||
<span className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label="CPU使用率"
|
||||
color={getProgressColor(
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.cpuUsage
|
||||
: 0,
|
||||
node.connectionStatus !== "online",
|
||||
)}
|
||||
size="sm"
|
||||
value={
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.cpuUsage
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>内存</span>
|
||||
<span className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label="内存使用率"
|
||||
color={getProgressColor(
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.memoryUsage
|
||||
: 0,
|
||||
node.connectionStatus !== "online",
|
||||
)}
|
||||
size="sm"
|
||||
value={
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.memoryUsage
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
|
||||
<div className="text-default-600 mb-0.5">
|
||||
上传
|
||||
</div>
|
||||
<div className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatSpeed(node.systemInfo.uploadSpeed)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
|
||||
<div className="text-default-600 mb-0.5">
|
||||
下载
|
||||
</div>
|
||||
<div className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatSpeed(node.systemInfo.downloadSpeed)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 流量统计 */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="text-center p-2 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20">
|
||||
<div className="text-primary-600 dark:text-primary-400 mb-0.5">
|
||||
↑ 上行流量
|
||||
</div>
|
||||
<div className="font-mono text-primary-700 dark:text-primary-300">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatTraffic(node.systemInfo.uploadTraffic)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20">
|
||||
<div className="text-success-600 dark:text-success-400 mb-0.5">
|
||||
↓ 下行流量
|
||||
</div>
|
||||
<div className="font-mono text-success-700 dark:text-success-300">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatTraffic(
|
||||
node.systemInfo.downloadTraffic,
|
||||
)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex gap-1.5">
|
||||
{/* 操作按钮 */}
|
||||
<div className="space-y-1.5">
|
||||
{!isRemoteNode && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
className="min-h-8"
|
||||
color="success"
|
||||
isLoading={node.copyLoading}
|
||||
size="sm"
|
||||
@@ -1387,7 +1575,41 @@ export default function NodePage() {
|
||||
安装
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
className="min-h-8"
|
||||
color="warning"
|
||||
isDisabled={
|
||||
node.connectionStatus !== "online"
|
||||
}
|
||||
isLoading={node.upgradeLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() =>
|
||||
openUpgradeModal("single", node.id)
|
||||
}
|
||||
>
|
||||
升级
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-8"
|
||||
color="secondary"
|
||||
isDisabled={
|
||||
node.connectionStatus !== "online"
|
||||
}
|
||||
isLoading={node.rollbackLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleRollbackNode(node)}
|
||||
>
|
||||
回退
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`grid gap-1.5 ${isRemoteNode ? "grid-cols-1" : "grid-cols-2"}`}
|
||||
>
|
||||
{!isRemoteNode && (
|
||||
<Button
|
||||
className="min-h-8"
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
@@ -1395,23 +1617,22 @@ export default function NodePage() {
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
className={`min-h-8 ${isRemoteNode ? "w-full" : "flex-1"}`}
|
||||
color="danger"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleDelete(node)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-8"
|
||||
color="danger"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleDelete(node)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</SortableItem>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</SortableItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -1844,6 +2065,90 @@ export default function NodePage() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* 版本选择升级模态框 */}
|
||||
<Modal
|
||||
backdrop="blur"
|
||||
isOpen={upgradeModalOpen}
|
||||
placement="center"
|
||||
scrollBehavior="outside"
|
||||
size="md"
|
||||
onOpenChange={setUpgradeModalOpen}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<h2 className="text-xl font-bold">
|
||||
{upgradeTarget === "batch"
|
||||
? `批量升级 (${selectedIds.size} 个节点)`
|
||||
: "升级节点"}
|
||||
</h2>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
{releasesLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Select
|
||||
label="选择版本"
|
||||
placeholder="留空则使用最新版本"
|
||||
selectedKeys={selectedVersion ? [selectedVersion] : []}
|
||||
onSelectionChange={(keys) => {
|
||||
const selected = Array.from(keys)[0] as string;
|
||||
|
||||
setSelectedVersion(selected || "");
|
||||
}}
|
||||
>
|
||||
{releases.map((r) => (
|
||||
<SelectItem key={r.version} textValue={r.version}>
|
||||
<div className="flex justify-between items-center">
|
||||
<span>{r.version}</span>
|
||||
<span className="text-xs text-default-400">
|
||||
{r.publishedAt
|
||||
? new Date(r.publishedAt).toLocaleDateString()
|
||||
: ""}
|
||||
{r.prerelease && (
|
||||
<Chip
|
||||
className="ml-1"
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
预览
|
||||
</Chip>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<p className="text-sm text-default-500">
|
||||
{selectedVersion
|
||||
? `将升级到版本 ${selectedVersion}`
|
||||
: "未选择版本,将自动使用最新稳定版"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="warning"
|
||||
isDisabled={releasesLoading}
|
||||
onPress={handleConfirmUpgrade}
|
||||
>
|
||||
确认升级
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* 批量删除确认模态框 */}
|
||||
<Modal
|
||||
backdrop="blur"
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@heroui/modal";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
import {
|
||||
getNodeList,
|
||||
createPeerShare,
|
||||
@@ -128,6 +129,7 @@ export default function PanelSharingPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getPeerShareList();
|
||||
|
||||
if (res.code === 0) {
|
||||
setShares(res.data || []);
|
||||
} else {
|
||||
@@ -141,10 +143,12 @@ export default function PanelSharingPage() {
|
||||
const loadNodes = useCallback(async () => {
|
||||
try {
|
||||
const res = await getNodeList();
|
||||
|
||||
if (res.code === 0) {
|
||||
const localNodes: Node[] = (res.data || []).filter(
|
||||
(node: Node) => (node?.isRemote ?? 0) !== 1,
|
||||
);
|
||||
|
||||
setNodes(localNodes);
|
||||
setShareForm((prev) => {
|
||||
if (!prev.nodeId) {
|
||||
@@ -153,6 +157,7 @@ export default function PanelSharingPage() {
|
||||
const hasSelectedNode = localNodes.some(
|
||||
(node: Node) => String(node.id) === prev.nodeId,
|
||||
);
|
||||
|
||||
return hasSelectedNode ? prev : { ...prev, nodeId: "" };
|
||||
});
|
||||
}
|
||||
@@ -165,6 +170,7 @@ export default function PanelSharingPage() {
|
||||
setRemoteUsageLoading(true);
|
||||
try {
|
||||
const res = await getPeerRemoteUsageList();
|
||||
|
||||
if (res.code === 0) {
|
||||
setRemoteUsageNodes(res.data || []);
|
||||
} else {
|
||||
@@ -179,6 +185,7 @@ export default function PanelSharingPage() {
|
||||
if (selectedTab === "my-shares") {
|
||||
loadShares();
|
||||
loadNodes();
|
||||
|
||||
return;
|
||||
}
|
||||
if (selectedTab === "remote-nodes") {
|
||||
@@ -189,15 +196,19 @@ export default function PanelSharingPage() {
|
||||
const handleCreateShare = async () => {
|
||||
if (!shareForm.name || !shareForm.nodeId) {
|
||||
toast.error("请填写必要信息");
|
||||
|
||||
return;
|
||||
}
|
||||
const nodeId = parseInt(shareForm.nodeId, 10);
|
||||
|
||||
if (Number.isNaN(nodeId) || !nodes.some((node) => node.id === nodeId)) {
|
||||
toast.error("仅可选择本地节点");
|
||||
|
||||
return;
|
||||
}
|
||||
if (shareForm.maxBandwidth < 0) {
|
||||
toast.error("流量上限不能为负数");
|
||||
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -213,6 +224,7 @@ export default function PanelSharingPage() {
|
||||
allowedDomains: shareForm.allowedDomains,
|
||||
allowedIps: shareForm.allowedIps,
|
||||
});
|
||||
|
||||
if (res.code === 0) {
|
||||
toast.success("创建成功");
|
||||
setCreateShareOpen(false);
|
||||
@@ -228,6 +240,7 @@ export default function PanelSharingPage() {
|
||||
const handleDeleteShare = async (id: number) => {
|
||||
try {
|
||||
const res = await deletePeerShare(id);
|
||||
|
||||
if (res.code === 0) {
|
||||
toast.success("删除成功");
|
||||
loadShares();
|
||||
@@ -242,6 +255,7 @@ export default function PanelSharingPage() {
|
||||
const handleResetShareFlow = async (id: number) => {
|
||||
try {
|
||||
const res = await resetPeerShareFlow(id);
|
||||
|
||||
if (res.code === 0) {
|
||||
toast.success("共享流量已重置");
|
||||
loadShares();
|
||||
@@ -257,7 +271,10 @@ export default function PanelSharingPage() {
|
||||
setEditForm({
|
||||
id: share.id,
|
||||
name: share.name,
|
||||
maxBandwidth: share.maxBandwidth > 0 ? Math.round(share.maxBandwidth / (1024 * 1024 * 1024)) : 0,
|
||||
maxBandwidth:
|
||||
share.maxBandwidth > 0
|
||||
? Math.round(share.maxBandwidth / (1024 * 1024 * 1024))
|
||||
: 0,
|
||||
expiryTime: share.expiryTime,
|
||||
portRangeStart: share.portRangeStart,
|
||||
portRangeEnd: share.portRangeEnd,
|
||||
@@ -270,10 +287,12 @@ export default function PanelSharingPage() {
|
||||
const handleEditShare = async () => {
|
||||
if (!editForm.name) {
|
||||
toast.error("名称不能为空");
|
||||
|
||||
return;
|
||||
}
|
||||
if (editForm.maxBandwidth < 0) {
|
||||
toast.error("流量上限不能为负数");
|
||||
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -287,6 +306,7 @@ export default function PanelSharingPage() {
|
||||
allowedDomains: editForm.allowedDomains,
|
||||
allowedIps: editForm.allowedIps,
|
||||
});
|
||||
|
||||
if (res.code === 0) {
|
||||
toast.success("编辑成功");
|
||||
setEditShareOpen(false);
|
||||
@@ -302,19 +322,22 @@ export default function PanelSharingPage() {
|
||||
const handleImportNode = async () => {
|
||||
if (!importForm.remoteUrl || !importForm.token) {
|
||||
toast.error("请填写完整信息");
|
||||
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Automatically add http/https if missing
|
||||
let url = importForm.remoteUrl.trim();
|
||||
|
||||
if (!url.startsWith("http")) {
|
||||
url = "http://" + url;
|
||||
}
|
||||
|
||||
|
||||
const res = await importRemoteNode({
|
||||
remoteUrl: url,
|
||||
token: importForm.token.trim(),
|
||||
});
|
||||
|
||||
if (res.code === 0) {
|
||||
toast.success("导入成功,请前往节点列表查看");
|
||||
setImportNodeOpen(false);
|
||||
@@ -341,6 +364,7 @@ export default function PanelSharingPage() {
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB";
|
||||
if (bytes < 1024 * 1024 * 1024)
|
||||
return (bytes / (1024 * 1024)).toFixed(2) + " MB";
|
||||
|
||||
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB";
|
||||
};
|
||||
|
||||
@@ -351,6 +375,7 @@ export default function PanelSharingPage() {
|
||||
if (chainType === 3) {
|
||||
return "出口节点";
|
||||
}
|
||||
|
||||
return "未知链路";
|
||||
};
|
||||
|
||||
@@ -370,11 +395,14 @@ export default function PanelSharingPage() {
|
||||
<Card>
|
||||
<CardBody>
|
||||
<div className="mb-4">
|
||||
<Button color="primary" onPress={() => setCreateShareOpen(true)}>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={() => setCreateShareOpen(true)}
|
||||
>
|
||||
创建分享
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-10 text-gray-500">加载中...</div>
|
||||
) : shares.length === 0 ? (
|
||||
@@ -382,7 +410,10 @@ export default function PanelSharingPage() {
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{shares.map((share) => (
|
||||
<Card key={share.id} className="border border-divider shadow-sm">
|
||||
<Card
|
||||
key={share.id}
|
||||
className="border border-divider shadow-sm"
|
||||
>
|
||||
<CardHeader className="flex justify-between">
|
||||
<h3 className="font-bold">{share.name}</h3>
|
||||
<div className="flex gap-2">
|
||||
@@ -400,29 +431,67 @@ export default function PanelSharingPage() {
|
||||
>
|
||||
重置流量
|
||||
</Button>
|
||||
<Button size="sm" color="danger" variant="flat" onPress={() => handleDeleteShare(share.id)}>删除</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleDeleteShare(share.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="text-sm space-y-2">
|
||||
<p>端口范围: {share.portRangeStart} - {share.portRangeEnd}</p>
|
||||
<p>流量上限: {share.maxBandwidth > 0 ? formatFlowGB(share.maxBandwidth) : "不限制"}</p>
|
||||
<p>
|
||||
端口范围: {share.portRangeStart} -{" "}
|
||||
{share.portRangeEnd}
|
||||
</p>
|
||||
<p>
|
||||
流量上限:{" "}
|
||||
{share.maxBandwidth > 0
|
||||
? formatFlowGB(share.maxBandwidth)
|
||||
: "不限制"}
|
||||
</p>
|
||||
<p>当前流量: {formatFlowGB(share.currentFlow || 0)}</p>
|
||||
<p>远程占用端口: {share.usedPorts && share.usedPorts.length > 0 ? share.usedPorts.join(", ") : "暂无"}</p>
|
||||
{share.usedPortDetails && share.usedPortDetails.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{share.usedPortDetails.map((item) => (
|
||||
<span key={item.runtimeId} className="text-xs rounded-full px-2 py-1 bg-default-100">
|
||||
{item.port} / {item.role || "reserved"}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p>
|
||||
远程占用端口:{" "}
|
||||
{share.usedPorts && share.usedPorts.length > 0
|
||||
? share.usedPorts.join(", ")
|
||||
: "暂无"}
|
||||
</p>
|
||||
{share.usedPortDetails &&
|
||||
share.usedPortDetails.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{share.usedPortDetails.map((item) => (
|
||||
<span
|
||||
key={item.runtimeId}
|
||||
className="text-xs rounded-full px-2 py-1 bg-default-100"
|
||||
>
|
||||
{item.port} / {item.role || "reserved"}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{share.allowedDomains && (
|
||||
<p>允许域名: {share.allowedDomains}</p>
|
||||
)}
|
||||
{share.allowedDomains && <p>允许域名: {share.allowedDomains}</p>}
|
||||
{share.allowedIps && <p>允许API IP: {share.allowedIps}</p>}
|
||||
<p>过期时间: {share.expiryTime === 0 ? "永久" : new Date(share.expiryTime).toLocaleDateString()}</p>
|
||||
{share.allowedIps && (
|
||||
<p>允许API IP: {share.allowedIps}</p>
|
||||
)}
|
||||
<p>
|
||||
过期时间:{" "}
|
||||
{share.expiryTime === 0
|
||||
? "永久"
|
||||
: new Date(share.expiryTime).toLocaleDateString()}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input readOnly size="sm" value={share.token} />
|
||||
<Button size="sm" onPress={() => copyToken(share.token)}>复制</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onPress={() => copyToken(share.token)}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -436,7 +505,10 @@ export default function PanelSharingPage() {
|
||||
<Card>
|
||||
<CardBody>
|
||||
<div className="mb-4">
|
||||
<Button color="secondary" onPress={() => setImportNodeOpen(true)}>
|
||||
<Button
|
||||
color="secondary"
|
||||
onPress={() => setImportNodeOpen(true)}
|
||||
>
|
||||
导入远程节点
|
||||
</Button>
|
||||
</div>
|
||||
@@ -446,15 +518,22 @@ export default function PanelSharingPage() {
|
||||
) : remoteUsageNodes.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500">
|
||||
<p>暂无远程节点占用记录。</p>
|
||||
<p className="mt-2">导入远程节点并创建隧道后,这里会显示远端端口占用情况。</p>
|
||||
<p className="mt-2">
|
||||
导入远程节点并创建隧道后,这里会显示远端端口占用情况。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{remoteUsageNodes.map((node) => (
|
||||
<Card key={node.nodeId} className="border border-divider shadow-sm">
|
||||
<Card
|
||||
key={node.nodeId}
|
||||
className="border border-divider shadow-sm"
|
||||
>
|
||||
<CardHeader className="flex justify-between">
|
||||
<h3 className="font-bold">{node.nodeName}</h3>
|
||||
<span className="text-xs text-default-500">绑定 {node.activeBindingNum || 0}</span>
|
||||
<span className="text-xs text-default-500">
|
||||
绑定 {node.activeBindingNum || 0}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardBody className="text-sm space-y-2">
|
||||
{node.syncError && (
|
||||
@@ -470,18 +549,40 @@ export default function PanelSharingPage() {
|
||||
)}
|
||||
{node.remoteUrl && <p>远程地址: {node.remoteUrl}</p>}
|
||||
<p>共享ID: {node.shareId || "-"}</p>
|
||||
<p>端口范围: {node.portRangeStart > 0 && node.portRangeEnd > 0 ? `${node.portRangeStart} - ${node.portRangeEnd}` : "-"}</p>
|
||||
<p>共享流量: {node.maxBandwidth > 0 ? `${formatFlowGB(node.currentFlow || 0)} / ${formatFlowGB(node.maxBandwidth)}` : `${formatFlowGB(node.currentFlow || 0)} / 不限制`}</p>
|
||||
<p>远端占用端口: {node.usedPorts && node.usedPorts.length > 0 ? node.usedPorts.join(", ") : "暂无"}</p>
|
||||
<p>
|
||||
端口范围:{" "}
|
||||
{node.portRangeStart > 0 && node.portRangeEnd > 0
|
||||
? `${node.portRangeStart} - ${node.portRangeEnd}`
|
||||
: "-"}
|
||||
</p>
|
||||
<p>
|
||||
共享流量:{" "}
|
||||
{node.maxBandwidth > 0
|
||||
? `${formatFlowGB(node.currentFlow || 0)} / ${formatFlowGB(node.maxBandwidth)}`
|
||||
: `${formatFlowGB(node.currentFlow || 0)} / 不限制`}
|
||||
</p>
|
||||
<p>
|
||||
远端占用端口:{" "}
|
||||
{node.usedPorts && node.usedPorts.length > 0
|
||||
? node.usedPorts.join(", ")
|
||||
: "暂无"}
|
||||
</p>
|
||||
{node.bindings && node.bindings.length > 0 && (
|
||||
<div className="space-y-1 pt-1">
|
||||
{node.bindings.map((binding) => (
|
||||
<p key={binding.bindingId} className="text-xs text-default-600">
|
||||
隧道 {binding.tunnelName || `#${binding.tunnelId}`}
|
||||
<p
|
||||
key={binding.bindingId}
|
||||
className="text-xs text-default-600"
|
||||
>
|
||||
隧道{" "}
|
||||
{binding.tunnelName || `#${binding.tunnelId}`}
|
||||
{" · "}
|
||||
端口 {binding.allocatedPort}
|
||||
{" · "}
|
||||
{formatChainType(binding.chainType, binding.hopInx)}
|
||||
{formatChainType(
|
||||
binding.chainType,
|
||||
binding.hopInx,
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
@@ -505,13 +606,17 @@ export default function PanelSharingPage() {
|
||||
label="名称"
|
||||
placeholder="备注名称"
|
||||
value={shareForm.name}
|
||||
onChange={(e) => setShareForm({ ...shareForm, name: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setShareForm({ ...shareForm, name: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
label="选择节点"
|
||||
placeholder="选择要分享的本地节点"
|
||||
selectedKeys={shareForm.nodeId ? [shareForm.nodeId] : []}
|
||||
onChange={(e) => setShareForm({ ...shareForm, nodeId: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setShareForm({ ...shareForm, nodeId: e.target.value })
|
||||
}
|
||||
>
|
||||
{nodes.map((node) => (
|
||||
<SelectItem key={node.id} textValue={node.name}>
|
||||
@@ -524,47 +629,73 @@ export default function PanelSharingPage() {
|
||||
label="起始端口"
|
||||
type="number"
|
||||
value={shareForm.portRangeStart.toString()}
|
||||
onChange={(e) => setShareForm({ ...shareForm, portRangeStart: parseInt(e.target.value) })}
|
||||
onChange={(e) =>
|
||||
setShareForm({
|
||||
...shareForm,
|
||||
portRangeStart: parseInt(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="结束端口"
|
||||
type="number"
|
||||
value={shareForm.portRangeEnd.toString()}
|
||||
onChange={(e) => setShareForm({ ...shareForm, portRangeEnd: parseInt(e.target.value) })}
|
||||
onChange={(e) =>
|
||||
setShareForm({
|
||||
...shareForm,
|
||||
portRangeEnd: parseInt(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
description="0 表示永久"
|
||||
label="有效期 (天)"
|
||||
type="number"
|
||||
description="0 表示永久"
|
||||
value={shareForm.expiryDays.toString()}
|
||||
onChange={(e) => setShareForm({ ...shareForm, expiryDays: parseInt(e.target.value) })}
|
||||
onChange={(e) =>
|
||||
setShareForm({
|
||||
...shareForm,
|
||||
expiryDays: parseInt(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
description="0 表示不限流量"
|
||||
label="流量上限 (GB)"
|
||||
type="number"
|
||||
description="0 表示不限流量"
|
||||
value={shareForm.maxBandwidth.toString()}
|
||||
onChange={(e) => setShareForm({ ...shareForm, maxBandwidth: parseInt(e.target.value, 10) || 0 })}
|
||||
onChange={(e) =>
|
||||
setShareForm({
|
||||
...shareForm,
|
||||
maxBandwidth: parseInt(e.target.value, 10) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
|
||||
label="允许的域名 (可选)"
|
||||
placeholder="example.com, panel.test.com"
|
||||
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
|
||||
value={shareForm.allowedDomains}
|
||||
onChange={(e) => setShareForm({ ...shareForm, allowedDomains: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setShareForm({ ...shareForm, allowedDomains: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
|
||||
label="允许的API IP (可选)"
|
||||
placeholder="203.0.113.10, 2001:db8::10, 198.51.100.0/24"
|
||||
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
|
||||
value={shareForm.allowedIps}
|
||||
onChange={(e) => setShareForm({ ...shareForm, allowedIps: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setShareForm({ ...shareForm, allowedIps: e.target.value })
|
||||
}
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onPress={() => setCreateShareOpen(false)}>取消</Button>
|
||||
<Button color="primary" onPress={handleCreateShare}>创建</Button>
|
||||
<Button color="primary" onPress={handleCreateShare}>
|
||||
创建
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
@@ -578,54 +709,88 @@ export default function PanelSharingPage() {
|
||||
label="名称"
|
||||
placeholder="备注名称"
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, name: e.target.value })
|
||||
}
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<Input
|
||||
label="起始端口"
|
||||
type="number"
|
||||
value={editForm.portRangeStart.toString()}
|
||||
onChange={(e) => setEditForm({ ...editForm, portRangeStart: parseInt(e.target.value) || 0 })}
|
||||
onChange={(e) =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
portRangeStart: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="结束端口"
|
||||
type="number"
|
||||
value={editForm.portRangeEnd.toString()}
|
||||
onChange={(e) => setEditForm({ ...editForm, portRangeEnd: parseInt(e.target.value) || 0 })}
|
||||
onChange={(e) =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
portRangeEnd: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
description="0 表示不限流量"
|
||||
label="流量上限 (GB)"
|
||||
type="number"
|
||||
description="0 表示不限流量"
|
||||
value={editForm.maxBandwidth.toString()}
|
||||
onChange={(e) => setEditForm({ ...editForm, maxBandwidth: parseInt(e.target.value, 10) || 0 })}
|
||||
onChange={(e) =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
maxBandwidth: parseInt(e.target.value, 10) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
description="留空或清除表示永久有效"
|
||||
label="过期时间"
|
||||
type="datetime-local"
|
||||
description="留空或清除表示永久有效"
|
||||
value={editForm.expiryTime > 0 ? new Date(editForm.expiryTime).toISOString().slice(0, 16) : ""}
|
||||
onChange={(e) => setEditForm({ ...editForm, expiryTime: e.target.value ? new Date(e.target.value).getTime() : 0 })}
|
||||
value={
|
||||
editForm.expiryTime > 0
|
||||
? new Date(editForm.expiryTime).toISOString().slice(0, 16)
|
||||
: ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
expiryTime: e.target.value
|
||||
? new Date(e.target.value).getTime()
|
||||
: 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
|
||||
label="允许的域名 (可选)"
|
||||
placeholder="example.com, panel.test.com"
|
||||
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
|
||||
value={editForm.allowedDomains}
|
||||
onChange={(e) => setEditForm({ ...editForm, allowedDomains: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, allowedDomains: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
|
||||
label="允许的API IP (可选)"
|
||||
placeholder="203.0.113.10, 2001:db8::10, 198.51.100.0/24"
|
||||
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
|
||||
value={editForm.allowedIps}
|
||||
onChange={(e) => setEditForm({ ...editForm, allowedIps: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, allowedIps: e.target.value })
|
||||
}
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onPress={() => setEditShareOpen(false)}>取消</Button>
|
||||
<Button color="primary" onPress={handleEditShare}>保存</Button>
|
||||
<Button color="primary" onPress={handleEditShare}>
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
@@ -639,18 +804,24 @@ export default function PanelSharingPage() {
|
||||
label="远程面板地址"
|
||||
placeholder="http://panel.example.com:8088"
|
||||
value={importForm.remoteUrl}
|
||||
onChange={(e) => setImportForm({ ...importForm, remoteUrl: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setImportForm({ ...importForm, remoteUrl: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="Token"
|
||||
placeholder="Bearer Token"
|
||||
value={importForm.token}
|
||||
onChange={(e) => setImportForm({ ...importForm, token: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setImportForm({ ...importForm, token: e.target.value })
|
||||
}
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onPress={() => setImportNodeOpen(false)}>取消</Button>
|
||||
<Button color="secondary" onPress={handleImportNode}>导入</Button>
|
||||
<Button color="secondary" onPress={handleImportNode}>
|
||||
导入
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
@@ -46,6 +46,16 @@ html, body {
|
||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
[data-slot="input-wrapper"] {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-slot="input-wrapper"]:focus-within:not([data-invalid="true"]) {
|
||||
outline: none;
|
||||
outline-offset: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.safe-top {
|
||||
padding-top: var(--safe-area-top);
|
||||
}
|
||||
@@ -85,4 +95,4 @@ html, body {
|
||||
}
|
||||
}
|
||||
|
||||
@config "../../tailwind.config.js"
|
||||
@config "../../tailwind.config.js"
|
||||
|
||||
Reference in New Issue
Block a user