diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index b75e9d8..88fc4b9 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -112,6 +112,12 @@ jobs: upx --best --lzma gost-amd64 upx --best --lzma gost-arm64 + - name: Generate SHA256 checksums + working-directory: ./go-gost + run: | + sha256sum gost-amd64 > gost-amd64.sha256 + sha256sum gost-arm64 > gost-arm64.sha256 + - name: Upload GOST AMD64 artifact uses: actions/upload-artifact@v4 with: @@ -124,6 +130,18 @@ jobs: name: gost-binary-arm64 path: ./go-gost/gost-arm64 + - name: Upload GOST AMD64 checksum artifact + uses: actions/upload-artifact@v4 + with: + name: gost-checksum-amd64 + path: ./go-gost/gost-amd64.sha256 + + - name: Upload GOST ARM64 checksum artifact + uses: actions/upload-artifact@v4 + with: + name: gost-checksum-arm64 + path: ./go-gost/gost-arm64.sha256 + build-vite: name: Build & Push Vite Frontend needs: check-version @@ -238,7 +256,20 @@ jobs: name: gost-binary-arm64 path: ./artifacts/arm64 + - name: Download GOST AMD64 checksum + uses: actions/download-artifact@v4 + with: + name: gost-checksum-amd64 + path: ./artifacts/ + + - name: Download GOST ARM64 checksum + uses: actions/download-artifact@v4 + with: + name: gost-checksum-arm64 + path: ./artifacts/ + - name: Prepare release files + run: | VERSION="${{ needs.check-version.outputs.version }}" OWNER="${{ needs.check-version.outputs.image_owner }}" @@ -331,6 +362,10 @@ jobs: gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber + echo "📤 上传 GOST 校验文件..." + gh release upload "${VERSION}" ./artifacts/gost-amd64.sha256 --clobber + gh release upload "${VERSION}" ./artifacts/gost-arm64.sha256 --clobber + echo "📤 上传安装脚本..." gh release upload "${VERSION}" ./artifacts/install.sh --clobber gh release upload "${VERSION}" ./artifacts/panel_install.sh --clobber @@ -363,6 +398,18 @@ jobs: name: gost-binary-arm64 path: ./artifacts/arm64 + - name: Download GOST AMD64 checksum + uses: actions/download-artifact@v4 + with: + name: gost-checksum-amd64 + path: ./artifacts/ + + - name: Download GOST ARM64 checksum + uses: actions/download-artifact@v4 + with: + name: gost-checksum-arm64 + path: ./artifacts/ + - name: Rename binaries run: | mv ./artifacts/amd64/gost-amd64 ./artifacts/gost-amd64 @@ -379,4 +426,9 @@ jobs: gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber + echo "📤 上传 GOST 校验文件..." + gh release upload "${VERSION}" ./artifacts/gost-amd64.sha256 --clobber + gh release upload "${VERSION}" ./artifacts/gost-arm64.sha256 --clobber + echo "✅ GOST 二进制文件更新完成" + diff --git a/.gitignore b/.gitignore index a422608..33f5c97 100644 --- a/.gitignore +++ b/.gitignore @@ -177,6 +177,7 @@ build/ *.dylib your_app.exe go-backend/paneld +go-gost/gost # Go 测试二进制文件 *.test @@ -262,3 +263,6 @@ sql/ !go-backend/internal/store/sqlite/sql/ !go-backend/internal/store/sqlite/sql/schema.sql !go-backend/internal/store/sqlite/sql/data.sql +!go-backend/internal/store/postgres/sql/ +!go-backend/internal/store/postgres/sql/schema.sql +!go-backend/internal/store/postgres/sql/data.sql diff --git a/README.md b/README.md index 66ae164..ce0fee1 100644 --- a/README.md +++ b/README.md @@ -2,35 +2,7 @@ > **联系我们**: [Telegram群组](https://t.me/flvxpanel) -## Original Project -- **Name**: flux-panel -- **Source**: https://github.com/bqlpfy/flux-panel -- **License**: Apache License 2.0 -## Modifications -The following major changes and additions have been made in this fork (FLVX): - -### 1. Backend Architecture (Replaced) -- **Removed**: The original `springboot-backend/` (Java/Spring Boot) has been entirely removed. -- **Added**: A new `go-backend/` (Go/SQLite) implementation replaces the original backend. - -### 2. Forwarding Agent (Modified) -- **Modified**: `go-gost/` - Modified forwarding agent wrapper. -- **Modified**: `go-gost/x/` - Modified local fork of the `gost` extensions library. - -### 3. Frontend (Modified) -- **Modified**: `vite-frontend/` - Significant updates to the React/Vite dashboard to compatible with the new Go backend, including UI/UX improvements (HeroUI + Tailwind). - -### 4. Mobile Applications (Removed) -- **Removed**: `android-app/` - Source code for the Android client. -- **Removed**: `ios-app/` - Source code for the iOS client. - -### 5. Infrastructure & Scripts -- **Modified**: `docker-compose-v4.yml`, `docker-compose-v6.yml` (Updated for Go backend). -- **Modified**: `install.sh`, `panel_install.sh` (Updated installation logic). -- **Added**: `AGENTS.md` (Project documentation). - ---- ## 特性 - 支持按 **隧道账号级别** 管理流量转发数量,可用于用户/隧道配额控制 @@ -39,6 +11,10 @@ The following major changes and additions have been made in this fork (FLVX): - 可针对 **指定用户的指定隧道进行限速** 设置 - 支持配置 **单向或双向流量计费方式**,灵活适配不同计费模型 - 提供灵活的转发策略配置,适用于多种网络场景 +- 面板分享,支持将节点分享给其他人,面板对接面板 +- 支持分组权限管理,隧道分组、用户分组 +- 支持批量功能,可以批量下发配置,启停等 +- 支持隧道修改配置、转发修改隧道 ## 部署流程 @@ -66,6 +42,79 @@ curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/panel_in curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/install.sh -o install.sh && chmod +x install.sh && ./install.sh ``` +#### PostgreSQL 部署(Docker Compose) + +安装脚本会根据环境自动下载对应的 Compose 配置并保存为 `docker-compose.yml`。默认仍使用 SQLite,切换到 PostgreSQL 只需要配置环境变量。 + +1) 在 `docker-compose` 同目录创建或修改 `.env`: + +```bash +JWT_SECRET=replace_with_your_secret +BACKEND_PORT=6365 +FRONTEND_PORT=6366 + +DB_TYPE=postgres +DATABASE_URL=postgres://flux_panel:replace_with_strong_password@postgres:5432/flux_panel?sslmode=disable + +POSTGRES_DB=flux_panel +POSTGRES_USER=flux_panel +POSTGRES_PASSWORD=replace_with_strong_password +``` + +> 📌 使用安装脚本部署时,`POSTGRES_PASSWORD` 会自动随机生成并写入 `.env`。 + +2) 启动服务: + +```bash +docker compose up -d +``` + +3) 如果你想继续使用 SQLite,保留 `DB_TYPE=sqlite`(或不设置 `DB_TYPE`)即可。 + +#### 从 SQLite 迁移到 PostgreSQL + +如果你是通过 `panel_install.sh` 安装面板,推荐直接使用脚本菜单一键迁移: + +```bash +./panel_install.sh +# 选择 4. 迁移到 PostgreSQL +``` + +脚本会自动完成 SQLite 备份、PostgreSQL 启动、`pgloader` 导入、`.env` 中 `DB_TYPE`/`DATABASE_URL` 更新,并重启服务。 + +如果你希望手动迁移,以下示例基于 Docker Volume `sqlite_data`(项目默认配置)与 `pgloader`: + +1) 停止服务并备份 SQLite 数据: + +```bash +docker compose down +docker run --rm -v sqlite_data:/data -v "$(pwd)":/backup alpine sh -c "cp /data/gost.db /backup/gost.db.bak" +``` + +2) 仅启动 PostgreSQL: + +```bash +docker compose up -d postgres +``` + +3) 使用 `pgloader` 迁移: + +```bash +source .env +docker run --rm --network gost-network -v sqlite_data:/sqlite dimitri/pgloader:latest pgloader /sqlite/gost.db "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}" +``` + +4) 切换后端到 PostgreSQL 并启动: + +```bash +source .env +export DB_TYPE=postgres +export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable" +docker compose up -d +``` + +5) 迁移完成后,登录面板检查用户、隧道、转发、节点数据是否正确。 + #### 默认管理员账号 - **账号**: admin_user @@ -73,6 +122,37 @@ curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/install. > ⚠️ 首次登录后请立即修改默认密码! +--- +## Original Project +- **Name**: flux-panel +- **Source**: https://github.com/bqlpfy/flux-panel +- **License**: Apache License 2.0 + +## Modifications +The following major changes and additions have been made in this fork (FLVX): + +### 1. Backend Architecture (Replaced) +- **Removed**: The original `springboot-backend/` (Java/Spring Boot) has been entirely removed. +- **Added**: A new `go-backend/` (Go/SQLite) implementation replaces the original backend. + +### 2. Forwarding Agent (Modified) +- **Modified**: `go-gost/` - Modified forwarding agent wrapper. +- **Modified**: `go-gost/x/` - Modified local fork of the `gost` extensions library. + +### 3. Frontend (Modified) +- **Modified**: `vite-frontend/` - Significant updates to the React/Vite dashboard to compatible with the new Go backend, including UI/UX improvements (HeroUI + Tailwind). + +### 4. Mobile Applications (Removed) +- **Removed**: `android-app/` - Source code for the Android client. +- **Removed**: `ios-app/` - Source code for the iOS client. + +### 5. Infrastructure & Scripts +- **Modified**: `docker-compose.yml` (installer output name, auto-selects IPv4/IPv6 template, updated for Go backend). +- **Modified**: `install.sh`, `panel_install.sh` (Updated installation logic). +- **Added**: `AGENTS.md` (Project documentation). + +--- + ## 免责声明 diff --git a/doc/faq.md b/doc/faq.md index b49afe4..54b763a 100644 --- a/doc/faq.md +++ b/doc/faq.md @@ -22,3 +22,19 @@ ### Q5: IPv6 无法使用? **A**: 面板安装脚本会自动尝试配置 Docker 的 IPv6。如果失败,请手动检查 `/etc/docker/daemon.json` 配置,确保 `ipv6: true` 且分配了正确的 `fixed-cidr-v6` 子网。 + +### Q6: 如何切换到 PostgreSQL? +**A**: 在 `.env` 文件中设置 `DB_TYPE=postgres`,并让 `DATABASE_URL` 与 `POSTGRES_*` 保持一致,然后执行 `docker compose up -d` 重启服务即可。使用安装脚本部署时,`POSTGRES_PASSWORD` 会自动随机生成并写入 `.env`。详见 [PostgreSQL 数据库指南](./postgresql.md)。 + +### Q7: 从 SQLite 迁移到 PostgreSQL 后数据丢失? +**A**: +1. 确认迁移前已备份 SQLite 文件(`gost.db.bak`)。 +2. 确认 `pgloader` 命令执行成功,检查其输出是否有报错。 +3. 确认 `.env` 中 `DATABASE_URL` 的密码与 `POSTGRES_PASSWORD` 一致。 +4. 详细迁移步骤参考 [PostgreSQL 数据库指南 - 从 SQLite 迁移](./postgresql.md)。 + +### Q8: PostgreSQL 容器启动失败? +**A**: +1. 检查 `POSTGRES_PASSWORD` 是否已设置(不能为空)。 +2. 查看容器日志:`docker logs flux-panel-postgres`。 +3. 如果是首次启动后修改了密码,需要删除旧的数据卷重新初始化:`docker volume rm postgres_data`。 diff --git a/doc/index.md b/doc/index.md index 2672494..e7a69f4 100644 --- a/doc/index.md +++ b/doc/index.md @@ -17,6 +17,7 @@ - [安装部署](./install.md) - [使用指南](./usage.md) +- [PostgreSQL 数据库指南](./postgresql.md) - [常见问题](./faq.md) ## 免责声明 diff --git a/doc/install.md b/doc/install.md index c76fbde..3067103 100644 --- a/doc/install.md +++ b/doc/install.md @@ -41,6 +41,8 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_instal 1. 安装面板 2. 更新面板 3. 卸载面板 +4. 迁移到 PostgreSQL +5. 退出 --- @@ -77,3 +79,57 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh - 安装完成后,服务会自动启动。 - 查看状态: `systemctl status flux_agent` - 回到面板 **节点管理** 页面,该节点状态应显示为 **在线**。 + +--- + +## 三、Caddy 反向代理(可选) + +如果需要通过域名访问面板并自动获取 HTTPS 证书,可以使用 Caddy 作为反向代理。 + +### 1. 安装 Caddy + +```bash +# Debian / Ubuntu +sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl +curl -1sLf 'https://dl.cloudflare.com/content/v1/e2qwFJ2fRP2b2q/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg +curl -1sLf 'https://dl.cloudflare.com/content/v1/e2qwFJ2fRP2b2q/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list +sudo apt update +sudo apt install caddy +``` + +其他系统请参考 [Caddy 官方安装文档](https://caddyserver.com/docs/install)。 + +### 2. 配置 Caddyfile + +编辑 Caddy 配置文件: + +```bash +sudo nano /etc/caddy/Caddyfile +``` + +#### 面板域名配置 + +将 `panel.example.com` 替换为你自己的域名: + +```caddyfile +panel.example.com { + reverse_proxy localhost:6366 +} +``` + +Caddy 会自动为域名申请和续期 HTTPS 证书,无需额外配置。 + +### 3. 重启 Caddy + +```bash +sudo systemctl restart caddy +``` + +### 4. 注意事项 + +- 确保域名已正确解析到服务器 IP。 +- 确保服务器防火墙放行了 **80** 和 **443** 端口(Caddy 自动申请证书需要)。 +- 使用 Caddy 反向代理后,可以在 `.env` 中将前端端口改为仅监听本地,避免直接暴露: + ``` + FRONTEND_PORT=127.0.0.1:6366 + ``` diff --git a/doc/postgresql.md b/doc/postgresql.md new file mode 100644 index 0000000..6a2965d --- /dev/null +++ b/doc/postgresql.md @@ -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 +``` diff --git a/docker-compose-v4.yml b/docker-compose-v4.yml index 1169509..ea85469 100644 --- a/docker-compose-v4.yml +++ b/docker-compose-v4.yml @@ -7,16 +7,17 @@ services: driver: json-file options: max-size: "20m" + max-file: "3" environment: + DB_TYPE: ${DB_TYPE:-sqlite} DB_PATH: /app/data/gost.db + DATABASE_URL: ${DATABASE_URL:-} JWT_SECRET: ${JWT_SECRET} - LOG_DIR: /app/logs SERVER_ADDR: :6365 TZ: Asia/Shanghai ports: - "${BACKEND_PORT}:6365" volumes: - - backend_logs:/app/logs - sqlite_data:/app/data networks: - gost-network @@ -29,6 +30,30 @@ services: retries: 5 start_period: 30s + postgres: + image: postgres:16-alpine + container_name: flux-panel-postgres + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "20m" + environment: + POSTGRES_DB: ${POSTGRES_DB:-flux_panel} + POSTGRES_USER: ${POSTGRES_USER:-flux_panel} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flux_panel_change_me} + TZ: Asia/Shanghai + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - gost-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-flux_panel} -d ${POSTGRES_DB:-flux_panel}"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + frontend: image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest} container_name: vite-frontend @@ -37,6 +62,7 @@ services: driver: json-file options: max-size: "20m" + max-file: "3" ports: - "${FRONTEND_PORT}:80" depends_on: @@ -50,8 +76,8 @@ volumes: sqlite_data: name: sqlite_data driver: local - backend_logs: - name: backend_logs + postgres_data: + name: postgres_data driver: local diff --git a/docker-compose-v6.yml b/docker-compose-v6.yml index e0832eb..cb7ae47 100644 --- a/docker-compose-v6.yml +++ b/docker-compose-v6.yml @@ -7,16 +7,17 @@ services: driver: json-file options: max-size: "20m" + max-file: "3" environment: + DB_TYPE: ${DB_TYPE:-sqlite} DB_PATH: /app/data/gost.db + DATABASE_URL: ${DATABASE_URL:-} JWT_SECRET: ${JWT_SECRET} - LOG_DIR: /app/logs SERVER_ADDR: :6365 TZ: Asia/Shanghai ports: - "${BACKEND_PORT}:6365" volumes: - - backend_logs:/app/logs - sqlite_data:/app/data networks: - gost-network @@ -29,6 +30,30 @@ services: retries: 5 start_period: 30s + postgres: + image: postgres:16-alpine + container_name: flux-panel-postgres + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "20m" + environment: + POSTGRES_DB: ${POSTGRES_DB:-flux_panel} + POSTGRES_USER: ${POSTGRES_USER:-flux_panel} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flux_panel_change_me} + TZ: Asia/Shanghai + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - gost-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-flux_panel} -d ${POSTGRES_DB:-flux_panel}"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + frontend: image: ghcr.io/sagit-chu/vite-frontend:${FLUX_VERSION:-latest} container_name: vite-frontend @@ -37,6 +62,7 @@ services: driver: json-file options: max-size: "20m" + max-file: "3" ports: - "${FRONTEND_PORT}:80" depends_on: @@ -50,8 +76,8 @@ volumes: sqlite_data: name: sqlite_data driver: local - backend_logs: - name: backend_logs + postgres_data: + name: postgres_data driver: local diff --git a/go-backend/Dockerfile b/go-backend/Dockerfile index 394fb50..6c69f87 100644 --- a/go-backend/Dockerfile +++ b/go-backend/Dockerfile @@ -1,7 +1,7 @@ -FROM golang:1.23-bookworm AS builder +FROM golang:1.24-bookworm AS builder WORKDIR /src -COPY go.mod ./ +COPY go.mod go.sum ./ RUN go mod download COPY . . diff --git a/go-backend/go.mod b/go-backend/go.mod index 2c26ac9..4295d62 100644 --- a/go-backend/go.mod +++ b/go-backend/go.mod @@ -1,22 +1,29 @@ module go-backend -go 1.23.0 +go 1.24.0 toolchain go1.24.4 require ( github.com/gorilla/websocket v1.5.3 + github.com/jackc/pgx/v5 v5.7.3 modernc.org/sqlite v1.37.1 ) require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/crypto v0.31.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.29.0 // indirect modernc.org/libc v1.65.7 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go-backend/go.sum b/go-backend/go.sum index fa6b48b..207fe9e 100644 --- a/go-backend/go.sum +++ b/go-backend/go.sum @@ -1,3 +1,6 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= @@ -6,23 +9,46 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.3 h1:PO1wNKj/bTAwxSJnO1Z4Ai8j4magtqg2SLNjEDzcXQo= +github.com/jackc/pgx/v5 v5.7.3/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= -golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= diff --git a/go-backend/internal/app/app.go b/go-backend/internal/app/app.go index 425b05a..527b866 100644 --- a/go-backend/internal/app/app.go +++ b/go-backend/internal/app/app.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "strings" "time" "go-backend/internal/config" @@ -20,9 +21,24 @@ type App struct { } func New(cfg config.Config) (*App, error) { - repo, err := sqlite.Open(cfg.DBPath) - if err != nil { - return nil, fmt.Errorf("open sqlite: %w", err) + var ( + repo *sqlite.Repository + err error + ) + + switch strings.ToLower(strings.TrimSpace(cfg.DBType)) { + case "", "sqlite": + repo, err = sqlite.Open(cfg.DBPath) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + case "postgres", "postgresql": + repo, err = sqlite.OpenPostgres(cfg.DatabaseURL) + if err != nil { + return nil, fmt.Errorf("open postgres: %w", err) + } + default: + return nil, fmt.Errorf("unsupported DB_TYPE %q", cfg.DBType) } h := handler.New(repo, cfg.JWTSecret) diff --git a/go-backend/internal/config/config.go b/go-backend/internal/config/config.go index 043730c..f94c721 100644 --- a/go-backend/internal/config/config.go +++ b/go-backend/internal/config/config.go @@ -3,18 +3,22 @@ package config import "os" type Config struct { - Addr string - DBPath string - JWTSecret string - LogDir string + Addr string + DBType string + DBPath string + DatabaseURL string + JWTSecret string + LogDir string } func FromEnv() Config { cfg := Config{ - Addr: getEnv("SERVER_ADDR", ":6365"), - DBPath: getEnv("DB_PATH", "/app/data/gost.db"), - JWTSecret: getEnv("JWT_SECRET", ""), - LogDir: getEnv("LOG_DIR", "/app/logs"), + Addr: getEnv("SERVER_ADDR", ":6365"), + DBType: getEnv("DB_TYPE", "sqlite"), + DBPath: getEnv("DB_PATH", "/app/data/gost.db"), + DatabaseURL: getEnv("DATABASE_URL", ""), + JWTSecret: getEnv("JWT_SECRET", ""), + LogDir: getEnv("LOG_DIR", "/app/logs"), } return cfg diff --git a/go-backend/internal/http/client/federation.go b/go-backend/internal/http/client/federation.go index fdc04ee..128055a 100644 --- a/go-backend/internal/http/client/federation.go +++ b/go-backend/internal/http/client/federation.go @@ -85,6 +85,14 @@ func NewFederationClient() *FederationClient { } } +func NewFederationClientWithTimeout(timeout time.Duration) *FederationClient { + return &FederationClient{ + client: &http.Client{ + Timeout: timeout, + }, + } +} + func (c *FederationClient) Connect(url, token, localDomain string) (*RemoteNodeInfo, error) { url = strings.TrimSuffix(url, "/") req, err := http.NewRequest("POST", url+"/api/v1/federation/connect", nil) diff --git a/go-backend/internal/http/handler/control_plane.go b/go-backend/internal/http/handler/control_plane.go index 0c3cf5a..0e3d084 100644 --- a/go-backend/internal/http/handler/control_plane.go +++ b/go-backend/internal/http/handler/control_plane.go @@ -889,11 +889,11 @@ func firstPortFromRange(portRange string) int { func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, error) { rows, err := h.repo.DB().Query(` - SELECT ct.chain_type, COALESCE(ct.inx, 0), ct.node_id, COALESCE(ct.port, 0), n.name, ct.protocol, ct.strategy + SELECT CAST(ct.chain_type AS INTEGER), COALESCE(ct.inx, 0), ct.node_id, COALESCE(ct.port, 0), n.name, ct.protocol, ct.strategy FROM chain_tunnel ct LEFT JOIN node n ON n.id = ct.node_id WHERE ct.tunnel_id = ? - ORDER BY ct.chain_type ASC, COALESCE(ct.inx, 0) ASC, ct.id ASC + ORDER BY CAST(ct.chain_type AS INTEGER) ASC, COALESCE(ct.inx, 0) ASC, ct.id ASC `, tunnelID) if err != nil { return nil, err diff --git a/go-backend/internal/http/handler/federation.go b/go-backend/internal/http/handler/federation.go index be38834..09abef2 100644 --- a/go-backend/internal/http/handler/federation.go +++ b/go-backend/internal/http/handler/federation.go @@ -8,6 +8,7 @@ import ( "net/http" "sort" "strings" + "sync" "time" "go-backend/internal/http/client" @@ -40,6 +41,17 @@ type resetPeerShareFlowRequest struct { ID int64 `json:"id"` } +type updatePeerShareRequest struct { + ID int64 `json:"id"` + Name string `json:"name"` + MaxBandwidth int64 `json:"maxBandwidth"` + ExpiryTime int64 `json:"expiryTime"` + PortRangeStart int `json:"portRangeStart"` + PortRangeEnd int `json:"portRangeEnd"` + AllowedDomains string `json:"allowedDomains"` + AllowedIPs string `json:"allowedIps"` +} + type nodeImportRequest struct { RemoteURL string `json:"remoteUrl"` Token string `json:"token"` @@ -325,6 +337,80 @@ func (h *Handler) federationShareResetFlow(w http.ResponseWriter, r *http.Reques response.WriteJSON(w, response.OKEmpty()) } +func (h *Handler) federationShareUpdate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("Invalid method")) + return + } + + var req updatePeerShareRequest + if err := decodeJSON(r.Body, &req); err != nil { + response.WriteJSON(w, response.ErrDefault("Invalid JSON")) + return + } + if req.ID <= 0 { + response.WriteJSON(w, response.ErrDefault("Share ID is required")) + return + } + + share, err := h.repo.GetPeerShare(req.ID) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + if share == nil { + response.WriteJSON(w, response.ErrDefault("Share not found")) + return + } + + if req.Name == "" { + response.WriteJSON(w, response.ErrDefault("Name is required")) + return + } + + if req.MaxBandwidth < 0 { + response.WriteJSON(w, response.ErrDefault("Max bandwidth cannot be negative")) + return + } + + if req.ExpiryTime < 0 { + response.WriteJSON(w, response.ErrDefault("Expiry time cannot be negative")) + return + } + + if req.PortRangeStart < 0 || req.PortRangeStart > 65535 || req.PortRangeEnd < 0 || req.PortRangeEnd > 65535 { + response.WriteJSON(w, response.ErrDefault("Invalid port range")) + return + } + + if req.PortRangeStart > req.PortRangeEnd { + response.WriteJSON(w, response.ErrDefault("Port range start cannot be greater than end")) + return + } + + allowedIPs, err := normalizePeerShareAllowedIPs(req.AllowedIPs) + if err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + + share.Name = req.Name + share.MaxBandwidth = req.MaxBandwidth + share.ExpiryTime = req.ExpiryTime + share.PortRangeStart = req.PortRangeStart + share.PortRangeEnd = req.PortRangeEnd + share.AllowedDomains = req.AllowedDomains + share.AllowedIPs = allowedIPs + share.UpdatedTime = time.Now().UnixMilli() + + if err := h.repo.UpdatePeerShare(share); err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + + response.WriteJSON(w, response.OKEmpty()) +} + func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { response.WriteJSON(w, response.ErrDefault("Invalid method")) @@ -700,7 +786,7 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request) defer tx.Rollback() now := time.Now().UnixMilli() - res, err := tx.Exec(`INSERT INTO tunnel (name, type, protocol, flow, created_time, updated_time, status, in_ip) VALUES (?, ?, ?, 0, ?, ?, 1, ?)`, + tunnelID, err := tx.ExecReturningID(`INSERT INTO tunnel (name, type, protocol, flow, created_time, updated_time, status, in_ip) VALUES (?, ?, ?, 0, ?, ?, 1, ?)`, fmt.Sprintf("Share-%d-Port-%d", share.ID, req.RemotePort), tunnelType, req.Protocol, @@ -713,9 +799,7 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request) return } - tunnelID, _ := res.LastInsertId() - - _, err = tx.Exec(`INSERT INTO chain_tunnel (tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES (?, 1, ?, ?, 'fifo', 0, ?)`, + _, err = tx.Exec(`INSERT INTO chain_tunnel (tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES (?, '1', ?, ?, 'fifo', 0, ?)`, tunnelID, share.NodeID, req.RemotePort, @@ -1325,6 +1409,74 @@ func isPeerIPAllowed(clientIP net.IP, whitelist string) bool { return false } +func (h *Handler) syncRemoteNodeStatuses(items []map[string]interface{}) { + type remoteEntry struct { + index int + remoteURL string + remoteToken string + } + + var remotes []remoteEntry + for i, item := range items { + isRemote, _ := item["isRemote"].(int) + if isRemote != 1 { + continue + } + url, _ := item["remoteUrl"].(string) + token, _ := item["remoteToken"].(string) + url = strings.TrimSpace(url) + token = strings.TrimSpace(token) + if url == "" || token == "" { + continue + } + remotes = append(remotes, remoteEntry{index: i, remoteURL: url, remoteToken: token}) + } + if len(remotes) == 0 { + return + } + + localDomain := h.federationLocalDomain() + fc := client.NewFederationClientWithTimeout(5 * time.Second) + + type syncResult struct { + index int + status int + syncError string + } + + results := make([]syncResult, len(remotes)) + var wg sync.WaitGroup + for i, entry := range remotes { + wg.Add(1) + go func(idx int, e remoteEntry) { + defer wg.Done() + info, err := fc.Connect(e.remoteURL, e.remoteToken, localDomain) + if err != nil { + errMsg := err.Error() + if strings.Contains(errMsg, "401") || strings.Contains(errMsg, "Invalid token") || strings.Contains(errMsg, "Unauthorized") { + results[idx] = syncResult{index: e.index, status: 0, syncError: "provider_share_deleted"} + } else if strings.Contains(errMsg, "403") || strings.Contains(errMsg, "Share is disabled") { + results[idx] = syncResult{index: e.index, status: 0, syncError: "provider_share_disabled"} + } else if strings.Contains(errMsg, "Share expired") { + results[idx] = syncResult{index: e.index, status: 0, syncError: "provider_share_expired"} + } else { + results[idx] = syncResult{index: e.index, status: 0, syncError: errMsg} + } + } else { + results[idx] = syncResult{index: e.index, status: info.Status, syncError: ""} + } + }(i, entry) + } + wg.Wait() + + for _, r := range results { + items[r.index]["status"] = r.status + if r.syncError != "" { + items[r.index]["syncError"] = r.syncError + } + } +} + func (h *Handler) cleanupPeerShareRuntimes(shareID int64) { if h == nil || h.repo == nil || shareID <= 0 { return diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index 4bfec49..204613f 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -105,6 +105,10 @@ func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/node/update-order", h.nodeUpdateOrder) mux.HandleFunc("/api/v1/node/batch-delete", h.nodeBatchDelete) mux.HandleFunc("/api/v1/node/check-status", h.nodeCheckStatus) + mux.HandleFunc("/api/v1/node/upgrade", h.nodeUpgrade) + mux.HandleFunc("/api/v1/node/batch-upgrade", h.nodeBatchUpgrade) + mux.HandleFunc("/api/v1/node/rollback", h.nodeRollback) + mux.HandleFunc("/api/v1/node/releases", h.listReleases) mux.HandleFunc("/api/v1/tunnel/list", h.tunnelList) mux.HandleFunc("/api/v1/tunnel/create", h.tunnelCreate) mux.HandleFunc("/api/v1/tunnel/get", h.tunnelGet) @@ -155,6 +159,7 @@ func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/open_api/sub_store", h.openAPISubStore) mux.HandleFunc("/api/v1/federation/share/list", h.federationShareList) mux.HandleFunc("/api/v1/federation/share/create", h.federationShareCreate) + mux.HandleFunc("/api/v1/federation/share/update", h.federationShareUpdate) mux.HandleFunc("/api/v1/federation/share/delete", h.federationShareDelete) mux.HandleFunc("/api/v1/federation/share/reset-flow", h.federationShareResetFlow) mux.HandleFunc("/api/v1/federation/share/remote-usage/list", h.federationRemoteUsageList) @@ -301,11 +306,35 @@ func (h *Handler) userList(w http.ResponseWriter, r *http.Request) { return } + var req struct { + Current int `json:"current"` + Size int `json:"size"` + Keyword string `json:"keyword"` + } + if err := decodeJSON(r.Body, &req); err != nil && err != io.EOF { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + users, err := h.repo.ListUsers() if err != nil { response.WriteJSON(w, response.Err(-2, err.Error())) return } + + keyword := strings.ToLower(strings.TrimSpace(req.Keyword)) + if keyword != "" { + filtered := make([]map[string]interface{}, 0, len(users)) + for _, item := range users { + username := strings.ToLower(strings.TrimSpace(fmt.Sprint(item["user"]))) + displayName := strings.ToLower(strings.TrimSpace(fmt.Sprint(item["name"]))) + if strings.Contains(username, keyword) || strings.Contains(displayName, keyword) { + filtered = append(filtered, item) + } + } + users = filtered + } + response.WriteJSON(w, response.OK(users)) } @@ -320,6 +349,9 @@ func (h *Handler) nodeList(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.Err(-2, err.Error())) return } + + h.syncRemoteNodeStatuses(items) + response.WriteJSON(w, response.OK(items)) } diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 3e30f3f..cff0744 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -18,6 +18,7 @@ import ( "go-backend/internal/http/client" "go-backend/internal/http/response" "go-backend/internal/security" + "go-backend/internal/store" "go-backend/internal/store/sqlite" ) @@ -413,7 +414,7 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.Err(-2, err.Error())) return } - cmd := fmt.Sprintf("curl -L https://github.com/Sagit-chu/flux-panel/releases/latest/download/install.sh -o ./install.sh && chmod +x ./install.sh && ./install.sh -a %s -s %s", processServerAddress(panelAddr), secret) + cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/latest/download/install.sh -o ./install.sh && chmod +x ./install.sh && ./install.sh -a %s -s %s", processServerAddress(panelAddr), secret) response.WriteJSON(w, response.OK(cmd)) } @@ -559,13 +560,12 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) { } } - res, err := tx.Exec(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + tunnelID, err := tx.ExecReturningID(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, name, trafficRatio, typeVal, "tls", flow, now, now, status, nullableText(inIP), inx) if err != nil { response.WriteJSON(w, response.Err(-2, err.Error())) return } - tunnelID, _ := res.LastInsertId() runtimeState.TunnelID = tunnelID var federationBindings []sqlite.FederationTunnelBinding var federationReleaseRefs []federationRuntimeReleaseRef @@ -688,6 +688,9 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) { return } runtimeState.TunnelID = id + + inIp := buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes) + var federationBindings []sqlite.FederationTunnelBinding var federationReleaseRefs []federationRuntimeReleaseRef if typeVal == 2 { @@ -700,7 +703,7 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) { applyTunnelPortsToRequest(req, runtimeState) _, err = tx.Exec(`UPDATE tunnel SET name=?, type=?, flow=?, traffic_ratio=?, status=?, in_ip=?, updated_time=? WHERE id=?`, - asString(req["name"]), typeVal, asInt64(req["flow"], 1), asFloat(req["trafficRatio"], 1.0), asInt(req["status"], 1), nullableText(asString(req["inIp"])), now, id) + asString(req["name"]), typeVal, asInt64(req["flow"], 1), asFloat(req["trafficRatio"], 1.0), asInt(req["status"], 1), nullableText(inIp), now, id) if err != nil { response.WriteJSON(w, response.Err(-2, err.Error())) return @@ -1119,7 +1122,7 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) { return } defer func() { _ = tx.Rollback() }() - res, err := tx.Exec(` + forwardID, err := tx.ExecReturningID(` INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx) VALUES(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?) `, userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, now, inx) @@ -1127,7 +1130,6 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.Err(-2, err.Error())) return } - forwardID, _ := res.LastInsertId() entryNodes, _ := h.tunnelEntryNodeIDs(tunnelID) for _, nodeID := range entryNodes { _, _ = tx.Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port) @@ -1587,13 +1589,12 @@ func (h *Handler) speedLimitCreate(w http.ResponseWriter, r *http.Request) { } now := time.Now().UnixMilli() speed := asInt(req["speed"], 100) - res, err := h.repo.DB().Exec(`INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status) VALUES(?, ?, ?, ?, ?, ?, ?)`, + id, err := h.repo.DB().ExecReturningID(`INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status) VALUES(?, ?, ?, ?, ?, ?, ?)`, name, speed, tunnelID, tunnelName, now, now, asInt(req["status"], 1)) if err != nil { response.WriteJSON(w, response.Err(-2, err.Error())) return } - id, _ := res.LastInsertId() _ = h.sendLimiterConfig(id, speed, tunnelID) response.WriteJSON(w, response.OKEmpty()) } @@ -1687,7 +1688,7 @@ func (h *Handler) groupTunnelAssign(w http.ResponseWriter, r *http.Request) { defer func() { _ = tx.Rollback() }() _, _ = tx.Exec(`DELETE FROM tunnel_group_tunnel WHERE tunnel_group_id = ?`, req.GroupID) for _, tid := range req.TunnelIDs { - _, _ = tx.Exec(`INSERT OR IGNORE INTO tunnel_group_tunnel(tunnel_group_id, tunnel_id, created_time) VALUES(?, ?, ?)`, req.GroupID, tid, time.Now().UnixMilli()) + _, _ = tx.Exec(`INSERT INTO tunnel_group_tunnel(tunnel_group_id, tunnel_id, created_time) VALUES(?, ?, ?) ON CONFLICT DO NOTHING`, req.GroupID, tid, time.Now().UnixMilli()) } if err := tx.Commit(); err != nil { response.WriteJSON(w, response.Err(-2, err.Error())) @@ -1714,7 +1715,7 @@ func (h *Handler) groupUserAssign(w http.ResponseWriter, r *http.Request) { defer func() { _ = tx.Rollback() }() _, _ = tx.Exec(`DELETE FROM user_group_user WHERE user_group_id = ?`, req.GroupID) for _, uid := range req.UserIDs { - _, _ = tx.Exec(`INSERT OR IGNORE INTO user_group_user(user_group_id, user_id, created_time) VALUES(?, ?, ?)`, req.GroupID, uid, time.Now().UnixMilli()) + _, _ = tx.Exec(`INSERT INTO user_group_user(user_group_id, user_id, created_time) VALUES(?, ?, ?) ON CONFLICT DO NOTHING`, req.GroupID, uid, time.Now().UnixMilli()) } if err := tx.Commit(); err != nil { response.WriteJSON(w, response.Err(-2, err.Error())) @@ -1733,7 +1734,7 @@ func (h *Handler) groupPermissionAssign(w http.ResponseWriter, r *http.Request) response.WriteJSON(w, response.ErrDefault("请求参数错误")) return } - _, err := h.repo.DB().Exec(`INSERT OR IGNORE INTO group_permission(user_group_id, tunnel_group_id, created_time) VALUES(?, ?, ?)`, req.UserGroupID, req.TunnelGroupID, time.Now().UnixMilli()) + _, err := h.repo.DB().Exec(`INSERT INTO group_permission(user_group_id, tunnel_group_id, created_time) VALUES(?, ?, ?) ON CONFLICT DO NOTHING`, req.UserGroupID, req.TunnelGroupID, time.Now().UnixMilli()) if err != nil { response.WriteJSON(w, response.Err(-2, err.Error())) return @@ -1835,7 +1836,7 @@ func (h *Handler) applyGroupPermission(userGroupID, tunnelGroupID int64) error { if created { createdByGroup = 1 } - _, _ = db.Exec(`INSERT OR IGNORE INTO group_permission_grant(user_group_id, tunnel_group_id, user_tunnel_id, created_by_group, created_time) VALUES(?, ?, ?, ?, ?)`, + _, _ = db.Exec(`INSERT INTO group_permission_grant(user_group_id, tunnel_group_id, user_tunnel_id, created_by_group, created_time) VALUES(?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`, userGroupID, tunnelGroupID, utID, createdByGroup, time.Now().UnixMilli()) } } @@ -1866,7 +1867,7 @@ func (h *Handler) syncPermissionsByTunnelGroup(tunnelGroupID int64) error { return nil } -func ensureUserTunnelGrant(db *sql.DB, userID, tunnelID int64) (int64, bool, error) { +func ensureUserTunnelGrant(db *store.DB, userID, tunnelID int64) (int64, bool, error) { var id int64 err := db.QueryRow(`SELECT id FROM user_tunnel WHERE user_id = ? AND tunnel_id = ? LIMIT 1`, userID, tunnelID).Scan(&id) if err == nil { @@ -1882,16 +1883,15 @@ func ensureUserTunnelGrant(db *sql.DB, userID, tunnelID int64) (int64, bool, err if err := db.QueryRow(`SELECT flow, num, exp_time, flow_reset_time FROM user WHERE id = ?`, userID).Scan(&flow, &num, &expTime, &flowReset); err != nil { return 0, false, err } - res, err := db.Exec(`INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(?, ?, NULL, ?, ?, 0, 0, ?, ?, 1)`, + id, err = db.ExecReturningID(`INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(?, ?, NULL, ?, ?, 0, 0, ?, ?, 1)`, userID, tunnelID, num, flow, flowReset, expTime) if err != nil { return 0, false, err } - id, _ = res.LastInsertId() return id, true, nil } -func queryInt64List(db *sql.DB, q string, args ...interface{}) ([]int64, error) { +func queryInt64List(db *store.DB, q string, args ...interface{}) ([]int64, error) { rows, err := db.Query(q, args...) if err != nil { return nil, err @@ -1908,7 +1908,7 @@ func queryInt64List(db *sql.DB, q string, args ...interface{}) ([]int64, error) return out, rows.Err() } -func queryPairs(db *sql.DB, q string, args ...interface{}) ([][2]int64, error) { +func queryPairs(db *store.DB, q string, args ...interface{}) ([][2]int64, error) { rows, err := db.Query(q, args...) if err != nil { return nil, err @@ -1944,7 +1944,7 @@ type tunnelCreateState struct { NodeIDList []int64 } -func (h *Handler) prepareTunnelCreateState(tx *sql.Tx, req map[string]interface{}, tunnelType int, excludeTunnelID int64) (*tunnelCreateState, error) { +func (h *Handler) prepareTunnelCreateState(tx *store.Tx, req map[string]interface{}, tunnelType int, excludeTunnelID int64) (*tunnelCreateState, error) { state := &tunnelCreateState{ Type: tunnelType, InNodes: make([]tunnelRuntimeNode, 0), @@ -2403,7 +2403,7 @@ func (h *Handler) cleanupFederationRuntime(tunnelID int64) { _ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID) } -func replaceFederationTunnelBindingsTx(tx *sql.Tx, tunnelID int64, bindings []sqlite.FederationTunnelBinding) error { +func replaceFederationTunnelBindingsTx(tx *store.Tx, tunnelID int64, bindings []sqlite.FederationTunnelBinding) error { if tx == nil { return errors.New("database unavailable") } @@ -2727,7 +2727,7 @@ func pickNodeAddressV6(node *nodeRecord) string { return strings.TrimSpace(node.ServerIP) } -func isRemoteNodeTx(tx *sql.Tx, nodeID int64) (bool, error) { +func isRemoteNodeTx(tx *store.Tx, nodeID int64) (bool, error) { if tx == nil { return false, errors.New("database unavailable") } @@ -2744,7 +2744,7 @@ func isRemoteNodeTx(tx *sql.Tx, nodeID int64) (bool, error) { return isRemote == 1, nil } -func pickNodePortTx(tx *sql.Tx, nodeID int64, allocated map[int64]int, excludeTunnelID int64) (int, error) { +func pickNodePortTx(tx *store.Tx, nodeID int64, allocated map[int64]int, excludeTunnelID int64) (int, error) { if tx == nil { return 0, errors.New("database unavailable") } @@ -2855,7 +2855,7 @@ func parsePortRangeSpec(input string) []int { return out } -func replaceTunnelChainsTx(tx *sql.Tx, tunnelID int64, req map[string]interface{}) error { +func replaceTunnelChainsTx(tx *store.Tx, tunnelID int64, req map[string]interface{}) error { allocated := map[int64]int{} inNodes := asMapSlice(req["inNodeId"]) for _, n := range inNodes { @@ -2863,7 +2863,7 @@ func replaceTunnelChainsTx(tx *sql.Tx, tunnelID int64, req map[string]interface{ if nodeID <= 0 { continue } - _, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 1, ?, NULL, NULL, 0, ?)`, + _, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, '1', ?, NULL, NULL, 0, ?)`, tunnelID, nodeID, defaultString(asString(n["protocol"]), "tls")) if err != nil { return err @@ -2882,7 +2882,7 @@ func replaceTunnelChainsTx(tx *sql.Tx, tunnelID int64, req map[string]interface{ return pickErr } } - _, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 3, ?, ?, ?, 0, ?)`, + _, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, '3', ?, ?, ?, 0, ?)`, tunnelID, nodeID, port, defaultString(asString(n["strategy"]), "round"), defaultString(asString(n["protocol"]), "tls")) if err != nil { return err @@ -2903,7 +2903,7 @@ func replaceTunnelChainsTx(tx *sql.Tx, tunnelID int64, req map[string]interface{ return pickErr } } - _, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, 2, ?, ?, ?, ?, ?)`, + _, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, '2', ?, ?, ?, ?, ?)`, tunnelID, nodeID, port, defaultString(asString(n["strategy"]), "round"), i+1, defaultString(asString(n["protocol"]), "tls")) if err != nil { return err @@ -2989,7 +2989,7 @@ func (h *Handler) batchForwardStatus(ids []int64, status int) (int, int) { } func (h *Handler) tunnelEntryNodeIDs(tunnelID int64) ([]int64, error) { - rows, err := h.repo.DB().Query(`SELECT node_id FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 1 ORDER BY inx ASC, id ASC`, tunnelID) + rows, err := h.repo.DB().Query(`SELECT node_id FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = '1' ORDER BY inx ASC, id ASC`, tunnelID) if err != nil { return nil, err } @@ -3476,7 +3476,7 @@ func randomToken(n int) string { return hex.EncodeToString(buf) } -func nextIndex(db *sql.DB, table string) int { +func nextIndex(db *store.DB, table string) int { if db == nil { return 0 } diff --git a/go-backend/internal/http/handler/upgrade.go b/go-backend/internal/http/handler/upgrade.go new file mode 100644 index 0000000..99e0cbb --- /dev/null +++ b/go-backend/internal/http/handler/upgrade.go @@ -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, + })) +} diff --git a/go-backend/internal/store/db.go b/go-backend/internal/store/db.go new file mode 100644 index 0000000..a597eef --- /dev/null +++ b/go-backend/internal/store/db.go @@ -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 + } +} diff --git a/go-backend/internal/store/db_test.go b/go-backend/internal/store/db_test.go new file mode 100644 index 0000000..4a3f580 --- /dev/null +++ b/go-backend/internal/store/db_test.go @@ -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) + } +} diff --git a/go-backend/internal/store/postgres/embed.go b/go-backend/internal/store/postgres/embed.go new file mode 100644 index 0000000..749852d --- /dev/null +++ b/go-backend/internal/store/postgres/embed.go @@ -0,0 +1,9 @@ +package postgres + +import _ "embed" + +//go:embed sql/schema.sql +var EmbeddedSchema string + +//go:embed sql/data.sql +var EmbeddedSeedData string diff --git a/go-backend/internal/store/postgres/sql/data.sql b/go-backend/internal/store/postgres/sql/data.sql new file mode 100644 index 0000000..ee3f9dd --- /dev/null +++ b/go-backend/internal/store/postgres/sql/data.sql @@ -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 +$$; diff --git a/go-backend/internal/store/postgres/sql/schema.sql b/go-backend/internal/store/postgres/sql/schema.sql new file mode 100644 index 0000000..fcc6cc1 --- /dev/null +++ b/go-backend/internal/store/postgres/sql/schema.sql @@ -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); diff --git a/go-backend/internal/store/sqlite/repository.go b/go-backend/internal/store/sqlite/repository.go index 7f6bc00..0ff75fe 100644 --- a/go-backend/internal/store/sqlite/repository.go +++ b/go-backend/internal/store/sqlite/repository.go @@ -12,6 +12,9 @@ import ( "strings" "time" + _ "github.com/jackc/pgx/v5/stdlib" + "go-backend/internal/store" + pgstore "go-backend/internal/store/postgres" _ "modernc.org/sqlite" ) @@ -22,10 +25,10 @@ var embeddedSchema string var embeddedSeedData string type Repository struct { - db *sql.DB + db *store.DB } -func (r *Repository) DB() *sql.DB { +func (r *Repository) DB() *store.DB { if r == nil { return nil } @@ -165,17 +168,53 @@ func Open(path string) (*Repository, error) { return nil, err } - db, err := sql.Open("sqlite", path) + // Use _pragma DSN parameters so every connection from the pool gets + // the same settings (busy_timeout and synchronous are per-connection). + dsn := "file:" + path + + "?_pragma=busy_timeout(5000)" + + "&_pragma=journal_mode(WAL)" + + "&_pragma=synchronous(NORMAL)" + raw, err := sql.Open("sqlite", dsn) if err != nil { return nil, err } + db := store.Wrap(raw, store.DialectSQLite) if err := db.Ping(); err != nil { _ = db.Close() return nil, err } - if err := bootstrapSchema(db); err != nil { + if err := bootstrapSchema(db, embeddedSchema, embeddedSeedData); err != nil { + _ = db.Close() + return nil, err + } + + if err := migrateSchema(db); err != nil { + _ = db.Close() + return nil, err + } + + return &Repository{db: db}, nil +} + +func OpenPostgres(dsn string) (*Repository, error) { + if strings.TrimSpace(dsn) == "" { + return nil, fmt.Errorf("empty postgres dsn") + } + + raw, err := sql.Open("pgx", dsn) + if err != nil { + return nil, err + } + db := store.Wrap(raw, store.DialectPostgres) + + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, err + } + + if err := bootstrapSchema(db, pgstore.EmbeddedSchema, pgstore.EmbeddedSeedData); err != nil { _ = db.Close() return nil, err } @@ -361,7 +400,7 @@ func (r *Repository) GetUserPackageForwards(userID int64) ([]UserForwardDetail, } rows, err := r.db.Query(` - SELECT f.id, f.name, f.tunnel_id, t.name, f.remote_addr, f.in_flow, f.out_flow, f.status, f.created_time + SELECT f.id, f.name, f.tunnel_id, COALESCE(t.name, ''), f.remote_addr, f.in_flow, f.out_flow, f.status, f.created_time FROM forward f LEFT JOIN tunnel t ON t.id = f.tunnel_id WHERE f.user_id = ? @@ -680,7 +719,7 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) { } rows, err := r.db.Query(` - SELECT f.id, f.user_id, f.user_name, f.name, f.tunnel_id, t.name, f.remote_addr, f.strategy, + SELECT f.id, f.user_id, f.user_name, f.name, f.tunnel_id, COALESCE(t.name, ''), f.remote_addr, f.strategy, f.in_flow, f.out_flow, f.created_time, f.status, f.inx FROM forward f LEFT JOIN tunnel t ON t.id = f.tunnel_id @@ -858,9 +897,9 @@ func (r *Repository) ListTunnels() ([]map[string]interface{}, error) { } chainRows, err := r.db.Query(` - SELECT tunnel_id, chain_type, node_id, protocol, strategy, COALESCE(inx, 0) + SELECT tunnel_id, CAST(chain_type AS INTEGER), node_id, protocol, strategy, COALESCE(inx, 0) FROM chain_tunnel - ORDER BY tunnel_id ASC, chain_type ASC, inx ASC, id ASC + ORDER BY tunnel_id ASC, CAST(chain_type AS INTEGER) ASC, inx ASC, id ASC `) if err != nil { return nil, err @@ -1141,7 +1180,7 @@ func nullableForwardIngress(v string) interface{} { return v } -func resolveForwardIngress(db *sql.DB, forwardID int64, tunnelID int64) (string, sql.NullInt64, error) { +func resolveForwardIngress(db *store.DB, forwardID int64, tunnelID int64) (string, sql.NullInt64, error) { var tunnelInIP sql.NullString if err := db.QueryRow(`SELECT in_ip FROM tunnel WHERE id = ? LIMIT 1`, tunnelID).Scan(&tunnelInIP); err != nil { if !errors.Is(err, sql.ErrNoRows) { @@ -1243,33 +1282,54 @@ func ensureParentDir(dbPath string) error { return osMkdirAll(dir) } -func bootstrapSchema(db *sql.DB) error { +func bootstrapSchema(db *store.DB, schemaSQL, seedSQL string) error { if db == nil { return errors.New("nil db") } - if _, err := db.Exec(embeddedSchema); err != nil { + if _, err := db.Exec(schemaSQL); err != nil { return fmt.Errorf("apply schema.sql: %w", err) } - if _, err := db.Exec(embeddedSeedData); err != nil { + if _, err := db.Exec(seedSQL); err != nil { return fmt.Errorf("apply data.sql: %w", err) } return nil } -func migrateSchema(db *sql.DB) error { +const currentSchemaVersion = 1 + +func getSchemaVersion(db *store.DB) int { + _, _ = db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL DEFAULT 0)`) + var v int + if err := db.QueryRow(`SELECT version FROM schema_version LIMIT 1`).Scan(&v); err != nil { + _, _ = db.Exec(`INSERT INTO schema_version(version) VALUES(0)`) + return 0 + } + return v +} + +func setSchemaVersion(db *store.DB, v int) { + _, _ = db.Exec(`UPDATE schema_version SET version = ?`, v) +} + +func migrateSchema(db *store.DB) error { if db == nil { return errors.New("nil db") } + ver := getSchemaVersion(db) + if ver >= currentSchemaVersion { + return nil + } + ensureColumn := func(table, col, typ string) { var dummy interface{} err := db.QueryRow(fmt.Sprintf("SELECT %s FROM %s LIMIT 1", col, table)).Scan(&dummy) if err == nil || errors.Is(err, sql.ErrNoRows) { return } - if strings.Contains(err.Error(), "no such column") { + if isMissingColumnError(db.Dialect(), err) { if _, alterErr := db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, col, typ)); alterErr != nil { log.Printf("failed to add column %s to %s: %v", col, table, alterErr) } @@ -1306,9 +1366,177 @@ func migrateSchema(db *sql.DB) error { ensureColumn(table, col, typ) } } + + if db.Dialect() == store.DialectPostgres { + if err := ensurePostgresIDDefaults(db); err != nil { + return err + } + } + setSchemaVersion(db, currentSchemaVersion) return nil } +func ensurePostgresIDDefaults(db *store.DB) error { + rows, err := db.Query(` + SELECT c.table_schema, c.table_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + JOIN information_schema.columns c + ON c.table_schema = kcu.table_schema + AND c.table_name = kcu.table_name + AND c.column_name = kcu.column_name + WHERE tc.constraint_type = 'PRIMARY KEY' + AND kcu.column_name = 'id' + AND c.data_type IN ('integer', 'bigint') + AND c.is_identity = 'NO' + AND c.table_schema = current_schema() + ORDER BY c.table_name ASC + `) + if err != nil { + return fmt.Errorf("discover postgres id columns: %w", err) + } + defer rows.Close() + + for rows.Next() { + var schemaName string + var tableName string + if err := rows.Scan(&schemaName, &tableName); err != nil { + return fmt.Errorf("scan postgres id table row: %w", err) + } + if err := ensurePostgresTableIDDefault(db, schemaName, tableName); err != nil { + return fmt.Errorf("repair %s.%s id default: %w", schemaName, tableName, err) + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterate postgres id tables: %w", err) + } + + return nil +} + +func ensurePostgresTableIDDefault(db *store.DB, schemaName, tableName string) error { + var defaultExpr sql.NullString + if err := db.QueryRow(` + SELECT column_default + FROM information_schema.columns + WHERE table_schema = ? + AND table_name = ? + AND column_name = 'id' + LIMIT 1 + `, schemaName, tableName).Scan(&defaultExpr); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return err + } + + hasNextvalDefault := defaultExpr.Valid && strings.Contains(strings.ToLower(defaultExpr.String), "nextval(") + + var serialSeq sql.NullString + if err := db.QueryRow(` + SELECT pg_get_serial_sequence(quote_ident(?) || '.' || quote_ident(?), 'id') + `, schemaName, tableName).Scan(&serialSeq); err != nil { + return err + } + + seqRef := strings.TrimSpace(serialSeq.String) + if seqRef == "" && hasNextvalDefault { + seqRef = extractNextvalRegclass(defaultExpr.String) + } + + if !hasNextvalDefault || seqRef == "" { + seqName := tableName + "_id_seq" + if _, err := db.Exec(fmt.Sprintf("CREATE SEQUENCE IF NOT EXISTS %s.%s", quoteSQLIdentifier(schemaName), quoteSQLIdentifier(seqName))); err != nil { + return err + } + + seqRef = schemaName + "." + seqName + if _, err := db.Exec(fmt.Sprintf( + "ALTER TABLE %s.%s ALTER COLUMN id SET DEFAULT nextval(%s::regclass)", + quoteSQLIdentifier(schemaName), + quoteSQLIdentifier(tableName), + quoteSQLLiteral(seqRef), + )); err != nil { + return err + } + + if _, err := db.Exec(fmt.Sprintf( + "ALTER SEQUENCE %s.%s OWNED BY %s.%s.id", + quoteSQLIdentifier(schemaName), + quoteSQLIdentifier(seqName), + quoteSQLIdentifier(schemaName), + quoteSQLIdentifier(tableName), + )); err != nil { + return err + } + } + + return syncPostgresTableIDSequence(db, schemaName, tableName, seqRef) +} + +func syncPostgresTableIDSequence(db *store.DB, schemaName, tableName, seqRef string) error { + var maxID int64 + if err := db.QueryRow(fmt.Sprintf( + "SELECT COALESCE(MAX(id), 0) FROM %s.%s", + quoteSQLIdentifier(schemaName), + quoteSQLIdentifier(tableName), + )).Scan(&maxID); err != nil { + return err + } + + setVal := maxID + isCalled := true + if maxID <= 0 { + setVal = 1 + isCalled = false + } + + if _, err := db.Exec(`SELECT setval(?::regclass, ?, ?)`, seqRef, setVal, isCalled); err != nil { + return err + } + + return nil +} + +func extractNextvalRegclass(defaultExpr string) string { + nextvalIdx := strings.Index(strings.ToLower(defaultExpr), "nextval(") + if nextvalIdx < 0 { + return "" + } + expr := defaultExpr[nextvalIdx:] + firstQuote := strings.Index(expr, "'") + if firstQuote < 0 { + return "" + } + expr = expr[firstQuote+1:] + secondQuote := strings.Index(expr, "'") + if secondQuote < 0 { + return "" + } + return strings.TrimSpace(expr[:secondQuote]) +} + +func quoteSQLIdentifier(ident string) string { + return `"` + strings.ReplaceAll(ident, `"`, `""`) + `"` +} + +func quoteSQLLiteral(value string) string { + return "'" + strings.ReplaceAll(value, "'", "''") + "'" +} + +func isMissingColumnError(dialect store.Dialect, err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + if dialect == store.DialectPostgres { + return strings.Contains(msg, "column") && strings.Contains(msg, "does not exist") + } + return strings.Contains(msg, "no such column") +} + func (r *Repository) CreatePeerShare(share *PeerShare) error { if r == nil || r.db == nil { return errors.New("repository not initialized") diff --git a/go-backend/internal/ws/server.go b/go-backend/internal/ws/server.go index f36ec97..54edfa0 100644 --- a/go-backend/internal/ws/server.go +++ b/go-backend/internal/ws/server.go @@ -54,6 +54,12 @@ type pendingRequest struct { ch chan CommandResult } +const ( + wsPingPeriod = 15 * time.Second + wsPongWait = 45 * time.Second + wsWriteWait = 5 * time.Second +) + type CommandResult struct { Type string `json:"type"` Success bool `json:"success"` @@ -120,12 +126,19 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) { return } cw := &connWrap{conn: conn} + _ = conn.SetReadDeadline(time.Now().Add(wsPongWait)) + conn.SetPongHandler(func(string) error { + return conn.SetReadDeadline(time.Now().Add(wsPongWait)) + }) + done := make(chan struct{}) + go startKeepalive(cw, done) s.mu.Lock() s.admins[cw] = struct{}{} s.mu.Unlock() defer func() { + close(done) s.mu.Lock() delete(s.admins, cw) s.mu.Unlock() @@ -145,6 +158,12 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64 return } cw := &connWrap{conn: conn} + _ = conn.SetReadDeadline(time.Now().Add(wsPongWait)) + conn.SetPongHandler(func(string) error { + return conn.SetReadDeadline(time.Now().Add(wsPongWait)) + }) + done := make(chan struct{}) + go startKeepalive(cw, done) version := r.URL.Query().Get("version") httpVal := parseIntDefault(r.URL.Query().Get("http"), 0) @@ -165,6 +184,7 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64 s.broadcastStatus(nodeID, 1) defer func() { + close(done) needOfflineBroadcast := false s.mu.Lock() current, ok := s.nodes[nodeID] @@ -190,7 +210,15 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64 msg := decryptIfNeeded(payload, secret) s.tryResolvePending(nodeID, msg) - s.broadcastInfo(nodeID, msg) + + var parsed struct { + Type string `json:"type"` + } + if json.Unmarshal([]byte(msg), &parsed) == nil && parsed.Type == "UpgradeProgress" { + s.broadcastTyped(nodeID, "upgrade_progress", msg) + } else { + s.broadcastInfo(nodeID, msg) + } } } @@ -264,7 +292,9 @@ func (s *Server) SendCommand(nodeID int64, cmdType string, data interface{}, tim } ns.conn.mu.Lock() + _ = ns.conn.conn.SetWriteDeadline(time.Now().Add(wsWriteWait)) err = ns.conn.conn.WriteMessage(websocket.TextMessage, messageData) + _ = ns.conn.conn.SetWriteDeadline(time.Time{}) ns.conn.mu.Unlock() if err != nil { cleanup() @@ -385,6 +415,12 @@ func (s *Server) broadcastInfo(nodeID int64, data string) { s.broadcastToAdmins(string(raw)) } +func (s *Server) broadcastTyped(nodeID int64, msgType string, data string) { + payload := broadcastMessage{ID: nodeID, Type: msgType, Data: data} + raw, _ := json.Marshal(payload) + s.broadcastToAdmins(string(raw)) +} + func (s *Server) broadcastToAdmins(message string) { s.mu.RLock() admins := make([]*connWrap, 0, len(s.admins)) @@ -395,7 +431,9 @@ func (s *Server) broadcastToAdmins(message string) { for _, c := range admins { c.mu.Lock() + _ = c.conn.SetWriteDeadline(time.Now().Add(wsWriteWait)) err := c.conn.WriteMessage(websocket.TextMessage, []byte(message)) + _ = c.conn.SetWriteDeadline(time.Time{}) c.mu.Unlock() if err != nil { log.Printf("websocket broadcast failed: %v", err) @@ -428,3 +466,28 @@ func parseIntDefault(v string, fallback int) int { } return x } + +func startKeepalive(cw *connWrap, done <-chan struct{}) { + if cw == nil || cw.conn == nil { + return + } + ticker := time.NewTicker(wsPingPeriod) + defer ticker.Stop() + + for { + select { + case <-done: + return + case <-ticker.C: + cw.mu.Lock() + _ = cw.conn.SetWriteDeadline(time.Now().Add(wsWriteWait)) + err := cw.conn.WriteMessage(websocket.PingMessage, nil) + _ = cw.conn.SetWriteDeadline(time.Time{}) + cw.mu.Unlock() + if err != nil { + _ = cw.conn.Close() + return + } + } + } +} diff --git a/go-backend/tests/contract/migration_contract_test.go b/go-backend/tests/contract/migration_contract_test.go index 5795474..89780cc 100644 --- a/go-backend/tests/contract/migration_contract_test.go +++ b/go-backend/tests/contract/migration_contract_test.go @@ -17,6 +17,7 @@ import ( httpserver "go-backend/internal/http" "go-backend/internal/http/handler" "go-backend/internal/http/response" + "go-backend/internal/store" "go-backend/internal/store/sqlite" _ "modernc.org/sqlite" @@ -305,7 +306,7 @@ func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) { } } -func readTableColumns(t *testing.T, db *sql.DB, table string) map[string]bool { +func readTableColumns(t *testing.T, db *store.DB, table string) map[string]bool { t.Helper() rows, err := db.Query("PRAGMA table_info(" + table + ")") diff --git a/go-gost/x/socket/websocket_reporter.go b/go-gost/x/socket/websocket_reporter.go index 9a5817f..743f510 100644 --- a/go-gost/x/socket/websocket_reporter.go +++ b/go-gost/x/socket/websocket_reporter.go @@ -4,10 +4,17 @@ import ( "bytes" "compress/gzip" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" + "io" "net" + "net/http" "net/url" + "os" + "os/exec" + "runtime" "strconv" "strings" "sync" // 新增:用于管理连接状态的互斥锁 @@ -21,7 +28,6 @@ import ( "github.com/shirou/gopsutil/v3/host" "github.com/shirou/gopsutil/v3/mem" psnet "github.com/shirou/gopsutil/v3/net" - "os" ) // SystemInfo 系统信息结构体 @@ -85,6 +91,11 @@ type TcpPingResponse struct { RequestId string `json:"requestId,omitempty"` } +const ( + reporterReadWait = 60 * time.Second + reporterWriteWait = 5 * time.Second +) + type WebSocketReporter struct { url string addr string // 保存服务器地址 @@ -237,6 +248,14 @@ func (w *WebSocketReporter) connect() error { w.conn = conn w.connected = true + _ = conn.SetReadDeadline(time.Now().Add(reporterReadWait)) + conn.SetPingHandler(func(appData string) error { + _ = conn.SetReadDeadline(time.Now().Add(reporterReadWait)) + return conn.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(reporterWriteWait)) + }) + conn.SetPongHandler(func(string) error { + return conn.SetReadDeadline(time.Now().Add(reporterReadWait)) + }) // 设置关闭处理器来检测连接状态 w.conn.SetCloseHandler(func(code int, text string) error { @@ -377,7 +396,7 @@ func (w *WebSocketReporter) receiveMessages() { } // 设置读取超时 - conn.SetReadDeadline(time.Now().Add(30 * time.Second)) + conn.SetReadDeadline(time.Now().Add(reporterReadWait)) messageType, message, err := conn.ReadMessage() if err != nil { @@ -466,9 +485,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt } if cmdMsg.Type != "call" { - // TcpPing 诊断命令异步执行,避免阻塞其他命令 // 其他状态变更命令保持同步,确保顺序执行 - if cmdMsg.Type == "TcpPing" { + if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" { go w.routeCommand(cmdMsg) } else { w.routeCommand(cmdMsg) @@ -483,9 +501,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt return } if cmdMsg.Type != "call" { - // TcpPing 诊断命令异步执行,避免阻塞其他命令 // 其他状态变更命令保持同步,确保顺序执行 - if cmdMsg.Type == "TcpPing" { + if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" { go w.routeCommand(cmdMsg) } else { w.routeCommand(cmdMsg) @@ -579,6 +596,18 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) { response.Type = "SetProtocolResponse" needSaveConfig = true + // 升级 Agent 命令(异步执行,不需要保存配置) + case "UpgradeAgent": + err = w.handleUpgradeAgent(cmd.Data) + response.Type = "UpgradeAgentResponse" + // needSaveConfig = false (默认值) + + // 回退 Agent 到旧版本 + case "RollbackAgent": + err = w.handleRollbackAgent(cmd.Data) + response.Type = "RollbackAgentResponse" + // needSaveConfig = false (默认值) + default: err = fmt.Errorf("未知命令类型: %s", cmd.Type) response.Type = "UnknownCommandResponse" @@ -881,6 +910,186 @@ func (w *WebSocketReporter) handleSetProtocol(data interface{}) error { return nil } +// sendUpgradeProgress 通过 WS 发送升级进度消息 +func (w *WebSocketReporter) sendUpgradeProgress(stage string, percent int, message string) { + response := CommandResponse{ + Type: "UpgradeProgress", + Success: true, + Message: message, + Data: map[string]interface{}{ + "stage": stage, + "percent": percent, + }, + } + w.sendResponse(response) +} + +func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error { + jsonData, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("序列化数据失败: %v", err) + } + + var req struct { + DownloadURL string `json:"downloadUrl"` + ChecksumURL string `json:"checksumUrl"` + } + if err := json.Unmarshal(jsonData, &req); err != nil { + return fmt.Errorf("解析升级参数失败: %v", err) + } + if strings.TrimSpace(req.DownloadURL) == "" { + return fmt.Errorf("下载地址不能为空") + } + + // 替换架构占位符 + downloadURL := strings.ReplaceAll(req.DownloadURL, "{ARCH}", runtime.GOARCH) + checksumURL := strings.ReplaceAll(req.ChecksumURL, "{ARCH}", runtime.GOARCH) + + w.sendUpgradeProgress("downloading", 0, "开始下载升级包...") + fmt.Printf("📦 开始下载升级包: %s\n", downloadURL) + + // 下载新版本二进制 + const binaryPath = "/etc/flux_agent/flux_agent" + tmpPath := binaryPath + ".new" + backupPath := binaryPath + ".old" + + resp, err := http.Get(downloadURL) + if err != nil { + return fmt.Errorf("下载升级包失败: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("下载升级包失败, HTTP状态码: %d", resp.StatusCode) + } + + outFile, err := os.Create(tmpPath) + if err != nil { + return fmt.Errorf("创建临时文件失败: %v", err) + } + + // 带进度的下载 + totalSize := resp.ContentLength + var downloaded int64 + buf := make([]byte, 32*1024) + lastPercent := 0 + hasher := sha256.New() + + for { + n, readErr := resp.Body.Read(buf) + if n > 0 { + if _, wErr := outFile.Write(buf[:n]); wErr != nil { + outFile.Close() + os.Remove(tmpPath) + return fmt.Errorf("写入升级包失败: %v", wErr) + } + hasher.Write(buf[:n]) + downloaded += int64(n) + if totalSize > 0 { + percent := int(downloaded * 100 / totalSize) + if percent-lastPercent >= 10 { + lastPercent = percent + w.sendUpgradeProgress("downloading", percent, fmt.Sprintf("下载中... %d%%", percent)) + } + } + } + if readErr != nil { + if readErr == io.EOF { + break + } + outFile.Close() + os.Remove(tmpPath) + return fmt.Errorf("读取升级包失败: %v", readErr) + } + } + outFile.Close() + + if downloaded == 0 { + os.Remove(tmpPath) + return fmt.Errorf("下载的升级包为空") + } + + w.sendUpgradeProgress("downloading", 100, fmt.Sprintf("下载完成 (%d bytes)", downloaded)) + + // Checksum 校验 + if checksumURL != "" { + w.sendUpgradeProgress("verifying", 0, "校验文件完整性...") + checksumResp, err := http.Get(checksumURL) + if err == nil { + defer checksumResp.Body.Close() + if checksumResp.StatusCode == http.StatusOK { + checksumBody, err := io.ReadAll(checksumResp.Body) + if err == nil { + // 格式: " " 或 "" + expectedHash := strings.TrimSpace(strings.Split(string(checksumBody), " ")[0]) + actualHash := hex.EncodeToString(hasher.Sum(nil)) + if !strings.EqualFold(expectedHash, actualHash) { + os.Remove(tmpPath) + return fmt.Errorf("校验失败: 期望 %s, 实际 %s", expectedHash, actualHash) + } + fmt.Printf("✅ Checksum 校验通过: %s\n", actualHash) + } + } + } + w.sendUpgradeProgress("verifying", 100, "校验通过") + } + + if err := os.Chmod(tmpPath, 0755); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("设置执行权限失败: %v", err) + } + + // 备份旧版本 + w.sendUpgradeProgress("installing", 50, "备份旧版本...") + if _, err := os.Stat(binaryPath); err == nil { + // 复制旧文件作为备份(不用 rename,因为可能正在运行) + oldData, err := os.ReadFile(binaryPath) + if err == nil { + _ = os.WriteFile(backupPath, oldData, 0755) + fmt.Println("📦 旧版本已备份到", backupPath) + } + } + + w.sendUpgradeProgress("installing", 80, "准备重启...") + fmt.Printf("✅ 升级包下载完成 (%d bytes), 准备重启...\n", downloaded) + + // 执行重启脚本 + // 使用 systemd-run 在独立的 transient unit 中运行重启脚本, + // 避免 systemctl stop 杀死 flux_agent cgroup 内所有进程(包括此脚本自身)导致 mv 未执行。 + script := fmt.Sprintf("sleep 1 && systemctl stop flux_agent && mv %s %s && systemctl start flux_agent", tmpPath, binaryPath) + cmd := exec.Command("systemd-run", "--quiet", "/bin/sh", "-c", script) + if err := cmd.Start(); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("启动重启脚本失败: %v", err) + } + + w.sendUpgradeProgress("installing", 100, "重启中...") + fmt.Println("🔄 重启脚本已启动, Agent 将在 1 秒后重启...") + return nil +} + +func (w *WebSocketReporter) handleRollbackAgent(data interface{}) error { + const binaryPath = "/etc/flux_agent/flux_agent" + backupPath := binaryPath + ".old" + + // 检查备份文件是否存在 + if _, err := os.Stat(backupPath); os.IsNotExist(err) { + return fmt.Errorf("没有可用的备份文件,无法回退") + } + + fmt.Println("🔄 开始回退到旧版本...") + + // 执行回退脚本(同升级逻辑,使用 systemd-run 避免 cgroup 问题) + script := fmt.Sprintf("sleep 1 && systemctl stop flux_agent && cp %s %s && systemctl start flux_agent", backupPath, binaryPath) + cmd := exec.Command("systemd-run", "--quiet", "/bin/sh", "-c", script) + if err := cmd.Start(); err != nil { + return fmt.Errorf("启动回退脚本失败: %v", err) + } + + fmt.Println("🔄 回退脚本已启动, Agent 将在 1 秒后重启...") + return nil +} + // updateLocalConfigJSON 将 http/tls/socks 写入工作目录下的 config.json func updateLocalConfigJSON(httpVal int, tlsVal int, socksVal int) error { path := "config.json" diff --git a/panel_install.sh b/panel_install.sh index 4a76f76..ca70f48 100755 --- a/panel_install.sh +++ b/panel_install.sh @@ -203,7 +203,8 @@ show_menu() { echo "1. 安装面板" echo "2. 更新面板" echo "3. 卸载面板" - echo "4. 退出" + echo "4. 迁移到 PostgreSQL" + echo "5. 退出" echo "===============================================" } @@ -232,6 +233,92 @@ upsert_env_var() { mv "$tmp_file" "$file" } +get_env_var() { + local key="$1" + local file="${2:-.env}" + + if [[ ! -f "$file" ]]; then + return 0 + fi + + grep -m1 "^${key}=" "$file" | cut -d= -f2- +} + +get_current_db_type() { + local db_type database_url + + db_type=$(get_env_var "DB_TYPE") + database_url=$(get_env_var "DATABASE_URL") + + if [[ "$db_type" == "sqlite" ]]; then + echo "sqlite" + elif [[ "$db_type" == "postgres" || "$database_url" == postgres://* || "$database_url" == postgresql://* ]]; then + echo "postgres" + else + echo "sqlite" + fi +} + +wait_for_postgres_healthy() { + local pg_health + + echo "🔍 检查 PostgreSQL 服务状态..." + for i in {1..90}; do + if docker ps --format "{{.Names}}" | grep -q "^flux-panel-postgres$"; then + pg_health=$(docker inspect -f '{{.State.Health.Status}}' flux-panel-postgres 2>/dev/null || echo "unknown") + if [[ "$pg_health" == "healthy" ]]; then + echo "✅ PostgreSQL 服务健康检查通过" + return 0 + elif [[ "$pg_health" == "unhealthy" ]]; then + echo "⚠️ PostgreSQL 健康状态:$pg_health" + fi + else + pg_health="not_running" + fi + + if [ $i -eq 90 ]; then + echo "❌ PostgreSQL 启动超时(90秒)" + echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' flux-panel-postgres 2>/dev/null || echo '容器不存在')" + return 1 + fi + + if [ $((i % 15)) -eq 1 ]; then + echo "⏳ 等待 PostgreSQL 启动... ($i/90) 状态:${pg_health:-unknown}" + fi + sleep 1 + done +} + +wait_for_backend_healthy() { + local backend_health + + echo "🔍 检查后端服务状态..." + for i in {1..90}; do + if docker ps --format "{{.Names}}" | grep -q "^flux-panel-backend$"; then + backend_health=$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo "unknown") + if [[ "$backend_health" == "healthy" ]]; then + echo "✅ 后端服务健康检查通过" + return 0 + elif [[ "$backend_health" == "unhealthy" ]]; then + echo "⚠️ 后端健康状态:$backend_health" + fi + else + backend_health="not_running" + fi + + if [ $i -eq 90 ]; then + echo "❌ 后端服务启动超时(90秒)" + echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo '容器不存在')" + return 1 + fi + + if [ $((i % 15)) -eq 1 ]; then + echo "⏳ 等待后端服务启动... ($i/90) 状态:${backend_health:-unknown}" + fi + sleep 1 + done +} + # 删除脚本自身 delete_self() { echo "" @@ -253,6 +340,33 @@ get_config_params() { read -p "后端端口(默认 6365): " BACKEND_PORT BACKEND_PORT=${BACKEND_PORT:-6365} + echo "请选择数据库类型:" + echo "1. SQLite(默认)" + echo "2. PostgreSQL" + read -p "数据库类型(1/2,默认 1): " DB_CHOICE + case "$DB_CHOICE" in + 2) + DB_TYPE="postgres" + ;; + ""|1) + DB_TYPE="sqlite" + ;; + *) + echo "⚠️ 输入无效,默认使用 SQLite" + DB_TYPE="sqlite" + ;; + esac + + POSTGRES_DB="flux_panel" + POSTGRES_USER="flux_panel" + POSTGRES_PASSWORD=$(generate_random) + + if [[ "$DB_TYPE" == "postgres" ]]; then + DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable" + else + DATABASE_URL="" + fi + # 生成JWT密钥 JWT_SECRET=$(generate_random) } @@ -280,10 +394,23 @@ JWT_SECRET=$JWT_SECRET FRONTEND_PORT=$FRONTEND_PORT BACKEND_PORT=$BACKEND_PORT FLUX_VERSION=$RESOLVED_VERSION + +DB_TYPE=$DB_TYPE +DATABASE_URL=$DATABASE_URL + +POSTGRES_DB=$POSTGRES_DB +POSTGRES_USER=$POSTGRES_USER +POSTGRES_PASSWORD=$POSTGRES_PASSWORD EOF echo "🚀 启动 docker 服务..." - $DOCKER_CMD up -d + if [[ "$DB_TYPE" == "postgres" ]]; then + $DOCKER_CMD up -d postgres + wait_for_postgres_healthy + $DOCKER_CMD up -d backend frontend + else + $DOCKER_CMD up -d backend frontend + fi echo "🎉 部署完成" echo "🌐 访问地址: http://服务器IP:$FRONTEND_PORT" @@ -300,6 +427,12 @@ update_panel() { echo "🔄 开始更新面板..." check_docker + if [[ ! -f ".env" ]]; then + echo "⚠️ 未找到 .env,默认按 SQLite 模式更新" + fi + CURRENT_DB_TYPE=$(get_current_db_type) + echo "🗄️ 当前数据库类型:$CURRENT_DB_TYPE" + echo "🔍 获取最新版本号..." LATEST_VERSION=$(resolve_latest_release_tag) || { echo "❌ 无法获取最新版本号,更新终止" @@ -333,49 +466,113 @@ update_panel() { $DOCKER_CMD down echo "⬇️ 拉取最新镜像..." - $DOCKER_CMD pull + if [[ "$CURRENT_DB_TYPE" == "postgres" ]]; then + $DOCKER_CMD pull backend frontend postgres + else + $DOCKER_CMD pull backend frontend + fi echo "🚀 启动更新后的服务..." - $DOCKER_CMD up -d + if [[ "$CURRENT_DB_TYPE" == "postgres" ]]; then + $DOCKER_CMD up -d postgres + wait_for_postgres_healthy + $DOCKER_CMD up -d backend frontend + else + $DOCKER_CMD up -d backend frontend + fi # 等待服务启动 echo "⏳ 等待服务启动..." - # 检查后端容器健康状态 - echo "🔍 检查后端服务状态..." - for i in {1..90}; do - if docker ps --format "{{.Names}}" | grep -q "^flux-panel-backend$"; then - BACKEND_HEALTH=$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo "unknown") - if [[ "$BACKEND_HEALTH" == "healthy" ]]; then - echo "✅ 后端服务健康检查通过" - break - elif [[ "$BACKEND_HEALTH" == "starting" ]]; then - # 继续等待 - : - elif [[ "$BACKEND_HEALTH" == "unhealthy" ]]; then - echo "⚠️ 后端健康状态:$BACKEND_HEALTH" - fi - else - echo "⚠️ 后端容器未找到或未运行" - BACKEND_HEALTH="not_running" - fi - if [ $i -eq 90 ]; then - echo "❌ 后端服务启动超时(90秒)" - echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo '容器不存在')" - echo "🛑 更新终止" - return 1 - fi - # 每15秒显示一次进度 - if [ $((i % 15)) -eq 1 ]; then - echo "⏳ 等待后端服务启动... ($i/90) 状态:${BACKEND_HEALTH:-unknown}" - fi - sleep 1 - done + if ! wait_for_backend_healthy; then + echo "🛑 更新终止" + return 1 + fi echo "✅ 更新完成" } +migrate_to_postgres() { + local current_db_type postgres_db postgres_user postgres_password database_url + + echo "🔄 开始迁移 SQLite -> PostgreSQL..." + check_docker + + if [[ ! -f ".env" ]]; then + echo "❌ 未找到 .env 文件,请先安装面板" + return 1 + fi + + if [[ ! -f "docker-compose.yml" ]]; then + echo "⚠️ 未找到 docker-compose.yml 文件,正在下载..." + DOCKER_COMPOSE_URL=$(get_docker_compose_url) + echo "📡 选择配置文件:$(basename "$DOCKER_COMPOSE_URL")" + curl -L -o docker-compose.yml "$DOCKER_COMPOSE_URL" + echo "✅ docker-compose.yml 下载完成" + fi + + current_db_type=$(get_current_db_type) + if [[ "$current_db_type" == "postgres" ]]; then + echo "ℹ️ 当前已使用 PostgreSQL,无需迁移" + return 0 + fi + + postgres_db=$(get_env_var "POSTGRES_DB") + postgres_user=$(get_env_var "POSTGRES_USER") + postgres_password=$(get_env_var "POSTGRES_PASSWORD") + + postgres_db=${postgres_db:-flux_panel} + postgres_user=${postgres_user:-flux_panel} + postgres_password=${postgres_password:-$(generate_random)} + + upsert_env_var ".env" "POSTGRES_DB" "$postgres_db" + upsert_env_var ".env" "POSTGRES_USER" "$postgres_user" + upsert_env_var ".env" "POSTGRES_PASSWORD" "$postgres_password" + + echo "🛑 停止当前服务..." + docker stop -t 30 flux-panel-backend 2>/dev/null || true + docker stop -t 10 vite-frontend 2>/dev/null || true + echo "⏳ 等待数据同步..." + sleep 5 + $DOCKER_CMD down + + echo "💾 备份 SQLite 数据到当前目录..." + if ! docker run --rm -v sqlite_data:/data -v "$(pwd)":/backup alpine sh -c "cp /data/gost.db /backup/gost.db.bak"; then + echo "❌ SQLite 备份失败,迁移终止" + return 1 + fi + + echo "🚀 启动 PostgreSQL..." + $DOCKER_CMD up -d postgres + if ! wait_for_postgres_healthy; then + echo "🛑 PostgreSQL 未就绪,迁移终止" + return 1 + fi + + echo "🔄 执行 pgloader 迁移..." + if ! docker run --rm --network gost-network -v sqlite_data:/sqlite dimitri/pgloader:latest pgloader /sqlite/gost.db "postgresql://${postgres_user}:${postgres_password}@postgres:5432/${postgres_db}"; then + echo "❌ pgloader 迁移失败,迁移终止(如报 28P01,可执行 docker volume rm postgres_data 后重试)" + return 1 + fi + + database_url="postgresql://${postgres_user}:${postgres_password}@postgres:5432/${postgres_db}?sslmode=disable" + upsert_env_var ".env" "DB_TYPE" "postgres" + upsert_env_var ".env" "DATABASE_URL" "$database_url" + + echo "🚀 启动迁移后的服务..." + $DOCKER_CMD up -d postgres backend frontend + + echo "⏳ 等待服务启动..." + if ! wait_for_backend_healthy; then + echo "🛑 迁移后服务启动失败" + return 1 + fi + + echo "✅ SQLite -> PostgreSQL 迁移完成" +} + + # 卸载功能 uninstall_panel() { @@ -428,6 +625,11 @@ main() { exit 0 ;; 4) + migrate_to_postgres + delete_self + exit 0 + ;; + 5) echo "👋 退出脚本" delete_self exit 0 diff --git a/vite-frontend/nginx.conf b/vite-frontend/nginx.conf index e832412..207eb04 100644 --- a/vite-frontend/nginx.conf +++ b/vite-frontend/nginx.conf @@ -87,6 +87,8 @@ http { proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -94,4 +96,4 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } } -} \ No newline at end of file +} diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index f0b56d2..846283d 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -41,6 +41,14 @@ export const checkNodeStatus = (nodeId?: number) => { return Network.post("/node/check-status", params); }; +export const upgradeNode = (id: number, version?: string) => + Network.post("/node/upgrade", { id, version: version || "" }, { timeout: 5 * 60 * 1000 }); +export const batchUpgradeNodes = (ids: number[], version?: string) => + Network.post("/node/batch-upgrade", { ids, version: version || "" }, { timeout: 15 * 60 * 1000 }); +export const getNodeReleases = () => Network.post("/node/releases"); +export const rollbackNode = (id: number) => + Network.post("/node/rollback", { id }); + // 隧道CRUD操作 - 全部使用POST请求 export const createTunnel = (data: any) => Network.post("/tunnel/create", data); export const getTunnelList = () => Network.post("/tunnel/list"); @@ -200,6 +208,16 @@ export const createPeerShare = (data: { allowedDomains?: string; allowedIps?: string; }) => Network.post("/federation/share/create", data); +export const updatePeerShare = (data: { + id: number; + name: string; + maxBandwidth: number; + expiryTime: number; + portRangeStart: number; + portRangeEnd: number; + allowedDomains: string; + allowedIps: string; +}) => Network.post("/federation/share/update", data); export const deletePeerShare = (id: number) => Network.post("/federation/share/delete", { id }); export const resetPeerShareFlow = (id: number) => diff --git a/vite-frontend/src/api/network.ts b/vite-frontend/src/api/network.ts index 53486e9..d7af375 100644 --- a/vite-frontend/src/api/network.ts +++ b/vite-frontend/src/api/network.ts @@ -43,6 +43,10 @@ interface ApiResponse { data: T; } +interface RequestOptions { + timeout?: number; +} + // 处理token失效的逻辑 function handleTokenExpired() { // 清除localStorage中的token @@ -71,6 +75,7 @@ const Network = { get: function ( path: string = "", data: any = {}, + options: RequestOptions = {}, ): Promise> { return new Promise(function (resolve) { // 如果baseURL是默认值且是WebView环境,说明没有设置面板地址 @@ -83,7 +88,7 @@ const Network = { axios .get(path, { params: data, - timeout: 30000, + timeout: options.timeout ?? 30000, headers: { Authorization: window.localStorage.getItem("token"), }, @@ -117,6 +122,7 @@ const Network = { post: function ( path: string = "", data: any = {}, + options: RequestOptions = {}, ): Promise> { return new Promise(function (resolve) { // 如果baseURL是默认值且是WebView环境,说明没有设置面板地址 @@ -128,7 +134,7 @@ const Network = { axios .post(path, data, { - timeout: 30000, + timeout: options.timeout ?? 30000, headers: { Authorization: window.localStorage.getItem("token"), "Content-Type": "application/json", diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index 178c0b5..14b6f97 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -16,6 +16,7 @@ import { Spinner } from "@heroui/spinner"; import { Alert } from "@heroui/alert"; import { Progress } from "@heroui/progress"; import { Accordion, AccordionItem } from "@heroui/accordion"; +import { Select, SelectItem } from "@heroui/select"; import { Checkbox } from "@heroui/checkbox"; import toast from "react-hot-toast"; import axios from "axios"; @@ -45,6 +46,10 @@ import { getNodeInstallCommand, updateNodeOrder, batchDeleteNodes, + upgradeNode, + batchUpgradeNodes, + getNodeReleases, + rollbackNode, } from "@/api"; interface Node { @@ -65,6 +70,7 @@ interface Node { status: number; isRemote?: number; remoteUrl?: string; + syncError?: string; connectionStatus: "online" | "offline"; systemInfo?: { cpuUsage: number; @@ -76,6 +82,8 @@ interface Node { uptime: number; } | null; copyLoading?: boolean; + upgradeLoading?: boolean; + rollbackLoading?: boolean; } interface NodeForm { @@ -117,7 +125,7 @@ const SortableItem = ({ }; return ( -
+
{children(listeners)}
); @@ -164,6 +172,16 @@ export default function NodePage() { const [installCommand, setInstallCommand] = useState(""); const [currentNodeName, setCurrentNodeName] = useState(""); + // 升级相关状态 + const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); + const [upgradeTarget, setUpgradeTarget] = useState<"single" | "batch">("single"); + const [upgradeTargetNodeId, setUpgradeTargetNodeId] = useState(null); + const [releases, setReleases] = useState>([]); + const [releasesLoading, setReleasesLoading] = useState(false); + const [selectedVersion, setSelectedVersion] = useState(""); + const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false); + const [upgradeProgress, setUpgradeProgress] = useState>({}); + const websocketRef = useRef(null); const reconnectTimerRef = useRef(null); const reconnectAttemptsRef = useRef(0); @@ -219,7 +237,8 @@ export default function NodePage() { const nodesData: Node[] = (res.data || []).map((node: any) => ({ ...node, inx: node.inx ?? 0, - connectionStatus: node.status === 1 ? "online" : "offline", + connectionStatus: node.syncError ? "offline" : node.status === 1 ? "online" : "offline", + syncError: node.syncError || undefined, systemInfo: null, copyLoading: false, })); @@ -423,6 +442,22 @@ export default function NodePage() { return node; }), ); + } else if (type === "upgrade_progress") { + try { + const progressData = typeof messageData === "string" ? JSON.parse(messageData) : messageData; + if (progressData?.data) { + setUpgradeProgress((prev) => ({ + ...prev, + [nodeId]: { + stage: progressData.data.stage || "", + percent: progressData.data.percent || 0, + message: progressData.message || "", + }, + })); + } + } catch { + // ignore parse errors + } } }; @@ -768,6 +803,93 @@ export default function NodePage() { } }; + + + // 打开版本选择弹窗 + const openUpgradeModal = async (target: "single" | "batch", nodeId?: number) => { + setUpgradeTarget(target); + setUpgradeTargetNodeId(nodeId || null); + setSelectedVersion(""); + setUpgradeModalOpen(true); + setReleasesLoading(true); + try { + const res = await getNodeReleases(); + if (res.code === 0 && Array.isArray(res.data)) { + setReleases(res.data); + } else { + toast.error(res.msg || "获取版本列表失败"); + } + } catch { + toast.error("获取版本列表失败"); + } finally { + setReleasesLoading(false); + } + }; + + // 确认升级(从版本弹窗) + const handleConfirmUpgrade = async () => { + const version = selectedVersion || undefined; + if (upgradeTarget === "single" && upgradeTargetNodeId) { + setUpgradeModalOpen(false); + // Find the node + const node = nodeList.find((n) => n.id === upgradeTargetNodeId); + if (!node) return; + setNodeList((prev) => + prev.map((n) => (n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: true } : n)), + ); + try { + const res = await upgradeNode(upgradeTargetNodeId, version); + if (res.code === 0) { + toast.success(`节点升级命令已发送,节点将自动重启`); + } else { + toast.error(res.msg || "升级失败"); + } + } catch { + toast.error("网络错误,请重试"); + } finally { + setNodeList((prev) => + prev.map((n) => (n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: false } : n)), + ); + } + } else if (upgradeTarget === "batch") { + setBatchUpgradeLoading(true); + setUpgradeModalOpen(false); + try { + const res = await batchUpgradeNodes(Array.from(selectedIds), version); + if (res.code === 0) { + toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`); + } else { + toast.error(res.msg || "批量升级失败"); + } + } catch { + toast.error("网络错误,请重试"); + } finally { + setBatchUpgradeLoading(false); + } + } + }; + + // 回退节点 + const handleRollbackNode = async (node: Node) => { + setNodeList((prev) => + prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: true } : n)), + ); + try { + const res = await rollbackNode(node.id); + if (res.code === 0) { + toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`); + } else { + toast.error(res.msg || "回退失败"); + } + } catch { + toast.error("网络错误,请重试"); + } finally { + setNodeList((prev) => + prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: false } : n)), + ); + } + }; + // 提交表单 const handleSubmit = async () => { if (!validateForm()) return; @@ -1046,6 +1168,15 @@ export default function NodePage() { + + + +
+ )} +
{!isRemoteNode && ( - <> - - - + )} + + + + )} + + + {/* 批量删除确认模态框 */} { setLoading(true); try { @@ -239,6 +253,52 @@ export default function PanelSharingPage() { } }; + const openEditShare = (share: PeerShare) => { + setEditForm({ + id: share.id, + name: share.name, + maxBandwidth: share.maxBandwidth > 0 ? Math.round(share.maxBandwidth / (1024 * 1024 * 1024)) : 0, + expiryTime: share.expiryTime, + portRangeStart: share.portRangeStart, + portRangeEnd: share.portRangeEnd, + allowedDomains: share.allowedDomains || "", + allowedIps: share.allowedIps || "", + }); + setEditShareOpen(true); + }; + + const handleEditShare = async () => { + if (!editForm.name) { + toast.error("名称不能为空"); + return; + } + if (editForm.maxBandwidth < 0) { + toast.error("流量上限不能为负数"); + return; + } + try { + const res = await updatePeerShare({ + id: editForm.id, + name: editForm.name, + maxBandwidth: Math.max(0, editForm.maxBandwidth) * 1024 * 1024 * 1024, + expiryTime: editForm.expiryTime, + portRangeStart: editForm.portRangeStart, + portRangeEnd: editForm.portRangeEnd, + allowedDomains: editForm.allowedDomains, + allowedIps: editForm.allowedIps, + }); + if (res.code === 0) { + toast.success("编辑成功"); + setEditShareOpen(false); + loadShares(); + } else { + toast.error(res.msg || "编辑失败"); + } + } catch { + toast.error("网络错误"); + } + }; + const handleImportNode = async () => { if (!importForm.remoteUrl || !importForm.token) { toast.error("请填写完整信息"); @@ -326,6 +386,13 @@ export default function PanelSharingPage() {

{share.name}

+ + + + + + {/* Import Node Modal */} setImportNodeOpen(false)}>