mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-16 01:24:24 +08:00
Compare commits
95 Commits
2.1.1
...
2.1.3-beta8
| Author | SHA1 | Date | |
|---|---|---|---|
| 5271efec1e | |||
| 2d39cb3005 | |||
| 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-amd64
|
||||||
upx --best --lzma gost-arm64
|
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
|
- name: Upload GOST AMD64 artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -124,6 +130,18 @@ jobs:
|
|||||||
name: gost-binary-arm64
|
name: gost-binary-arm64
|
||||||
path: ./go-gost/gost-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:
|
build-vite:
|
||||||
name: Build & Push Vite Frontend
|
name: Build & Push Vite Frontend
|
||||||
needs: check-version
|
needs: check-version
|
||||||
@@ -238,7 +256,20 @@ jobs:
|
|||||||
name: gost-binary-arm64
|
name: gost-binary-arm64
|
||||||
path: ./artifacts/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
|
- name: Prepare release files
|
||||||
|
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ needs.check-version.outputs.version }}"
|
VERSION="${{ needs.check-version.outputs.version }}"
|
||||||
OWNER="${{ needs.check-version.outputs.image_owner }}"
|
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-amd64 --clobber
|
||||||
gh release upload "${VERSION}" ./artifacts/gost-arm64 --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 "📤 上传安装脚本..."
|
echo "📤 上传安装脚本..."
|
||||||
gh release upload "${VERSION}" ./artifacts/install.sh --clobber
|
gh release upload "${VERSION}" ./artifacts/install.sh --clobber
|
||||||
gh release upload "${VERSION}" ./artifacts/panel_install.sh --clobber
|
gh release upload "${VERSION}" ./artifacts/panel_install.sh --clobber
|
||||||
@@ -363,6 +398,18 @@ jobs:
|
|||||||
name: gost-binary-arm64
|
name: gost-binary-arm64
|
||||||
path: ./artifacts/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
|
- name: Rename binaries
|
||||||
run: |
|
run: |
|
||||||
mv ./artifacts/amd64/gost-amd64 ./artifacts/gost-amd64
|
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-amd64 --clobber
|
||||||
gh release upload "${VERSION}" ./artifacts/gost-arm64 --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 二进制文件更新完成"
|
echo "✅ GOST 二进制文件更新完成"
|
||||||
|
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ build/
|
|||||||
*.dylib
|
*.dylib
|
||||||
your_app.exe
|
your_app.exe
|
||||||
go-backend/paneld
|
go-backend/paneld
|
||||||
|
go-gost/gost
|
||||||
|
|
||||||
# Go 测试二进制文件
|
# Go 测试二进制文件
|
||||||
*.test
|
*.test
|
||||||
@@ -262,3 +263,6 @@ sql/
|
|||||||
!go-backend/internal/store/sqlite/sql/
|
!go-backend/internal/store/sqlite/sql/
|
||||||
!go-backend/internal/store/sqlite/sql/schema.sql
|
!go-backend/internal/store/sqlite/sql/schema.sql
|
||||||
!go-backend/internal/store/sqlite/sql/data.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
|
# PROJECT KNOWLEDGE BASE
|
||||||
|
|
||||||
**Generated:** Mon Feb 02 2026
|
**Generated:** Fri Feb 13 2026
|
||||||
**Commit:** 7ca01ab
|
**Commit:** 3799729
|
||||||
**Branch:** beta
|
**Branch:** (detached)
|
||||||
|
|
||||||
## OVERVIEW
|
## 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.
|
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.
|
- `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
|
||||||
|
|
||||||
## ANTI-PATTERNS (THIS PROJECT)
|
## 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
|
## COMMANDS
|
||||||
```bash
|
```bash
|
||||||
@@ -68,3 +72,6 @@ docker compose -f docker-compose-v6.yml up -d
|
|||||||
## NOTES
|
## NOTES
|
||||||
- LSP servers are not installed in this environment (gopls/jdtls/typescript-language-server); rely on grep-based navigation.
|
- 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.
|
- `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
|
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
|
- **账号**: 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.
|
- **Removed**: `ios-app/` - Source code for the iOS client.
|
||||||
|
|
||||||
### 5. Infrastructure & Scripts
|
### 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).
|
- **Modified**: `install.sh`, `panel_install.sh` (Updated installation logic).
|
||||||
- **Added**: `AGENTS.md` (Project documentation).
|
- **Added**: `AGENTS.md` (Project documentation).
|
||||||
|
|
||||||
|
|||||||
+16
@@ -22,3 +22,19 @@
|
|||||||
|
|
||||||
### Q5: IPv6 无法使用?
|
### Q5: IPv6 无法使用?
|
||||||
**A**: 面板安装脚本会自动尝试配置 Docker 的 IPv6。如果失败,请手动检查 `/etc/docker/daemon.json` 配置,确保 `ipv6: true` 且分配了正确的 `fixed-cidr-v6` 子网。
|
**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)
|
- [安装部署](./install.md)
|
||||||
- [使用指南](./usage.md)
|
- [使用指南](./usage.md)
|
||||||
|
- [PostgreSQL 数据库指南](./postgresql.md)
|
||||||
- [常见问题](./faq.md)
|
- [常见问题](./faq.md)
|
||||||
|
|
||||||
## 免责声明
|
## 免责声明
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_instal
|
|||||||
1. 安装面板
|
1. 安装面板
|
||||||
2. 更新面板
|
2. 更新面板
|
||||||
3. 卸载面板
|
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
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
max-size: "20m"
|
max-size: "20m"
|
||||||
|
max-file: "3"
|
||||||
environment:
|
environment:
|
||||||
|
DB_TYPE: ${DB_TYPE:-sqlite}
|
||||||
DB_PATH: /app/data/gost.db
|
DB_PATH: /app/data/gost.db
|
||||||
|
DATABASE_URL: ${DATABASE_URL:-}
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
LOG_DIR: /app/logs
|
|
||||||
SERVER_ADDR: :6365
|
SERVER_ADDR: :6365
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT}:6365"
|
- "${BACKEND_PORT}:6365"
|
||||||
volumes:
|
volumes:
|
||||||
- backend_logs:/app/logs
|
|
||||||
- sqlite_data:/app/data
|
- sqlite_data:/app/data
|
||||||
networks:
|
networks:
|
||||||
- gost-network
|
- gost-network
|
||||||
@@ -29,6 +30,30 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
start_period: 30s
|
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:
|
frontend:
|
||||||
image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}
|
image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}
|
||||||
container_name: vite-frontend
|
container_name: vite-frontend
|
||||||
@@ -37,6 +62,7 @@ services:
|
|||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
max-size: "20m"
|
max-size: "20m"
|
||||||
|
max-file: "3"
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT}:80"
|
- "${FRONTEND_PORT}:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -50,8 +76,8 @@ volumes:
|
|||||||
sqlite_data:
|
sqlite_data:
|
||||||
name: sqlite_data
|
name: sqlite_data
|
||||||
driver: local
|
driver: local
|
||||||
backend_logs:
|
postgres_data:
|
||||||
name: backend_logs
|
name: postgres_data
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
|
|
||||||
@@ -61,4 +87,4 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
ipam:
|
ipam:
|
||||||
config:
|
config:
|
||||||
- subnet: 172.20.0.0/16
|
- subnet: 172.80.0.0/16
|
||||||
|
|||||||
+31
-5
@@ -7,16 +7,17 @@ services:
|
|||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
max-size: "20m"
|
max-size: "20m"
|
||||||
|
max-file: "3"
|
||||||
environment:
|
environment:
|
||||||
|
DB_TYPE: ${DB_TYPE:-sqlite}
|
||||||
DB_PATH: /app/data/gost.db
|
DB_PATH: /app/data/gost.db
|
||||||
|
DATABASE_URL: ${DATABASE_URL:-}
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
LOG_DIR: /app/logs
|
|
||||||
SERVER_ADDR: :6365
|
SERVER_ADDR: :6365
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT}:6365"
|
- "${BACKEND_PORT}:6365"
|
||||||
volumes:
|
volumes:
|
||||||
- backend_logs:/app/logs
|
|
||||||
- sqlite_data:/app/data
|
- sqlite_data:/app/data
|
||||||
networks:
|
networks:
|
||||||
- gost-network
|
- gost-network
|
||||||
@@ -29,6 +30,30 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
start_period: 30s
|
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:
|
frontend:
|
||||||
image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}
|
image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest}
|
||||||
container_name: vite-frontend
|
container_name: vite-frontend
|
||||||
@@ -37,6 +62,7 @@ services:
|
|||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
max-size: "20m"
|
max-size: "20m"
|
||||||
|
max-file: "3"
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT}:80"
|
- "${FRONTEND_PORT}:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -50,8 +76,8 @@ volumes:
|
|||||||
sqlite_data:
|
sqlite_data:
|
||||||
name: sqlite_data
|
name: sqlite_data
|
||||||
driver: local
|
driver: local
|
||||||
backend_logs:
|
postgres_data:
|
||||||
name: backend_logs
|
name: postgres_data
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
|
|
||||||
@@ -62,5 +88,5 @@ networks:
|
|||||||
enable_ipv6: true
|
enable_ipv6: true
|
||||||
ipam:
|
ipam:
|
||||||
config:
|
config:
|
||||||
- subnet: 172.20.0.0/16
|
- subnet: 172.80.0.0/16
|
||||||
- subnet: fd00:dead:beef::/48
|
- 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
|
WORKDIR /src
|
||||||
|
|
||||||
COPY go.mod ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
+8
-1
@@ -1,22 +1,29 @@
|
|||||||
module go-backend
|
module go-backend
|
||||||
|
|
||||||
go 1.23.0
|
go 1.24.0
|
||||||
|
|
||||||
toolchain go1.24.4
|
toolchain go1.24.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/jackc/pgx/v5 v5.7.3
|
||||||
modernc.org/sqlite v1.37.1
|
modernc.org/sqlite v1.37.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // 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/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/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/sys v0.33.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
modernc.org/libc v1.65.7 // indirect
|
modernc.org/libc v1.65.7 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // 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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e 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/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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
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.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
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 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
|
||||||
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go-backend/internal/config"
|
"go-backend/internal/config"
|
||||||
@@ -20,9 +21,24 @@ type App struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.Config) (*App, error) {
|
func New(cfg config.Config) (*App, error) {
|
||||||
repo, err := sqlite.Open(cfg.DBPath)
|
var (
|
||||||
if err != nil {
|
repo *sqlite.Repository
|
||||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
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)
|
h := handler.New(repo, cfg.JWTSecret)
|
||||||
|
|||||||
@@ -3,18 +3,22 @@ package config
|
|||||||
import "os"
|
import "os"
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Addr string
|
Addr string
|
||||||
DBPath string
|
DBType string
|
||||||
JWTSecret string
|
DBPath string
|
||||||
LogDir string
|
DatabaseURL string
|
||||||
|
JWTSecret string
|
||||||
|
LogDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func FromEnv() Config {
|
func FromEnv() Config {
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
Addr: getEnv("SERVER_ADDR", ":6365"),
|
Addr: getEnv("SERVER_ADDR", ":6365"),
|
||||||
DBPath: getEnv("DB_PATH", "/app/data/gost.db"),
|
DBType: getEnv("DB_TYPE", "sqlite"),
|
||||||
JWTSecret: getEnv("JWT_SECRET", ""),
|
DBPath: getEnv("DB_PATH", "/app/data/gost.db"),
|
||||||
LogDir: getEnv("LOG_DIR", "/app/logs"),
|
DatabaseURL: getEnv("DATABASE_URL", ""),
|
||||||
|
JWTSecret: getEnv("JWT_SECRET", ""),
|
||||||
|
LogDir: getEnv("LOG_DIR", "/app/logs"),
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
|
|||||||
@@ -77,6 +77,18 @@ type RuntimeDiagnoseRequest struct {
|
|||||||
Timeout int `json:"timeout"`
|
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 {
|
func NewFederationClient() *FederationClient {
|
||||||
return &FederationClient{
|
return &FederationClient{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
@@ -333,3 +345,42 @@ func (c *FederationClient) Diagnose(url, token, localDomain string, reqData Runt
|
|||||||
|
|
||||||
return res.Data, nil
|
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) {
|
func (h *Handler) getForwardRecord(forwardID int64) (*forwardRecord, error) {
|
||||||
row := h.repo.DB().QueryRow(`
|
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
|
FROM forward WHERE id = ? LIMIT 1
|
||||||
`, forwardID)
|
`, forwardID)
|
||||||
var fr forwardRecord
|
var fr forwardRecord
|
||||||
@@ -152,7 +152,7 @@ func (h *Handler) getTunnelRecord(tunnelID int64) (*tunnelRecord, error) {
|
|||||||
|
|
||||||
func (h *Handler) listForwardsByTunnel(tunnelID int64) ([]forwardRecord, error) {
|
func (h *Handler) listForwardsByTunnel(tunnelID int64) ([]forwardRecord, error) {
|
||||||
rows, err := h.repo.DB().Query(`
|
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
|
FROM forward
|
||||||
WHERE tunnel_id = ?
|
WHERE tunnel_id = ?
|
||||||
ORDER BY id ASC
|
ORDER BY id ASC
|
||||||
@@ -200,6 +200,26 @@ func (h *Handler) listForwardPorts(forwardID int64) ([]forwardPortRecord, error)
|
|||||||
return result, nil
|
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) {
|
func (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {
|
||||||
row := h.repo.DB().QueryRow(`
|
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
|
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
|
return err
|
||||||
}
|
}
|
||||||
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, userTunnelID)
|
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, userTunnelID)
|
||||||
|
tunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
for _, fp := range ports {
|
for _, fp := range ports {
|
||||||
if limiterID != nil && speed != nil {
|
if limiterID != nil && speed != nil {
|
||||||
@@ -356,7 +380,7 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
_, err = h.sendNodeCommand(node.ID, method, services, true, false)
|
||||||
if err != nil && allowFallbackAdd && method == "UpdateService" {
|
if err != nil && allowFallbackAdd && method == "UpdateService" {
|
||||||
_, err = h.sendNodeCommand(node.ID, "AddService", services, true, false)
|
_, 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) {
|
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 {
|
if err == nil {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -475,6 +509,44 @@ func (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interfa
|
|||||||
return result, err
|
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) {
|
func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]interface{}, error) {
|
||||||
if forward == nil {
|
if forward == nil {
|
||||||
return nil, errForwardNotFound
|
return nil, errForwardNotFound
|
||||||
@@ -889,11 +961,11 @@ func firstPortFromRange(portRange string) int {
|
|||||||
|
|
||||||
func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, error) {
|
func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, error) {
|
||||||
rows, err := h.repo.DB().Query(`
|
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
|
FROM chain_tunnel ct
|
||||||
LEFT JOIN node n ON n.id = ct.node_id
|
LEFT JOIN node n ON n.id = ct.node_id
|
||||||
WHERE ct.tunnel_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)
|
`, tunnelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1047,7 +1119,7 @@ func isNotFoundError(err error) bool {
|
|||||||
return strings.Contains(msg, "not found") || strings.Contains(msg, "不存在")
|
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"}
|
protocols := []string{"tcp", "udp"}
|
||||||
services := make([]map[string]interface{}, 0, 2)
|
services := make([]map[string]interface{}, 0, 2)
|
||||||
targets := splitRemoteTargets(forward.RemoteAddr)
|
targets := splitRemoteTargets(forward.RemoteAddr)
|
||||||
@@ -1080,7 +1152,11 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
if protocol == "udp" {
|
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 {
|
if tunnel != nil && tunnel.Type == 2 {
|
||||||
service["handler"].(map[string]interface{})["chain"] = fmt.Sprintf("chains_%d", forward.TunnelID)
|
service["handler"].(map[string]interface{})["chain"] = fmt.Sprintf("chains_%d", forward.TunnelID)
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ type federationRuntimeDiagnoseRequest struct {
|
|||||||
Timeout int `json:"timeout"`
|
Timeout int `json:"timeout"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type federationRuntimeCommandRequest struct {
|
||||||
|
CommandType string `json:"commandType"`
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
type peerShareUsedPort struct {
|
type peerShareUsedPort struct {
|
||||||
RuntimeID int64 `json:"runtimeId"`
|
RuntimeID int64 `json:"runtimeId"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
@@ -786,7 +791,7 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
|
|||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
now := time.Now().UnixMilli()
|
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),
|
fmt.Sprintf("Share-%d-Port-%d", share.ID, req.RemotePort),
|
||||||
tunnelType,
|
tunnelType,
|
||||||
req.Protocol,
|
req.Protocol,
|
||||||
@@ -799,9 +804,7 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
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,
|
tunnelID,
|
||||||
share.NodeID,
|
share.NodeID,
|
||||||
req.RemotePort,
|
req.RemotePort,
|
||||||
@@ -1002,14 +1005,19 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
|
|||||||
response.WriteJSON(w, response.ErrDefault("Invalid target"))
|
response.WriteJSON(w, response.ErrDefault("Invalid target"))
|
||||||
return
|
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{}{
|
nodeItems = append(nodeItems, map[string]interface{}{
|
||||||
"name": fmt.Sprintf("node_%d", i+1),
|
"name": fmt.Sprintf("node_%d", i+1),
|
||||||
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, target.Port)),
|
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, target.Port)),
|
||||||
"connector": map[string]interface{}{
|
"connector": connector,
|
||||||
"type": "relay",
|
|
||||||
},
|
|
||||||
"dialer": map[string]interface{}{
|
"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,
|
"type": protocol,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if isTLSTunnelProtocol(protocol) {
|
||||||
|
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{"nodelay": true}
|
||||||
|
}
|
||||||
if req.Role == "middle" {
|
if req.Role == "middle" {
|
||||||
service["handler"].(map[string]interface{})["chain"] = chainName
|
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))
|
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) {
|
func (h *Handler) pickPeerSharePort(share *sqlite.PeerShare, requestedPort int) (int, error) {
|
||||||
if share == nil {
|
if share == nil {
|
||||||
return 0, fmt.Errorf("share not found")
|
return 0, fmt.Errorf("share not found")
|
||||||
@@ -1350,7 +1406,7 @@ func parseIPLiteral(raw string) net.IP {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ip := net.ParseIP(value); ip != nil {
|
if ip := net.ParseIP(value); ip != nil {
|
||||||
return ip
|
return normalizeIPAddress(ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
host, _, err := net.SplitHostPort(value)
|
host, _, err := net.SplitHostPort(value)
|
||||||
@@ -1362,7 +1418,17 @@ func parseIPLiteral(raw string) net.IP {
|
|||||||
if host == "" {
|
if host == "" {
|
||||||
return nil
|
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 {
|
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) {
|
func TestFederationRuntimeReservePortRejectsWhenShareFlowExceeded(t *testing.T) {
|
||||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -567,6 +567,13 @@ func TestAuthPeerAllowedIPs(t *testing.T) {
|
|||||||
xff: "198.51.100.20, 172.20.0.3",
|
xff: "198.51.100.20, 172.20.0.3",
|
||||||
wantAllowed: true,
|
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",
|
name: "non whitelisted ip denied",
|
||||||
allowedIPs: "203.0.113.10",
|
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) {
|
func (h *Handler) listActiveForwardsByUser(userID int64) ([]forwardRecord, error) {
|
||||||
rows, err := h.repo.DB().Query(`
|
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
|
FROM forward
|
||||||
WHERE user_id = ? AND status = 1
|
WHERE user_id = ? AND status = 1
|
||||||
ORDER BY id ASC
|
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) {
|
func (h *Handler) listActiveForwardsByUserTunnel(userID int64, tunnelID int64) ([]forwardRecord, error) {
|
||||||
rows, err := h.repo.DB().Query(`
|
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
|
FROM forward
|
||||||
WHERE user_id = ? AND tunnel_id = ? AND status = 1
|
WHERE user_id = ? AND tunnel_id = ? AND status = 1
|
||||||
ORDER BY id ASC
|
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/list", h.getConfigs)
|
||||||
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
|
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
|
||||||
mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig)
|
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/check", h.checkCaptcha)
|
||||||
mux.HandleFunc("/api/v1/captcha/verify", h.captchaVerify)
|
mux.HandleFunc("/api/v1/captcha/verify", h.captchaVerify)
|
||||||
mux.HandleFunc("/api/v1/user/package", h.userPackage)
|
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/update-order", h.nodeUpdateOrder)
|
||||||
mux.HandleFunc("/api/v1/node/batch-delete", h.nodeBatchDelete)
|
mux.HandleFunc("/api/v1/node/batch-delete", h.nodeBatchDelete)
|
||||||
mux.HandleFunc("/api/v1/node/check-status", h.nodeCheckStatus)
|
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/list", h.tunnelList)
|
||||||
mux.HandleFunc("/api/v1/tunnel/create", h.tunnelCreate)
|
mux.HandleFunc("/api/v1/tunnel/create", h.tunnelCreate)
|
||||||
mux.HandleFunc("/api/v1/tunnel/get", h.tunnelGet)
|
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/apply-role", h.authPeer(h.federationRuntimeApplyRole))
|
||||||
mux.HandleFunc("/api/v1/federation/runtime/release-role", h.authPeer(h.federationRuntimeReleaseRole))
|
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/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("/api/v1/federation/node/import", h.nodeImport)
|
||||||
|
|
||||||
mux.HandleFunc("/flow/test", h.flowTest)
|
mux.HandleFunc("/flow/test", h.flowTest)
|
||||||
@@ -302,11 +313,35 @@ func (h *Handler) userList(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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()
|
users, err := h.repo.ListUsers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
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))
|
response.WriteJSON(w, response.OK(users))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1112,3 +1147,84 @@ func (h *Handler) verifyCloudflareTurnstile(token, secretKey string) bool {
|
|||||||
}
|
}
|
||||||
return body.Success
|
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/client"
|
||||||
"go-backend/internal/http/response"
|
"go-backend/internal/http/response"
|
||||||
"go-backend/internal/security"
|
"go-backend/internal/security"
|
||||||
|
"go-backend/internal/store"
|
||||||
"go-backend/internal/store/sqlite"
|
"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()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
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))
|
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)
|
name, trafficRatio, typeVal, "tls", flow, now, now, status, nullableText(inIP), inx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tunnelID, _ := res.LastInsertId()
|
|
||||||
runtimeState.TunnelID = tunnelID
|
runtimeState.TunnelID = tunnelID
|
||||||
var federationBindings []sqlite.FederationTunnelBinding
|
var federationBindings []sqlite.FederationTunnelBinding
|
||||||
var federationReleaseRefs []federationRuntimeReleaseRef
|
var federationReleaseRefs []federationRuntimeReleaseRef
|
||||||
@@ -688,6 +688,9 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
runtimeState.TunnelID = id
|
runtimeState.TunnelID = id
|
||||||
|
|
||||||
|
inIp := buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes)
|
||||||
|
|
||||||
var federationBindings []sqlite.FederationTunnelBinding
|
var federationBindings []sqlite.FederationTunnelBinding
|
||||||
var federationReleaseRefs []federationRuntimeReleaseRef
|
var federationReleaseRefs []federationRuntimeReleaseRef
|
||||||
if typeVal == 2 {
|
if typeVal == 2 {
|
||||||
@@ -700,7 +703,7 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
applyTunnelPortsToRequest(req, runtimeState)
|
applyTunnelPortsToRequest(req, runtimeState)
|
||||||
|
|
||||||
_, err = tx.Exec(`UPDATE tunnel SET name=?, type=?, flow=?, traffic_ratio=?, status=?, in_ip=?, updated_time=? WHERE id=?`,
|
_, 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 {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
@@ -1119,7 +1122,7 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
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)
|
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, ?)
|
VALUES(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
|
||||||
`, userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, now, inx)
|
`, 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()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
forwardID, _ := res.LastInsertId()
|
|
||||||
entryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)
|
entryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)
|
||||||
for _, nodeID := range entryNodes {
|
for _, nodeID := range entryNodes {
|
||||||
_, _ = tx.Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port)
|
_, _ = 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()
|
now := time.Now().UnixMilli()
|
||||||
speed := asInt(req["speed"], 100)
|
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))
|
name, speed, tunnelID, tunnelName, now, now, asInt(req["status"], 1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id, _ := res.LastInsertId()
|
|
||||||
_ = h.sendLimiterConfig(id, speed, tunnelID)
|
_ = h.sendLimiterConfig(id, speed, tunnelID)
|
||||||
response.WriteJSON(w, response.OKEmpty())
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
}
|
}
|
||||||
@@ -1687,7 +1688,7 @@ func (h *Handler) groupTunnelAssign(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
_, _ = tx.Exec(`DELETE FROM tunnel_group_tunnel WHERE tunnel_group_id = ?`, req.GroupID)
|
_, _ = tx.Exec(`DELETE FROM tunnel_group_tunnel WHERE tunnel_group_id = ?`, req.GroupID)
|
||||||
for _, tid := range req.TunnelIDs {
|
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 {
|
if err := tx.Commit(); err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
@@ -1712,9 +1713,18 @@ func (h *Handler) groupUserAssign(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
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)
|
_, _ = tx.Exec(`DELETE FROM user_group_user WHERE user_group_id = ?`, req.GroupID)
|
||||||
for _, uid := range req.UserIDs {
|
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 {
|
if err := tx.Commit(); err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
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("请求参数错误"))
|
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
@@ -1747,10 +1757,35 @@ func (h *Handler) groupPermissionRemove(w http.ResponseWriter, r *http.Request)
|
|||||||
if id <= 0 {
|
if id <= 0 {
|
||||||
return
|
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
|
var ug, tg int64
|
||||||
_ = h.repo.DB().QueryRow(`SELECT user_group_id, tunnel_group_id FROM group_permission WHERE id = ?`, id).Scan(&ug, &tg)
|
err = tx.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)
|
if err != nil && err != sql.ErrNoRows {
|
||||||
_, _ = h.repo.DB().Exec(`DELETE FROM group_permission_grant WHERE user_group_id = ? AND tunnel_group_id = ?`, ug, tg)
|
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())
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1835,7 +1870,7 @@ func (h *Handler) applyGroupPermission(userGroupID, tunnelGroupID int64) error {
|
|||||||
if created {
|
if created {
|
||||||
createdByGroup = 1
|
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())
|
userGroupID, tunnelGroupID, utID, createdByGroup, time.Now().UnixMilli())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1866,7 +1901,7 @@ func (h *Handler) syncPermissionsByTunnelGroup(tunnelGroupID int64) error {
|
|||||||
return nil
|
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
|
var id int64
|
||||||
err := db.QueryRow(`SELECT id FROM user_tunnel WHERE user_id = ? AND tunnel_id = ? LIMIT 1`, userID, tunnelID).Scan(&id)
|
err := db.QueryRow(`SELECT id FROM user_tunnel WHERE user_id = ? AND tunnel_id = ? LIMIT 1`, userID, tunnelID).Scan(&id)
|
||||||
if err == nil {
|
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 {
|
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
|
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)
|
userID, tunnelID, num, flow, flowReset, expTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, false, err
|
return 0, false, err
|
||||||
}
|
}
|
||||||
id, _ = res.LastInsertId()
|
|
||||||
return id, true, nil
|
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...)
|
rows, err := db.Query(q, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1908,7 +1942,145 @@ func queryInt64List(db *sql.DB, q string, args ...interface{}) ([]int64, error)
|
|||||||
return out, rows.Err()
|
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...)
|
rows, err := db.Query(q, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1944,7 +2116,7 @@ type tunnelCreateState struct {
|
|||||||
NodeIDList []int64
|
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{
|
state := &tunnelCreateState{
|
||||||
Type: tunnelType,
|
Type: tunnelType,
|
||||||
InNodes: make([]tunnelRuntimeNode, 0),
|
InNodes: make([]tunnelRuntimeNode, 0),
|
||||||
@@ -2061,7 +2233,7 @@ func (h *Handler) prepareTunnelCreateState(tx *sql.Tx, req map[string]interface{
|
|||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if node.Status != 1 {
|
if node.IsRemote != 1 && node.Status != 1 {
|
||||||
return nil, errors.New("部分节点不在线")
|
return nil, errors.New("部分节点不在线")
|
||||||
}
|
}
|
||||||
state.Nodes[nodeID] = node
|
state.Nodes[nodeID] = node
|
||||||
@@ -2403,7 +2575,7 @@ func (h *Handler) cleanupFederationRuntime(tunnelID int64) {
|
|||||||
_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)
|
_ = 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 {
|
if tx == nil {
|
||||||
return errors.New("database unavailable")
|
return errors.New("database unavailable")
|
||||||
}
|
}
|
||||||
@@ -2441,9 +2613,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, inNode := range state.InNodes {
|
for _, inNode := range state.InNodes {
|
||||||
if node := state.Nodes[inNode.NodeID]; node != nil && node.IsRemote == 1 {
|
node := state.Nodes[inNode.NodeID]
|
||||||
continue
|
|
||||||
}
|
|
||||||
targets := state.OutNodes
|
targets := state.OutNodes
|
||||||
if len(state.ChainHops) > 0 {
|
if len(state.ChainHops) > 0 {
|
||||||
targets = state.ChainHops[0]
|
targets = state.ChainHops[0]
|
||||||
@@ -2453,6 +2623,9 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
|||||||
return createdChains, createdServices, err
|
return createdChains, createdServices, err
|
||||||
}
|
}
|
||||||
if _, err := h.sendNodeCommand(inNode.NodeID, "AddChains", chainData, true, false); err != nil {
|
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)
|
return createdChains, createdServices, fmt.Errorf("入口节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[inNode.NodeID]), err)
|
||||||
}
|
}
|
||||||
createdChains = append(createdChains, inNode.NodeID)
|
createdChains = append(createdChains, inNode.NodeID)
|
||||||
@@ -2564,14 +2737,19 @@ func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRu
|
|||||||
if port <= 0 {
|
if port <= 0 {
|
||||||
return nil, errors.New("节点端口不能为空")
|
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{}{
|
nodeItems = append(nodeItems, map[string]interface{}{
|
||||||
"name": fmt.Sprintf("node_%d", idx+1),
|
"name": fmt.Sprintf("node_%d", idx+1),
|
||||||
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, port)),
|
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, port)),
|
||||||
"connector": map[string]interface{}{
|
"connector": connector,
|
||||||
"type": "relay",
|
|
||||||
},
|
|
||||||
"dialer": map[string]interface{}{
|
"dialer": map[string]interface{}{
|
||||||
"type": defaultString(target.Protocol, "tls"),
|
"type": protocol,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -2600,14 +2778,19 @@ func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode,
|
|||||||
if node == nil {
|
if node == nil {
|
||||||
return 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{}{
|
service := map[string]interface{}{
|
||||||
"name": fmt.Sprintf("%d_tls", tunnelID),
|
"name": fmt.Sprintf("%d_tls", tunnelID),
|
||||||
"addr": fmt.Sprintf("%s:%d", node.TCPListenAddr, chainNode.Port),
|
"addr": fmt.Sprintf("%s:%d", node.TCPListenAddr, chainNode.Port),
|
||||||
"handler": map[string]interface{}{
|
"handler": handlerCfg,
|
||||||
"type": "relay",
|
|
||||||
},
|
|
||||||
"listener": map[string]interface{}{
|
"listener": map[string]interface{}{
|
||||||
"type": defaultString(chainNode.Protocol, "tls"),
|
"type": protocol,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if chainNode.ChainType == 2 {
|
if chainNode.ChainType == 2 {
|
||||||
@@ -2653,6 +2836,10 @@ func nodeDisplayName(node *nodeRecord) string {
|
|||||||
return fmt.Sprintf("node_%d", node.ID)
|
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 {
|
func nodeSupportsV4(node *nodeRecord) bool {
|
||||||
if node == nil {
|
if node == nil {
|
||||||
return false
|
return false
|
||||||
@@ -2713,7 +2900,7 @@ func pickNodeAddressV6(node *nodeRecord) string {
|
|||||||
return strings.TrimSpace(node.ServerIP)
|
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 {
|
if tx == nil {
|
||||||
return false, errors.New("database unavailable")
|
return false, errors.New("database unavailable")
|
||||||
}
|
}
|
||||||
@@ -2730,7 +2917,7 @@ func isRemoteNodeTx(tx *sql.Tx, nodeID int64) (bool, error) {
|
|||||||
return isRemote == 1, nil
|
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 {
|
if tx == nil {
|
||||||
return 0, errors.New("database unavailable")
|
return 0, errors.New("database unavailable")
|
||||||
}
|
}
|
||||||
@@ -2841,7 +3028,7 @@ func parsePortRangeSpec(input string) []int {
|
|||||||
return out
|
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{}
|
allocated := map[int64]int{}
|
||||||
inNodes := asMapSlice(req["inNodeId"])
|
inNodes := asMapSlice(req["inNodeId"])
|
||||||
for _, n := range inNodes {
|
for _, n := range inNodes {
|
||||||
@@ -2849,8 +3036,8 @@ func replaceTunnelChainsTx(tx *sql.Tx, tunnelID int64, req map[string]interface{
|
|||||||
if nodeID <= 0 {
|
if nodeID <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 1, ?, NULL, NULL, 0, ?)`,
|
_, 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["protocol"]), "tls"))
|
tunnelID, nodeID, defaultString(asString(n["strategy"]), "round"), defaultString(asString(n["protocol"]), "tls"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -2868,7 +3055,7 @@ func replaceTunnelChainsTx(tx *sql.Tx, tunnelID int64, req map[string]interface{
|
|||||||
return pickErr
|
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"))
|
tunnelID, nodeID, port, defaultString(asString(n["strategy"]), "round"), defaultString(asString(n["protocol"]), "tls"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -2889,7 +3076,7 @@ func replaceTunnelChainsTx(tx *sql.Tx, tunnelID int64, req map[string]interface{
|
|||||||
return pickErr
|
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"))
|
tunnelID, nodeID, port, defaultString(asString(n["strategy"]), "round"), i+1, defaultString(asString(n["protocol"]), "tls"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -2975,7 +3162,7 @@ func (h *Handler) batchForwardStatus(ids []int64, status int) (int, int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) tunnelEntryNodeIDs(tunnelID int64) ([]int64, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -3462,7 +3649,7 @@ func randomToken(n int) string {
|
|||||||
return hex.EncodeToString(buf)
|
return hex.EncodeToString(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
func nextIndex(db *sql.DB, table string) int {
|
func nextIndex(db *store.DB, table string) int {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return 0
|
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
|
return true
|
||||||
case path == "/api/v1/federation/runtime/diagnose":
|
case path == "/api/v1/federation/runtime/diagnose":
|
||||||
return true
|
return true
|
||||||
|
case path == "/api/v1/federation/runtime/command":
|
||||||
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -115,6 +117,14 @@ func requiresAdmin(path string) bool {
|
|||||||
return true
|
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/") {
|
||||||
if strings.HasPrefix(path, "/api/v1/tunnel/user/tunnel") {
|
if strings.HasPrefix(path, "/api/v1/tunnel/user/tunnel") {
|
||||||
return false
|
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
|
ch chan CommandResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
wsPingPeriod = 15 * time.Second
|
||||||
|
wsPongWait = 45 * time.Second
|
||||||
|
wsWriteWait = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
type CommandResult struct {
|
type CommandResult struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
@@ -120,12 +126,19 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
cw := &connWrap{conn: conn}
|
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.mu.Lock()
|
||||||
s.admins[cw] = struct{}{}
|
s.admins[cw] = struct{}{}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
|
close(done)
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
delete(s.admins, cw)
|
delete(s.admins, cw)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
@@ -145,6 +158,12 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
cw := &connWrap{conn: conn}
|
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")
|
version := r.URL.Query().Get("version")
|
||||||
httpVal := parseIntDefault(r.URL.Query().Get("http"), 0)
|
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)
|
s.broadcastStatus(nodeID, 1)
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
|
close(done)
|
||||||
needOfflineBroadcast := false
|
needOfflineBroadcast := false
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
current, ok := s.nodes[nodeID]
|
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)
|
msg := decryptIfNeeded(payload, secret)
|
||||||
s.tryResolvePending(nodeID, msg)
|
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.mu.Lock()
|
||||||
|
_ = ns.conn.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||||
err = ns.conn.conn.WriteMessage(websocket.TextMessage, messageData)
|
err = ns.conn.conn.WriteMessage(websocket.TextMessage, messageData)
|
||||||
|
_ = ns.conn.conn.SetWriteDeadline(time.Time{})
|
||||||
ns.conn.mu.Unlock()
|
ns.conn.mu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cleanup()
|
cleanup()
|
||||||
@@ -385,6 +415,12 @@ func (s *Server) broadcastInfo(nodeID int64, data string) {
|
|||||||
s.broadcastToAdmins(string(raw))
|
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) {
|
func (s *Server) broadcastToAdmins(message string) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
admins := make([]*connWrap, 0, len(s.admins))
|
admins := make([]*connWrap, 0, len(s.admins))
|
||||||
@@ -395,7 +431,9 @@ func (s *Server) broadcastToAdmins(message string) {
|
|||||||
|
|
||||||
for _, c := range admins {
|
for _, c := range admins {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
_ = c.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||||
err := c.conn.WriteMessage(websocket.TextMessage, []byte(message))
|
err := c.conn.WriteMessage(websocket.TextMessage, []byte(message))
|
||||||
|
_ = c.conn.SetWriteDeadline(time.Time{})
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("websocket broadcast failed: %v", err)
|
log.Printf("websocket broadcast failed: %v", err)
|
||||||
@@ -428,3 +466,28 @@ func parseIntDefault(v string, fallback int) int {
|
|||||||
}
|
}
|
||||||
return x
|
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")
|
middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-token")
|
||||||
exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-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")
|
stopMiddle := startMockNodeSession(t, providerServer.URL, "provider-middle-secret")
|
||||||
defer stopMiddle()
|
defer stopMiddle()
|
||||||
stopExit := startMockNodeSession(t, providerServer.URL, "provider-exit-secret")
|
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, 2, middleRemoteNodeID, 44000, 44010)
|
||||||
assertTunnelPortInRange(t, consumerRepo, secondTunnelID, 3, exitRemoteNodeID, 45000, 45010)
|
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`, 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 = ? AND status = 1 AND applied = 1`, exitShareID, 1)
|
||||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ?`, entryShareID, 0)
|
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 {
|
func insertContractNode(t *testing.T, repo *sqlite.Repository, name, ip, portRange, secret string, status int) int64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
now := time.Now().UnixMilli()
|
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() {
|
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()
|
t.Helper()
|
||||||
u, err := url.Parse(baseURL)
|
u, err := url.Parse(baseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -449,6 +602,9 @@ func startMockNodeSession(t *testing.T, baseURL string, nodeSecret string) func(
|
|||||||
if strings.TrimSpace(cmd.RequestID) == "" {
|
if strings.TrimSpace(cmd.RequestID) == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if onCommand != nil {
|
||||||
|
onCommand(strings.TrimSpace(cmd.Type))
|
||||||
|
}
|
||||||
|
|
||||||
respType := fmt.Sprintf("%sResponse", cmd.Type)
|
respType := fmt.Sprintf("%sResponse", cmd.Type)
|
||||||
respPayload := map[string]interface{}{
|
respPayload := map[string]interface{}{
|
||||||
@@ -473,9 +629,27 @@ func startMockNodeSession(t *testing.T, baseURL string, nodeSecret string) func(
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
var stopOnce sync.Once
|
||||||
return func() {
|
return func() {
|
||||||
_ = conn.Close()
|
stopOnce.Do(func() {
|
||||||
wg.Wait()
|
_ = 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"
|
httpserver "go-backend/internal/http"
|
||||||
"go-backend/internal/http/handler"
|
"go-backend/internal/http/handler"
|
||||||
"go-backend/internal/http/response"
|
"go-backend/internal/http/response"
|
||||||
|
"go-backend/internal/store"
|
||||||
"go-backend/internal/store/sqlite"
|
"go-backend/internal/store/sqlite"
|
||||||
|
|
||||||
_ "modernc.org/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) {
|
func setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *sqlite.Repository) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dbPath := filepath.Join(t.TempDir(), "contract.db")
|
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()
|
t.Helper()
|
||||||
|
|
||||||
rows, err := db.Query("PRAGMA table_info(" + table + ")")
|
rows, err := db.Query("PRAGMA table_info(" + table + ")")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package contract_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -144,6 +145,17 @@ func TestTunnelUpdateAssignsChainPortsContract(t *testing.T) {
|
|||||||
if outPort <= 0 {
|
if outPort <= 0 {
|
||||||
t.Fatalf("expected out node port to be assigned, got %d", outPort)
|
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 {
|
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
|
// SetProtocolBlock sets protocol blocking switches and recomputes wrapper need
|
||||||
func SetProtocolBlock(httpOn int, tlsOn int, socksOn int) {
|
func SetProtocolBlock(httpOn int, tlsOn int, socksOn int) {
|
||||||
isHttp = httpOn
|
isHttp = httpOn
|
||||||
isTls = tlsOn
|
isTls = tlsOn
|
||||||
isSocks = socksOn
|
isSocks = socksOn
|
||||||
needWrap = isTls+isSocks+isHttp > 0
|
needWrap = isTls+isSocks+isHttp > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
type Option func(opts *options)
|
type Option func(opts *options)
|
||||||
@@ -292,7 +292,9 @@ func (s *defaultService) Serve() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.handler.Handle(ctx, conn); err != nil {
|
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,
|
if v := xmetrics.GetCounter(xmetrics.MetricServiceHandlerErrorsCounter,
|
||||||
metrics.Labels{"service": s.name, "client": clientIP}); v != nil {
|
metrics.Labels{"service": s.name, "client": clientIP}); v != nil {
|
||||||
v.Inc()
|
v.Inc()
|
||||||
@@ -403,12 +405,12 @@ func (s *defaultService) observeStats(ctx context.Context) {
|
|||||||
TotalErrs: st.Get(stats.KindTotalErrs),
|
TotalErrs: st.Get(stats.KindTotalErrs),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将流量累积到全局管理器,而不是立即上报
|
// 将流量累积到全局管理器,而不是立即上报
|
||||||
if outputBytes > 0 || inputBytes > 0 {
|
if outputBytes > 0 || inputBytes > 0 {
|
||||||
globalManager := GetGlobalTrafficManager()
|
globalManager := GetGlobalTrafficManager()
|
||||||
globalManager.AddTraffic(s.name, int64(outputBytes), int64(inputBytes))
|
globalManager.AddTraffic(s.name, int64(outputBytes), int64(inputBytes))
|
||||||
|
|
||||||
// 立即重置流量计数(因为已经记录到全局管理器中)
|
// 立即重置流量计数(因为已经记录到全局管理器中)
|
||||||
if xstats, ok := st.(*xstats.Stats); ok {
|
if xstats, ok := st.(*xstats.Stats); ok {
|
||||||
xstats.ResetTraffic(st.Get(stats.KindInputBytes)-inputBytes, st.Get(stats.KindOutputBytes)-outputBytes)
|
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"
|
"bytes"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync" // 新增:用于管理连接状态的互斥锁
|
"sync" // 新增:用于管理连接状态的互斥锁
|
||||||
@@ -21,7 +28,6 @@ import (
|
|||||||
"github.com/shirou/gopsutil/v3/host"
|
"github.com/shirou/gopsutil/v3/host"
|
||||||
"github.com/shirou/gopsutil/v3/mem"
|
"github.com/shirou/gopsutil/v3/mem"
|
||||||
psnet "github.com/shirou/gopsutil/v3/net"
|
psnet "github.com/shirou/gopsutil/v3/net"
|
||||||
"os"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// SystemInfo 系统信息结构体
|
// SystemInfo 系统信息结构体
|
||||||
@@ -85,6 +91,11 @@ type TcpPingResponse struct {
|
|||||||
RequestId string `json:"requestId,omitempty"`
|
RequestId string `json:"requestId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
reporterReadWait = 60 * time.Second
|
||||||
|
reporterWriteWait = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
type WebSocketReporter struct {
|
type WebSocketReporter struct {
|
||||||
url string
|
url string
|
||||||
addr string // 保存服务器地址
|
addr string // 保存服务器地址
|
||||||
@@ -237,6 +248,14 @@ func (w *WebSocketReporter) connect() error {
|
|||||||
|
|
||||||
w.conn = conn
|
w.conn = conn
|
||||||
w.connected = true
|
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 {
|
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()
|
messageType, message, err := conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -466,9 +485,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cmdMsg.Type != "call" {
|
if cmdMsg.Type != "call" {
|
||||||
// TcpPing 诊断命令异步执行,避免阻塞其他命令
|
|
||||||
// 其他状态变更命令保持同步,确保顺序执行
|
// 其他状态变更命令保持同步,确保顺序执行
|
||||||
if cmdMsg.Type == "TcpPing" {
|
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
|
||||||
go w.routeCommand(cmdMsg)
|
go w.routeCommand(cmdMsg)
|
||||||
} else {
|
} else {
|
||||||
w.routeCommand(cmdMsg)
|
w.routeCommand(cmdMsg)
|
||||||
@@ -483,9 +501,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if cmdMsg.Type != "call" {
|
if cmdMsg.Type != "call" {
|
||||||
// TcpPing 诊断命令异步执行,避免阻塞其他命令
|
|
||||||
// 其他状态变更命令保持同步,确保顺序执行
|
// 其他状态变更命令保持同步,确保顺序执行
|
||||||
if cmdMsg.Type == "TcpPing" {
|
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
|
||||||
go w.routeCommand(cmdMsg)
|
go w.routeCommand(cmdMsg)
|
||||||
} else {
|
} else {
|
||||||
w.routeCommand(cmdMsg)
|
w.routeCommand(cmdMsg)
|
||||||
@@ -579,6 +596,18 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
|
|||||||
response.Type = "SetProtocolResponse"
|
response.Type = "SetProtocolResponse"
|
||||||
needSaveConfig = true
|
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:
|
default:
|
||||||
err = fmt.Errorf("未知命令类型: %s", cmd.Type)
|
err = fmt.Errorf("未知命令类型: %s", cmd.Type)
|
||||||
response.Type = "UnknownCommandResponse"
|
response.Type = "UnknownCommandResponse"
|
||||||
@@ -881,6 +910,186 @@ func (w *WebSocketReporter) handleSetProtocol(data interface{}) error {
|
|||||||
return nil
|
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
|
// updateLocalConfigJSON 将 http/tls/socks 写入工作目录下的 config.json
|
||||||
func updateLocalConfigJSON(httpVal int, tlsVal int, socksVal int) error {
|
func updateLocalConfigJSON(httpVal int, tlsVal int, socksVal int) error {
|
||||||
path := "config.json"
|
path := "config.json"
|
||||||
|
|||||||
+236
-34
@@ -203,7 +203,8 @@ show_menu() {
|
|||||||
echo "1. 安装面板"
|
echo "1. 安装面板"
|
||||||
echo "2. 更新面板"
|
echo "2. 更新面板"
|
||||||
echo "3. 卸载面板"
|
echo "3. 卸载面板"
|
||||||
echo "4. 退出"
|
echo "4. 迁移到 PostgreSQL"
|
||||||
|
echo "5. 退出"
|
||||||
echo "==============================================="
|
echo "==============================================="
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +233,92 @@ upsert_env_var() {
|
|||||||
mv "$tmp_file" "$file"
|
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() {
|
delete_self() {
|
||||||
echo ""
|
echo ""
|
||||||
@@ -253,6 +340,33 @@ get_config_params() {
|
|||||||
read -p "后端端口(默认 6365): " BACKEND_PORT
|
read -p "后端端口(默认 6365): " BACKEND_PORT
|
||||||
BACKEND_PORT=${BACKEND_PORT:-6365}
|
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密钥
|
||||||
JWT_SECRET=$(generate_random)
|
JWT_SECRET=$(generate_random)
|
||||||
}
|
}
|
||||||
@@ -280,10 +394,23 @@ JWT_SECRET=$JWT_SECRET
|
|||||||
FRONTEND_PORT=$FRONTEND_PORT
|
FRONTEND_PORT=$FRONTEND_PORT
|
||||||
BACKEND_PORT=$BACKEND_PORT
|
BACKEND_PORT=$BACKEND_PORT
|
||||||
FLUX_VERSION=$RESOLVED_VERSION
|
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
|
EOF
|
||||||
|
|
||||||
echo "🚀 启动 docker 服务..."
|
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 "🎉 部署完成"
|
||||||
echo "🌐 访问地址: http://服务器IP:$FRONTEND_PORT"
|
echo "🌐 访问地址: http://服务器IP:$FRONTEND_PORT"
|
||||||
@@ -300,6 +427,12 @@ update_panel() {
|
|||||||
echo "🔄 开始更新面板..."
|
echo "🔄 开始更新面板..."
|
||||||
check_docker
|
check_docker
|
||||||
|
|
||||||
|
if [[ ! -f ".env" ]]; then
|
||||||
|
echo "⚠️ 未找到 .env,默认按 SQLite 模式更新"
|
||||||
|
fi
|
||||||
|
CURRENT_DB_TYPE=$(get_current_db_type)
|
||||||
|
echo "🗄️ 当前数据库类型:$CURRENT_DB_TYPE"
|
||||||
|
|
||||||
echo "🔍 获取最新版本号..."
|
echo "🔍 获取最新版本号..."
|
||||||
LATEST_VERSION=$(resolve_latest_release_tag) || {
|
LATEST_VERSION=$(resolve_latest_release_tag) || {
|
||||||
echo "❌ 无法获取最新版本号,更新终止"
|
echo "❌ 无法获取最新版本号,更新终止"
|
||||||
@@ -333,49 +466,113 @@ update_panel() {
|
|||||||
$DOCKER_CMD down
|
$DOCKER_CMD down
|
||||||
|
|
||||||
echo "⬇️ 拉取最新镜像..."
|
echo "⬇️ 拉取最新镜像..."
|
||||||
$DOCKER_CMD pull
|
if [[ "$CURRENT_DB_TYPE" == "postgres" ]]; then
|
||||||
|
$DOCKER_CMD pull backend frontend postgres
|
||||||
|
else
|
||||||
|
$DOCKER_CMD pull backend frontend
|
||||||
|
fi
|
||||||
|
|
||||||
echo "🚀 启动更新后的服务..."
|
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 "⏳ 等待服务启动..."
|
||||||
|
|
||||||
# 检查后端容器健康状态
|
if ! wait_for_backend_healthy; then
|
||||||
echo "🔍 检查后端服务状态..."
|
echo "🛑 更新终止"
|
||||||
for i in {1..90}; do
|
return 1
|
||||||
if docker ps --format "{{.Names}}" | grep -q "^flux-panel-backend$"; then
|
fi
|
||||||
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
|
|
||||||
|
|
||||||
echo "✅ 更新完成"
|
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() {
|
uninstall_panel() {
|
||||||
@@ -428,6 +625,11 @@ main() {
|
|||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
4)
|
4)
|
||||||
|
migrate_to_postgres
|
||||||
|
delete_self
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
5)
|
||||||
echo "👋 退出脚本"
|
echo "👋 退出脚本"
|
||||||
delete_self
|
delete_self
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ http {
|
|||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -94,4 +96,4 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
"@heroui/system": "2.4.19",
|
"@heroui/system": "2.4.19",
|
||||||
"@heroui/table": "^2.2.24",
|
"@heroui/table": "^2.2.24",
|
||||||
"@heroui/tabs": "^2.2.27",
|
"@heroui/tabs": "^2.2.27",
|
||||||
"@heroui/theme": "2.4.19",
|
"@heroui/theme": "2.4.24",
|
||||||
"@heroui/use-theme": "2.1.10",
|
"@heroui/use-theme": "2.1.10",
|
||||||
"@marsidev/react-turnstile": "^1.1.0",
|
"@marsidev/react-turnstile": "^1.1.0",
|
||||||
"@nextui-org/system": "^2.4.6",
|
"@nextui-org/system": "^2.4.6",
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
import Network from "./network";
|
import Network from "./network";
|
||||||
|
|
||||||
// 登陆相关接口
|
// 登陆相关接口
|
||||||
@@ -41,6 +43,22 @@ export const checkNodeStatus = (nodeId?: number) => {
|
|||||||
return Network.post("/node/check-status", params);
|
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请求
|
// 隧道CRUD操作 - 全部使用POST请求
|
||||||
export const createTunnel = (data: any) => Network.post("/tunnel/create", data);
|
export const createTunnel = (data: any) => Network.post("/tunnel/create", data);
|
||||||
export const getTunnelList = () => Network.post("/tunnel/list");
|
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) =>
|
export const updateConfig = (name: string, value: string) =>
|
||||||
Network.post("/config/update-single", { name, value });
|
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 checkCaptcha = () => Network.post("/captcha/check");
|
||||||
export const generateCaptcha = () => Network.post(`/captcha/generate`);
|
export const generateCaptcha = () => Network.post(`/captcha/generate`);
|
||||||
@@ -216,7 +238,50 @@ export const resetPeerShareFlow = (id: number) =>
|
|||||||
Network.post("/federation/share/reset-flow", { id });
|
Network.post("/federation/share/reset-flow", { id });
|
||||||
export const getPeerRemoteUsageList = () =>
|
export const getPeerRemoteUsageList = () =>
|
||||||
Network.post("/federation/share/remote-usage/list");
|
Network.post("/federation/share/remote-usage/list");
|
||||||
export const importRemoteNode = (data: {
|
export const importRemoteNode = (data: { remoteUrl: string; token: string }) =>
|
||||||
remoteUrl: string;
|
Network.post("/federation/node/import", data);
|
||||||
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;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RequestOptions {
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
// 处理token失效的逻辑
|
// 处理token失效的逻辑
|
||||||
function handleTokenExpired() {
|
function handleTokenExpired() {
|
||||||
// 清除localStorage中的token
|
// 清除localStorage中的token
|
||||||
@@ -71,6 +75,7 @@ const Network = {
|
|||||||
get: function <T = any>(
|
get: function <T = any>(
|
||||||
path: string = "",
|
path: string = "",
|
||||||
data: any = {},
|
data: any = {},
|
||||||
|
options: RequestOptions = {},
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<ApiResponse<T>> {
|
||||||
return new Promise(function (resolve) {
|
return new Promise(function (resolve) {
|
||||||
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
|
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
|
||||||
@@ -83,7 +88,7 @@ const Network = {
|
|||||||
axios
|
axios
|
||||||
.get(path, {
|
.get(path, {
|
||||||
params: data,
|
params: data,
|
||||||
timeout: 30000,
|
timeout: options.timeout ?? 30000,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: window.localStorage.getItem("token"),
|
Authorization: window.localStorage.getItem("token"),
|
||||||
},
|
},
|
||||||
@@ -117,6 +122,7 @@ const Network = {
|
|||||||
post: function <T = any>(
|
post: function <T = any>(
|
||||||
path: string = "",
|
path: string = "",
|
||||||
data: any = {},
|
data: any = {},
|
||||||
|
options: RequestOptions = {},
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<ApiResponse<T>> {
|
||||||
return new Promise(function (resolve) {
|
return new Promise(function (resolve) {
|
||||||
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
|
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
|
||||||
@@ -128,7 +134,7 @@ const Network = {
|
|||||||
|
|
||||||
axios
|
axios
|
||||||
.post(path, data, {
|
.post(path, data, {
|
||||||
timeout: 30000,
|
timeout: options.timeout ?? 30000,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: window.localStorage.getItem("token"),
|
Authorization: window.localStorage.getItem("token"),
|
||||||
"Content-Type": "application/json",
|
"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 { useNavigate } from "react-router-dom";
|
||||||
import { Button } from "@heroui/button";
|
import { Button } from "@heroui/button";
|
||||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||||
@@ -7,9 +7,10 @@ import { Spinner } from "@heroui/spinner";
|
|||||||
import { Divider } from "@heroui/divider";
|
import { Divider } from "@heroui/divider";
|
||||||
import { Switch } from "@heroui/switch";
|
import { Switch } from "@heroui/switch";
|
||||||
import { Select, SelectItem } from "@heroui/select";
|
import { Select, SelectItem } from "@heroui/select";
|
||||||
|
import { Checkbox, CheckboxGroup } from "@heroui/checkbox";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { updateConfigs } from "@/api";
|
import { updateConfigs, exportBackup, importBackup } from "@/api";
|
||||||
import { SettingsIcon } from "@/components/icons";
|
import { SettingsIcon } from "@/components/icons";
|
||||||
import { isAdmin } from "@/utils/auth";
|
import { isAdmin } from "@/utils/auth";
|
||||||
import {
|
import {
|
||||||
@@ -130,12 +131,19 @@ export default function ConfigPage() {
|
|||||||
useState<Record<string, string>>(initialConfigs);
|
useState<Record<string, string>>(initialConfigs);
|
||||||
const [loading, setLoading] = useState(
|
const [loading, setLoading] = useState(
|
||||||
Object.keys(initialConfigs).length === 0,
|
Object.keys(initialConfigs).length === 0,
|
||||||
); // 如果有缓存数据,不显示loading
|
);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
const [originalConfigs, setOriginalConfigs] =
|
const [originalConfigs, setOriginalConfigs] =
|
||||||
useState<Record<string, string>>(initialConfigs);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isAdmin()) {
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
@@ -427,6 +492,140 @@ export default function ConfigPage() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,8 +83,9 @@ export default function IndexPage() {
|
|||||||
if (response.code !== 0) {
|
if (response.code !== 0) {
|
||||||
toast.error(response.msg || "登录失败");
|
toast.error(response.msg || "登录失败");
|
||||||
if (showCaptcha) {
|
if (showCaptcha) {
|
||||||
setForm((prev) => ({ ...prev, captchaId: "" }));
|
setForm((prev) => ({ ...prev, captchaId: "" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,11 +139,7 @@ export default function IndexPage() {
|
|||||||
} else {
|
} else {
|
||||||
const configResp = await getConfigByName("cloudflare_site_key");
|
const configResp = await getConfigByName("cloudflare_site_key");
|
||||||
|
|
||||||
if (
|
if (configResp.code === 0 && configResp.data && configResp.data.value) {
|
||||||
configResp.code === 0 &&
|
|
||||||
configResp.data &&
|
|
||||||
configResp.data.value
|
|
||||||
) {
|
|
||||||
setSiteKey(configResp.data.value);
|
setSiteKey(configResp.data.value);
|
||||||
setShowCaptcha(true);
|
setShowCaptcha(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -241,8 +238,10 @@ export default function IndexPage() {
|
|||||||
{showCaptcha && siteKey && (
|
{showCaptcha && siteKey && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<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"
|
className="absolute inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm captcha-backdrop-enter"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowCaptcha(false);
|
setShowCaptcha(false);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -253,32 +252,35 @@ export default function IndexPage() {
|
|||||||
setLoading(false);
|
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 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">
|
<div className="flex justify-center">
|
||||||
<Turnstile
|
<Turnstile
|
||||||
siteKey={siteKey}
|
options={{
|
||||||
onSuccess={(token) => {
|
theme: (document.documentElement.classList.contains(
|
||||||
setForm((prev) => ({ ...prev, captchaId: token }));
|
"dark",
|
||||||
void performLogin(token);
|
) ||
|
||||||
|
document.documentElement.getAttribute("data-theme") ===
|
||||||
|
"dark" ||
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light") as "light" | "dark" | "auto",
|
||||||
}}
|
}}
|
||||||
|
siteKey={siteKey}
|
||||||
onError={() => {
|
onError={() => {
|
||||||
toast.error("验证失败,请刷新重试");
|
toast.error("验证失败,请刷新重试");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}}
|
}}
|
||||||
onExpire={() => {
|
onExpire={() => {
|
||||||
setForm((prev) => ({ ...prev, captchaId: "" }));
|
setForm((prev) => ({ ...prev, captchaId: "" }));
|
||||||
}}
|
}}
|
||||||
options={{
|
onSuccess={(token) => {
|
||||||
theme: (document.documentElement.classList.contains("dark") ||
|
setForm((prev) => ({ ...prev, captchaId: token }));
|
||||||
document.documentElement.getAttribute("data-theme") === "dark" ||
|
void performLogin(token);
|
||||||
window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
||||||
? "dark"
|
|
||||||
: "light") as "light" | "dark" | "auto"
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+563
-258
@@ -16,6 +16,7 @@ import { Spinner } from "@heroui/spinner";
|
|||||||
import { Alert } from "@heroui/alert";
|
import { Alert } from "@heroui/alert";
|
||||||
import { Progress } from "@heroui/progress";
|
import { Progress } from "@heroui/progress";
|
||||||
import { Accordion, AccordionItem } from "@heroui/accordion";
|
import { Accordion, AccordionItem } from "@heroui/accordion";
|
||||||
|
import { Select, SelectItem } from "@heroui/select";
|
||||||
import { Checkbox } from "@heroui/checkbox";
|
import { Checkbox } from "@heroui/checkbox";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -45,6 +46,10 @@ import {
|
|||||||
getNodeInstallCommand,
|
getNodeInstallCommand,
|
||||||
updateNodeOrder,
|
updateNodeOrder,
|
||||||
batchDeleteNodes,
|
batchDeleteNodes,
|
||||||
|
upgradeNode,
|
||||||
|
batchUpgradeNodes,
|
||||||
|
getNodeReleases,
|
||||||
|
rollbackNode,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
|
||||||
interface Node {
|
interface Node {
|
||||||
@@ -77,6 +82,8 @@ interface Node {
|
|||||||
uptime: number;
|
uptime: number;
|
||||||
} | null;
|
} | null;
|
||||||
copyLoading?: boolean;
|
copyLoading?: boolean;
|
||||||
|
upgradeLoading?: boolean;
|
||||||
|
rollbackLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NodeForm {
|
interface NodeForm {
|
||||||
@@ -118,7 +125,12 @@ const SortableItem = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} {...attributes}>
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...attributes}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
{children(listeners)}
|
{children(listeners)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -165,6 +177,29 @@ export default function NodePage() {
|
|||||||
const [installCommand, setInstallCommand] = useState("");
|
const [installCommand, setInstallCommand] = useState("");
|
||||||
const [currentNodeName, setCurrentNodeName] = 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 websocketRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const reconnectAttemptsRef = useRef(0);
|
const reconnectAttemptsRef = useRef(0);
|
||||||
@@ -220,7 +255,11 @@ export default function NodePage() {
|
|||||||
const nodesData: Node[] = (res.data || []).map((node: any) => ({
|
const nodesData: Node[] = (res.data || []).map((node: any) => ({
|
||||||
...node,
|
...node,
|
||||||
inx: node.inx ?? 0,
|
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,
|
syncError: node.syncError || undefined,
|
||||||
systemInfo: null,
|
systemInfo: null,
|
||||||
copyLoading: false,
|
copyLoading: false,
|
||||||
@@ -425,6 +464,26 @@ export default function NodePage() {
|
|||||||
return node;
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
if (!validateForm()) return;
|
if (!validateForm()) return;
|
||||||
@@ -1048,6 +1207,15 @@ export default function NodePage() {
|
|||||||
<Button size="sm" variant="flat" onPress={deselectAll}>
|
<Button size="sm" variant="flat" onPress={deselectAll}>
|
||||||
清空
|
清空
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="warning"
|
||||||
|
isLoading={batchUpgradeLoading}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => openUpgradeModal("batch")}
|
||||||
|
>
|
||||||
|
升级
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1122,262 +1290,282 @@ export default function NodePage() {
|
|||||||
return (
|
return (
|
||||||
<SortableItem key={node.id} id={node.id}>
|
<SortableItem key={node.id} id={node.id}>
|
||||||
{(listeners) => (
|
{(listeners) => (
|
||||||
<Card
|
<Card
|
||||||
key={node.id}
|
key={node.id}
|
||||||
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200"
|
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex justify-between items-start w-full">
|
<div className="flex justify-between items-start w-full">
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
{selectMode && (
|
{selectMode && (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
isSelected={selectedIds.has(node.id)}
|
isSelected={selectedIds.has(node.id)}
|
||||||
onValueChange={() => toggleSelect(node.id)}
|
onValueChange={() => toggleSelect(node.id)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<h3 className="font-semibold text-foreground truncate text-sm">
|
<h3 className="font-semibold text-foreground truncate text-sm">
|
||||||
{node.name}
|
{node.name}
|
||||||
</h3>
|
</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>
|
|
||||||
</div>
|
</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
|
<Chip
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
color="secondary"
|
color={
|
||||||
|
node.connectionStatus === "online"
|
||||||
|
? "success"
|
||||||
|
: "danger"
|
||||||
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
>
|
>
|
||||||
远程
|
{node.connectionStatus === "online"
|
||||||
|
? "在线"
|
||||||
|
: "离线"}
|
||||||
</Chip>
|
</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>
|
||||||
</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 && (
|
{!isRemoteNode && (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between text-sm">
|
{/* 系统监控 */}
|
||||||
<span className="text-default-600">版本</span>
|
<div className="space-y-3 mb-4">
|
||||||
<span className="text-xs">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{node.version || "未知"}
|
<div>
|
||||||
</span>
|
<div className="flex justify-between text-xs mb-1">
|
||||||
</div>
|
<span>CPU</span>
|
||||||
<div className="flex justify-between text-sm">
|
<span className="font-mono">
|
||||||
<span className="text-default-600">开机时间</span>
|
{node.connectionStatus === "online" &&
|
||||||
<span className="text-xs">
|
node.systemInfo
|
||||||
{node.connectionStatus === "online" &&
|
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
|
||||||
node.systemInfo
|
: "-"}
|
||||||
? formatUptime(node.systemInfo.uptime)
|
</span>
|
||||||
: "-"}
|
</div>
|
||||||
</span>
|
<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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isRemoteNode && (
|
{/* 操作按钮 */}
|
||||||
<>
|
<div className="space-y-1.5">
|
||||||
{/* 系统监控 */}
|
|
||||||
<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">
|
|
||||||
{!isRemoteNode && (
|
{!isRemoteNode && (
|
||||||
<>
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
className="flex-1 min-h-8"
|
className="min-h-8"
|
||||||
color="success"
|
color="success"
|
||||||
isLoading={node.copyLoading}
|
isLoading={node.copyLoading}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1387,7 +1575,41 @@ export default function NodePage() {
|
|||||||
安装
|
安装
|
||||||
</Button>
|
</Button>
|
||||||
<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"
|
color="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@@ -1395,23 +1617,22 @@ export default function NodePage() {
|
|||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
)}
|
||||||
)}
|
<Button
|
||||||
<Button
|
className="min-h-8"
|
||||||
className={`min-h-8 ${isRemoteNode ? "w-full" : "flex-1"}`}
|
color="danger"
|
||||||
color="danger"
|
size="sm"
|
||||||
size="sm"
|
variant="flat"
|
||||||
variant="flat"
|
onPress={() => handleDelete(node)}
|
||||||
onPress={() => handleDelete(node)}
|
>
|
||||||
>
|
删除
|
||||||
删除
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardBody>
|
||||||
</CardBody>
|
</Card>
|
||||||
</Card>
|
)}
|
||||||
)}
|
</SortableItem>
|
||||||
</SortableItem>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -1844,6 +2065,90 @@ export default function NodePage() {
|
|||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</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
|
<Modal
|
||||||
backdrop="blur"
|
backdrop="blur"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@heroui/modal";
|
} from "@heroui/modal";
|
||||||
import { Select, SelectItem } from "@heroui/select";
|
import { Select, SelectItem } from "@heroui/select";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getNodeList,
|
getNodeList,
|
||||||
createPeerShare,
|
createPeerShare,
|
||||||
@@ -128,6 +129,7 @@ export default function PanelSharingPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await getPeerShareList();
|
const res = await getPeerShareList();
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
setShares(res.data || []);
|
setShares(res.data || []);
|
||||||
} else {
|
} else {
|
||||||
@@ -141,10 +143,12 @@ export default function PanelSharingPage() {
|
|||||||
const loadNodes = useCallback(async () => {
|
const loadNodes = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getNodeList();
|
const res = await getNodeList();
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
const localNodes: Node[] = (res.data || []).filter(
|
const localNodes: Node[] = (res.data || []).filter(
|
||||||
(node: Node) => (node?.isRemote ?? 0) !== 1,
|
(node: Node) => (node?.isRemote ?? 0) !== 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
setNodes(localNodes);
|
setNodes(localNodes);
|
||||||
setShareForm((prev) => {
|
setShareForm((prev) => {
|
||||||
if (!prev.nodeId) {
|
if (!prev.nodeId) {
|
||||||
@@ -153,6 +157,7 @@ export default function PanelSharingPage() {
|
|||||||
const hasSelectedNode = localNodes.some(
|
const hasSelectedNode = localNodes.some(
|
||||||
(node: Node) => String(node.id) === prev.nodeId,
|
(node: Node) => String(node.id) === prev.nodeId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return hasSelectedNode ? prev : { ...prev, nodeId: "" };
|
return hasSelectedNode ? prev : { ...prev, nodeId: "" };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -165,6 +170,7 @@ export default function PanelSharingPage() {
|
|||||||
setRemoteUsageLoading(true);
|
setRemoteUsageLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await getPeerRemoteUsageList();
|
const res = await getPeerRemoteUsageList();
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
setRemoteUsageNodes(res.data || []);
|
setRemoteUsageNodes(res.data || []);
|
||||||
} else {
|
} else {
|
||||||
@@ -179,6 +185,7 @@ export default function PanelSharingPage() {
|
|||||||
if (selectedTab === "my-shares") {
|
if (selectedTab === "my-shares") {
|
||||||
loadShares();
|
loadShares();
|
||||||
loadNodes();
|
loadNodes();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedTab === "remote-nodes") {
|
if (selectedTab === "remote-nodes") {
|
||||||
@@ -189,15 +196,19 @@ export default function PanelSharingPage() {
|
|||||||
const handleCreateShare = async () => {
|
const handleCreateShare = async () => {
|
||||||
if (!shareForm.name || !shareForm.nodeId) {
|
if (!shareForm.name || !shareForm.nodeId) {
|
||||||
toast.error("请填写必要信息");
|
toast.error("请填写必要信息");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nodeId = parseInt(shareForm.nodeId, 10);
|
const nodeId = parseInt(shareForm.nodeId, 10);
|
||||||
|
|
||||||
if (Number.isNaN(nodeId) || !nodes.some((node) => node.id === nodeId)) {
|
if (Number.isNaN(nodeId) || !nodes.some((node) => node.id === nodeId)) {
|
||||||
toast.error("仅可选择本地节点");
|
toast.error("仅可选择本地节点");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (shareForm.maxBandwidth < 0) {
|
if (shareForm.maxBandwidth < 0) {
|
||||||
toast.error("流量上限不能为负数");
|
toast.error("流量上限不能为负数");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -213,6 +224,7 @@ export default function PanelSharingPage() {
|
|||||||
allowedDomains: shareForm.allowedDomains,
|
allowedDomains: shareForm.allowedDomains,
|
||||||
allowedIps: shareForm.allowedIps,
|
allowedIps: shareForm.allowedIps,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
toast.success("创建成功");
|
toast.success("创建成功");
|
||||||
setCreateShareOpen(false);
|
setCreateShareOpen(false);
|
||||||
@@ -228,6 +240,7 @@ export default function PanelSharingPage() {
|
|||||||
const handleDeleteShare = async (id: number) => {
|
const handleDeleteShare = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
const res = await deletePeerShare(id);
|
const res = await deletePeerShare(id);
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
toast.success("删除成功");
|
toast.success("删除成功");
|
||||||
loadShares();
|
loadShares();
|
||||||
@@ -242,6 +255,7 @@ export default function PanelSharingPage() {
|
|||||||
const handleResetShareFlow = async (id: number) => {
|
const handleResetShareFlow = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
const res = await resetPeerShareFlow(id);
|
const res = await resetPeerShareFlow(id);
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
toast.success("共享流量已重置");
|
toast.success("共享流量已重置");
|
||||||
loadShares();
|
loadShares();
|
||||||
@@ -257,7 +271,10 @@ export default function PanelSharingPage() {
|
|||||||
setEditForm({
|
setEditForm({
|
||||||
id: share.id,
|
id: share.id,
|
||||||
name: share.name,
|
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,
|
expiryTime: share.expiryTime,
|
||||||
portRangeStart: share.portRangeStart,
|
portRangeStart: share.portRangeStart,
|
||||||
portRangeEnd: share.portRangeEnd,
|
portRangeEnd: share.portRangeEnd,
|
||||||
@@ -270,10 +287,12 @@ export default function PanelSharingPage() {
|
|||||||
const handleEditShare = async () => {
|
const handleEditShare = async () => {
|
||||||
if (!editForm.name) {
|
if (!editForm.name) {
|
||||||
toast.error("名称不能为空");
|
toast.error("名称不能为空");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (editForm.maxBandwidth < 0) {
|
if (editForm.maxBandwidth < 0) {
|
||||||
toast.error("流量上限不能为负数");
|
toast.error("流量上限不能为负数");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -287,6 +306,7 @@ export default function PanelSharingPage() {
|
|||||||
allowedDomains: editForm.allowedDomains,
|
allowedDomains: editForm.allowedDomains,
|
||||||
allowedIps: editForm.allowedIps,
|
allowedIps: editForm.allowedIps,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
toast.success("编辑成功");
|
toast.success("编辑成功");
|
||||||
setEditShareOpen(false);
|
setEditShareOpen(false);
|
||||||
@@ -302,19 +322,22 @@ export default function PanelSharingPage() {
|
|||||||
const handleImportNode = async () => {
|
const handleImportNode = async () => {
|
||||||
if (!importForm.remoteUrl || !importForm.token) {
|
if (!importForm.remoteUrl || !importForm.token) {
|
||||||
toast.error("请填写完整信息");
|
toast.error("请填写完整信息");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Automatically add http/https if missing
|
// Automatically add http/https if missing
|
||||||
let url = importForm.remoteUrl.trim();
|
let url = importForm.remoteUrl.trim();
|
||||||
|
|
||||||
if (!url.startsWith("http")) {
|
if (!url.startsWith("http")) {
|
||||||
url = "http://" + url;
|
url = "http://" + url;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await importRemoteNode({
|
const res = await importRemoteNode({
|
||||||
remoteUrl: url,
|
remoteUrl: url,
|
||||||
token: importForm.token.trim(),
|
token: importForm.token.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
toast.success("导入成功,请前往节点列表查看");
|
toast.success("导入成功,请前往节点列表查看");
|
||||||
setImportNodeOpen(false);
|
setImportNodeOpen(false);
|
||||||
@@ -341,6 +364,7 @@ export default function PanelSharingPage() {
|
|||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB";
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB";
|
||||||
if (bytes < 1024 * 1024 * 1024)
|
if (bytes < 1024 * 1024 * 1024)
|
||||||
return (bytes / (1024 * 1024)).toFixed(2) + " MB";
|
return (bytes / (1024 * 1024)).toFixed(2) + " MB";
|
||||||
|
|
||||||
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB";
|
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -351,6 +375,7 @@ export default function PanelSharingPage() {
|
|||||||
if (chainType === 3) {
|
if (chainType === 3) {
|
||||||
return "出口节点";
|
return "出口节点";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "未知链路";
|
return "未知链路";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -370,11 +395,14 @@ export default function PanelSharingPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Button color="primary" onPress={() => setCreateShareOpen(true)}>
|
<Button
|
||||||
|
color="primary"
|
||||||
|
onPress={() => setCreateShareOpen(true)}
|
||||||
|
>
|
||||||
创建分享
|
创建分享
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-10 text-gray-500">加载中...</div>
|
<div className="text-center py-10 text-gray-500">加载中...</div>
|
||||||
) : shares.length === 0 ? (
|
) : 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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{shares.map((share) => (
|
{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">
|
<CardHeader className="flex justify-between">
|
||||||
<h3 className="font-bold">{share.name}</h3>
|
<h3 className="font-bold">{share.name}</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -400,29 +431,67 @@ export default function PanelSharingPage() {
|
|||||||
>
|
>
|
||||||
重置流量
|
重置流量
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody className="text-sm space-y-2">
|
<CardBody className="text-sm space-y-2">
|
||||||
<p>端口范围: {share.portRangeStart} - {share.portRangeEnd}</p>
|
<p>
|
||||||
<p>流量上限: {share.maxBandwidth > 0 ? formatFlowGB(share.maxBandwidth) : "不限制"}</p>
|
端口范围: {share.portRangeStart} -{" "}
|
||||||
|
{share.portRangeEnd}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
流量上限:{" "}
|
||||||
|
{share.maxBandwidth > 0
|
||||||
|
? formatFlowGB(share.maxBandwidth)
|
||||||
|
: "不限制"}
|
||||||
|
</p>
|
||||||
<p>当前流量: {formatFlowGB(share.currentFlow || 0)}</p>
|
<p>当前流量: {formatFlowGB(share.currentFlow || 0)}</p>
|
||||||
<p>远程占用端口: {share.usedPorts && share.usedPorts.length > 0 ? share.usedPorts.join(", ") : "暂无"}</p>
|
<p>
|
||||||
{share.usedPortDetails && share.usedPortDetails.length > 0 && (
|
远程占用端口:{" "}
|
||||||
<div className="flex flex-wrap gap-2">
|
{share.usedPorts && share.usedPorts.length > 0
|
||||||
{share.usedPortDetails.map((item) => (
|
? share.usedPorts.join(", ")
|
||||||
<span key={item.runtimeId} className="text-xs rounded-full px-2 py-1 bg-default-100">
|
: "暂无"}
|
||||||
{item.port} / {item.role || "reserved"}
|
</p>
|
||||||
</span>
|
{share.usedPortDetails &&
|
||||||
))}
|
share.usedPortDetails.length > 0 && (
|
||||||
</div>
|
<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 && (
|
||||||
{share.allowedIps && <p>允许API IP: {share.allowedIps}</p>}
|
<p>允许API IP: {share.allowedIps}</p>
|
||||||
<p>过期时间: {share.expiryTime === 0 ? "永久" : new Date(share.expiryTime).toLocaleDateString()}</p>
|
)}
|
||||||
|
<p>
|
||||||
|
过期时间:{" "}
|
||||||
|
{share.expiryTime === 0
|
||||||
|
? "永久"
|
||||||
|
: new Date(share.expiryTime).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input readOnly size="sm" value={share.token} />
|
<Input readOnly size="sm" value={share.token} />
|
||||||
<Button size="sm" onPress={() => copyToken(share.token)}>复制</Button>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onPress={() => copyToken(share.token)}
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -436,7 +505,10 @@ export default function PanelSharingPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Button color="secondary" onPress={() => setImportNodeOpen(true)}>
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
onPress={() => setImportNodeOpen(true)}
|
||||||
|
>
|
||||||
导入远程节点
|
导入远程节点
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -446,15 +518,22 @@ export default function PanelSharingPage() {
|
|||||||
) : remoteUsageNodes.length === 0 ? (
|
) : remoteUsageNodes.length === 0 ? (
|
||||||
<div className="text-center py-10 text-gray-500">
|
<div className="text-center py-10 text-gray-500">
|
||||||
<p>暂无远程节点占用记录。</p>
|
<p>暂无远程节点占用记录。</p>
|
||||||
<p className="mt-2">导入远程节点并创建隧道后,这里会显示远端端口占用情况。</p>
|
<p className="mt-2">
|
||||||
|
导入远程节点并创建隧道后,这里会显示远端端口占用情况。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{remoteUsageNodes.map((node) => (
|
{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">
|
<CardHeader className="flex justify-between">
|
||||||
<h3 className="font-bold">{node.nodeName}</h3>
|
<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>
|
</CardHeader>
|
||||||
<CardBody className="text-sm space-y-2">
|
<CardBody className="text-sm space-y-2">
|
||||||
{node.syncError && (
|
{node.syncError && (
|
||||||
@@ -470,18 +549,40 @@ export default function PanelSharingPage() {
|
|||||||
)}
|
)}
|
||||||
{node.remoteUrl && <p>远程地址: {node.remoteUrl}</p>}
|
{node.remoteUrl && <p>远程地址: {node.remoteUrl}</p>}
|
||||||
<p>共享ID: {node.shareId || "-"}</p>
|
<p>共享ID: {node.shareId || "-"}</p>
|
||||||
<p>端口范围: {node.portRangeStart > 0 && node.portRangeEnd > 0 ? `${node.portRangeStart} - ${node.portRangeEnd}` : "-"}</p>
|
<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.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 && (
|
{node.bindings && node.bindings.length > 0 && (
|
||||||
<div className="space-y-1 pt-1">
|
<div className="space-y-1 pt-1">
|
||||||
{node.bindings.map((binding) => (
|
{node.bindings.map((binding) => (
|
||||||
<p key={binding.bindingId} className="text-xs text-default-600">
|
<p
|
||||||
隧道 {binding.tunnelName || `#${binding.tunnelId}`}
|
key={binding.bindingId}
|
||||||
|
className="text-xs text-default-600"
|
||||||
|
>
|
||||||
|
隧道{" "}
|
||||||
|
{binding.tunnelName || `#${binding.tunnelId}`}
|
||||||
{" · "}
|
{" · "}
|
||||||
端口 {binding.allocatedPort}
|
端口 {binding.allocatedPort}
|
||||||
{" · "}
|
{" · "}
|
||||||
{formatChainType(binding.chainType, binding.hopInx)}
|
{formatChainType(
|
||||||
|
binding.chainType,
|
||||||
|
binding.hopInx,
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -505,13 +606,17 @@ export default function PanelSharingPage() {
|
|||||||
label="名称"
|
label="名称"
|
||||||
placeholder="备注名称"
|
placeholder="备注名称"
|
||||||
value={shareForm.name}
|
value={shareForm.name}
|
||||||
onChange={(e) => setShareForm({ ...shareForm, name: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setShareForm({ ...shareForm, name: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="选择节点"
|
label="选择节点"
|
||||||
placeholder="选择要分享的本地节点"
|
placeholder="选择要分享的本地节点"
|
||||||
selectedKeys={shareForm.nodeId ? [shareForm.nodeId] : []}
|
selectedKeys={shareForm.nodeId ? [shareForm.nodeId] : []}
|
||||||
onChange={(e) => setShareForm({ ...shareForm, nodeId: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setShareForm({ ...shareForm, nodeId: e.target.value })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{nodes.map((node) => (
|
{nodes.map((node) => (
|
||||||
<SelectItem key={node.id} textValue={node.name}>
|
<SelectItem key={node.id} textValue={node.name}>
|
||||||
@@ -524,47 +629,73 @@ export default function PanelSharingPage() {
|
|||||||
label="起始端口"
|
label="起始端口"
|
||||||
type="number"
|
type="number"
|
||||||
value={shareForm.portRangeStart.toString()}
|
value={shareForm.portRangeStart.toString()}
|
||||||
onChange={(e) => setShareForm({ ...shareForm, portRangeStart: parseInt(e.target.value) })}
|
onChange={(e) =>
|
||||||
|
setShareForm({
|
||||||
|
...shareForm,
|
||||||
|
portRangeStart: parseInt(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="结束端口"
|
label="结束端口"
|
||||||
type="number"
|
type="number"
|
||||||
value={shareForm.portRangeEnd.toString()}
|
value={shareForm.portRangeEnd.toString()}
|
||||||
onChange={(e) => setShareForm({ ...shareForm, portRangeEnd: parseInt(e.target.value) })}
|
onChange={(e) =>
|
||||||
|
setShareForm({
|
||||||
|
...shareForm,
|
||||||
|
portRangeEnd: parseInt(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
|
description="0 表示永久"
|
||||||
label="有效期 (天)"
|
label="有效期 (天)"
|
||||||
type="number"
|
type="number"
|
||||||
description="0 表示永久"
|
|
||||||
value={shareForm.expiryDays.toString()}
|
value={shareForm.expiryDays.toString()}
|
||||||
onChange={(e) => setShareForm({ ...shareForm, expiryDays: parseInt(e.target.value) })}
|
onChange={(e) =>
|
||||||
|
setShareForm({
|
||||||
|
...shareForm,
|
||||||
|
expiryDays: parseInt(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
description="0 表示不限流量"
|
||||||
label="流量上限 (GB)"
|
label="流量上限 (GB)"
|
||||||
type="number"
|
type="number"
|
||||||
description="0 表示不限流量"
|
|
||||||
value={shareForm.maxBandwidth.toString()}
|
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
|
<Input
|
||||||
|
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
|
||||||
label="允许的域名 (可选)"
|
label="允许的域名 (可选)"
|
||||||
placeholder="example.com, panel.test.com"
|
placeholder="example.com, panel.test.com"
|
||||||
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
|
|
||||||
value={shareForm.allowedDomains}
|
value={shareForm.allowedDomains}
|
||||||
onChange={(e) => setShareForm({ ...shareForm, allowedDomains: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setShareForm({ ...shareForm, allowedDomains: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
|
||||||
label="允许的API IP (可选)"
|
label="允许的API IP (可选)"
|
||||||
placeholder="203.0.113.10, 2001:db8::10, 198.51.100.0/24"
|
placeholder="203.0.113.10, 2001:db8::10, 198.51.100.0/24"
|
||||||
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
|
|
||||||
value={shareForm.allowedIps}
|
value={shareForm.allowedIps}
|
||||||
onChange={(e) => setShareForm({ ...shareForm, allowedIps: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setShareForm({ ...shareForm, allowedIps: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button onPress={() => setCreateShareOpen(false)}>取消</Button>
|
<Button onPress={() => setCreateShareOpen(false)}>取消</Button>
|
||||||
<Button color="primary" onPress={handleCreateShare}>创建</Button>
|
<Button color="primary" onPress={handleCreateShare}>
|
||||||
|
创建
|
||||||
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -578,54 +709,88 @@ export default function PanelSharingPage() {
|
|||||||
label="名称"
|
label="名称"
|
||||||
placeholder="备注名称"
|
placeholder="备注名称"
|
||||||
value={editForm.name}
|
value={editForm.name}
|
||||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setEditForm({ ...editForm, name: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<Input
|
<Input
|
||||||
label="起始端口"
|
label="起始端口"
|
||||||
type="number"
|
type="number"
|
||||||
value={editForm.portRangeStart.toString()}
|
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
|
<Input
|
||||||
label="结束端口"
|
label="结束端口"
|
||||||
type="number"
|
type="number"
|
||||||
value={editForm.portRangeEnd.toString()}
|
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>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
|
description="0 表示不限流量"
|
||||||
label="流量上限 (GB)"
|
label="流量上限 (GB)"
|
||||||
type="number"
|
type="number"
|
||||||
description="0 表示不限流量"
|
|
||||||
value={editForm.maxBandwidth.toString()}
|
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
|
<Input
|
||||||
|
description="留空或清除表示永久有效"
|
||||||
label="过期时间"
|
label="过期时间"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
description="留空或清除表示永久有效"
|
value={
|
||||||
value={editForm.expiryTime > 0 ? new Date(editForm.expiryTime).toISOString().slice(0, 16) : ""}
|
editForm.expiryTime > 0
|
||||||
onChange={(e) => setEditForm({ ...editForm, expiryTime: e.target.value ? new Date(e.target.value).getTime() : 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
|
<Input
|
||||||
|
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
|
||||||
label="允许的域名 (可选)"
|
label="允许的域名 (可选)"
|
||||||
placeholder="example.com, panel.test.com"
|
placeholder="example.com, panel.test.com"
|
||||||
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
|
|
||||||
value={editForm.allowedDomains}
|
value={editForm.allowedDomains}
|
||||||
onChange={(e) => setEditForm({ ...editForm, allowedDomains: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setEditForm({ ...editForm, allowedDomains: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
|
||||||
label="允许的API IP (可选)"
|
label="允许的API IP (可选)"
|
||||||
placeholder="203.0.113.10, 2001:db8::10, 198.51.100.0/24"
|
placeholder="203.0.113.10, 2001:db8::10, 198.51.100.0/24"
|
||||||
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
|
|
||||||
value={editForm.allowedIps}
|
value={editForm.allowedIps}
|
||||||
onChange={(e) => setEditForm({ ...editForm, allowedIps: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setEditForm({ ...editForm, allowedIps: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button onPress={() => setEditShareOpen(false)}>取消</Button>
|
<Button onPress={() => setEditShareOpen(false)}>取消</Button>
|
||||||
<Button color="primary" onPress={handleEditShare}>保存</Button>
|
<Button color="primary" onPress={handleEditShare}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -639,18 +804,24 @@ export default function PanelSharingPage() {
|
|||||||
label="远程面板地址"
|
label="远程面板地址"
|
||||||
placeholder="http://panel.example.com:8088"
|
placeholder="http://panel.example.com:8088"
|
||||||
value={importForm.remoteUrl}
|
value={importForm.remoteUrl}
|
||||||
onChange={(e) => setImportForm({ ...importForm, remoteUrl: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setImportForm({ ...importForm, remoteUrl: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Token"
|
label="Token"
|
||||||
placeholder="Bearer Token"
|
placeholder="Bearer Token"
|
||||||
value={importForm.token}
|
value={importForm.token}
|
||||||
onChange={(e) => setImportForm({ ...importForm, token: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setImportForm({ ...importForm, token: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button onPress={() => setImportNodeOpen(false)}>取消</Button>
|
<Button onPress={() => setImportNodeOpen(false)}>取消</Button>
|
||||||
<Button color="secondary" onPress={handleImportNode}>导入</Button>
|
<Button color="secondary" onPress={handleImportNode}>
|
||||||
|
导入
|
||||||
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -46,6 +46,25 @@ html, body {
|
|||||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
--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"]),
|
||||||
|
[data-slot="input-wrapper"][data-focus="true"]:not([data-invalid="true"]),
|
||||||
|
[data-slot="input-wrapper"][data-focused="true"]:not([data-invalid="true"]) {
|
||||||
|
border-color: var(--heroui-default-200, #e5e7eb) !important;
|
||||||
|
outline: none !important;
|
||||||
|
outline-offset: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="input-wrapper"] input:focus {
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
.safe-top {
|
.safe-top {
|
||||||
padding-top: var(--safe-area-top);
|
padding-top: var(--safe-area-top);
|
||||||
}
|
}
|
||||||
@@ -85,4 +104,4 @@ html, body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@config "../../tailwind.config.js"
|
@config "../../tailwind.config.js"
|
||||||
|
|||||||
Reference in New Issue
Block a user