mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-25 03:23:41 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
539e9e64c4 | ||
|
|
83bf5ec656 | ||
|
|
66373fa8e4 | ||
|
|
3a4c2edd9b | ||
|
|
a6dd8033ed |
63
.github/workflows/docs.yml
vendored
Normal file
63
.github/workflows/docs.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
name: Deploy Docs
|
||||
|
||||
# 触发条件:
|
||||
# - 推送 main 时,如果 docs-site/ 或站点相关 README 有变化
|
||||
# - 手动触发(在 Actions 页面)
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs-site/**'
|
||||
- '.github/workflows/docs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# 允许写入 Pages,用于发布到 github.com/Awuqing/BackupX 的 Pages 站点
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# 同时只保留一个部署任务
|
||||
concurrency:
|
||||
group: pages-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: docs-site
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: docs-site/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build site
|
||||
run: npm run build
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs-site/build
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
server/bin/
|
||||
server/bin/
|
||||
.claude/
|
||||
472
README.md
472
README.md
@@ -1,11 +1,11 @@
|
||||
<p align="right">
|
||||
<a href="README_EN.md">English</a> | <strong>中文</strong>
|
||||
<strong>English</strong> | <a href="README.zh-CN.md">中文</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<h1 align="center">BackupX</h1>
|
||||
<p align="center">
|
||||
<strong>自托管服务器备份管理平台</strong><br>
|
||||
一个二进制,一条命令,管好你所有服务器的备份。
|
||||
<strong>Self-hosted server backup management</strong><br>
|
||||
One binary, one command — manage every backup of every server.
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/Awuqing/BackupX/stargazers"><img src="https://img.shields.io/github/stars/Awuqing/BackupX?style=flat-square&color=f5c542" alt="Stars"></a>
|
||||
@@ -15,461 +15,79 @@
|
||||
<img src="https://img.shields.io/badge/SQLite-embedded-003B57?style=flat-square&logo=sqlite" alt="SQLite">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Awuqing/BackupX?style=flat-square" alt="License"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://awuqing.github.io/BackupX/"><strong>Docs</strong></a> ·
|
||||
<a href="https://github.com/Awuqing/BackupX/releases"><strong>Downloads</strong></a> ·
|
||||
<a href="https://hub.docker.com/r/awuqing/backupx"><strong>Docker Hub</strong></a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="screenshots/dashboard.png" alt="仪表盘"></td>
|
||||
<td width="50%"><img src="screenshots/backup-tasks.png" alt="备份任务"></td>
|
||||
<td width="50%"><img src="screenshots/dashboard.png" alt="Dashboard"></td>
|
||||
<td width="50%"><img src="screenshots/backup-tasks.png" alt="Backup Tasks"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="screenshots/storage-targets.png" alt="存储目标"></td>
|
||||
<td><img src="screenshots/backup-records.png" alt="备份记录"></td>
|
||||
<td><img src="screenshots/storage-targets.png" alt="Storage Targets"></td>
|
||||
<td><img src="screenshots/backup-records.png" alt="Backup Records"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 功能亮点
|
||||
## Highlights
|
||||
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| **备份类型** | 文件/目录(多源路径)、MySQL、PostgreSQL、SQLite、SAP HANA(完整/增量/差异/日志备份 + 并行通道 + 失败重试) |
|
||||
| **SAP HANA Backint 代理** | 内置 SAP HANA Backint 协议代理,HANA 原生备份接口可直接把数据路由到 BackupX 支持的任意存储后端 |
|
||||
| **70+ 存储后端** | 内置阿里云 OSS / 腾讯云 COS / 七牛云 / S3 / Google Drive / WebDAV / FTP + 通过 rclone 集成 SFTP、Azure Blob、Dropbox、OneDrive 等 70+ 后端 |
|
||||
| **自动调度** | Cron 定时 + 可视化编辑器 + 自动保留策略(按天数/份数清理,自动回收空目录) |
|
||||
| **多节点** | Master-Agent 集群,统一管理多台服务器的备份,支持远程目录浏览与节点编辑 |
|
||||
| **安全** | JWT + bcrypt + AES-256-GCM 加密配置 + 可选备份文件加密 + 完整审计日志 |
|
||||
| **通知** | 邮件 / Webhook / Telegram,备份成功或失败时自动推送 |
|
||||
| **部署** | 单二进制 + 内嵌 SQLite,Docker 一键启动,零外部依赖 |
|
||||
| Capability | Details |
|
||||
|-----------|---------|
|
||||
| **Backup Types** | Files/directories (multi-source), MySQL, PostgreSQL, SQLite, SAP HANA (full / incremental / differential / log + parallel channels + retry) |
|
||||
| **SAP HANA Backint Agent** | Built-in Backint protocol — HANA's native interface routes data directly to any BackupX storage backend |
|
||||
| **70+ Storage Backends** | Alibaba OSS, Tencent COS, Qiniu, S3, Google Drive, WebDAV, FTP + SFTP, Azure Blob, Dropbox, OneDrive and dozens more via rclone |
|
||||
| **Scheduling** | Cron + visual editor + auto-retention (by days/count + empty-directory cleanup) |
|
||||
| **Multi-Node Cluster** | Master-Agent mode via HTTP long-polling — Agents run tasks locally, upload straight to storage, no reverse connectivity required |
|
||||
| **Security** | JWT + bcrypt + AES-256-GCM encrypted config + optional backup encryption + full audit log |
|
||||
| **Notifications** | Email / Webhook / Telegram on success or failure |
|
||||
| **Deployment** | Single binary + embedded SQLite, Docker one-click, zero external dependencies |
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装
|
||||
|
||||
**Docker(推荐,无需克隆仓库):**
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 创建 docker-compose.yml 后一键启动
|
||||
docker compose up -d
|
||||
|
||||
# 或直接运行
|
||||
# Docker (recommended)
|
||||
docker run -d --name backupx -p 8340:8340 -v backupx-data:/app/data awuqing/backupx:latest
|
||||
|
||||
# Or prebuilt archive
|
||||
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-linux-amd64.tar.gz
|
||||
tar xzf backupx-*.tar.gz && cd backupx-* && sudo ./install.sh
|
||||
```
|
||||
|
||||
> Docker Hub 镜像:[`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx),支持 linux/amd64 和 linux/arm64。
|
||||
Open `http://your-server:8340`, create the admin account, then follow the [5-minute Quick Start](https://awuqing.github.io/BackupX/docs/getting-started/quick-start).
|
||||
|
||||
<details>
|
||||
<summary>docker-compose.yml 参考</summary>
|
||||
## Documentation
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backupx:
|
||||
image: awuqing/backupx:latest
|
||||
container_name: backupx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8340:8340"
|
||||
volumes:
|
||||
- backupx-data:/app/data
|
||||
# 挂载需要备份的宿主机目录(按需添加):
|
||||
# - /var/www:/mnt/www:ro
|
||||
# - /etc/nginx:/mnt/nginx-conf:ro
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
The full docs live at **https://awuqing.github.io/BackupX/** — Getting Started, Deployment, SAP HANA, Multi-Node Cluster, API reference, and more. Switch to Chinese via the language dropdown in the top-right nav.
|
||||
|
||||
volumes:
|
||||
backupx-data:
|
||||
```
|
||||
Quick links:
|
||||
|
||||
</details>
|
||||
- [Quick Start](https://awuqing.github.io/BackupX/docs/getting-started/quick-start) — first backup in five minutes
|
||||
- [Installation](https://awuqing.github.io/BackupX/docs/getting-started/installation) — Docker / bare metal / source
|
||||
- [Multi-Node Cluster](https://awuqing.github.io/BackupX/docs/features/multi-node) — deploy the Agent on remote servers
|
||||
- [SAP HANA Support](https://awuqing.github.io/BackupX/docs/features/sap-hana) — hdbsql Runner and native Backint
|
||||
- [API Reference](https://awuqing.github.io/BackupX/docs/reference/api) — REST endpoints
|
||||
|
||||
**预编译包(裸机部署):**
|
||||
|
||||
从 [Releases](https://github.com/Awuqing/BackupX/releases) 下载对应平台的压缩包:
|
||||
|
||||
```bash
|
||||
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
|
||||
sudo ./install.sh # 自动配置 systemd + Nginx
|
||||
```
|
||||
|
||||
**从源码构建:**
|
||||
## Development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
|
||||
make build # 构建前后端
|
||||
make docker-cn # 或用国内镜像构建 Docker(goproxy.cn / npmmirror / 阿里云 apk)
|
||||
make dev-server # Terminal 1 — backend (:8340)
|
||||
make dev-web # Terminal 2 — frontend (Vite HMR)
|
||||
make test # run all tests
|
||||
make build # produce server/bin/backupx + web/dist
|
||||
```
|
||||
|
||||
### 2. 打开控制台
|
||||
|
||||
浏览器访问 `http://your-server:8340`,首次打开会引导创建管理员账户。
|
||||
|
||||
### 3. 添加存储目标
|
||||
|
||||
进入 **存储目标** 页面,点击 **添加**,选择存储类型并填写凭证:
|
||||
|
||||
| 存储类型 | 需要填写 |
|
||||
|---------|---------|
|
||||
| 阿里云 OSS | Region + AccessKey ID/Secret + Bucket |
|
||||
| 腾讯云 COS | Region + SecretId/SecretKey + Bucket(格式 `name-appid`) |
|
||||
| 七牛云 Kodo | Region + AccessKey/SecretKey + Bucket |
|
||||
| S3 兼容 | Endpoint + AccessKey + Bucket |
|
||||
| Google Drive | Client ID/Secret → 点击授权完成 OAuth |
|
||||
| WebDAV | 服务器地址 + 用户名/密码 |
|
||||
| FTP | 主机 + 端口 + 用户名/密码 |
|
||||
| 本地磁盘 | 目标目录路径 |
|
||||
| SFTP / Azure / Dropbox / OneDrive 等 | 选择对应类型后填写必填项,高级配置可折叠展开 |
|
||||
|
||||
> 国内云厂商只需填 Region 和 AccessKey,系统自动组装 Endpoint。Rclone 类型的配置项按必填/可选分层展示,高级选项默认折叠。
|
||||
|
||||
添加后点击 **测试连接** 确认配置正确。
|
||||
|
||||
### 4. 创建备份任务
|
||||
|
||||
进入 **备份任务** 页面,点击 **新建**,三步完成:
|
||||
|
||||
1. **基础信息** — 任务名称、备份类型、Cron 表达式(留空则仅手动执行)
|
||||
2. **源配置** — 文件备份选择源路径(支持多个)、数据库备份填写连接信息
|
||||
3. **存储与策略** — 选择存储目标(支持多个)、压缩策略、保留天数、是否加密
|
||||
|
||||
保存后可以点击 **立即执行** 测试,在 **备份记录** 页面实时查看执行日志。
|
||||
|
||||
> 删除备份任务时会自动清理远端存储上的备份文件,但保留备份记录以供审计追溯。
|
||||
|
||||
### 5. 配置通知(可选)
|
||||
|
||||
进入 **通知配置** 页面,支持邮件、Webhook、Telegram 三种方式,可分别配置成功/失败时是否推送。
|
||||
|
||||
---
|
||||
|
||||
## 部署指南
|
||||
|
||||
### Docker 部署
|
||||
|
||||
```bash
|
||||
docker compose up -d # 使用上方的 docker-compose.yml
|
||||
```
|
||||
|
||||
备份宿主机目录时需要挂载路径(在 docker-compose.yml 的 `volumes` 中添加):
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- backupx-data:/app/data
|
||||
- /var/www:/mnt/www:ro # 挂载需要备份的目录
|
||||
- /etc/nginx:/mnt/nginx-conf:ro # 可以挂载多个
|
||||
```
|
||||
|
||||
通过环境变量调整配置:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- BACKUPX_LOG_LEVEL=debug
|
||||
- BACKUPX_BACKUP_MAX_CONCURRENT=4
|
||||
```
|
||||
|
||||
版本更新:在 **系统设置** 页面点击「检查更新」查看是否有新版本,然后手动执行 `docker compose pull && docker compose up -d` 完成升级。
|
||||
|
||||
### 裸机部署
|
||||
|
||||
```bash
|
||||
# 使用预编译包
|
||||
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
|
||||
sudo ./install.sh
|
||||
|
||||
# 或从源码
|
||||
make build
|
||||
sudo ./deploy/install.sh
|
||||
```
|
||||
|
||||
安装脚本自动完成:创建系统用户 → 安装二进制到 `/opt/backupx/` → 配置 systemd → 配置 Nginx 反向代理。
|
||||
|
||||
### Nginx 反向代理(裸机部署时)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name backup.example.com;
|
||||
|
||||
location / {
|
||||
root /opt/backupx/web;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8340;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 配置文件
|
||||
|
||||
配置文件路径 `./config.yaml`,也可通过 `BACKUPX_` 前缀环境变量覆盖:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
port: 8340
|
||||
database:
|
||||
path: "./data/backupx.db"
|
||||
security:
|
||||
jwt_secret: "" # 留空自动生成并持久化到数据库
|
||||
encryption_key: "" # 留空自动生成
|
||||
backup:
|
||||
temp_dir: "/tmp/backupx"
|
||||
max_concurrent: 2
|
||||
log:
|
||||
level: "info" # debug | info | warn | error
|
||||
file: "./data/backupx.log"
|
||||
```
|
||||
|
||||
### 密码重置
|
||||
|
||||
忘记管理员密码时通过 CLI 重置:
|
||||
|
||||
```bash
|
||||
# 裸机
|
||||
./backupx reset-password --username admin --password newpass123
|
||||
|
||||
# Docker
|
||||
docker exec -it backupx /app/bin/backupx reset-password --username admin --password newpass123
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SAP HANA 支持
|
||||
|
||||
BackupX 提供两种 SAP HANA 备份模式,按需选用:
|
||||
|
||||
### 模式一:hdbsql Runner(Web 控制台托管)
|
||||
|
||||
通过 Web 控制台创建 SAP HANA 备份任务,后端调用 `hdbsql` 执行备份,适合 BackupX 调度的周期性作业。
|
||||
|
||||
**源配置步骤支持:**
|
||||
|
||||
| 字段 | 可选值 | 说明 |
|
||||
|------|--------|------|
|
||||
| 备份类型 | `data` / `log` | 数据备份或日志备份 |
|
||||
| 备份级别 | `full` / `incremental` / `differential` | 日志备份时自动禁用 |
|
||||
| 并行通道数 | `1 ~ 32` | `BACKUP DATA USING FILE ('c1','c2',...)` 多路径并发 |
|
||||
| 失败重试次数 | `1 ~ 10` | 指数退避(5s × 尝试次数²) |
|
||||
| 实例编号 | 可选 | 从端口推断或手动指定 |
|
||||
|
||||
### 模式二:Backint 协议代理(HANA 原生接口)
|
||||
|
||||
BackupX 内置 Backint Agent,SAP HANA 通过原生 `BACKUP DATA USING BACKINT` 语法调用,数据自动路由到 BackupX 存储目标(S3 / OSS / COS / WebDAV / 70+ 后端)。
|
||||
|
||||
**1. 准备参数文件** `/opt/backupx/backint_params.ini`:
|
||||
|
||||
```ini
|
||||
#STORAGE_TYPE = s3
|
||||
#STORAGE_CONFIG_JSON = /opt/backupx/storage.json
|
||||
#PARALLEL_FACTOR = 4
|
||||
#COMPRESS = true
|
||||
#KEY_PREFIX = hana-backup
|
||||
#CATALOG_DB = /opt/backupx/backint_catalog.db
|
||||
#LOG_FILE = /var/log/backupx/backint.log
|
||||
```
|
||||
|
||||
**2. 准备存储配置** `/opt/backupx/storage.json`(与 BackupX 存储目标配置一致):
|
||||
|
||||
```json
|
||||
{
|
||||
"endpoint": "https://s3.amazonaws.com",
|
||||
"region": "us-east-1",
|
||||
"bucket": "hana-prod",
|
||||
"accessKeyId": "AKIA...",
|
||||
"secretAccessKey": "..."
|
||||
}
|
||||
```
|
||||
|
||||
**3. 创建 hdbbackint 软链接:**
|
||||
|
||||
```bash
|
||||
ln -s /opt/backupx/backupx /usr/sap/<SID>/SYS/global/hdb/opt/hdbbackint
|
||||
```
|
||||
|
||||
**4. 在 HANA `global.ini` 中启用:**
|
||||
|
||||
```ini
|
||||
[backup]
|
||||
data_backup_using_backint = true
|
||||
catalog_backup_using_backint = true
|
||||
log_backup_using_backint = true
|
||||
data_backup_parameter_file = /opt/backupx/backint_params.ini
|
||||
log_backup_parameter_file = /opt/backupx/backint_params.ini
|
||||
```
|
||||
|
||||
**5. CLI 手动调用(用于排查):**
|
||||
|
||||
```bash
|
||||
backupx backint -f backup -i input.txt -o output.txt -p backint_params.ini
|
||||
backupx backint -f restore -i input.txt -o output.txt -p backint_params.ini
|
||||
backupx backint -f inquire -i input.txt -o output.txt -p backint_params.ini
|
||||
backupx backint -f delete -i input.txt -o output.txt -p backint_params.ini
|
||||
```
|
||||
|
||||
Backint Agent 使用本地 SQLite 维护 `EBID ↔ 对象键` 目录,所有操作遵循 SAP HANA Backint 协议(`#PIPE` / `#SAVED` / `#RESTORED` / `#BACKUP` / `#NOTFOUND` / `#DELETED` / `#ERROR`)。
|
||||
|
||||
---
|
||||
|
||||
## 多节点集群
|
||||
|
||||
BackupX 支持 Master-Agent 模式管理多台服务器:备份任务可以指定在哪个节点执行,Agent 在本地完成备份并直接上传到存储后端。
|
||||
|
||||
### 架构概览
|
||||
|
||||
```
|
||||
[Web 控制台] ←── JWT ──→ [Master (backupx)]
|
||||
↑ ↓
|
||||
│ │ HTTP 长轮询 (token 认证)
|
||||
│ ↓
|
||||
[Agent (backupx agent)] ← 运行在远程服务器
|
||||
↓
|
||||
[70+ 存储后端]
|
||||
```
|
||||
|
||||
- **通信协议**:HTTP 长轮询,Agent 主动发起所有连接,无需 Master 反向访问
|
||||
- **心跳**:Agent 每 15s 上报一次;Master 每 15s 扫描,超过 45s 未心跳判为离线
|
||||
- **任务下发**:Master 通过数据库命令队列派发 `run_task`,Agent 轮询拉取
|
||||
- **执行**:Agent 本地复用 BackupRunner(file / mysql / postgresql / sqlite / saphana)并直接上传到存储
|
||||
- **安全**:每个节点独立 Token;Agent 不持有 Master 的 JWT 密钥和加密密钥
|
||||
|
||||
### 使用步骤
|
||||
|
||||
**1. 在 Master 创建节点并获取 Token**
|
||||
|
||||
Web 控制台 → **节点管理** → **添加节点**,填写节点名称并保存。界面会显示一个 64 字节十六进制令牌(仅显示一次,请妥善保存)。
|
||||
|
||||
**2. 在远程服务器部署 Agent**
|
||||
|
||||
把 BackupX 二进制上传到目标服务器(与 Master 同一个文件),然后用以下任一方式启动:
|
||||
|
||||
```bash
|
||||
# 方式 A:CLI 参数
|
||||
backupx agent --master http://master.example.com:8340 --token <token>
|
||||
|
||||
# 方式 B:配置文件
|
||||
cat > /etc/backupx/agent.yaml <<EOF
|
||||
master: http://master.example.com:8340
|
||||
token: <token>
|
||||
heartbeatInterval: 15s
|
||||
pollInterval: 5s
|
||||
tempDir: /var/lib/backupx-agent
|
||||
EOF
|
||||
backupx agent --config /etc/backupx/agent.yaml
|
||||
|
||||
# 方式 C:环境变量(适合 Docker / systemd)
|
||||
BACKUPX_AGENT_MASTER=http://master.example.com:8340 \
|
||||
BACKUPX_AGENT_TOKEN=<token> \
|
||||
backupx agent
|
||||
```
|
||||
|
||||
启动成功后,Master 的节点列表会把该节点标记为**在线**。
|
||||
|
||||
**3. 创建路由到该节点的备份任务**
|
||||
|
||||
在 **备份任务** 页面新建任务时选择对应节点。任务被触发后:
|
||||
|
||||
- 本机节点或未指定节点(`nodeId=0`):由 Master 进程本地执行
|
||||
- 远程节点:Master 写入命令队列 → Agent 轮询拉取 → 本地执行并上传 → 上报记录
|
||||
|
||||
### 限制说明
|
||||
|
||||
- **不支持加密备份**:Agent 不持有 Master 的 AES-256 加密密钥,启用 `encrypt: true` 的任务会路由到 Agent 时失败
|
||||
- **目录浏览超时**:远程目录浏览通过命令队列做同步 RPC,默认 15s 超时,网络慢时可能失败
|
||||
- **命令超时**:Agent 领取但未完成的命令超过 10min 会被标记为超时
|
||||
|
||||
### CLI 参考
|
||||
|
||||
```bash
|
||||
backupx agent --help
|
||||
-master string Master URL
|
||||
-token string Agent 认证令牌
|
||||
-config string YAML 配置文件路径(优先级高于环境变量)
|
||||
-temp-dir string 本地临时目录(默认 /tmp/backupx-agent)
|
||||
-insecure-tls 跳过 TLS 证书校验(仅测试用)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 开发指南
|
||||
|
||||
**环境要求:** Go >= 1.25 · Node.js >= 20 · npm
|
||||
|
||||
```bash
|
||||
# 开发模式
|
||||
make dev-server # 终端 1:后端(默认 :8340)
|
||||
make dev-web # 终端 2:前端(Vite HMR)
|
||||
|
||||
# 测试
|
||||
make test # 运行全部测试
|
||||
|
||||
# 构建
|
||||
make build # 前后端一起构建
|
||||
make docker # Docker 构建
|
||||
make docker-cn # 国内 Docker 构建(镜像加速)
|
||||
```
|
||||
|
||||
### 发版
|
||||
|
||||
```bash
|
||||
git tag v1.4.3 && git push --tags
|
||||
# GitHub Actions 自动:编译双架构二进制 → 发布 GitHub Release → 推送 Docker Hub 镜像
|
||||
```
|
||||
|
||||
也可在 GitHub Actions 页面手动触发 Release workflow。
|
||||
|
||||
---
|
||||
|
||||
## API 参考
|
||||
|
||||
所有接口以 `/api` 为前缀,使用 JWT Bearer Token 认证。
|
||||
|
||||
| 模块 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| **认证** | `POST /auth/setup` | 初始化管理员 |
|
||||
| | `POST /auth/login` | 登录 |
|
||||
| | `PUT /auth/password` | 修改密码 |
|
||||
| **备份任务** | `GET\|POST /backup/tasks` | 列表 / 创建 |
|
||||
| | `GET\|PUT\|DELETE /backup/tasks/:id` | 详情 / 更新 / 删除 |
|
||||
| | `PUT /backup/tasks/:id/toggle` | 启用/禁用 |
|
||||
| | `POST /backup/tasks/:id/run` | 手动执行 |
|
||||
| **备份记录** | `GET /backup/records` | 列表(支持筛选) |
|
||||
| | `GET /backup/records/:id/logs/stream` | 实时日志 (SSE) |
|
||||
| | `GET /backup/records/:id/download` | 下载 |
|
||||
| | `POST /backup/records/:id/restore` | 恢复 |
|
||||
| **存储目标** | `GET\|POST /storage-targets` | 列表 / 添加 |
|
||||
| | `POST /storage-targets/test` | 测试连接 |
|
||||
| | `GET /storage-targets/rclone/backends` | Rclone 后端列表 |
|
||||
| **节点** | `GET\|POST /nodes` | 列表 / 添加 |
|
||||
| | `PUT /nodes/:id` | 编辑节点 |
|
||||
| | `GET /nodes/:id/fs/list` | 目录浏览 |
|
||||
| | `POST /agent/heartbeat` | Agent 心跳(Token 认证) |
|
||||
| **通知** | `GET\|POST /notifications` | 列表 / 添加 |
|
||||
| **仪表盘** | `GET /dashboard/stats` | 概览统计 |
|
||||
| **审计日志** | `GET /audit-logs` | 操作审计 |
|
||||
| **系统** | `GET /system/info` | 系统信息 |
|
||||
| | `GET /system/update-check` | 检查版本更新 |
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 组件 | 技术 |
|
||||
|------|------|
|
||||
| **后端** | Go · Gin · GORM · SQLite · robfig/cron · rclone |
|
||||
| **前端** | React 18 · TypeScript · ArcoDesign · Vite · Zustand · ECharts |
|
||||
| **存储** | rclone(70+ 后端)· AWS SDK v2 · Google Drive API v3 |
|
||||
| **安全** | JWT · bcrypt · AES-256-GCM |
|
||||
See the [development guide](https://awuqing.github.io/BackupX/docs/development/setup) for more.
|
||||
|
||||
## Contributing
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
Issues and pull requests welcome. Please read the [contributing guide](https://awuqing.github.io/BackupX/docs/development/contributing) before opening a PR — commit messages and PRs on this project are written in Chinese.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
94
README.zh-CN.md
Normal file
94
README.zh-CN.md
Normal file
@@ -0,0 +1,94 @@
|
||||
<p align="right">
|
||||
<a href="README.md">English</a> | <strong>中文</strong>
|
||||
</p>
|
||||
<p align="center">
|
||||
<h1 align="center">BackupX</h1>
|
||||
<p align="center">
|
||||
<strong>自托管服务器备份管理平台</strong><br>
|
||||
一个二进制,一条命令,管好你所有服务器的备份。
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/Awuqing/BackupX/stargazers"><img src="https://img.shields.io/github/stars/Awuqing/BackupX?style=flat-square&color=f5c542" alt="Stars"></a>
|
||||
<a href="https://github.com/Awuqing/BackupX/releases"><img src="https://img.shields.io/github/v/release/Awuqing/BackupX?style=flat-square&color=brightgreen" alt="Release"></a>
|
||||
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go" alt="Go">
|
||||
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
|
||||
<img src="https://img.shields.io/badge/SQLite-embedded-003B57?style=flat-square&logo=sqlite" alt="SQLite">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Awuqing/BackupX?style=flat-square" alt="License"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://awuqing.github.io/BackupX/zh-Hans/"><strong>文档</strong></a> ·
|
||||
<a href="https://github.com/Awuqing/BackupX/releases"><strong>下载</strong></a> ·
|
||||
<a href="https://hub.docker.com/r/awuqing/backupx"><strong>Docker Hub</strong></a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="screenshots/dashboard.png" alt="仪表盘"></td>
|
||||
<td width="50%"><img src="screenshots/backup-tasks.png" alt="备份任务"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="screenshots/storage-targets.png" alt="存储目标"></td>
|
||||
<td><img src="screenshots/backup-records.png" alt="备份记录"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 功能亮点
|
||||
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| **备份类型** | 文件/目录(多源路径)、MySQL、PostgreSQL、SQLite、SAP HANA(完整/增量/差异/日志备份 + 并行通道 + 失败重试) |
|
||||
| **SAP HANA Backint 代理** | 内置 SAP HANA Backint 协议代理,HANA 原生备份接口可直接把数据路由到 BackupX 支持的任意存储后端 |
|
||||
| **70+ 存储后端** | 内置阿里云 OSS / 腾讯云 COS / 七牛云 / S3 / Google Drive / WebDAV / FTP + 通过 rclone 集成 SFTP、Azure Blob、Dropbox、OneDrive 等 70+ 后端 |
|
||||
| **自动调度** | Cron 定时 + 可视化编辑器 + 自动保留策略(按天数/份数清理,自动回收空目录) |
|
||||
| **多节点集群** | Master-Agent 模式,基于 HTTP 长轮询跨多台服务器管理备份。Agent 本地执行任务并直接上传到存储,无需反向连通性 |
|
||||
| **安全** | JWT + bcrypt + AES-256-GCM 加密配置 + 可选备份文件加密 + 完整审计日志 |
|
||||
| **通知** | 邮件 / Webhook / Telegram,备份成功或失败时自动推送 |
|
||||
| **部署** | 单二进制 + 内嵌 SQLite,Docker 一键启动,零外部依赖 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# Docker(推荐)
|
||||
docker run -d --name backupx -p 8340:8340 -v backupx-data:/app/data awuqing/backupx:latest
|
||||
|
||||
# 或使用预编译包
|
||||
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-linux-amd64.tar.gz
|
||||
tar xzf backupx-*.tar.gz && cd backupx-* && sudo ./install.sh
|
||||
```
|
||||
|
||||
打开 `http://your-server:8340`,创建管理员账户,按 [5 分钟快速开始](https://awuqing.github.io/BackupX/zh-Hans/docs/getting-started/quick-start) 完成首次备份。
|
||||
|
||||
## 文档
|
||||
|
||||
完整文档见 **https://awuqing.github.io/BackupX/zh-Hans/** — 快速开始、部署、SAP HANA、多节点集群、API 参考等。
|
||||
|
||||
快捷链接:
|
||||
|
||||
- [快速开始](https://awuqing.github.io/BackupX/zh-Hans/docs/getting-started/quick-start) — 五分钟跑通第一个备份
|
||||
- [安装](https://awuqing.github.io/BackupX/zh-Hans/docs/getting-started/installation) — Docker / 裸机 / 源码
|
||||
- [多节点集群](https://awuqing.github.io/BackupX/zh-Hans/docs/features/multi-node) — 远程服务器部署 Agent
|
||||
- [SAP HANA 支持](https://awuqing.github.io/BackupX/zh-Hans/docs/features/sap-hana) — hdbsql Runner 与原生 Backint
|
||||
- [API 参考](https://awuqing.github.io/BackupX/zh-Hans/docs/reference/api) — REST 端点
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
|
||||
make dev-server # 终端 1:后端(:8340)
|
||||
make dev-web # 终端 2:前端(Vite HMR)
|
||||
make test # 运行全部测试
|
||||
make build # 产出 server/bin/backupx + web/dist
|
||||
```
|
||||
|
||||
更多细节见 [开发指南](https://awuqing.github.io/BackupX/zh-Hans/docs/development/setup)。
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Issue 与 Pull Request。提交 PR 前请先阅读 [贡献指南](https://awuqing.github.io/BackupX/zh-Hans/docs/development/contributing) — 本项目的 commit message 和 PR 正文均使用中文。
|
||||
|
||||
## License
|
||||
|
||||
[Apache License 2.0](LICENSE)
|
||||
474
README_EN.md
474
README_EN.md
@@ -1,474 +0,0 @@
|
||||
<p align="right">
|
||||
<strong>English</strong> | <a href="README.md">中文</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<h1 align="center">BackupX</h1>
|
||||
<p align="center">
|
||||
<strong>Self-hosted Server Backup Management Platform</strong><br>
|
||||
One binary, one command — manage all your server backups.
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/Awuqing/BackupX/stargazers"><img src="https://img.shields.io/github/stars/Awuqing/BackupX?style=flat-square&color=f5c542" alt="Stars"></a>
|
||||
<a href="https://github.com/Awuqing/BackupX/releases"><img src="https://img.shields.io/github/v/release/Awuqing/BackupX?style=flat-square&color=brightgreen" alt="Release"></a>
|
||||
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go" alt="Go">
|
||||
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
|
||||
<img src="https://img.shields.io/badge/SQLite-embedded-003B57?style=flat-square&logo=sqlite" alt="SQLite">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Awuqing/BackupX?style=flat-square" alt="License"></a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="screenshots/dashboard.png" alt="Dashboard"></td>
|
||||
<td width="50%"><img src="screenshots/backup-tasks.png" alt="Backup Tasks"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="screenshots/storage-targets.png" alt="Storage Targets"></td>
|
||||
<td><img src="screenshots/backup-records.png" alt="Backup Records"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Highlights
|
||||
|
||||
| Capability | Details |
|
||||
|-----------|---------|
|
||||
| **Backup Types** | Files/Directories (multi-source), MySQL, PostgreSQL, SQLite, SAP HANA (full / incremental / differential / log backups + parallel channels + retry) |
|
||||
| **SAP HANA Backint Agent** | Built-in SAP HANA Backint protocol agent — HANA's native backup interface can route data directly to any storage backend supported by BackupX |
|
||||
| **70+ Storage Backends** | Built-in Alibaba OSS / Tencent COS / Qiniu / S3 / Google Drive / WebDAV / FTP + 70+ backends via rclone (SFTP, Azure Blob, Dropbox, OneDrive, etc.) |
|
||||
| **Scheduling** | Cron-based + visual editor + auto-retention policy (by days/count, auto empty directory cleanup) |
|
||||
| **Multi-Node** | Master-Agent cluster for managing backups across multiple servers with remote directory browsing and node editing |
|
||||
| **Security** | JWT + bcrypt + AES-256-GCM encrypted config + optional backup encryption + comprehensive audit logs |
|
||||
| **Notifications** | Email / Webhook / Telegram — push on success or failure |
|
||||
| **Deployment** | Single binary + embedded SQLite, Docker one-click, zero external dependencies |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install
|
||||
|
||||
**Docker (recommended, no clone needed):**
|
||||
|
||||
```bash
|
||||
# Create a docker-compose.yml then start
|
||||
docker compose up -d
|
||||
|
||||
# Or run directly
|
||||
docker run -d --name backupx -p 8340:8340 -v backupx-data:/app/data awuqing/backupx:latest
|
||||
```
|
||||
|
||||
> Docker Hub: [`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx) — supports linux/amd64 and linux/arm64.
|
||||
|
||||
<details>
|
||||
<summary>docker-compose.yml reference</summary>
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backupx:
|
||||
image: awuqing/backupx:latest
|
||||
container_name: backupx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8340:8340"
|
||||
volumes:
|
||||
- backupx-data:/app/data
|
||||
# Mount host directories to back up (add as needed):
|
||||
# - /var/www:/mnt/www:ro
|
||||
# - /etc/nginx:/mnt/nginx-conf:ro
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
|
||||
volumes:
|
||||
backupx-data:
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Pre-built binaries (bare metal):**
|
||||
|
||||
Download from [Releases](https://github.com/Awuqing/BackupX/releases):
|
||||
|
||||
```bash
|
||||
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
|
||||
sudo ./install.sh # Auto-configures systemd + Nginx
|
||||
```
|
||||
|
||||
**Build from source:**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
|
||||
make build # Build frontend + backend
|
||||
make docker-cn # Or Docker build with China mirrors (goproxy.cn / npmmirror / Aliyun apk)
|
||||
```
|
||||
|
||||
### 2. Open the Console
|
||||
|
||||
Visit `http://your-server:8340` in your browser. First-time access guides you through admin account creation.
|
||||
|
||||
### 3. Add a Storage Target
|
||||
|
||||
Go to **Storage Targets** → **Add**, choose a storage type and enter credentials:
|
||||
|
||||
| Storage Type | Required Fields |
|
||||
|-------------|----------------|
|
||||
| Alibaba Cloud OSS | Region + AccessKey ID/Secret + Bucket |
|
||||
| Tencent Cloud COS | Region + SecretId/SecretKey + Bucket (`name-appid`) |
|
||||
| Qiniu Cloud Kodo | Region + AccessKey/SecretKey + Bucket |
|
||||
| S3 Compatible | Endpoint + AccessKey + Bucket |
|
||||
| Google Drive | Client ID/Secret → click Authorize for OAuth |
|
||||
| WebDAV | Server URL + Username/Password |
|
||||
| FTP | Host + Port + Username/Password |
|
||||
| Local Disk | Target directory path |
|
||||
| SFTP / Azure / Dropbox / OneDrive etc. | Select the type, fill in required fields; advanced options are collapsible |
|
||||
|
||||
> For Chinese cloud providers, just enter Region and AccessKey — the system auto-assembles the Endpoint. Rclone-type configs separate required fields from optional advanced options (collapsed by default).
|
||||
|
||||
Click **Test Connection** to verify.
|
||||
|
||||
### 4. Create a Backup Task
|
||||
|
||||
Go to **Backup Tasks** → **Create**, complete 3 steps:
|
||||
|
||||
1. **Basic Info** — Task name, backup type, Cron expression (leave empty for manual-only)
|
||||
2. **Source Config** — File backup: select source paths (supports multiple); Database: enter connection info
|
||||
3. **Storage & Policy** — Select storage target(s) (supports multiple), compression, retention days, encryption toggle
|
||||
|
||||
Save, then click **Run Now** to test. View real-time logs in **Backup Records**.
|
||||
|
||||
> Deleting a backup task automatically cleans up remote storage files while preserving backup records for audit purposes.
|
||||
|
||||
### 5. Set Up Notifications (Optional)
|
||||
|
||||
Go to **Notifications** to configure Email, Webhook, or Telegram alerts for backup success/failure.
|
||||
|
||||
---
|
||||
|
||||
## Deployment Guide
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker compose up -d # Using the docker-compose.yml above
|
||||
```
|
||||
|
||||
Mount host directories for file backup (add to `volumes` in docker-compose.yml):
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- backupx-data:/app/data
|
||||
- /var/www:/mnt/www:ro
|
||||
- /etc/nginx:/mnt/nginx-conf:ro
|
||||
```
|
||||
|
||||
Override config via environment variables:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- BACKUPX_LOG_LEVEL=debug
|
||||
- BACKUPX_BACKUP_MAX_CONCURRENT=4
|
||||
```
|
||||
|
||||
To upgrade: go to **System Settings**, click "Check for Updates" to see if a new version is available, then run `docker compose pull && docker compose up -d`.
|
||||
|
||||
### Bare Metal
|
||||
|
||||
```bash
|
||||
# From pre-built package
|
||||
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
|
||||
sudo ./install.sh
|
||||
|
||||
# Or from source
|
||||
make build
|
||||
sudo ./deploy/install.sh
|
||||
```
|
||||
|
||||
The install script creates a system user, installs to `/opt/backupx/`, configures systemd, and sets up Nginx reverse proxy.
|
||||
|
||||
### Nginx Reverse Proxy (bare metal)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name backup.example.com;
|
||||
|
||||
location / {
|
||||
root /opt/backupx/web;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8340;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Config file: `./config.yaml` (or override with `BACKUPX_` prefixed env vars):
|
||||
|
||||
```yaml
|
||||
server:
|
||||
port: 8340
|
||||
database:
|
||||
path: "./data/backupx.db"
|
||||
security:
|
||||
jwt_secret: "" # Auto-generated and persisted to DB
|
||||
encryption_key: "" # Auto-generated
|
||||
backup:
|
||||
temp_dir: "/tmp/backupx"
|
||||
max_concurrent: 2
|
||||
log:
|
||||
level: "info" # debug | info | warn | error
|
||||
file: "./data/backupx.log"
|
||||
```
|
||||
|
||||
### Password Reset
|
||||
|
||||
```bash
|
||||
# Bare metal
|
||||
./backupx reset-password --username admin --password newpass123
|
||||
|
||||
# Docker
|
||||
docker exec -it backupx /app/bin/backupx reset-password --username admin --password newpass123
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SAP HANA Support
|
||||
|
||||
BackupX offers two SAP HANA backup modes — pick whichever fits:
|
||||
|
||||
### Mode 1: hdbsql Runner (Web-console managed)
|
||||
|
||||
Create a SAP HANA backup task in the Web console. The backend runs `hdbsql` to perform backups, suitable for BackupX-scheduled recurring jobs.
|
||||
|
||||
**Source configuration supports:**
|
||||
|
||||
| Field | Options | Description |
|
||||
|-------|---------|-------------|
|
||||
| Backup type | `data` / `log` | Data or log backup |
|
||||
| Backup level | `full` / `incremental` / `differential` | Auto-disabled for log backups |
|
||||
| Parallel channels | `1 ~ 32` | `BACKUP DATA USING FILE ('c1','c2',...)` parallel paths |
|
||||
| Retry count | `1 ~ 10` | Exponential backoff (5s × attempt²) |
|
||||
| Instance number | Optional | Inferred from port or manually specified |
|
||||
|
||||
### Mode 2: Backint Protocol Agent (HANA native)
|
||||
|
||||
BackupX ships a built-in Backint Agent. SAP HANA calls it via native `BACKUP DATA USING BACKINT` syntax, and data is routed automatically to BackupX storage targets (S3 / OSS / COS / WebDAV / 70+ backends).
|
||||
|
||||
**1. Prepare parameter file** `/opt/backupx/backint_params.ini`:
|
||||
|
||||
```ini
|
||||
#STORAGE_TYPE = s3
|
||||
#STORAGE_CONFIG_JSON = /opt/backupx/storage.json
|
||||
#PARALLEL_FACTOR = 4
|
||||
#COMPRESS = true
|
||||
#KEY_PREFIX = hana-backup
|
||||
#CATALOG_DB = /opt/backupx/backint_catalog.db
|
||||
#LOG_FILE = /var/log/backupx/backint.log
|
||||
```
|
||||
|
||||
**2. Prepare storage config** `/opt/backupx/storage.json` (same schema as BackupX storage targets):
|
||||
|
||||
```json
|
||||
{
|
||||
"endpoint": "https://s3.amazonaws.com",
|
||||
"region": "us-east-1",
|
||||
"bucket": "hana-prod",
|
||||
"accessKeyId": "AKIA...",
|
||||
"secretAccessKey": "..."
|
||||
}
|
||||
```
|
||||
|
||||
**3. Create the hdbbackint symlink:**
|
||||
|
||||
```bash
|
||||
ln -s /opt/backupx/backupx /usr/sap/<SID>/SYS/global/hdb/opt/hdbbackint
|
||||
```
|
||||
|
||||
**4. Enable in HANA `global.ini`:**
|
||||
|
||||
```ini
|
||||
[backup]
|
||||
data_backup_using_backint = true
|
||||
catalog_backup_using_backint = true
|
||||
log_backup_using_backint = true
|
||||
data_backup_parameter_file = /opt/backupx/backint_params.ini
|
||||
log_backup_parameter_file = /opt/backupx/backint_params.ini
|
||||
```
|
||||
|
||||
**5. Manual CLI invocation (for troubleshooting):**
|
||||
|
||||
```bash
|
||||
backupx backint -f backup -i input.txt -o output.txt -p backint_params.ini
|
||||
backupx backint -f restore -i input.txt -o output.txt -p backint_params.ini
|
||||
backupx backint -f inquire -i input.txt -o output.txt -p backint_params.ini
|
||||
backupx backint -f delete -i input.txt -o output.txt -p backint_params.ini
|
||||
```
|
||||
|
||||
The Backint Agent maintains an `EBID ↔ object-key` catalog in a local SQLite DB. All operations follow the SAP HANA Backint protocol (`#PIPE` / `#SAVED` / `#RESTORED` / `#BACKUP` / `#NOTFOUND` / `#DELETED` / `#ERROR`).
|
||||
|
||||
---
|
||||
|
||||
## Multi-Node Cluster
|
||||
|
||||
BackupX supports Master-Agent mode for managing multiple servers. Backup tasks can be routed to specific nodes — the Agent runs the backup locally and uploads straight to storage backends.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
[Web Console] ←── JWT ──→ [Master (backupx)]
|
||||
↑ ↓
|
||||
│ │ HTTP long-poll (token auth)
|
||||
│ ↓
|
||||
[Agent (backupx agent)] ← runs on remote host
|
||||
↓
|
||||
[70+ Storage Backends]
|
||||
```
|
||||
|
||||
- **Protocol**: HTTP long-polling; the Agent initiates all connections — Master never needs reverse access
|
||||
- **Heartbeat**: Agent reports every 15s; Master marks nodes offline after 45s of silence
|
||||
- **Dispatch**: Master persists `run_task` commands to a queue; Agent polls and claims them
|
||||
- **Execution**: Agent reuses the same BackupRunner (file / mysql / postgresql / sqlite / saphana) and uploads directly to storage
|
||||
- **Security**: Each node gets its own token; the Agent never holds the Master's JWT secret or encryption key
|
||||
|
||||
### Walkthrough
|
||||
|
||||
**1. Create a node on Master and copy the token**
|
||||
|
||||
Web Console → **Node Management** → **Add Node**. The dialog shows a 64-byte hex token once — keep it safe.
|
||||
|
||||
**2. Deploy the Agent on a remote host**
|
||||
|
||||
Upload the BackupX binary (same file as Master) to the target host, then start the Agent:
|
||||
|
||||
```bash
|
||||
# Option A: CLI flags
|
||||
backupx agent --master http://master.example.com:8340 --token <token>
|
||||
|
||||
# Option B: config file
|
||||
cat > /etc/backupx/agent.yaml <<EOF
|
||||
master: http://master.example.com:8340
|
||||
token: <token>
|
||||
heartbeatInterval: 15s
|
||||
pollInterval: 5s
|
||||
tempDir: /var/lib/backupx-agent
|
||||
EOF
|
||||
backupx agent --config /etc/backupx/agent.yaml
|
||||
|
||||
# Option C: environment variables (Docker / systemd-friendly)
|
||||
BACKUPX_AGENT_MASTER=http://master.example.com:8340 \
|
||||
BACKUPX_AGENT_TOKEN=<token> \
|
||||
backupx agent
|
||||
```
|
||||
|
||||
Once connected, the node appears as **online** in the list.
|
||||
|
||||
**3. Create a task routed to that node**
|
||||
|
||||
In the **Backup Tasks** page, pick the target node when creating the task. When triggered:
|
||||
|
||||
- Local / unassigned (`nodeId=0`) tasks run in-process on Master
|
||||
- Remote-node tasks are enqueued → Agent claims → Agent runs locally → uploads → reports back
|
||||
|
||||
### Limitations
|
||||
|
||||
- **No encrypted backups via Agent**: the Agent doesn't hold Master's AES-256 key. Tasks with `encrypt: true` will fail if routed to an Agent
|
||||
- **Directory browse timeout**: remote dir listing is a synchronous RPC through the queue; default 15s timeout
|
||||
- **Command timeout**: claimed-but-unfinished commands are marked timed out after 10 minutes
|
||||
|
||||
### CLI Reference
|
||||
|
||||
```bash
|
||||
backupx agent --help
|
||||
-master string Master URL
|
||||
-token string Agent auth token
|
||||
-config string YAML config path (takes precedence over env)
|
||||
-temp-dir string Local temp directory (default /tmp/backupx-agent)
|
||||
-insecure-tls Skip TLS verification (testing only)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
**Requirements:** Go >= 1.25 · Node.js >= 20 · npm
|
||||
|
||||
```bash
|
||||
# Dev mode
|
||||
make dev-server # Terminal 1: backend (:8340)
|
||||
make dev-web # Terminal 2: frontend (Vite HMR)
|
||||
|
||||
# Test
|
||||
make test # Run all tests
|
||||
|
||||
# Build
|
||||
make build # Build frontend + backend
|
||||
make docker # Docker build
|
||||
make docker-cn # Docker build with China mirrors
|
||||
```
|
||||
|
||||
### Release
|
||||
|
||||
```bash
|
||||
git tag v1.4.3 && git push --tags
|
||||
# GitHub Actions: compile dual-arch binaries → publish GitHub Release → push Docker Hub image
|
||||
```
|
||||
|
||||
Or manually trigger the Release workflow from GitHub Actions page.
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
All endpoints prefixed with `/api`, authenticated via JWT Bearer Token.
|
||||
|
||||
| Module | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| **Auth** | `POST /auth/setup` | Initialize admin |
|
||||
| | `POST /auth/login` | Login |
|
||||
| | `PUT /auth/password` | Change password |
|
||||
| **Backup Tasks** | `GET\|POST /backup/tasks` | List / Create |
|
||||
| | `GET\|PUT\|DELETE /backup/tasks/:id` | Detail / Update / Delete |
|
||||
| | `PUT /backup/tasks/:id/toggle` | Enable / Disable |
|
||||
| | `POST /backup/tasks/:id/run` | Manual run |
|
||||
| **Backup Records** | `GET /backup/records` | List (with filter) |
|
||||
| | `GET /backup/records/:id/logs/stream` | Real-time logs (SSE) |
|
||||
| | `GET /backup/records/:id/download` | Download |
|
||||
| | `POST /backup/records/:id/restore` | Restore |
|
||||
| **Storage Targets** | `GET\|POST /storage-targets` | List / Add |
|
||||
| | `POST /storage-targets/test` | Test connection |
|
||||
| | `GET /storage-targets/rclone/backends` | Rclone backend list |
|
||||
| **Nodes** | `GET\|POST /nodes` | List / Add |
|
||||
| | `PUT /nodes/:id` | Edit node |
|
||||
| | `GET /nodes/:id/fs/list` | Directory browser |
|
||||
| | `POST /agent/heartbeat` | Agent heartbeat (Token auth) |
|
||||
| **Notifications** | `GET\|POST /notifications` | List / Add |
|
||||
| **Dashboard** | `GET /dashboard/stats` | Overview stats |
|
||||
| **Audit Logs** | `GET /audit-logs` | Operation audit |
|
||||
| **System** | `GET /system/info` | System info |
|
||||
| | `GET /system/update-check` | Check for updates |
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|-----------|
|
||||
| **Backend** | Go · Gin · GORM · SQLite · robfig/cron · rclone |
|
||||
| **Frontend** | React 18 · TypeScript · ArcoDesign · Vite · Zustand · ECharts |
|
||||
| **Storage** | rclone (70+ backends) · AWS SDK v2 · Google Drive API v3 |
|
||||
| **Security** | JWT · bcrypt · AES-256-GCM |
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and Pull Requests are welcome!
|
||||
|
||||
## License
|
||||
|
||||
[Apache License 2.0](LICENSE)
|
||||
20
docs-site/.gitignore
vendored
Normal file
20
docs-site/.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# Dependencies
|
||||
/node_modules
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Generated files
|
||||
.docusaurus
|
||||
.cache-loader
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
41
docs-site/README.md
Normal file
41
docs-site/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Website
|
||||
|
||||
This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
yarn
|
||||
```
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
yarn start
|
||||
```
|
||||
|
||||
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
yarn build
|
||||
```
|
||||
|
||||
This command generates static content into the `build` directory and can be served using any static contents hosting service.
|
||||
|
||||
## Deployment
|
||||
|
||||
Using SSH:
|
||||
|
||||
```bash
|
||||
USE_SSH=true yarn deploy
|
||||
```
|
||||
|
||||
Not using SSH:
|
||||
|
||||
```bash
|
||||
GIT_USER=<Your GitHub username> yarn deploy
|
||||
```
|
||||
|
||||
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
|
||||
86
docs-site/docs/deployment/bare-metal.md
Normal file
86
docs-site/docs/deployment/bare-metal.md
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: Bare-metal Deployment
|
||||
description: systemd + Nginx deployment from the prebuilt release tarball or source.
|
||||
---
|
||||
|
||||
# Bare-metal Deployment
|
||||
|
||||
## From prebuilt release
|
||||
|
||||
```bash
|
||||
# Download the matching tarball
|
||||
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-v1.6.0-linux-amd64.tar.gz
|
||||
|
||||
# Extract and install
|
||||
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
The installer performs these steps automatically:
|
||||
|
||||
1. Creates a system user `backupx`
|
||||
2. Copies the binary to `/opt/backupx/`
|
||||
3. Generates a default `config.yaml` with safe JWT/encryption secrets
|
||||
4. Installs `backupx.service` (systemd), enabled at boot
|
||||
5. (Optional) installs an Nginx site file — see [Nginx Reverse Proxy](./nginx)
|
||||
|
||||
## From source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
|
||||
make build
|
||||
sudo ./deploy/install.sh
|
||||
```
|
||||
|
||||
`make build` compiles:
|
||||
|
||||
- `server/bin/backupx` (Go backend, no CGO)
|
||||
- `web/dist/` (React frontend, `npm run build`)
|
||||
|
||||
## systemd
|
||||
|
||||
The installed unit:
|
||||
|
||||
```ini title="/etc/systemd/system/backupx.service"
|
||||
[Unit]
|
||||
Description=BackupX backup management service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=backupx
|
||||
WorkingDirectory=/opt/backupx
|
||||
ExecStart=/opt/backupx/backupx --config /opt/backupx/config.yaml
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
LimitNOFILE=65536
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Typical operations:
|
||||
|
||||
```bash
|
||||
sudo systemctl status backupx
|
||||
sudo journalctl -u backupx -f # live logs
|
||||
sudo systemctl restart backupx
|
||||
```
|
||||
|
||||
## Password reset
|
||||
|
||||
If the admin password is lost:
|
||||
|
||||
```bash
|
||||
/opt/backupx/backupx reset-password \
|
||||
--username admin \
|
||||
--password 'newpass123' \
|
||||
--config /opt/backupx/config.yaml
|
||||
```
|
||||
|
||||
Docker equivalent:
|
||||
|
||||
```bash
|
||||
docker exec -it backupx /app/bin/backupx reset-password --username admin --password 'newpass123'
|
||||
```
|
||||
52
docs-site/docs/deployment/configuration.md
Normal file
52
docs-site/docs/deployment/configuration.md
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
title: Configuration Reference
|
||||
description: All server.yaml configuration keys with defaults and matching environment variables.
|
||||
---
|
||||
|
||||
# Configuration Reference
|
||||
|
||||
BackupX loads `./config.yaml` from the working directory by default. You can override the path with `--config`. Every key can also be set via a `BACKUPX_` prefixed environment variable.
|
||||
|
||||
## Full config reference
|
||||
|
||||
```yaml title="config.yaml"
|
||||
server:
|
||||
host: "0.0.0.0" # BACKUPX_SERVER_HOST
|
||||
port: 8340 # BACKUPX_SERVER_PORT
|
||||
mode: "release" # release | debug
|
||||
|
||||
database:
|
||||
path: "./data/backupx.db" # BACKUPX_DATABASE_PATH — embedded SQLite
|
||||
|
||||
security:
|
||||
jwt_secret: "" # BACKUPX_SECURITY_JWT_SECRET — auto-generated if empty
|
||||
jwt_expires_in: "24h"
|
||||
encryption_key: "" # AES-256-GCM key for storage config encryption
|
||||
|
||||
backup:
|
||||
temp_dir: "/tmp/backupx" # BACKUPX_BACKUP_TEMP_DIR
|
||||
max_concurrent: 2 # BACKUPX_BACKUP_MAX_CONCURRENT
|
||||
retries: 3 # Per-upload rclone low-level retries
|
||||
bandwidth_limit: "" # e.g. "10M" to cap transfers at 10 MB/s
|
||||
|
||||
log:
|
||||
level: "info" # debug | info | warn | error
|
||||
file: "./data/backupx.log"
|
||||
```
|
||||
|
||||
## Secret generation
|
||||
|
||||
If `jwt_secret` or `encryption_key` is empty on first start, BackupX generates a random value and persists it to the `system_configs` table. Keep a backup of `data/backupx.db` — losing it invalidates all existing encrypted storage configurations.
|
||||
|
||||
## Environment variables
|
||||
|
||||
The environment wins when both file and env are set. All dot-paths become underscores and uppercase:
|
||||
|
||||
| Config key | Env variable |
|
||||
|------------|--------------|
|
||||
| `server.port` | `BACKUPX_SERVER_PORT` |
|
||||
| `log.level` | `BACKUPX_LOG_LEVEL` |
|
||||
| `backup.max_concurrent` | `BACKUPX_BACKUP_MAX_CONCURRENT` |
|
||||
| `backup.temp_dir` | `BACKUPX_BACKUP_TEMP_DIR` |
|
||||
| `backup.bandwidth_limit` | `BACKUPX_BACKUP_BANDWIDTH_LIMIT` |
|
||||
68
docs-site/docs/deployment/docker.md
Normal file
68
docs-site/docs/deployment/docker.md
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: Docker Deployment
|
||||
description: Production-style Docker deployment with docker compose, mounted source directories, and environment overrides.
|
||||
---
|
||||
|
||||
# Docker Deployment
|
||||
|
||||
BackupX's official Docker image [`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx) supports multi-architecture (linux/amd64 + linux/arm64).
|
||||
|
||||
## Compose file
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
backupx:
|
||||
image: awuqing/backupx:latest
|
||||
container_name: backupx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8340:8340"
|
||||
volumes:
|
||||
- backupx-data:/app/data
|
||||
# Mount host directories you want to back up:
|
||||
- /var/www:/mnt/www:ro
|
||||
- /etc/nginx:/mnt/nginx-conf:ro
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- BACKUPX_LOG_LEVEL=info
|
||||
- BACKUPX_BACKUP_MAX_CONCURRENT=2
|
||||
|
||||
volumes:
|
||||
backupx-data:
|
||||
```
|
||||
|
||||
Start with:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Host-directory backup
|
||||
|
||||
To back up files from the host, mount them into the container. When creating a file-type task in the web UI, point the source path at the mount location (e.g. `/mnt/www`). Make sure the directory is visible inside the container.
|
||||
|
||||
## Environment variables
|
||||
|
||||
All configuration keys can be overridden with the `BACKUPX_` prefix:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- BACKUPX_SERVER_PORT=8340
|
||||
- BACKUPX_LOG_LEVEL=debug
|
||||
- BACKUPX_BACKUP_MAX_CONCURRENT=4
|
||||
- BACKUPX_BACKUP_TEMP_DIR=/tmp/backupx
|
||||
```
|
||||
|
||||
See the [Configuration](./configuration) page for the full list.
|
||||
|
||||
## Upgrades
|
||||
|
||||
Check **System Settings → Check Updates** in the UI to see if a new version is available, then on the host:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
No migrations needed — BackupX auto-migrates the SQLite schema on startup.
|
||||
53
docs-site/docs/deployment/nginx.md
Normal file
53
docs-site/docs/deployment/nginx.md
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: Nginx Reverse Proxy
|
||||
description: Expose BackupX behind Nginx with HTTPS and SSE-friendly buffering disabled.
|
||||
---
|
||||
|
||||
# Nginx Reverse Proxy
|
||||
|
||||
A minimal production-ready Nginx site for BackupX:
|
||||
|
||||
```nginx title="/etc/nginx/sites-available/backupx"
|
||||
server {
|
||||
listen 80;
|
||||
server_name backup.example.com;
|
||||
|
||||
# Static UI (served from /opt/backupx/web)
|
||||
location / {
|
||||
root /opt/backupx/web;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API reverse proxy
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8340;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Large uploads (restore flow)
|
||||
client_max_body_size 0;
|
||||
|
||||
# Live log stream uses SSE — buffering must be off
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## HTTPS with certbot
|
||||
|
||||
```bash
|
||||
sudo apt install certbot python3-certbot-nginx
|
||||
sudo certbot --nginx -d backup.example.com
|
||||
```
|
||||
|
||||
Certbot rewrites the config to listen on 443 with auto-renewal.
|
||||
|
||||
:::caution Agent needs a stable URL
|
||||
If Master is behind HTTPS, remote Agent deployments must use the public HTTPS URL for `--master`. Self-signed certs require `--insecure-tls` (testing only).
|
||||
:::
|
||||
41
docs-site/docs/development/contributing.md
Normal file
41
docs-site/docs/development/contributing.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: Contributing
|
||||
description: How to report issues, propose changes, and submit PRs.
|
||||
---
|
||||
|
||||
# Contributing
|
||||
|
||||
BackupX is open-source under Apache License 2.0. Issues and pull requests are welcome.
|
||||
|
||||
## Reporting bugs
|
||||
|
||||
Open an issue at [github.com/Awuqing/BackupX/issues](https://github.com/Awuqing/BackupX/issues). Please include:
|
||||
|
||||
- BackupX version (`backupx --version`)
|
||||
- Your deployment mode (Docker / bare metal / from source)
|
||||
- Relevant backup task type and storage backend
|
||||
- Steps to reproduce
|
||||
- Stdout / `backupx.log` excerpt for the window around the problem
|
||||
|
||||
## Proposing changes
|
||||
|
||||
For significant features or refactors, open an issue first to align on scope before investing in a PR.
|
||||
|
||||
## Pull requests
|
||||
|
||||
1. Fork and create a topic branch (e.g. `fix/windows-path-escape`)
|
||||
2. Run `make test` and make sure everything passes
|
||||
3. Keep changes focused — one concern per PR
|
||||
4. Write commit messages in Chinese following `类型: 简要描述` — examples:
|
||||
- `功能: 新增审计日志模块`
|
||||
- `修复: 目录浏览器无法进入子目录`
|
||||
- `重构: 简化存储目标解密逻辑`
|
||||
- Types: `功能` / `修复` / `重构` / `文档` / `构建` / `测试`
|
||||
5. PR title and body in Chinese too. Describe the why and how, not just the what.
|
||||
|
||||
## Coding guidelines
|
||||
|
||||
- **Go** — handle every error (no `_ = err`); use the existing logger (`zap`); no `fmt.Println` in production paths
|
||||
- **TypeScript** — strict mode, no implicit any, follow existing ESLint/Prettier configs
|
||||
- **Commit scope** — one logical change per commit; don't mix drive-by cleanups with feature work
|
||||
83
docs-site/docs/development/setup.md
Normal file
83
docs-site/docs/development/setup.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: Development Setup
|
||||
description: Get a BackupX dev environment running — backend, frontend, tests.
|
||||
---
|
||||
|
||||
# Development Setup
|
||||
|
||||
**Requirements:** Go ≥ 1.25, Node.js ≥ 20, npm.
|
||||
|
||||
## Clone & install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
|
||||
cd web && npm install && cd ..
|
||||
```
|
||||
|
||||
## Dev servers
|
||||
|
||||
Run the backend and the Vite dev server in two terminals:
|
||||
|
||||
```bash
|
||||
# Terminal 1: backend on :8340
|
||||
make dev-server
|
||||
|
||||
# Terminal 2: Vite with HMR on :5173
|
||||
make dev-web
|
||||
```
|
||||
|
||||
The Vite config proxies `/api` to `http://127.0.0.1:8340` so you can open the UI at `http://localhost:5173`.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
make test # runs Go + Web test suites
|
||||
make test-server # Go only
|
||||
make test-web # Vitest only
|
||||
```
|
||||
|
||||
## Production build
|
||||
|
||||
```bash
|
||||
make build # server/bin/backupx + web/dist
|
||||
make docker # Docker image
|
||||
make docker-cn # Docker image with mainland China mirrors
|
||||
```
|
||||
|
||||
## Tech stack
|
||||
|
||||
| Component | Stack |
|
||||
|-----------|-------|
|
||||
| **Backend** | Go · Gin · GORM · SQLite · robfig/cron · rclone |
|
||||
| **Frontend** | React 18 · TypeScript · ArcoDesign · Vite · Zustand · ECharts |
|
||||
| **Storage** | rclone (70+ backends) · AWS SDK v2 · Google Drive API v3 |
|
||||
| **Security** | JWT · bcrypt · AES-256-GCM |
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
BackupX/
|
||||
├── server/ # Go backend
|
||||
│ ├── cmd/backupx/ # Entry point + subcommands (agent, backint, reset-password)
|
||||
│ ├── internal/
|
||||
│ │ ├── agent/ # Agent CLI logic
|
||||
│ │ ├── app/ # Wiring (repositories → services → handlers)
|
||||
│ │ ├── backup/ # Backup runners (file / mysql / postgres / sqlite / saphana)
|
||||
│ │ ├── backint/ # SAP HANA Backint protocol
|
||||
│ │ ├── http/ # HTTP handlers + router
|
||||
│ │ ├── model/ # GORM models
|
||||
│ │ ├── repository/ # DB access
|
||||
│ │ ├── service/ # Business logic
|
||||
│ │ └── storage/ # Storage providers (rclone + direct SDKs)
|
||||
│ └── pkg/ # Generic utilities
|
||||
├── web/ # React frontend (Vite)
|
||||
│ └── src/
|
||||
│ ├── components/
|
||||
│ ├── pages/
|
||||
│ ├── services/
|
||||
│ └── types/
|
||||
├── docs-site/ # This documentation site (Docusaurus)
|
||||
├── deploy/ # install.sh, systemd unit, nginx config
|
||||
└── Makefile
|
||||
```
|
||||
42
docs-site/docs/features/backup-types.md
Normal file
42
docs-site/docs/features/backup-types.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: Backup Types
|
||||
description: File, MySQL, PostgreSQL, SQLite and SAP HANA — what they back up and what to configure.
|
||||
---
|
||||
|
||||
# Backup Types
|
||||
|
||||
BackupX supports five built-in backup types. Type determines which runner executes the job.
|
||||
|
||||
## File / Directory
|
||||
|
||||
Tars (and optionally gzips) one or more filesystem paths.
|
||||
|
||||
- **Source** accepts multiple paths — one per line in the UI
|
||||
- **Exclude patterns** accept gitignore-style globs
|
||||
- Supports following symlinks, preserving permissions
|
||||
- Output is a single `.tar` or `.tar.gz` artifact
|
||||
|
||||
## MySQL
|
||||
|
||||
Uses `mysqldump` under the hood. Requires `mysqldump` to be on `$PATH` of the host running the task (Master or Agent).
|
||||
|
||||
- **Host / port / user / password / database** — multi-database allowed (comma-separated)
|
||||
- Output: `.sql` or `.sql.gz`
|
||||
- Default flags: `--single-transaction --routines --triggers --events`
|
||||
|
||||
## PostgreSQL
|
||||
|
||||
Uses `pg_dump`. Same connection fields as MySQL plus database name.
|
||||
|
||||
## SQLite
|
||||
|
||||
Copies the database file directly (with a consistency snapshot). No external tool required.
|
||||
|
||||
## SAP HANA
|
||||
|
||||
Two modes are supported — see the dedicated [SAP HANA](./sap-hana) page.
|
||||
|
||||
## Deletion behavior
|
||||
|
||||
When a task is deleted, BackupX removes backup artifacts from every storage target but preserves backup records for audit. Task deletion also tears down the cron schedule entry.
|
||||
115
docs-site/docs/features/multi-node.md
Normal file
115
docs-site/docs/features/multi-node.md
Normal file
@@ -0,0 +1,115 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
title: Multi-Node Cluster
|
||||
description: Master-Agent mode — route backups to remote servers via HTTP long-polling.
|
||||
---
|
||||
|
||||
# Multi-Node Cluster
|
||||
|
||||
BackupX supports Master-Agent mode: backup tasks can be routed to specific nodes. The Agent runs the backup locally and uploads straight to storage. All connections are initiated by the Agent, so remote networks only need outbound HTTP access.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
[Web Console] ─── JWT ──→ [Master (backupx)]
|
||||
↑ ↓
|
||||
│ │ HTTP long-poll (token auth)
|
||||
│ ↓
|
||||
[Agent (backupx agent)] ← runs on remote host
|
||||
↓
|
||||
[70+ Storage Backends]
|
||||
```
|
||||
|
||||
- **Protocol** — HTTP long-polling; the Agent initiates every connection
|
||||
- **Heartbeat** — Agent reports every 15s; Master marks nodes offline after 45s of silence
|
||||
- **Dispatch** — Master persists `run_task` commands to a queue; Agent polls and claims them
|
||||
- **Execution** — Agent reuses the same BackupRunner (file / mysql / postgresql / sqlite / saphana) and uploads directly to storage
|
||||
- **Security** — Each node has its own token; the Agent never holds the Master's JWT secret or AES-256 key
|
||||
|
||||
## Walkthrough
|
||||
|
||||
### 1. Open the install wizard
|
||||
|
||||
In the Web Console → **Node Management** → **Add Node**. You'll see a three-step wizard.
|
||||
|
||||
- **Step 1 — Node info.** Give the node a name, or switch to batch mode and paste multiple names (one per line, max 50).
|
||||
- **Step 2 — Deploy options.** Pick install mode (`systemd` recommended, `docker`, or `foreground` for debugging), architecture (auto-detect by default), agent version (defaults to the master's version), TTL for the install link (5 min / 15 min / 1 h / 24 h), and download source (`github` direct, or the `ghproxy` mirror for mainland China).
|
||||
- **Step 3 — Copy the command.** A single `curl ... | sudo sh` line is shown with a live countdown. Click copy, paste into the target machine, and run with root privileges.
|
||||
|
||||
### 2. One-line install on the target host
|
||||
|
||||
Example (systemd mode):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh
|
||||
```
|
||||
|
||||
The script runs automatically and:
|
||||
|
||||
1. Detects OS and architecture (`uname -m`)
|
||||
2. Downloads the matching `backupx` binary from GitHub Release (or the ghproxy mirror)
|
||||
3. Installs to `/opt/backupx-agent` and creates a `backupx` system user
|
||||
4. Writes `/etc/systemd/system/backupx-agent.service` with the token baked into environment variables
|
||||
5. Runs `systemctl enable --now backupx-agent`
|
||||
6. Polls `/api/v1/agent/self` until the master confirms `status: online` (up to 30 s)
|
||||
|
||||
Reruns are idempotent — to upgrade or re-provision, simply generate a new install command and run it again. The one-time install link expires after its TTL or after first consumption, whichever is sooner.
|
||||
|
||||
### 3. Rotate agent tokens at any time
|
||||
|
||||
Go to the node's action menu (︙) → **Rotate Token**. The new token is shown once and the old token remains valid for 24 h, allowing rolling restarts without downtime. After 24 h, the old token is rejected.
|
||||
|
||||
### 4. Batch deployment
|
||||
|
||||
In Step 1 choose "Batch" and paste node names (one per line, max 50). Step 3 shows a table with one command per node plus a **Download .sh** button that bundles all commands into a shell script, convenient for SSH loops or Ansible tasks.
|
||||
|
||||
### 5. Route a task to the node
|
||||
|
||||
In the **Backup Tasks** page, pick the target node when creating the task. When the task runs:
|
||||
|
||||
- Local (`nodeId=0`) → Master executes in-process
|
||||
- Remote node → Master enqueues the command → Agent claims → Agent runs locally → uploads → reports back
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **Encrypted backups don't work via Agent** — the Agent doesn't hold Master's AES-256 key. Tasks with `encrypt: true` will fail if routed to an Agent
|
||||
- **Directory browser timeout** — remote dir listing is a synchronous RPC through the queue (15s default)
|
||||
- **Dispatched command timeout** — claimed-but-unfinished commands are marked `timeout` after 10 minutes
|
||||
|
||||
## CLI reference
|
||||
|
||||
```
|
||||
backupx agent --help
|
||||
-master string Master URL
|
||||
-token string Agent auth token
|
||||
-config string YAML config path (takes precedence over env)
|
||||
-temp-dir string Local temp directory (default /tmp/backupx-agent)
|
||||
-insecure-tls Skip TLS verification (testing only)
|
||||
```
|
||||
|
||||
## systemd unit
|
||||
|
||||
```ini title="/etc/systemd/system/backupx-agent.service"
|
||||
[Unit]
|
||||
Description=BackupX Agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=backupx
|
||||
Environment="BACKUPX_AGENT_MASTER=https://master.example.com"
|
||||
Environment="BACKUPX_AGENT_TOKEN=your-token"
|
||||
ExecStart=/opt/backupx/backupx agent
|
||||
Restart=on-failure
|
||||
RestartSec=10s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now backupx-agent
|
||||
sudo journalctl -u backupx-agent -f
|
||||
```
|
||||
49
docs-site/docs/features/notifications.md
Normal file
49
docs-site/docs/features/notifications.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
sidebar_position: 5
|
||||
title: Notifications
|
||||
description: Email, webhook, and Telegram notifications on backup success or failure.
|
||||
---
|
||||
|
||||
# Notifications
|
||||
|
||||
BackupX supports three notification channels. Configure per-channel rules for success-only, failure-only, or both.
|
||||
|
||||
## Email (SMTP)
|
||||
|
||||
| Field | Notes |
|
||||
|-------|-------|
|
||||
| SMTP host / port | e.g. `smtp.gmail.com:587` |
|
||||
| Username / password | App-specific password recommended |
|
||||
| From address | Used in `From:` header |
|
||||
| Recipients | Comma-separated list |
|
||||
| Use TLS / StartTLS | Match your SMTP provider |
|
||||
|
||||
## Webhook
|
||||
|
||||
Send a JSON POST to an arbitrary URL. Body shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "backup_result",
|
||||
"task": {"id": 1, "name": "web-files", "type": "file"},
|
||||
"record": {"id": 42, "status": "success", "fileSize": 1048576, "durationSeconds": 12},
|
||||
"error": ""
|
||||
}
|
||||
```
|
||||
|
||||
Useful for custom workflows: Slack incoming webhook, PagerDuty, your own API, etc.
|
||||
|
||||
## Telegram
|
||||
|
||||
| Field | Notes |
|
||||
|-------|-------|
|
||||
| Bot token | From [@BotFather](https://t.me/BotFather) |
|
||||
| Chat ID | Numeric — obtain via `/start` + bot's `getUpdates` |
|
||||
|
||||
## Event rules
|
||||
|
||||
Each notification configuration can be scoped to:
|
||||
|
||||
- **Success only** — quiet during normal runs, pings on first failure
|
||||
- **Failure only** — recommended for loud channels
|
||||
- **Both** — useful during initial setup to verify notifications flow
|
||||
79
docs-site/docs/features/sap-hana.md
Normal file
79
docs-site/docs/features/sap-hana.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: SAP HANA Support
|
||||
description: Two SAP HANA backup modes — managed hdbsql runner and native Backint protocol agent.
|
||||
---
|
||||
|
||||
# SAP HANA Support
|
||||
|
||||
BackupX provides two SAP HANA backup modes. Pick whichever fits your operations workflow.
|
||||
|
||||
## Mode 1: hdbsql Runner (console-managed)
|
||||
|
||||
Create a SAP HANA backup task in the Web console. The backend invokes `hdbsql` to execute the backup. Use this when BackupX should own the schedule.
|
||||
|
||||
**Source configuration supports:**
|
||||
|
||||
| Field | Options | Description |
|
||||
|-------|---------|-------------|
|
||||
| Backup type | `data` / `log` | Data or log backup |
|
||||
| Backup level | `full` / `incremental` / `differential` | Auto-disabled for log backups |
|
||||
| Parallel channels | `1 ~ 32` | Multi-path SQL (`BACKUP DATA USING FILE ('c1', 'c2', ...)`) |
|
||||
| Retry count | `1 ~ 10` | Exponential backoff (`5s × attempt²`) |
|
||||
| Instance number | Optional | Inferred from port or specified manually |
|
||||
|
||||
## Mode 2: Backint Protocol Agent (HANA native)
|
||||
|
||||
BackupX ships a built-in Backint Agent. SAP HANA calls it via the native `BACKUP DATA USING BACKINT` syntax, and data is routed automatically to any BackupX storage target (S3 / OSS / COS / WebDAV / 70+ backends).
|
||||
|
||||
### 1. Parameter file
|
||||
|
||||
```ini title="/opt/backupx/backint_params.ini"
|
||||
#STORAGE_TYPE = s3
|
||||
#STORAGE_CONFIG_JSON = /opt/backupx/storage.json
|
||||
#PARALLEL_FACTOR = 4
|
||||
#COMPRESS = true
|
||||
#KEY_PREFIX = hana-backup
|
||||
#CATALOG_DB = /opt/backupx/backint_catalog.db
|
||||
#LOG_FILE = /var/log/backupx/backint.log
|
||||
```
|
||||
|
||||
### 2. Storage config (same schema as storage targets)
|
||||
|
||||
```json title="/opt/backupx/storage.json"
|
||||
{
|
||||
"endpoint": "https://s3.amazonaws.com",
|
||||
"region": "us-east-1",
|
||||
"bucket": "hana-prod",
|
||||
"accessKeyId": "AKIA...",
|
||||
"secretAccessKey": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Create the hdbbackint symlink
|
||||
|
||||
```bash
|
||||
ln -s /opt/backupx/backupx /usr/sap/<SID>/SYS/global/hdb/opt/hdbbackint
|
||||
```
|
||||
|
||||
### 4. Enable Backint in HANA `global.ini`
|
||||
|
||||
```ini
|
||||
[backup]
|
||||
data_backup_using_backint = true
|
||||
catalog_backup_using_backint = true
|
||||
log_backup_using_backint = true
|
||||
data_backup_parameter_file = /opt/backupx/backint_params.ini
|
||||
log_backup_parameter_file = /opt/backupx/backint_params.ini
|
||||
```
|
||||
|
||||
### 5. Manual CLI invocation (troubleshooting)
|
||||
|
||||
```bash
|
||||
backupx backint -f backup -i input.txt -o output.txt -p backint_params.ini
|
||||
backupx backint -f restore -i input.txt -o output.txt -p backint_params.ini
|
||||
backupx backint -f inquire -i input.txt -o output.txt -p backint_params.ini
|
||||
backupx backint -f delete -i input.txt -o output.txt -p backint_params.ini
|
||||
```
|
||||
|
||||
The Backint Agent maintains an `EBID ↔ object-key` catalog in a local SQLite DB. All operations follow the SAP HANA Backint protocol (`#PIPE` / `#SAVED` / `#RESTORED` / `#BACKUP` / `#NOTFOUND` / `#DELETED` / `#ERROR`).
|
||||
38
docs-site/docs/features/storage-backends.md
Normal file
38
docs-site/docs/features/storage-backends.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: Storage Backends
|
||||
description: 70+ storage backends — built-in cloud providers plus any rclone backend.
|
||||
---
|
||||
|
||||
# Storage Backends
|
||||
|
||||
BackupX aims to accept any place you'd want to drop a backup file.
|
||||
|
||||
## Built-in providers
|
||||
|
||||
| Type | Required fields |
|
||||
|------|-----------------|
|
||||
| **Alibaba OSS** | Region + AccessKey ID/Secret + Bucket (endpoint auto-assembled) |
|
||||
| **Tencent COS** | Region + SecretId/SecretKey + Bucket (format `name-appid`) |
|
||||
| **Qiniu Kodo** | Region + AccessKey/SecretKey + Bucket |
|
||||
| **S3-compatible** | Endpoint + AccessKey + Bucket |
|
||||
| **Google Drive** | Client ID/Secret + OAuth authorization |
|
||||
| **WebDAV** | URL + username/password |
|
||||
| **FTP / FTPS** | Host + port + username/password |
|
||||
| **Local disk** | Target directory (absolute path) |
|
||||
|
||||
## Rclone backends
|
||||
|
||||
Every [rclone backend](https://rclone.org/overview/) is exposed as a first-class storage type — SFTP, Azure Blob, Dropbox, OneDrive, Backblaze B2, Wasabi, pCloud, HDFS, and many more.
|
||||
|
||||
- The form groups fields into **required** and **advanced** (advanced collapsed by default)
|
||||
- Validation and connection tests reuse rclone's built-in probe
|
||||
|
||||
## Multiple targets per task
|
||||
|
||||
A backup task can fan out to multiple targets in parallel. All targets receive the same artifact; a per-target status is recorded:
|
||||
|
||||
- Success: storage path + size
|
||||
- Failed: error message
|
||||
|
||||
If any target fails after retries, the record status is `failed` but successful targets are preserved (no rollback).
|
||||
82
docs-site/docs/getting-started/installation.md
Normal file
82
docs-site/docs/getting-started/installation.md
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: Installation
|
||||
description: Install BackupX via Docker, prebuilt archive, or from source.
|
||||
---
|
||||
|
||||
# Installation
|
||||
|
||||
BackupX ships as a single static binary. Three ways to install, pick the one that matches your environment.
|
||||
|
||||
## Docker (recommended)
|
||||
|
||||
No cloning required.
|
||||
|
||||
```bash
|
||||
docker run -d --name backupx \
|
||||
-p 8340:8340 \
|
||||
-v backupx-data:/app/data \
|
||||
awuqing/backupx:latest
|
||||
```
|
||||
|
||||
Or use `docker compose`:
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
backupx:
|
||||
image: awuqing/backupx:latest
|
||||
container_name: backupx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8340:8340"
|
||||
volumes:
|
||||
- backupx-data:/app/data
|
||||
# Mount host directories to back up (as needed):
|
||||
# - /var/www:/mnt/www:ro
|
||||
# - /etc/nginx:/mnt/nginx-conf:ro
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
|
||||
volumes:
|
||||
backupx-data:
|
||||
```
|
||||
|
||||
Images: [`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx) — supports `linux/amd64` and `linux/arm64`.
|
||||
|
||||
## Prebuilt archive (bare metal)
|
||||
|
||||
Download from the [Releases page](https://github.com/Awuqing/BackupX/releases) and run the installer:
|
||||
|
||||
```bash
|
||||
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
|
||||
sudo ./install.sh # creates system user, installs to /opt/backupx, sets up systemd + nginx
|
||||
```
|
||||
|
||||
The installer:
|
||||
|
||||
1. Creates a `backupx` system user
|
||||
2. Installs binary to `/opt/backupx/backupx`
|
||||
3. Creates `/opt/backupx/config.yaml` with safe defaults
|
||||
4. Installs and enables the `backupx.service` systemd unit
|
||||
5. (Optional) Configures an Nginx reverse proxy
|
||||
|
||||
## From source
|
||||
|
||||
Requires Go ≥ 1.25 and Node.js ≥ 20.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
|
||||
make build
|
||||
# or, for builds behind the great firewall
|
||||
make docker-cn
|
||||
```
|
||||
|
||||
After `make build`, the binary is at `server/bin/backupx` and the built web UI is at `web/dist/`.
|
||||
|
||||
## Verify the install
|
||||
|
||||
```bash
|
||||
backupx --version # e.g. v1.6.0
|
||||
```
|
||||
|
||||
Then open `http://your-server:8340` to see the initial admin setup screen.
|
||||
59
docs-site/docs/getting-started/quick-start.md
Normal file
59
docs-site/docs/getting-started/quick-start.md
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: Quick Start
|
||||
description: Set up BackupX, add a storage target, create your first backup task.
|
||||
---
|
||||
|
||||
# Quick Start
|
||||
|
||||
After [installation](./installation), get a first backup running in five minutes.
|
||||
|
||||
## 1. Open the console
|
||||
|
||||
Browse to `http://your-server:8340`. The first time, you'll be guided through creating an admin account.
|
||||
|
||||
## 2. Add a storage target
|
||||
|
||||
Navigate to **Storage Targets → Add**. Pick a type and fill the required fields:
|
||||
|
||||
| Type | Fields |
|
||||
|------|--------|
|
||||
| Alibaba OSS | Region + AccessKey ID/Secret + Bucket |
|
||||
| Tencent COS | Region + SecretId/SecretKey + Bucket (format `name-appid`) |
|
||||
| Qiniu Kodo | Region + AccessKey/SecretKey + Bucket |
|
||||
| S3-compatible | Endpoint + AccessKey + Bucket |
|
||||
| Google Drive | Client ID/Secret → click "Authorize" for OAuth flow |
|
||||
| WebDAV | URL + username/password |
|
||||
| FTP | Host + port + username/password |
|
||||
| Local disk | Target directory |
|
||||
| SFTP / Azure / Dropbox / OneDrive | Type-specific required fields; advanced options collapsed |
|
||||
|
||||
:::tip
|
||||
For mainland China cloud vendors you only fill Region and AccessKey — BackupX assembles the endpoint automatically. Rclone-style providers separate required fields from advanced ones, with advanced collapsed by default.
|
||||
:::
|
||||
|
||||
Click **Test Connection** to verify.
|
||||
|
||||
## 3. Create a backup task
|
||||
|
||||
Go to **Backup Tasks → New**. Three steps:
|
||||
|
||||
1. **Basic info** — name, type, cron expression (leave empty for manual-only)
|
||||
2. **Source** — paths for file backup (multi-source supported), or connection info for databases
|
||||
3. **Storage & policy** — pick target(s), compression, retention days, encryption on/off
|
||||
|
||||
Save, then click **Run Now** to trigger a test. Live logs stream on the **Backup Records** page.
|
||||
|
||||
:::note
|
||||
Deleting a task also removes remote backup files to prevent orphans, but records are kept for audit.
|
||||
:::
|
||||
|
||||
## 4. Configure notifications (optional)
|
||||
|
||||
**Notifications** page supports email, webhook, and Telegram. Configure per-channel rules for success/failure events.
|
||||
|
||||
## Next up
|
||||
|
||||
- Explore [backup types](/docs/features/backup-types) and [storage backends](/docs/features/storage-backends)
|
||||
- Running SAP HANA? See [SAP HANA Support](/docs/features/sap-hana)
|
||||
- Managing many servers? See [Multi-Node Cluster](/docs/features/multi-node)
|
||||
40
docs-site/docs/intro.md
Normal file
40
docs-site/docs/intro.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: intro
|
||||
slug: /intro
|
||||
sidebar_position: 1
|
||||
title: Introduction
|
||||
description: Overview of BackupX — a self-hosted server backup management platform.
|
||||
---
|
||||
|
||||
# BackupX
|
||||
|
||||
**BackupX** is a self-hosted server backup management platform. One static binary, one command, and every backup job for every server is under control.
|
||||
|
||||
- **Single binary + embedded SQLite** — no external database or orchestrator required
|
||||
- **Files, databases, SAP HANA** — in one place, with a visual scheduler
|
||||
- **70+ storage backends** — Alibaba OSS, Tencent COS, Qiniu, S3, Google Drive, WebDAV, FTP, plus SFTP / Azure Blob / Dropbox / OneDrive and dozens more via rclone
|
||||
- **Multi-node cluster** — Master-Agent mode manages backups across servers, agents run tasks locally and upload straight to storage
|
||||
- **Secure by default** — JWT auth, bcrypt, AES-256-GCM encrypted config, optional backup encryption, full audit log
|
||||
|
||||
## Architecture at a Glance
|
||||
|
||||
```
|
||||
[Web Console] ─── JWT ──→ [Master (backupx)]
|
||||
│
|
||||
│ HTTP long-poll (token auth)
|
||||
▼
|
||||
[Agent (backupx agent)]
|
||||
│
|
||||
▼
|
||||
[70+ Storage Backends]
|
||||
```
|
||||
|
||||
Tasks routed to the local Master run in-process; tasks assigned to remote nodes are dispatched through a command queue and executed by the Agent locally. Agents only ever initiate outbound HTTP — no reverse connectivity required.
|
||||
|
||||
## Where to Next
|
||||
|
||||
- **New to BackupX?** Read the [Quick Start](/docs/getting-started/quick-start) first.
|
||||
- **Deploying to production?** See the [Deployment Guide](/docs/deployment/docker).
|
||||
- **SAP HANA operator?** Both `hdbsql` Runner and native Backint are supported — see [SAP HANA](/docs/features/sap-hana).
|
||||
- **Managing multiple servers?** See [Multi-Node Cluster](/docs/features/multi-node).
|
||||
- **Integrating programmatically?** See the [API Reference](/docs/reference/api).
|
||||
135
docs-site/docs/reference/api.md
Normal file
135
docs-site/docs/reference/api.md
Normal file
@@ -0,0 +1,135 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: API Reference
|
||||
description: REST API endpoints — all under /api with JWT Bearer authentication.
|
||||
---
|
||||
|
||||
# API Reference
|
||||
|
||||
All endpoints are prefixed with `/api` and authenticated with a JWT Bearer token, obtained via `POST /api/auth/login`. Agent endpoints use `X-Agent-Token` instead.
|
||||
|
||||
## Authentication
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/auth/setup/status` | Check whether admin initialization is needed |
|
||||
| `POST` | `/api/auth/setup` | Initialize the first admin (only when no user exists) |
|
||||
| `POST` | `/api/auth/login` | Log in and receive a JWT |
|
||||
| `POST` | `/api/auth/logout` | Log out (invalidate current token) |
|
||||
| `GET` | `/api/auth/profile` | Current user profile |
|
||||
| `PUT` | `/api/auth/password` | Change password |
|
||||
|
||||
## Backup Tasks
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/backup/tasks` | List tasks |
|
||||
| `POST` | `/api/backup/tasks` | Create |
|
||||
| `GET` | `/api/backup/tasks/:id` | Detail |
|
||||
| `PUT` | `/api/backup/tasks/:id` | Update |
|
||||
| `DELETE` | `/api/backup/tasks/:id` | Delete |
|
||||
| `PUT` | `/api/backup/tasks/:id/toggle` | Enable / disable |
|
||||
| `POST` | `/api/backup/tasks/:id/run` | Trigger a manual run |
|
||||
|
||||
## Backup Records
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/backup/records` | List records with filters |
|
||||
| `GET` | `/api/backup/records/:id` | Record detail |
|
||||
| `GET` | `/api/backup/records/:id/logs/stream` | Live logs (SSE) |
|
||||
| `GET` | `/api/backup/records/:id/download` | Download the artifact |
|
||||
| `POST` | `/api/backup/records/:id/restore` | Restore to the original source |
|
||||
| `DELETE` | `/api/backup/records/:id` | Delete a record |
|
||||
| `POST` | `/api/backup/records/batch-delete` | Bulk delete |
|
||||
|
||||
## Storage Targets
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/storage-targets` | List |
|
||||
| `POST` | `/api/storage-targets` | Create |
|
||||
| `GET` | `/api/storage-targets/:id` | Detail |
|
||||
| `PUT` | `/api/storage-targets/:id` | Update |
|
||||
| `DELETE` | `/api/storage-targets/:id` | Delete |
|
||||
| `POST` | `/api/storage-targets/test` | Test connection with pending config |
|
||||
| `POST` | `/api/storage-targets/:id/test` | Re-test a saved target |
|
||||
| `PUT` | `/api/storage-targets/:id/star` | Toggle favourite |
|
||||
| `GET` | `/api/storage-targets/:id/usage` | Query remote usage (where supported) |
|
||||
| `GET` | `/api/storage-targets/rclone/backends` | List all available rclone backends |
|
||||
| `POST` | `/api/storage-targets/google-drive/auth-url` | Start Google Drive OAuth |
|
||||
| `POST` | `/api/storage-targets/google-drive/complete` | Complete OAuth flow |
|
||||
|
||||
## Nodes (Cluster)
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/nodes` | List nodes |
|
||||
| `POST` | `/api/nodes` | Create a node and return its token |
|
||||
| `GET` | `/api/nodes/:id` | Node detail |
|
||||
| `PUT` | `/api/nodes/:id` | Rename |
|
||||
| `DELETE` | `/api/nodes/:id` | Delete (rejected if tasks are still attached) |
|
||||
| `GET` | `/api/nodes/:id/fs/list` | Browse a directory (remote nodes use an async RPC via Agent) |
|
||||
|
||||
## Agent Protocol (X-Agent-Token)
|
||||
|
||||
Dedicated endpoints for the Agent CLI. Authenticated via the `X-Agent-Token` header instead of JWT.
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/api/agent/heartbeat` | Report liveness; returns the node ID |
|
||||
| `POST` | `/api/agent/commands/poll` | Claim one pending command |
|
||||
| `POST` | `/api/agent/commands/:id/result` | Report command result |
|
||||
| `GET` | `/api/agent/tasks/:id` | Fetch task spec with decrypted storage configs |
|
||||
| `POST` | `/api/agent/records/:id` | Append logs / update record status |
|
||||
|
||||
## Notifications
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/notifications` | List |
|
||||
| `POST` | `/api/notifications` | Create |
|
||||
| `GET` | `/api/notifications/:id` | Detail |
|
||||
| `PUT` | `/api/notifications/:id` | Update |
|
||||
| `DELETE` | `/api/notifications/:id` | Delete |
|
||||
| `POST` | `/api/notifications/test` | Test with pending config |
|
||||
| `POST` | `/api/notifications/:id/test` | Re-test a saved notifier |
|
||||
|
||||
## Dashboard
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/dashboard/stats` | Overview statistics |
|
||||
| `GET` | `/api/dashboard/timeline` | Recent activity timeline |
|
||||
|
||||
## Audit / System / Settings
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/audit-logs` | Audit log list |
|
||||
| `GET` | `/api/system/info` | System information |
|
||||
| `GET` | `/api/system/update-check` | Check for a newer release |
|
||||
| `GET` | `/api/settings` | System-level settings |
|
||||
| `PUT` | `/api/settings` | Update system settings |
|
||||
|
||||
## Response Envelope
|
||||
|
||||
All successful responses follow the shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "",
|
||||
"data": { /* actual payload */ }
|
||||
}
|
||||
```
|
||||
|
||||
Errors return an HTTP 4xx/5xx plus:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "BACKUP_TASK_NOT_FOUND",
|
||||
"message": "备份任务不存在",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
69
docs-site/docs/reference/cli.md
Normal file
69
docs-site/docs/reference/cli.md
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: CLI Reference
|
||||
description: backupx subcommands — server, agent, backint, reset-password.
|
||||
---
|
||||
|
||||
# CLI Reference
|
||||
|
||||
The `backupx` binary ships several subcommands. Running `backupx` with no subcommand starts the main server process.
|
||||
|
||||
## `backupx` (default: server)
|
||||
|
||||
```bash
|
||||
backupx --config /opt/backupx/config.yaml
|
||||
backupx --version
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--config <path>` | Path to config YAML (default: `./config.yaml`) |
|
||||
| `--version` | Print version and exit |
|
||||
|
||||
## `backupx agent`
|
||||
|
||||
Run in Agent mode, connecting to a Master. See [Multi-Node Cluster](../features/multi-node).
|
||||
|
||||
```bash
|
||||
backupx agent --master http://master:8340 --token <token>
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--master <url>` | Master URL |
|
||||
| `--token <token>` | Agent auth token |
|
||||
| `--config <path>` | YAML config (takes precedence over env) |
|
||||
| `--temp-dir <path>` | Local temp directory (default `/tmp/backupx-agent`) |
|
||||
| `--insecure-tls` | Skip TLS verification (testing only) |
|
||||
|
||||
Environment variables: `BACKUPX_AGENT_MASTER`, `BACKUPX_AGENT_TOKEN`, `BACKUPX_AGENT_HEARTBEAT`, `BACKUPX_AGENT_POLL`, `BACKUPX_AGENT_TEMP_DIR`, `BACKUPX_AGENT_INSECURE_TLS`.
|
||||
|
||||
## `backupx backint`
|
||||
|
||||
SAP HANA Backint protocol agent. See [SAP HANA Support](../features/sap-hana).
|
||||
|
||||
```bash
|
||||
backupx backint -f <function> -i <input> -o <output> -p <params>
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-f <fn>` | `backup` / `restore` / `inquire` / `delete` |
|
||||
| `-i <path>` | Input file |
|
||||
| `-o <path>` | Output file |
|
||||
| `-p <path>` | Parameter file |
|
||||
| `-u / -c / -l / -v` | Accepted and ignored for SAP compatibility |
|
||||
|
||||
## `backupx reset-password`
|
||||
|
||||
Reset an admin password directly in the SQLite database. No server restart needed.
|
||||
|
||||
```bash
|
||||
backupx reset-password --username admin --password 'newpass123' [--config /path/to/config.yaml]
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--username` | Target username (default: `admin`) |
|
||||
| `--password` | New password (min 8 chars, required) |
|
||||
| `--config` | Config path (used to locate the database file) |
|
||||
129
docs-site/docusaurus.config.ts
Normal file
129
docs-site/docusaurus.config.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import {themes as prismThemes} from 'prism-react-renderer';
|
||||
import type {Config} from '@docusaurus/types';
|
||||
import type * as Preset from '@docusaurus/preset-classic';
|
||||
|
||||
// BackupX 官方站点 — 托管在 GitHub Pages
|
||||
// https://awuqing.github.io/BackupX/
|
||||
const config: Config = {
|
||||
title: 'BackupX',
|
||||
tagline: 'Self-hosted server backup management — one binary, one command',
|
||||
favicon: 'img/favicon.ico',
|
||||
|
||||
future: {
|
||||
v4: true,
|
||||
},
|
||||
|
||||
url: 'https://awuqing.github.io',
|
||||
baseUrl: '/BackupX/',
|
||||
|
||||
organizationName: 'Awuqing',
|
||||
projectName: 'BackupX',
|
||||
deploymentBranch: 'gh-pages',
|
||||
trailingSlash: false,
|
||||
|
||||
onBrokenLinks: 'warn',
|
||||
markdown: {
|
||||
hooks: {
|
||||
onBrokenMarkdownLinks: 'warn',
|
||||
},
|
||||
},
|
||||
|
||||
i18n: {
|
||||
defaultLocale: 'en',
|
||||
locales: ['en', 'zh-Hans'],
|
||||
localeConfigs: {
|
||||
en: {label: 'English', direction: 'ltr', htmlLang: 'en-US'},
|
||||
'zh-Hans': {label: '简体中文', direction: 'ltr', htmlLang: 'zh-CN'},
|
||||
},
|
||||
},
|
||||
|
||||
presets: [
|
||||
[
|
||||
'classic',
|
||||
{
|
||||
docs: {
|
||||
sidebarPath: './sidebars.ts',
|
||||
editUrl: 'https://github.com/Awuqing/BackupX/edit/main/docs-site/',
|
||||
},
|
||||
blog: false,
|
||||
theme: {
|
||||
customCss: './src/css/custom.css',
|
||||
},
|
||||
} satisfies Preset.Options,
|
||||
],
|
||||
],
|
||||
|
||||
themeConfig: {
|
||||
image: 'img/social-card.png',
|
||||
colorMode: {
|
||||
respectPrefersColorScheme: true,
|
||||
},
|
||||
navbar: {
|
||||
title: 'BackupX',
|
||||
logo: {
|
||||
alt: 'BackupX Logo',
|
||||
src: 'img/logo.svg',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: 'docSidebar',
|
||||
sidebarId: 'docs',
|
||||
position: 'left',
|
||||
label: 'Docs',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/Awuqing/BackupX/releases',
|
||||
label: 'Downloads',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
type: 'localeDropdown',
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/Awuqing/BackupX',
|
||||
label: 'GitHub',
|
||||
position: 'right',
|
||||
},
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
style: 'dark',
|
||||
links: [
|
||||
{
|
||||
title: 'Docs',
|
||||
items: [
|
||||
{label: 'Introduction', to: '/docs/intro'},
|
||||
{label: 'Quick Start', to: '/docs/getting-started/quick-start'},
|
||||
{label: 'Installation', to: '/docs/getting-started/installation'},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Features',
|
||||
items: [
|
||||
{label: 'SAP HANA', to: '/docs/features/sap-hana'},
|
||||
{label: 'Multi-Node Cluster', to: '/docs/features/multi-node'},
|
||||
{label: 'API Reference', to: '/docs/reference/api'},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'More',
|
||||
items: [
|
||||
{label: 'GitHub', href: 'https://github.com/Awuqing/BackupX'},
|
||||
{label: 'Releases', href: 'https://github.com/Awuqing/BackupX/releases'},
|
||||
{label: 'Docker Hub', href: 'https://hub.docker.com/r/awuqing/backupx'},
|
||||
{label: 'Issues', href: 'https://github.com/Awuqing/BackupX/issues'},
|
||||
],
|
||||
},
|
||||
],
|
||||
copyright: `Copyright © ${new Date().getFullYear()} BackupX · Apache License 2.0`,
|
||||
},
|
||||
prism: {
|
||||
theme: prismThemes.github,
|
||||
darkTheme: prismThemes.dracula,
|
||||
additionalLanguages: ['bash', 'yaml', 'ini', 'json', 'go', 'sql', 'nginx'],
|
||||
},
|
||||
} satisfies Preset.ThemeConfig,
|
||||
};
|
||||
|
||||
export default config;
|
||||
82
docs-site/i18n/zh-CN/code.json
Normal file
82
docs-site/i18n/zh-CN/code.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"home.badge": {
|
||||
"message": "开源 · v1.6.0",
|
||||
"description": "Version badge on the hero"
|
||||
},
|
||||
"home.title.part1": {
|
||||
"message": "为每一台服务器提供",
|
||||
"description": "Hero title, first line"
|
||||
},
|
||||
"home.title.part2": {
|
||||
"message": "自托管备份管理。",
|
||||
"description": "Hero title accent second line"
|
||||
},
|
||||
"home.tagline": {
|
||||
"message": "一个二进制,一条命令。文件 / 数据库 / SAP HANA 备份直送 70+ 存储后端。",
|
||||
"description": "Tagline on the home page"
|
||||
},
|
||||
"home.pageTitle": {
|
||||
"message": "自托管备份管理",
|
||||
"description": "Page <title> element on the home page"
|
||||
},
|
||||
"home.getStarted": {
|
||||
"message": "快速开始",
|
||||
"description": "Primary CTA on the home page"
|
||||
},
|
||||
"home.metric.backends": {
|
||||
"message": "存储后端",
|
||||
"description": "Hero metric label: storage backends"
|
||||
},
|
||||
"home.metric.backupTypes": {
|
||||
"message": "备份类型",
|
||||
"description": "Hero metric label: backup types"
|
||||
},
|
||||
"home.metric.license": {
|
||||
"message": "开源协议",
|
||||
"description": "Hero metric label: license"
|
||||
},
|
||||
|
||||
"section.features.tag": {
|
||||
"message": "核心能力",
|
||||
"description": "FEATURES section tag"
|
||||
},
|
||||
"section.features.title": {
|
||||
"message": "该有的都有,多余的没有",
|
||||
"description": "Features section title"
|
||||
},
|
||||
"section.features.subtitle": {
|
||||
"message": "备份 Runner、存储 Provider、调度、集群 — 每一块都经过打磨。",
|
||||
"description": "Features section subtitle"
|
||||
},
|
||||
|
||||
"feat.types.title": {"message": "多种备份类型"},
|
||||
"feat.types.desc": {"message": "文件与目录(支持多源路径),以及 MySQL、PostgreSQL、SQLite、SAP HANA 统一管理。"},
|
||||
"feat.storage.title": {"message": "70+ 存储后端"},
|
||||
"feat.storage.desc": {"message": "内置阿里云 OSS、腾讯云 COS、七牛、S3、Google Drive、WebDAV、FTP,以及 SFTP、Azure Blob、Dropbox 等 rclone 后端。"},
|
||||
"feat.scheduling.title": {"message": "调度与保留策略"},
|
||||
"feat.scheduling.desc": {"message": "基于 Cron 的可视化调度编辑器,支持按天数/份数自动保留和空目录清理。"},
|
||||
"feat.cluster.title": {"message": "多节点集群"},
|
||||
"feat.cluster.desc": {"message": "Master-Agent 基于 HTTP 长轮询。Agent 在本地执行任务并直接上传到存储 — 无需反向连通性。"},
|
||||
"feat.security.title": {"message": "默认安全"},
|
||||
"feat.security.desc": {"message": "JWT 认证、bcrypt、AES-256-GCM 加密配置、可选备份加密、完整审计日志。"},
|
||||
"feat.deploy.title": {"message": "部署轻量"},
|
||||
"feat.deploy.desc": {"message": "单个静态二进制 + 内嵌 SQLite。Docker 一键启动或裸机 — 零外部依赖。"},
|
||||
"feat.learnMore": {"message": "了解更多"},
|
||||
|
||||
"showcase.tag": {"message": "产品界面"},
|
||||
"showcase.title": {"message": "精心打磨的控制台,而非 DIY 脚本"},
|
||||
"showcase.subtitle": {"message": "每个页面都为运维而生 — 可观测优先,可配置次之。"},
|
||||
"showcase.tab.dashboard": {"message": "仪表盘"},
|
||||
"showcase.tab.tasks": {"message": "备份任务"},
|
||||
"showcase.tab.storage": {"message": "存储目标"},
|
||||
"showcase.tab.nodes": {"message": "多节点"},
|
||||
"showcase.dashboard.title": {"message": "一眼掌握全局"},
|
||||
"showcase.dashboard.desc": {"message": "备份成功率、存储使用量、最近执行记录、即将触发的计划 — 一页实时数据。"},
|
||||
"showcase.tasks.title": {"message": "可视化任务编辑器"},
|
||||
"showcase.tasks.desc": {"message": "文件、MySQL、PostgreSQL、SQLite、SAP HANA — 三步完成。Cron 编辑器、多目标分发、保留策略、压缩、加密 — 点击即用。"},
|
||||
"showcase.storage.title": {"message": "70+ 后端,统一体验"},
|
||||
"showcase.storage.desc": {"message": "阿里云 OSS、腾讯云 COS、S3、Google Drive、WebDAV — 加上每一种 rclone 后端。测试连接、收藏、查看实时容量。"},
|
||||
"showcase.nodes.title": {"message": "几分钟搭起 Master-Agent"},
|
||||
"showcase.nodes.desc": {"message": "创建节点、复制令牌、在任意远程主机启动 Agent。路由到节点的任务在本地执行并直接上传到存储 — 无需反向连通性。"},
|
||||
"showcase.cta": {"message": "开始阅读文档"}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"version.label": {"message": "Next"},
|
||||
"sidebar.docs.category.Getting Started": {"message": "快速开始"},
|
||||
"sidebar.docs.category.Deployment": {"message": "部署"},
|
||||
"sidebar.docs.category.Features": {"message": "功能特性"},
|
||||
"sidebar.docs.category.Reference": {"message": "参考"},
|
||||
"sidebar.docs.category.Development": {"message": "开发"}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: 裸机部署
|
||||
description: 从预编译包或源码部署 BackupX(systemd + Nginx)。
|
||||
---
|
||||
|
||||
# 裸机部署
|
||||
|
||||
## 使用预编译包
|
||||
|
||||
```bash
|
||||
# 下载对应平台的压缩包
|
||||
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-v1.6.0-linux-amd64.tar.gz
|
||||
|
||||
# 解压并安装
|
||||
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
安装脚本自动完成以下步骤:
|
||||
|
||||
1. 创建系统用户 `backupx`
|
||||
2. 复制二进制到 `/opt/backupx/`
|
||||
3. 生成默认 `config.yaml`(含安全的 JWT/加密密钥)
|
||||
4. 安装并启用 `backupx.service` systemd 单元
|
||||
5. (可选)生成 Nginx 站点配置 — 参见 [Nginx 反向代理](./nginx)
|
||||
|
||||
## 从源码构建
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
|
||||
make build
|
||||
sudo ./deploy/install.sh
|
||||
```
|
||||
|
||||
`make build` 会产出:
|
||||
|
||||
- `server/bin/backupx`(Go 后端,无 CGO)
|
||||
- `web/dist/`(React 前端,执行 `npm run build`)
|
||||
|
||||
## systemd
|
||||
|
||||
安装后的 service 文件:
|
||||
|
||||
```ini title="/etc/systemd/system/backupx.service"
|
||||
[Unit]
|
||||
Description=BackupX backup management service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=backupx
|
||||
WorkingDirectory=/opt/backupx
|
||||
ExecStart=/opt/backupx/backupx --config /opt/backupx/config.yaml
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
LimitNOFILE=65536
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
sudo systemctl status backupx
|
||||
sudo journalctl -u backupx -f # 实时日志
|
||||
sudo systemctl restart backupx
|
||||
```
|
||||
|
||||
## 密码重置
|
||||
|
||||
忘记管理员密码时:
|
||||
|
||||
```bash
|
||||
/opt/backupx/backupx reset-password \
|
||||
--username admin \
|
||||
--password 'newpass123' \
|
||||
--config /opt/backupx/config.yaml
|
||||
```
|
||||
|
||||
Docker 等效命令:
|
||||
|
||||
```bash
|
||||
docker exec -it backupx /app/bin/backupx reset-password --username admin --password 'newpass123'
|
||||
```
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
title: 配置参考
|
||||
description: server.yaml 所有配置项及对应的环境变量。
|
||||
---
|
||||
|
||||
# 配置参考
|
||||
|
||||
BackupX 默认从工作目录加载 `./config.yaml`,可通过 `--config` 指定其他路径。所有配置项都可通过 `BACKUPX_` 前缀环境变量覆盖。
|
||||
|
||||
## 完整配置
|
||||
|
||||
```yaml title="config.yaml"
|
||||
server:
|
||||
host: "0.0.0.0" # BACKUPX_SERVER_HOST
|
||||
port: 8340 # BACKUPX_SERVER_PORT
|
||||
mode: "release" # release | debug
|
||||
|
||||
database:
|
||||
path: "./data/backupx.db" # BACKUPX_DATABASE_PATH — 内嵌 SQLite
|
||||
|
||||
security:
|
||||
jwt_secret: "" # BACKUPX_SECURITY_JWT_SECRET — 留空自动生成
|
||||
jwt_expires_in: "24h"
|
||||
encryption_key: "" # 用于加密存储配置的 AES-256-GCM 密钥
|
||||
|
||||
backup:
|
||||
temp_dir: "/tmp/backupx" # BACKUPX_BACKUP_TEMP_DIR
|
||||
max_concurrent: 2 # BACKUPX_BACKUP_MAX_CONCURRENT
|
||||
retries: 3 # 单次上传的 rclone 底层重试次数
|
||||
bandwidth_limit: "" # 例如 "10M" 表示限速 10 MB/s
|
||||
|
||||
log:
|
||||
level: "info" # debug | info | warn | error
|
||||
file: "./data/backupx.log"
|
||||
```
|
||||
|
||||
## 密钥生成
|
||||
|
||||
如果首次启动时 `jwt_secret` 或 `encryption_key` 为空,BackupX 会自动生成随机值并写入 `system_configs` 表。请妥善备份 `data/backupx.db`,一旦丢失将导致所有已加密的存储配置失效。
|
||||
|
||||
## 环境变量
|
||||
|
||||
文件和环境变量同时存在时,环境变量优先。配置路径转换规则:小写字母下划线 → 大写字母下划线:
|
||||
|
||||
| 配置项 | 环境变量 |
|
||||
|--------|----------|
|
||||
| `server.port` | `BACKUPX_SERVER_PORT` |
|
||||
| `log.level` | `BACKUPX_LOG_LEVEL` |
|
||||
| `backup.max_concurrent` | `BACKUPX_BACKUP_MAX_CONCURRENT` |
|
||||
| `backup.temp_dir` | `BACKUPX_BACKUP_TEMP_DIR` |
|
||||
| `backup.bandwidth_limit` | `BACKUPX_BACKUP_BANDWIDTH_LIMIT` |
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: Docker 部署
|
||||
description: 生产级 Docker 部署方案,含 compose 配置、宿主目录挂载、环境变量覆盖。
|
||||
---
|
||||
|
||||
# Docker 部署
|
||||
|
||||
BackupX 官方 Docker 镜像 [`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx) 支持多架构(linux/amd64 + linux/arm64)。
|
||||
|
||||
## Compose 文件
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
backupx:
|
||||
image: awuqing/backupx:latest
|
||||
container_name: backupx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8340:8340"
|
||||
volumes:
|
||||
- backupx-data:/app/data
|
||||
# 挂载需要备份的宿主机目录:
|
||||
- /var/www:/mnt/www:ro
|
||||
- /etc/nginx:/mnt/nginx-conf:ro
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- BACKUPX_LOG_LEVEL=info
|
||||
- BACKUPX_BACKUP_MAX_CONCURRENT=2
|
||||
|
||||
volumes:
|
||||
backupx-data:
|
||||
```
|
||||
|
||||
启动:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## 备份宿主机目录
|
||||
|
||||
想备份宿主机上的文件,需要将对应路径挂载进容器。在 Web UI 创建文件类型任务时,把源路径指向挂载后的容器内路径(如 `/mnt/www`)。
|
||||
|
||||
## 环境变量
|
||||
|
||||
所有配置项都可以通过 `BACKUPX_` 前缀环境变量覆盖:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- BACKUPX_SERVER_PORT=8340
|
||||
- BACKUPX_LOG_LEVEL=debug
|
||||
- BACKUPX_BACKUP_MAX_CONCURRENT=4
|
||||
- BACKUPX_BACKUP_TEMP_DIR=/tmp/backupx
|
||||
```
|
||||
|
||||
完整列表见 [配置参考](./configuration)。
|
||||
|
||||
## 升级
|
||||
|
||||
在 UI **系统设置 → 检查更新** 页面查看是否有新版,然后在宿主机上:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
无需手工迁移:BackupX 启动时自动迁移 SQLite schema。
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: Nginx 反向代理
|
||||
description: 通过 Nginx 发布 BackupX(HTTPS + SSE 友好的缓冲配置)。
|
||||
---
|
||||
|
||||
# Nginx 反向代理
|
||||
|
||||
生产环境可用的 Nginx 站点模板:
|
||||
|
||||
```nginx title="/etc/nginx/sites-available/backupx"
|
||||
server {
|
||||
listen 80;
|
||||
server_name backup.example.com;
|
||||
|
||||
# 静态 UI(由 /opt/backupx/web 提供)
|
||||
location / {
|
||||
root /opt/backupx/web;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API 反向代理
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8340;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 大文件上传(用于恢复流程)
|
||||
client_max_body_size 0;
|
||||
|
||||
# 实时日志使用 SSE,必须关闭缓冲
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## certbot 配置 HTTPS
|
||||
|
||||
```bash
|
||||
sudo apt install certbot python3-certbot-nginx
|
||||
sudo certbot --nginx -d backup.example.com
|
||||
```
|
||||
|
||||
certbot 会自动改写配置监听 443 并设置续期。
|
||||
|
||||
:::caution Agent 需要稳定的 URL
|
||||
如果 Master 部署在 HTTPS 后面,远程 Agent 的 `--master` 必须使用公网 HTTPS 地址。自签名证书需加 `--insecure-tls`(仅供测试)。
|
||||
:::
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: 贡献指南
|
||||
description: 如何反馈问题、提出改进、提交 PR。
|
||||
---
|
||||
|
||||
# 贡献指南
|
||||
|
||||
BackupX 使用 Apache License 2.0 开源,欢迎提交 Issue 与 Pull Request。
|
||||
|
||||
## 报告 Bug
|
||||
|
||||
在 [github.com/Awuqing/BackupX/issues](https://github.com/Awuqing/BackupX/issues) 提交 Issue,请附上:
|
||||
|
||||
- BackupX 版本(`backupx --version`)
|
||||
- 部署方式(Docker / 裸机 / 源码)
|
||||
- 相关的备份任务类型和存储后端
|
||||
- 复现步骤
|
||||
- 问题发生时段的 stdout / `backupx.log` 片段
|
||||
|
||||
## 提议改动
|
||||
|
||||
对于重要功能或重构,建议先开 Issue 对齐方案,避免 PR 大改动后被 Review 回退。
|
||||
|
||||
## 提交 PR
|
||||
|
||||
1. Fork 仓库,创建主题分支(如 `fix/windows-path-escape`)
|
||||
2. 执行 `make test` 确认本地全通过
|
||||
3. 保持每个 PR 只做一件事
|
||||
4. Commit message 使用中文,格式 `类型: 简要描述`:
|
||||
- `功能: 新增审计日志模块`
|
||||
- `修复: 目录浏览器无法进入子目录`
|
||||
- `重构: 简化存储目标解密逻辑`
|
||||
- 类型:`功能` / `修复` / `重构` / `文档` / `构建` / `测试`
|
||||
5. PR 标题和正文同样使用中文,描述"为什么"和"怎么做",而非仅仅"做了什么"
|
||||
|
||||
## 代码规范
|
||||
|
||||
- **Go** — 所有错误必须处理(禁止 `_ = err`),日志使用现有 `zap`,禁止生产路径中出现 `fmt.Println`
|
||||
- **TypeScript** — 严格模式,禁止隐式 any,遵循现有 ESLint/Prettier 配置
|
||||
- **Commit 粒度** — 每个 commit 一件事,不要把顺手的小修改和功能代码混在一起
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: 开发环境
|
||||
description: 搭建 BackupX 本地开发环境 — 后端、前端、测试。
|
||||
---
|
||||
|
||||
# 开发环境
|
||||
|
||||
**环境要求:** Go ≥ 1.25,Node.js ≥ 20,npm。
|
||||
|
||||
## 克隆与依赖
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
|
||||
cd web && npm install && cd ..
|
||||
```
|
||||
|
||||
## 开发服务
|
||||
|
||||
开两个终端分别跑后端和前端:
|
||||
|
||||
```bash
|
||||
# 终端 1:后端,监听 :8340
|
||||
make dev-server
|
||||
|
||||
# 终端 2:Vite HMR,监听 :5173
|
||||
make dev-web
|
||||
```
|
||||
|
||||
Vite 配置了 `/api` 代理到 `http://127.0.0.1:8340`,浏览器直接访问 `http://localhost:5173`。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
make test # 运行 Go + Web 全部测试
|
||||
make test-server # 仅 Go
|
||||
make test-web # 仅 Vitest
|
||||
```
|
||||
|
||||
## 生产构建
|
||||
|
||||
```bash
|
||||
make build # server/bin/backupx + web/dist
|
||||
make docker # Docker 镜像
|
||||
make docker-cn # 国内镜像加速构建
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 组件 | 技术 |
|
||||
|------|------|
|
||||
| **后端** | Go · Gin · GORM · SQLite · robfig/cron · rclone |
|
||||
| **前端** | React 18 · TypeScript · ArcoDesign · Vite · Zustand · ECharts |
|
||||
| **存储** | rclone(70+ 后端)· AWS SDK v2 · Google Drive API v3 |
|
||||
| **安全** | JWT · bcrypt · AES-256-GCM |
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
BackupX/
|
||||
├── server/ # Go 后端
|
||||
│ ├── cmd/backupx/ # 入口 + 子命令(agent / backint / reset-password)
|
||||
│ ├── internal/
|
||||
│ │ ├── agent/ # Agent CLI 逻辑
|
||||
│ │ ├── app/ # 装配(repo → service → handler)
|
||||
│ │ ├── backup/ # 备份 runner(file / mysql / postgres / sqlite / saphana)
|
||||
│ │ ├── backint/ # SAP HANA Backint 协议
|
||||
│ │ ├── http/ # HTTP handler + router
|
||||
│ │ ├── model/ # GORM 模型
|
||||
│ │ ├── repository/ # 数据访问
|
||||
│ │ ├── service/ # 业务逻辑
|
||||
│ │ └── storage/ # 存储 provider(rclone + 直接 SDK)
|
||||
│ └── pkg/ # 通用工具
|
||||
├── web/ # React 前端(Vite)
|
||||
│ └── src/
|
||||
│ ├── components/
|
||||
│ ├── pages/
|
||||
│ ├── services/
|
||||
│ └── types/
|
||||
├── docs-site/ # 文档站(Docusaurus)
|
||||
├── deploy/ # install.sh / systemd unit / nginx config
|
||||
└── Makefile
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: 备份类型
|
||||
description: 文件、MySQL、PostgreSQL、SQLite 和 SAP HANA — 各自的能力与配置说明。
|
||||
---
|
||||
|
||||
# 备份类型
|
||||
|
||||
BackupX 支持五种内置备份类型,类型决定了用哪个 runner 执行。
|
||||
|
||||
## 文件 / 目录
|
||||
|
||||
打包(可选 gzip)一个或多个文件系统路径。
|
||||
|
||||
- **源路径** 支持多个(UI 中每行一个)
|
||||
- **排除模式** 支持 gitignore 风格的通配符
|
||||
- 可选跟随符号链接、保留权限
|
||||
- 输出单个 `.tar` 或 `.tar.gz`
|
||||
|
||||
## MySQL
|
||||
|
||||
底层使用 `mysqldump`,需要执行任务的主机(Master 或 Agent)的 `$PATH` 中有 `mysqldump`。
|
||||
|
||||
- **主机 / 端口 / 用户 / 密码 / 数据库** — 支持多库(英文逗号分隔)
|
||||
- 输出:`.sql` 或 `.sql.gz`
|
||||
- 默认参数:`--single-transaction --routines --triggers --events`
|
||||
|
||||
## PostgreSQL
|
||||
|
||||
底层使用 `pg_dump`,连接字段与 MySQL 一致加数据库名。
|
||||
|
||||
## SQLite
|
||||
|
||||
直接复制数据库文件(使用一致性快照),无需外部工具。
|
||||
|
||||
## SAP HANA
|
||||
|
||||
支持两种模式 — 详见 [SAP HANA](./sap-hana) 专题页。
|
||||
|
||||
## 删除行为
|
||||
|
||||
删除备份任务时,BackupX 会从所有存储目标上移除备份产物,但保留备份记录以供审计。删除任务同时拆除其 Cron 定时调度。
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
title: 多节点集群
|
||||
description: Master-Agent 模式 — 通过 HTTP 长轮询把备份路由到远程服务器。
|
||||
---
|
||||
|
||||
# 多节点集群
|
||||
|
||||
BackupX 支持 Master-Agent 模式:备份任务可以指定在哪个节点执行,Agent 在本地完成备份并直接上传到存储。所有连接都由 Agent 主动发起,所以远程服务器只需要出站 HTTP 访问权限。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
[Web 控制台] ─── JWT ──→ [Master (backupx)]
|
||||
↑ ↓
|
||||
│ │ HTTP 长轮询(Token 认证)
|
||||
│ ↓
|
||||
[Agent (backupx agent)] ← 运行在远程服务器
|
||||
↓
|
||||
[70+ 存储后端]
|
||||
```
|
||||
|
||||
- **协议** — HTTP 长轮询,Agent 主动发起所有连接
|
||||
- **心跳** — Agent 每 15s 上报一次;Master 超过 45s 未收到心跳即判为离线
|
||||
- **下发** — Master 把 `run_task` 命令写入队列,Agent 轮询拉取
|
||||
- **执行** — Agent 复用 BackupRunner(file / mysql / postgresql / sqlite / saphana)并直接上传到存储
|
||||
- **安全** — 每个节点独立 Token;Agent 不持有 Master 的 JWT 密钥或 AES-256 加密密钥
|
||||
|
||||
## 一键部署步骤
|
||||
|
||||
### 1. 打开安装向导
|
||||
|
||||
Web 控制台 → **节点管理** → **添加节点**,打开三步向导:
|
||||
|
||||
- **第一步 · 节点信息**:填写节点名称;或切换"批量创建"粘贴多行名称(每行一个,最多 50 个)
|
||||
- **第二步 · 部署参数**:选择安装模式(`systemd` 推荐、`Docker`、`前台运行` 调试用)、架构(默认自动检测)、Agent 版本(默认跟随 Master 版本)、有效期(5 分钟 / 15 分钟 / 1 小时 / 24 小时)、下载源(`GitHub` 直连或 `ghproxy` 镜像,国内服务器建议后者)
|
||||
- **第三步 · 安装命令**:一行 `curl ... | sudo sh` 命令 + 实时倒计时。点击复制,粘贴到目标机以 root 权限执行
|
||||
|
||||
### 2. 目标机一条命令完成
|
||||
|
||||
示例(systemd 模式):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh
|
||||
```
|
||||
|
||||
脚本会自动:
|
||||
|
||||
1. 检测操作系统与架构(`uname -m`)
|
||||
2. 从 GitHub Release(或 ghproxy 镜像)下载匹配的 `backupx` 二进制
|
||||
3. 安装到 `/opt/backupx-agent`,创建系统用户 `backupx`
|
||||
4. 写入 `/etc/systemd/system/backupx-agent.service`(token 已烧入环境变量)
|
||||
5. 执行 `systemctl enable --now backupx-agent`
|
||||
6. 轮询 `/api/v1/agent/self`,直到 Master 确认 `status: online`(最多 30 秒)
|
||||
|
||||
脚本是幂等的:升级或重装只需重新生成一条安装命令再跑一次。一次性安装链接在 TTL 到期或被首次消费后立即作废。
|
||||
|
||||
### 3. 随时轮换 Agent Token
|
||||
|
||||
节点操作列(︙)→ **重新生成 Token**。新 Token 一次性显示,旧 Token 24 小时内仍有效,便于滚动替换无需停机。24 小时后旧 Token 被拒绝。
|
||||
|
||||
### 4. 批量部署
|
||||
|
||||
第一步选"批量创建"粘贴节点名(每行一个,最多 50 个)。第三步显示每个节点对应的命令表格,底部「导出 .sh」可打包为单个 shell 文件,方便 SSH 循环或 Ansible 任务。
|
||||
|
||||
### 5. 把任务路由到该节点
|
||||
|
||||
在 **备份任务** 页面新建任务时选择对应节点。任务触发时:
|
||||
|
||||
- 本机 / 未指定(`nodeId=0`):Master 进程内直接执行
|
||||
- 远程节点:Master 写入命令队列 → Agent 拉取 → Agent 本地执行 → 上传 → 回报
|
||||
|
||||
## 已知限制
|
||||
|
||||
- **Agent 不支持加密备份**:Agent 不持有 Master 的 AES-256 密钥。`encrypt: true` 的任务路由到 Agent 时会直接上报失败
|
||||
- **目录浏览超时**:远程目录浏览通过命令队列做同步 RPC,默认 15s 超时
|
||||
- **派发命令超时**:Agent 领取但未完成的命令超过 10 分钟会被置 `timeout`
|
||||
|
||||
## CLI 参考
|
||||
|
||||
```
|
||||
backupx agent --help
|
||||
-master string Master URL
|
||||
-token string Agent 认证令牌
|
||||
-config string YAML 配置文件路径(优先级高于环境变量)
|
||||
-temp-dir string 本地临时目录(默认 /tmp/backupx-agent)
|
||||
-insecure-tls 跳过 TLS 证书校验(仅测试用)
|
||||
```
|
||||
|
||||
## systemd 单元
|
||||
|
||||
```ini title="/etc/systemd/system/backupx-agent.service"
|
||||
[Unit]
|
||||
Description=BackupX Agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=backupx
|
||||
Environment="BACKUPX_AGENT_MASTER=https://master.example.com"
|
||||
Environment="BACKUPX_AGENT_TOKEN=your-token"
|
||||
ExecStart=/opt/backupx/backupx agent
|
||||
Restart=on-failure
|
||||
RestartSec=10s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
启用并启动:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now backupx-agent
|
||||
sudo journalctl -u backupx-agent -f
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
sidebar_position: 5
|
||||
title: 通知
|
||||
description: 备份成功或失败时通过邮件、Webhook、Telegram 推送通知。
|
||||
---
|
||||
|
||||
# 通知
|
||||
|
||||
BackupX 支持三种通知渠道,可为每个渠道单独配置成功/失败事件是否推送。
|
||||
|
||||
## 邮件(SMTP)
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| SMTP 主机 / 端口 | 如 `smtp.gmail.com:587` |
|
||||
| 用户名 / 密码 | 建议使用专用应用密码 |
|
||||
| 发件人地址 | 邮件 `From:` 头 |
|
||||
| 收件人列表 | 英文逗号分隔 |
|
||||
| 使用 TLS / StartTLS | 按 SMTP 提供方要求选择 |
|
||||
|
||||
## Webhook
|
||||
|
||||
向任意 URL 发送 JSON POST,请求体结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "backup_result",
|
||||
"task": {"id": 1, "name": "web-files", "type": "file"},
|
||||
"record": {"id": 42, "status": "success", "fileSize": 1048576, "durationSeconds": 12},
|
||||
"error": ""
|
||||
}
|
||||
```
|
||||
|
||||
适合自定义场景:Slack incoming webhook、PagerDuty、自建 API 等。
|
||||
|
||||
## Telegram
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| Bot Token | 在 [@BotFather](https://t.me/BotFather) 创建 |
|
||||
| Chat ID | 数字型,可通过 `/start` 后调 Bot 的 `getUpdates` 获取 |
|
||||
|
||||
## 事件规则
|
||||
|
||||
每个通知配置可以指定触发范围:
|
||||
|
||||
- **仅成功** — 正常运行时静默
|
||||
- **仅失败** — 适合高噪敏感通道
|
||||
- **全部** — 初始化配置时用于验证链路
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: SAP HANA 支持
|
||||
description: 两种 SAP HANA 备份模式 — 控制台托管的 hdbsql Runner 和原生 Backint 协议代理。
|
||||
---
|
||||
|
||||
# SAP HANA 支持
|
||||
|
||||
BackupX 提供两种 SAP HANA 备份模式,按实际运维流程选择。
|
||||
|
||||
## 模式一:hdbsql Runner(控制台托管)
|
||||
|
||||
通过 Web 控制台创建 SAP HANA 备份任务,后端调用 `hdbsql` 执行备份。适合希望 BackupX 来管理调度的场景。
|
||||
|
||||
**源配置支持:**
|
||||
|
||||
| 字段 | 可选值 | 说明 |
|
||||
|------|--------|------|
|
||||
| 备份类型 | `data` / `log` | 数据备份或日志备份 |
|
||||
| 备份级别 | `full` / `incremental` / `differential` | 日志备份时自动禁用 |
|
||||
| 并行通道数 | `1 ~ 32` | 多路径 SQL(`BACKUP DATA USING FILE ('c1', 'c2', ...)`) |
|
||||
| 失败重试次数 | `1 ~ 10` | 指数退避(`5s × 尝试次数²`) |
|
||||
| 实例编号 | 可选 | 从端口推断或手动指定 |
|
||||
|
||||
## 模式二:Backint 协议代理(HANA 原生接口)
|
||||
|
||||
BackupX 内置 Backint Agent,SAP HANA 通过原生 `BACKUP DATA USING BACKINT` 语法调用,数据自动路由到任意 BackupX 存储目标(S3 / OSS / COS / WebDAV / 70+ 后端)。
|
||||
|
||||
### 1. 参数文件
|
||||
|
||||
```ini title="/opt/backupx/backint_params.ini"
|
||||
#STORAGE_TYPE = s3
|
||||
#STORAGE_CONFIG_JSON = /opt/backupx/storage.json
|
||||
#PARALLEL_FACTOR = 4
|
||||
#COMPRESS = true
|
||||
#KEY_PREFIX = hana-backup
|
||||
#CATALOG_DB = /opt/backupx/backint_catalog.db
|
||||
#LOG_FILE = /var/log/backupx/backint.log
|
||||
```
|
||||
|
||||
### 2. 存储配置(与存储目标 schema 相同)
|
||||
|
||||
```json title="/opt/backupx/storage.json"
|
||||
{
|
||||
"endpoint": "https://s3.amazonaws.com",
|
||||
"region": "us-east-1",
|
||||
"bucket": "hana-prod",
|
||||
"accessKeyId": "AKIA...",
|
||||
"secretAccessKey": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 创建 hdbbackint 软链接
|
||||
|
||||
```bash
|
||||
ln -s /opt/backupx/backupx /usr/sap/<SID>/SYS/global/hdb/opt/hdbbackint
|
||||
```
|
||||
|
||||
### 4. 在 HANA `global.ini` 中启用
|
||||
|
||||
```ini
|
||||
[backup]
|
||||
data_backup_using_backint = true
|
||||
catalog_backup_using_backint = true
|
||||
log_backup_using_backint = true
|
||||
data_backup_parameter_file = /opt/backupx/backint_params.ini
|
||||
log_backup_parameter_file = /opt/backupx/backint_params.ini
|
||||
```
|
||||
|
||||
### 5. CLI 手动调用(用于排查)
|
||||
|
||||
```bash
|
||||
backupx backint -f backup -i input.txt -o output.txt -p backint_params.ini
|
||||
backupx backint -f restore -i input.txt -o output.txt -p backint_params.ini
|
||||
backupx backint -f inquire -i input.txt -o output.txt -p backint_params.ini
|
||||
backupx backint -f delete -i input.txt -o output.txt -p backint_params.ini
|
||||
```
|
||||
|
||||
Backint Agent 使用本地 SQLite 维护 `EBID ↔ 对象键` 目录,所有操作遵循 SAP HANA Backint 协议(`#PIPE` / `#SAVED` / `#RESTORED` / `#BACKUP` / `#NOTFOUND` / `#DELETED` / `#ERROR`)。
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: 存储后端
|
||||
description: 70+ 存储后端 — 内置云服务商 + 任意 rclone 后端。
|
||||
---
|
||||
|
||||
# 存储后端
|
||||
|
||||
BackupX 的目标是接入任何你想放置备份文件的地方。
|
||||
|
||||
## 内置后端
|
||||
|
||||
| 类型 | 必填字段 |
|
||||
|------|---------|
|
||||
| **阿里云 OSS** | Region + AccessKey ID/Secret + Bucket(endpoint 自动组装) |
|
||||
| **腾讯云 COS** | Region + SecretId/SecretKey + Bucket(格式 `name-appid`) |
|
||||
| **七牛云 Kodo** | Region + AccessKey/SecretKey + Bucket |
|
||||
| **S3 兼容** | Endpoint + AccessKey + Bucket |
|
||||
| **Google Drive** | Client ID/Secret + OAuth 授权 |
|
||||
| **WebDAV** | 地址 + 用户名/密码 |
|
||||
| **FTP / FTPS** | 主机 + 端口 + 用户名/密码 |
|
||||
| **本地磁盘** | 目标目录(绝对路径) |
|
||||
|
||||
## Rclone 后端
|
||||
|
||||
每一种 [rclone 后端](https://rclone.org/overview/) 都作为一等公民暴露 — SFTP、Azure Blob、Dropbox、OneDrive、Backblaze B2、Wasabi、pCloud、HDFS 等。
|
||||
|
||||
- 表单字段分为 **必填** 和 **高级**(高级默认折叠)
|
||||
- 校验与连接测试复用 rclone 自带的探测
|
||||
|
||||
## 一个任务多个目标
|
||||
|
||||
一个备份任务可以并行上传到多个存储目标。每个目标获得相同的产物,每目标的状态会单独记录:
|
||||
|
||||
- 成功:storage_path + 文件大小
|
||||
- 失败:错误信息
|
||||
|
||||
如果任一目标在重试后仍失败,整条记录的状态为 `failed`,但已成功的目标产物会被保留(不回滚)。
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: 安装
|
||||
description: 通过 Docker、预编译包或源码安装 BackupX。
|
||||
---
|
||||
|
||||
# 安装
|
||||
|
||||
BackupX 以单个静态二进制发布。三种安装方式,按实际环境选一种。
|
||||
|
||||
## Docker(推荐)
|
||||
|
||||
无需克隆仓库:
|
||||
|
||||
```bash
|
||||
docker run -d --name backupx \
|
||||
-p 8340:8340 \
|
||||
-v backupx-data:/app/data \
|
||||
awuqing/backupx:latest
|
||||
```
|
||||
|
||||
或使用 `docker compose`:
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
backupx:
|
||||
image: awuqing/backupx:latest
|
||||
container_name: backupx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8340:8340"
|
||||
volumes:
|
||||
- backupx-data:/app/data
|
||||
# 挂载需要备份的宿主机目录(按需添加):
|
||||
# - /var/www:/mnt/www:ro
|
||||
# - /etc/nginx:/mnt/nginx-conf:ro
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
|
||||
volumes:
|
||||
backupx-data:
|
||||
```
|
||||
|
||||
Docker Hub:[`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx),支持 linux/amd64 和 linux/arm64。
|
||||
|
||||
## 预编译包(裸机)
|
||||
|
||||
从 [Releases 页面](https://github.com/Awuqing/BackupX/releases) 下载对应平台的压缩包,执行安装脚本:
|
||||
|
||||
```bash
|
||||
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
|
||||
sudo ./install.sh # 创建系统用户、安装到 /opt/backupx、配置 systemd + Nginx
|
||||
```
|
||||
|
||||
安装脚本会自动:
|
||||
|
||||
1. 创建 `backupx` 系统用户
|
||||
2. 安装二进制到 `/opt/backupx/backupx`
|
||||
3. 生成 `/opt/backupx/config.yaml`(含安全默认值)
|
||||
4. 注册并启用 `backupx.service` systemd 单元
|
||||
5. (可选)配置 Nginx 反向代理
|
||||
|
||||
## 从源码构建
|
||||
|
||||
依赖:Go ≥ 1.25,Node.js ≥ 20。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
|
||||
make build
|
||||
# 或使用国内镜像加速构建 Docker
|
||||
make docker-cn
|
||||
```
|
||||
|
||||
`make build` 完成后,二进制位于 `server/bin/backupx`,构建好的 Web UI 位于 `web/dist/`。
|
||||
|
||||
## 验证安装
|
||||
|
||||
```bash
|
||||
backupx --version # 输出如 v1.6.0
|
||||
```
|
||||
|
||||
打开浏览器访问 `http://your-server:8340`,会进入初始化管理员账户页面。
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: 快速开始
|
||||
description: 部署 BackupX、添加存储目标、创建第一个备份任务。
|
||||
---
|
||||
|
||||
# 快速开始
|
||||
|
||||
完成 [安装](./installation) 后,花五分钟跑通第一个备份。
|
||||
|
||||
## 1. 打开控制台
|
||||
|
||||
浏览器访问 `http://your-server:8340`。首次打开会引导创建管理员账户。
|
||||
|
||||
## 2. 添加存储目标
|
||||
|
||||
进入 **存储目标 → 添加**,选择类型并填写凭证:
|
||||
|
||||
| 类型 | 需要填写 |
|
||||
|------|---------|
|
||||
| 阿里云 OSS | Region + AccessKey ID/Secret + Bucket |
|
||||
| 腾讯云 COS | Region + SecretId/SecretKey + Bucket(格式 `name-appid`) |
|
||||
| 七牛云 Kodo | Region + AccessKey/SecretKey + Bucket |
|
||||
| S3 兼容 | Endpoint + AccessKey + Bucket |
|
||||
| Google Drive | Client ID/Secret → 点击「授权」完成 OAuth |
|
||||
| WebDAV | 服务器地址 + 用户名/密码 |
|
||||
| FTP | 主机 + 端口 + 用户名/密码 |
|
||||
| 本地磁盘 | 目标目录路径 |
|
||||
| SFTP / Azure / Dropbox / OneDrive 等 | 选择对应类型后填写必填项,高级配置默认折叠 |
|
||||
|
||||
:::tip
|
||||
国内云厂商只需填 Region 和 AccessKey,系统自动组装 Endpoint。Rclone 类型的配置项按"必填 / 可选"分层展示,高级选项默认折叠。
|
||||
:::
|
||||
|
||||
添加后点击 **测试连接** 确认配置正确。
|
||||
|
||||
## 3. 创建备份任务
|
||||
|
||||
进入 **备份任务 → 新建**,三步完成:
|
||||
|
||||
1. **基础信息** — 任务名称、备份类型、Cron 表达式(留空则仅手动执行)
|
||||
2. **源配置** — 文件备份选择源路径(支持多个),数据库备份填写连接信息
|
||||
3. **存储与策略** — 选择存储目标(支持多个)、压缩策略、保留天数、是否加密
|
||||
|
||||
保存后可点击 **立即执行** 测试,**备份记录** 页面实时查看执行日志。
|
||||
|
||||
:::note
|
||||
删除备份任务时会自动清理远端存储上的备份文件,但保留备份记录以供审计追溯。
|
||||
:::
|
||||
|
||||
## 4. 配置通知(可选)
|
||||
|
||||
**通知配置** 页面支持邮件、Webhook、Telegram 三种方式,可分别配置成功/失败时是否推送。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- 了解 [备份类型](/docs/features/backup-types) 和 [存储后端](/docs/features/storage-backends)
|
||||
- 使用 SAP HANA?参考 [SAP HANA 支持](/docs/features/sap-hana)
|
||||
- 管理多台服务器?参考 [多节点集群](/docs/features/multi-node)
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: intro
|
||||
slug: /intro
|
||||
sidebar_position: 1
|
||||
title: 项目简介
|
||||
description: BackupX——自托管服务器备份管理平台概览。
|
||||
---
|
||||
|
||||
# BackupX
|
||||
|
||||
**BackupX** 是一款自托管的服务器备份管理平台:一个二进制,一条命令,管好所有服务器的所有备份。
|
||||
|
||||
- **单二进制 + 内嵌 SQLite** — 不依赖外部数据库或编排器
|
||||
- **文件、数据库、SAP HANA** — 统一管理,可视化调度
|
||||
- **70+ 存储后端** — 阿里云 OSS、腾讯云 COS、七牛、S3、Google Drive、WebDAV、FTP,以及通过 rclone 接入的 SFTP / Azure Blob / Dropbox / OneDrive 等数十种
|
||||
- **多节点集群** — Master-Agent 模式跨服务器管理备份,Agent 在本地执行并直接上传到存储
|
||||
- **默认安全** — JWT 认证、bcrypt、AES-256-GCM 加密配置、可选备份加密、完整审计日志
|
||||
|
||||
## 架构概览
|
||||
|
||||
```
|
||||
[Web 控制台] ─── JWT ──→ [Master (backupx)]
|
||||
│
|
||||
│ HTTP 长轮询(Token 认证)
|
||||
▼
|
||||
[Agent (backupx agent)]
|
||||
│
|
||||
▼
|
||||
[70+ 存储后端]
|
||||
```
|
||||
|
||||
路由到本机的任务在 Master 进程内直接执行;派到远程节点的任务通过命令队列下发,由 Agent 在本地执行。Agent 只发起出站 HTTP 连接 — 不需要任何反向连通性。
|
||||
|
||||
## 下一步
|
||||
|
||||
- **第一次使用 BackupX?** 先看 [快速开始](/docs/getting-started/quick-start)
|
||||
- **生产部署?** 参考 [部署指南](/docs/deployment/docker)
|
||||
- **SAP HANA 用户?** 支持 `hdbsql` Runner 和原生 Backint 两种模式 — 详见 [SAP HANA](/docs/features/sap-hana)
|
||||
- **管理多台服务器?** 参考 [多节点集群](/docs/features/multi-node)
|
||||
- **程序化集成?** 参考 [API 参考](/docs/reference/api)
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: API 参考
|
||||
description: REST API 端点 — 统一以 /api 为前缀,使用 JWT Bearer 认证。
|
||||
---
|
||||
|
||||
# API 参考
|
||||
|
||||
所有端点都以 `/api` 为前缀,使用 JWT Bearer 令牌认证(通过 `POST /api/auth/login` 获取)。Agent 专用端点使用 `X-Agent-Token` 头认证。
|
||||
|
||||
## 认证
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/auth/setup/status` | 查询是否需要初始化管理员 |
|
||||
| `POST` | `/api/auth/setup` | 初始化首个管理员(仅当系统无任何用户时) |
|
||||
| `POST` | `/api/auth/login` | 登录,返回 JWT |
|
||||
| `POST` | `/api/auth/logout` | 登出(使当前 Token 失效) |
|
||||
| `GET` | `/api/auth/profile` | 当前用户信息 |
|
||||
| `PUT` | `/api/auth/password` | 修改密码 |
|
||||
|
||||
## 备份任务
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/backup/tasks` | 列表 |
|
||||
| `POST` | `/api/backup/tasks` | 创建 |
|
||||
| `GET` | `/api/backup/tasks/:id` | 详情 |
|
||||
| `PUT` | `/api/backup/tasks/:id` | 更新 |
|
||||
| `DELETE` | `/api/backup/tasks/:id` | 删除 |
|
||||
| `PUT` | `/api/backup/tasks/:id/toggle` | 启用 / 禁用 |
|
||||
| `POST` | `/api/backup/tasks/:id/run` | 手动触发一次执行 |
|
||||
|
||||
## 备份记录
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/backup/records` | 列表(支持筛选) |
|
||||
| `GET` | `/api/backup/records/:id` | 记录详情 |
|
||||
| `GET` | `/api/backup/records/:id/logs/stream` | 实时日志(SSE) |
|
||||
| `GET` | `/api/backup/records/:id/download` | 下载备份产物 |
|
||||
| `POST` | `/api/backup/records/:id/restore` | 恢复到原始源 |
|
||||
| `DELETE` | `/api/backup/records/:id` | 删除记录 |
|
||||
| `POST` | `/api/backup/records/batch-delete` | 批量删除 |
|
||||
|
||||
## 存储目标
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/storage-targets` | 列表 |
|
||||
| `POST` | `/api/storage-targets` | 创建 |
|
||||
| `GET` | `/api/storage-targets/:id` | 详情 |
|
||||
| `PUT` | `/api/storage-targets/:id` | 更新 |
|
||||
| `DELETE` | `/api/storage-targets/:id` | 删除 |
|
||||
| `POST` | `/api/storage-targets/test` | 用待审核配置测试连接 |
|
||||
| `POST` | `/api/storage-targets/:id/test` | 重测已保存的目标 |
|
||||
| `PUT` | `/api/storage-targets/:id/star` | 切换收藏状态 |
|
||||
| `GET` | `/api/storage-targets/:id/usage` | 查询远端存储用量(支持此能力的后端) |
|
||||
| `GET` | `/api/storage-targets/rclone/backends` | 列出可用的 rclone 后端 |
|
||||
| `POST` | `/api/storage-targets/google-drive/auth-url` | 启动 Google Drive OAuth |
|
||||
| `POST` | `/api/storage-targets/google-drive/complete` | 完成 OAuth 流程 |
|
||||
|
||||
## 节点(集群)
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/nodes` | 节点列表 |
|
||||
| `POST` | `/api/nodes` | 创建节点并返回 Token |
|
||||
| `GET` | `/api/nodes/:id` | 节点详情 |
|
||||
| `PUT` | `/api/nodes/:id` | 重命名 |
|
||||
| `DELETE` | `/api/nodes/:id` | 删除(有关联任务时会被拒绝) |
|
||||
| `GET` | `/api/nodes/:id/fs/list` | 浏览目录(远程节点走 Agent 异步 RPC) |
|
||||
|
||||
## Agent 协议(X-Agent-Token)
|
||||
|
||||
Agent CLI 专用端点,通过 `X-Agent-Token` 头认证而非 JWT。
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| `POST` | `/api/agent/heartbeat` | 上报心跳(返回节点 ID) |
|
||||
| `POST` | `/api/agent/commands/poll` | 领取一条待执行命令 |
|
||||
| `POST` | `/api/agent/commands/:id/result` | 上报命令结果 |
|
||||
| `GET` | `/api/agent/tasks/:id` | 拉取任务规格(含解密后的存储配置) |
|
||||
| `POST` | `/api/agent/records/:id` | 追加日志 / 更新记录状态 |
|
||||
|
||||
## 通知
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/notifications` | 列表 |
|
||||
| `POST` | `/api/notifications` | 创建 |
|
||||
| `GET` | `/api/notifications/:id` | 详情 |
|
||||
| `PUT` | `/api/notifications/:id` | 更新 |
|
||||
| `DELETE` | `/api/notifications/:id` | 删除 |
|
||||
| `POST` | `/api/notifications/test` | 用待审核配置测试 |
|
||||
| `POST` | `/api/notifications/:id/test` | 重测已保存的通知器 |
|
||||
|
||||
## 仪表盘
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/dashboard/stats` | 概览统计 |
|
||||
| `GET` | `/api/dashboard/timeline` | 最近活动时间线 |
|
||||
|
||||
## 审计 / 系统 / 设置
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/audit-logs` | 审计日志 |
|
||||
| `GET` | `/api/system/info` | 系统信息 |
|
||||
| `GET` | `/api/system/update-check` | 检查新版本 |
|
||||
| `GET` | `/api/settings` | 系统级设置 |
|
||||
| `PUT` | `/api/settings` | 更新系统设置 |
|
||||
|
||||
## 响应结构
|
||||
|
||||
成功响应统一为:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "",
|
||||
"data": { /* 实际数据 */ }
|
||||
}
|
||||
```
|
||||
|
||||
错误返回 HTTP 4xx/5xx,并带:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "BACKUP_TASK_NOT_FOUND",
|
||||
"message": "备份任务不存在",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: CLI 参考
|
||||
description: backupx 子命令 — server / agent / backint / reset-password。
|
||||
---
|
||||
|
||||
# CLI 参考
|
||||
|
||||
`backupx` 二进制内置多个子命令。无子命令时默认启动主服务进程。
|
||||
|
||||
## `backupx`(默认:服务进程)
|
||||
|
||||
```bash
|
||||
backupx --config /opt/backupx/config.yaml
|
||||
backupx --version
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--config <path>` | 配置文件路径(默认 `./config.yaml`) |
|
||||
| `--version` | 打印版本后退出 |
|
||||
|
||||
## `backupx agent`
|
||||
|
||||
以 Agent 模式运行,连接到 Master。详见 [多节点集群](../features/multi-node)。
|
||||
|
||||
```bash
|
||||
backupx agent --master http://master:8340 --token <token>
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--master <url>` | Master URL |
|
||||
| `--token <token>` | Agent 认证令牌 |
|
||||
| `--config <path>` | YAML 配置文件(优先级高于环境变量) |
|
||||
| `--temp-dir <path>` | 本地临时目录(默认 `/tmp/backupx-agent`) |
|
||||
| `--insecure-tls` | 跳过 TLS 校验(仅测试用) |
|
||||
|
||||
环境变量:`BACKUPX_AGENT_MASTER`、`BACKUPX_AGENT_TOKEN`、`BACKUPX_AGENT_HEARTBEAT`、`BACKUPX_AGENT_POLL`、`BACKUPX_AGENT_TEMP_DIR`、`BACKUPX_AGENT_INSECURE_TLS`。
|
||||
|
||||
## `backupx backint`
|
||||
|
||||
SAP HANA Backint 协议代理,详见 [SAP HANA 支持](../features/sap-hana)。
|
||||
|
||||
```bash
|
||||
backupx backint -f <function> -i <input> -o <output> -p <params>
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `-f <fn>` | `backup` / `restore` / `inquire` / `delete` |
|
||||
| `-i <path>` | 输入文件 |
|
||||
| `-o <path>` | 输出文件 |
|
||||
| `-p <path>` | 参数文件 |
|
||||
| `-u / -c / -l / -v` | 接收但忽略(兼容 SAP 约定) |
|
||||
|
||||
## `backupx reset-password`
|
||||
|
||||
直接在 SQLite 中重置管理员密码,无需重启服务。
|
||||
|
||||
```bash
|
||||
backupx reset-password --username admin --password 'newpass123' [--config /path/to/config.yaml]
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--username` | 目标用户名(默认 `admin`) |
|
||||
| `--password` | 新密码(最少 8 字符,必填) |
|
||||
| `--config` | 配置文件路径(用于定位数据库文件) |
|
||||
15
docs-site/i18n/zh-CN/docusaurus-theme-classic/footer.json
Normal file
15
docs-site/i18n/zh-CN/docusaurus-theme-classic/footer.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"link.title.Docs": {"message": "文档"},
|
||||
"link.title.Features": {"message": "功能"},
|
||||
"link.title.More": {"message": "更多"},
|
||||
"link.item.label.Introduction": {"message": "简介"},
|
||||
"link.item.label.Quick Start": {"message": "快速开始"},
|
||||
"link.item.label.Installation": {"message": "安装"},
|
||||
"link.item.label.SAP HANA": {"message": "SAP HANA"},
|
||||
"link.item.label.Multi-Node Cluster": {"message": "多节点集群"},
|
||||
"link.item.label.API Reference": {"message": "API 参考"},
|
||||
"link.item.label.GitHub": {"message": "GitHub"},
|
||||
"link.item.label.Releases": {"message": "Releases"},
|
||||
"link.item.label.Docker Hub": {"message": "Docker Hub"},
|
||||
"link.item.label.Issues": {"message": "Issues"}
|
||||
}
|
||||
14
docs-site/i18n/zh-CN/docusaurus-theme-classic/navbar.json
Normal file
14
docs-site/i18n/zh-CN/docusaurus-theme-classic/navbar.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"item.label.Docs": {
|
||||
"message": "文档",
|
||||
"description": "Navbar item: Docs"
|
||||
},
|
||||
"item.label.Downloads": {
|
||||
"message": "下载",
|
||||
"description": "Navbar item: Downloads"
|
||||
},
|
||||
"item.label.GitHub": {
|
||||
"message": "GitHub",
|
||||
"description": "Navbar item: GitHub"
|
||||
}
|
||||
}
|
||||
19538
docs-site/package-lock.json
generated
Normal file
19538
docs-site/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
docs-site/package.json
Normal file
49
docs-site/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "backupx-docs",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.10.0",
|
||||
"@docusaurus/faster": "3.10.0",
|
||||
"@docusaurus/preset-classic": "3.10.0",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.0.0",
|
||||
"prism-react-renderer": "^2.3.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "3.10.0",
|
||||
"@docusaurus/tsconfig": "3.10.0",
|
||||
"@docusaurus/types": "3.10.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"typescript": "~6.0.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.5%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 3 chrome version",
|
||||
"last 3 firefox version",
|
||||
"last 5 safari version"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
}
|
||||
}
|
||||
55
docs-site/sidebars.ts
Normal file
55
docs-site/sidebars.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type {SidebarsConfig} from '@docusaurus/plugin-content-docs';
|
||||
|
||||
const sidebars: SidebarsConfig = {
|
||||
docs: [
|
||||
'intro',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Getting Started',
|
||||
collapsed: false,
|
||||
items: [
|
||||
'getting-started/installation',
|
||||
'getting-started/quick-start',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Deployment',
|
||||
items: [
|
||||
'deployment/docker',
|
||||
'deployment/bare-metal',
|
||||
'deployment/nginx',
|
||||
'deployment/configuration',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Features',
|
||||
items: [
|
||||
'features/backup-types',
|
||||
'features/storage-backends',
|
||||
'features/sap-hana',
|
||||
'features/multi-node',
|
||||
'features/notifications',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Reference',
|
||||
items: [
|
||||
'reference/api',
|
||||
'reference/cli',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Development',
|
||||
items: [
|
||||
'development/setup',
|
||||
'development/contributing',
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default sidebars;
|
||||
172
docs-site/src/components/HomepageFeatures/index.tsx
Normal file
172
docs-site/src/components/HomepageFeatures/index.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import Heading from '@theme/Heading';
|
||||
import Translate from '@docusaurus/Translate';
|
||||
import Link from '@docusaurus/Link';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
type FeatureItem = {
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
icon: ReactNode;
|
||||
link?: string;
|
||||
};
|
||||
|
||||
const DatabaseIcon = () => (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M3 5v6c0 1.66 4 3 9 3s9-1.34 9-3V5" />
|
||||
<path d="M3 11v6c0 1.66 4 3 9 3s9-1.34 9-3v-6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CloudIcon = () => (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M18 10h-1.26A8 8 0 109 20h9a5 5 0 000-10z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ClockIcon = () => (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const NetworkIcon = () => (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<rect x="9" y="2" width="6" height="6" rx="1" />
|
||||
<rect x="2" y="16" width="6" height="6" rx="1" />
|
||||
<rect x="16" y="16" width="6" height="6" rx="1" />
|
||||
<path d="M12 8v4" />
|
||||
<path d="M12 12H5v4" />
|
||||
<path d="M12 12h7v4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ShieldIcon = () => (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 2l9 4v6c0 5-3.5 9.5-9 10-5.5-.5-9-5-9-10V6l9-4z" />
|
||||
<polyline points="9 12 11 14 15 10" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RocketIcon = () => (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 00-2.91-.09z" />
|
||||
<path d="M12 15l-3-3a22 22 0 012-3.95A12.88 12.88 0 0122 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 01-4 2z" />
|
||||
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" />
|
||||
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FEATURES: FeatureItem[] = [
|
||||
{
|
||||
title: <Translate id="feat.types.title">Many Backup Types</Translate>,
|
||||
description: (
|
||||
<Translate id="feat.types.desc">
|
||||
Files and directories with multi-path sources, plus MySQL, PostgreSQL, SQLite, and SAP HANA — all in one place.
|
||||
</Translate>
|
||||
),
|
||||
icon: <DatabaseIcon />,
|
||||
link: '/docs/features/backup-types',
|
||||
},
|
||||
{
|
||||
title: <Translate id="feat.storage.title">70+ Storage Backends</Translate>,
|
||||
description: (
|
||||
<Translate id="feat.storage.desc">
|
||||
Native Alibaba OSS, Tencent COS, Qiniu, S3, Google Drive, WebDAV, FTP — plus SFTP, Azure Blob, Dropbox and more via rclone.
|
||||
</Translate>
|
||||
),
|
||||
icon: <CloudIcon />,
|
||||
link: '/docs/features/storage-backends',
|
||||
},
|
||||
{
|
||||
title: <Translate id="feat.scheduling.title">Scheduling & Retention</Translate>,
|
||||
description: (
|
||||
<Translate id="feat.scheduling.desc">
|
||||
Cron-based schedules with a visual editor and auto-retention (by days or count), plus empty-directory cleanup.
|
||||
</Translate>
|
||||
),
|
||||
icon: <ClockIcon />,
|
||||
},
|
||||
{
|
||||
title: <Translate id="feat.cluster.title">Multi-Node Cluster</Translate>,
|
||||
description: (
|
||||
<Translate id="feat.cluster.desc">
|
||||
Master-Agent via HTTP long-polling. Agents run tasks locally and upload directly to storage — no reverse connectivity.
|
||||
</Translate>
|
||||
),
|
||||
icon: <NetworkIcon />,
|
||||
link: '/docs/features/multi-node',
|
||||
},
|
||||
{
|
||||
title: <Translate id="feat.security.title">Secure by Default</Translate>,
|
||||
description: (
|
||||
<Translate id="feat.security.desc">
|
||||
JWT auth, bcrypt passwords, AES-256-GCM encrypted config, optional backup encryption, and a full audit log.
|
||||
</Translate>
|
||||
),
|
||||
icon: <ShieldIcon />,
|
||||
},
|
||||
{
|
||||
title: <Translate id="feat.deploy.title">Painless Deployment</Translate>,
|
||||
description: (
|
||||
<Translate id="feat.deploy.desc">
|
||||
Single static binary with embedded SQLite. Docker one-click or bare-metal — zero external dependencies.
|
||||
</Translate>
|
||||
),
|
||||
icon: <RocketIcon />,
|
||||
link: '/docs/getting-started/installation',
|
||||
},
|
||||
];
|
||||
|
||||
function Feature({title, description, icon, link}: FeatureItem) {
|
||||
const content = (
|
||||
<>
|
||||
<div className={styles.iconWrap}>{icon}</div>
|
||||
<Heading as="h3" className={styles.featureTitle}>{title}</Heading>
|
||||
<p className={styles.featureDesc}>{description}</p>
|
||||
{link && (
|
||||
<span className={styles.featureLink}>
|
||||
<Translate id="feat.learnMore">Learn more</Translate>
|
||||
<span className={styles.featureArrow} aria-hidden="true">→</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
if (link) {
|
||||
return (
|
||||
<Link to={link} className={styles.featureCardLink}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <div className={styles.featureCard}>{content}</div>;
|
||||
}
|
||||
|
||||
export default function HomepageFeatures(): ReactNode {
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<div className="container">
|
||||
<div className={styles.sectionHead}>
|
||||
<div className={styles.sectionTag}>
|
||||
<Translate id="section.features.tag">FEATURES</Translate>
|
||||
</div>
|
||||
<Heading as="h2" className={styles.sectionTitle}>
|
||||
<Translate id="section.features.title">Everything you need, nothing you don't</Translate>
|
||||
</Heading>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
<Translate id="section.features.subtitle">
|
||||
Battle-tested building blocks — backup runners, storage providers, scheduling, and clustering.
|
||||
</Translate>
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.grid}>
|
||||
{FEATURES.map((feat, idx) => (
|
||||
<Feature key={idx} {...feat} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
148
docs-site/src/components/HomepageFeatures/styles.module.css
Normal file
148
docs-site/src/components/HomepageFeatures/styles.module.css
Normal file
@@ -0,0 +1,148 @@
|
||||
.section {
|
||||
padding: 6rem 0 4rem;
|
||||
}
|
||||
|
||||
.sectionHead {
|
||||
text-align: center;
|
||||
max-width: 720px;
|
||||
margin: 0 auto 3rem;
|
||||
}
|
||||
|
||||
.sectionTag {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--ifm-color-primary);
|
||||
padding: 4px 12px;
|
||||
background: rgba(22, 93, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .sectionTag {
|
||||
background: rgba(96, 126, 255, 0.18);
|
||||
color: var(--ifm-color-primary-lighter);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: clamp(1.8rem, 3vw, 2.5rem);
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem;
|
||||
color: var(--ifm-heading-color);
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.65;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 996px) {
|
||||
.section {
|
||||
padding: 3.5rem 0 2rem;
|
||||
}
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 997px) and (max-width: 1200px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.featureCard,
|
||||
.featureCardLink {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.75rem;
|
||||
background: var(--ifm-background-color);
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
border-radius: 12px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
text-decoration: none !important;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.featureCardLink:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: var(--ifm-color-primary);
|
||||
box-shadow: 0 12px 30px -8px rgba(22, 93, 255, 0.18);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .featureCard,
|
||||
[data-theme='dark'] .featureCardLink {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .featureCardLink:hover {
|
||||
background: rgba(64, 128, 255, 0.05);
|
||||
border-color: var(--ifm-color-primary);
|
||||
box-shadow: 0 12px 30px -8px rgba(64, 128, 255, 0.25);
|
||||
}
|
||||
|
||||
.iconWrap {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, rgba(22, 93, 255, 0.1) 0%, rgba(143, 75, 255, 0.08) 100%);
|
||||
color: var(--ifm-color-primary);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .iconWrap {
|
||||
background: linear-gradient(135deg, rgba(96, 126, 255, 0.15) 0%, rgba(143, 75, 255, 0.12) 100%);
|
||||
color: var(--ifm-color-primary-lighter);
|
||||
}
|
||||
|
||||
.featureTitle {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.6rem;
|
||||
color: var(--ifm-heading-color);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.featureDesc {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.65;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.featureLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 1rem;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
.featureArrow {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.featureCardLink:hover .featureArrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
120
docs-site/src/components/HomepageShowcase/index.tsx
Normal file
120
docs-site/src/components/HomepageShowcase/index.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import {useState} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import Heading from '@theme/Heading';
|
||||
import Translate from '@docusaurus/Translate';
|
||||
import useBaseUrl from '@docusaurus/useBaseUrl';
|
||||
import Link from '@docusaurus/Link';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
type Tab = {
|
||||
id: string;
|
||||
label: ReactNode;
|
||||
image: string;
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
};
|
||||
|
||||
function useTabs(): Tab[] {
|
||||
return [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: <Translate id="showcase.tab.dashboard">Dashboard</Translate>,
|
||||
image: useBaseUrl('/img/screenshots/dashboard.png'),
|
||||
title: <Translate id="showcase.dashboard.title">Know at a glance</Translate>,
|
||||
description: (
|
||||
<Translate id="showcase.dashboard.desc">
|
||||
Backup success rates, storage usage, recent runs and upcoming schedules — all on one page with live data.
|
||||
</Translate>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'tasks',
|
||||
label: <Translate id="showcase.tab.tasks">Backup Tasks</Translate>,
|
||||
image: useBaseUrl('/img/screenshots/backup-tasks.png'),
|
||||
title: <Translate id="showcase.tasks.title">Visual task editor</Translate>,
|
||||
description: (
|
||||
<Translate id="showcase.tasks.desc">
|
||||
Files, MySQL, PostgreSQL, SQLite and SAP HANA with a three-step wizard. Cron editor, multi-target dispatch, retention, compression and encryption — point and click.
|
||||
</Translate>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'storage',
|
||||
label: <Translate id="showcase.tab.storage">Storage Targets</Translate>,
|
||||
image: useBaseUrl('/img/screenshots/storage-targets.png'),
|
||||
title: <Translate id="showcase.storage.title">70+ backends, one flow</Translate>,
|
||||
description: (
|
||||
<Translate id="showcase.storage.desc">
|
||||
Alibaba OSS, Tencent COS, S3, Google Drive, WebDAV — plus every rclone backend behind a uniform form. Test connection, favourite, and view live usage.
|
||||
</Translate>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'nodes',
|
||||
label: <Translate id="showcase.tab.nodes">Multi-Node</Translate>,
|
||||
image: useBaseUrl('/img/screenshots/nodes.png'),
|
||||
title: <Translate id="showcase.nodes.title">Master-Agent in minutes</Translate>,
|
||||
description: (
|
||||
<Translate id="showcase.nodes.desc">
|
||||
Create a node, copy the token, start the Agent on any remote host. Tasks routed to a node run locally there and upload directly to storage — no reverse connectivity required.
|
||||
</Translate>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function HomepageShowcase(): ReactNode {
|
||||
const tabs = useTabs();
|
||||
const [active, setActive] = useState(tabs[0].id);
|
||||
const current = tabs.find(t => t.id === active) ?? tabs[0];
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<div className="container">
|
||||
<div className={styles.sectionHead}>
|
||||
<div className={styles.sectionTag}>
|
||||
<Translate id="showcase.tag">PRODUCT</Translate>
|
||||
</div>
|
||||
<Heading as="h2" className={styles.sectionTitle}>
|
||||
<Translate id="showcase.title">A polished console, not a DIY script</Translate>
|
||||
</Heading>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
<Translate id="showcase.subtitle">
|
||||
Every screen designed for day-2 operations — visibility first, configuration second.
|
||||
</Translate>
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.tabs}>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={clsx(styles.tabBtn, active === tab.id && styles.tabBtnActive)}
|
||||
onClick={() => setActive(tab.id)}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.stage}>
|
||||
<div className={styles.browser}>
|
||||
<div className={styles.browserBar}>
|
||||
<span className={clsx(styles.browserDot, styles.browserDotRed)} />
|
||||
<span className={clsx(styles.browserDot, styles.browserDotYellow)} />
|
||||
<span className={clsx(styles.browserDot, styles.browserDotGreen)} />
|
||||
<div className={styles.browserUrl}>backupx.local</div>
|
||||
</div>
|
||||
<img src={current.image} alt="" className={styles.screenshot} />
|
||||
</div>
|
||||
<div className={styles.caption}>
|
||||
<Heading as="h3" className={styles.captionTitle}>{current.title}</Heading>
|
||||
<p className={styles.captionDesc}>{current.description}</p>
|
||||
<Link to="/docs/getting-started/quick-start" className={styles.captionLink}>
|
||||
<Translate id="showcase.cta">Explore the docs</Translate>
|
||||
<span aria-hidden="true"> →</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
196
docs-site/src/components/HomepageShowcase/styles.module.css
Normal file
196
docs-site/src/components/HomepageShowcase/styles.module.css
Normal file
@@ -0,0 +1,196 @@
|
||||
.section {
|
||||
padding: 4rem 0 6rem;
|
||||
background: linear-gradient(180deg, transparent 0%, rgba(22, 93, 255, 0.03) 100%);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .section {
|
||||
background: linear-gradient(180deg, transparent 0%, rgba(64, 128, 255, 0.04) 100%);
|
||||
}
|
||||
|
||||
.sectionHead {
|
||||
text-align: center;
|
||||
max-width: 720px;
|
||||
margin: 0 auto 2.5rem;
|
||||
}
|
||||
|
||||
.sectionTag {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.15em;
|
||||
color: #8f4bff;
|
||||
padding: 4px 12px;
|
||||
background: rgba(143, 75, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .sectionTag {
|
||||
background: rgba(143, 75, 255, 0.18);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: clamp(1.8rem, 3vw, 2.5rem);
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem;
|
||||
color: var(--ifm-heading-color);
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.65;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Tab bar */
|
||||
.tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tabBtn {
|
||||
padding: 8px 18px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 999px;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tabBtn:hover {
|
||||
color: var(--ifm-color-primary);
|
||||
border-color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
.tabBtnActive,
|
||||
.tabBtnActive:hover {
|
||||
background: linear-gradient(90deg, #165dff 0%, #4080ff 100%);
|
||||
color: #fff !important;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 4px 14px rgba(22, 93, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Stage */
|
||||
.stage {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 3rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 996px) {
|
||||
.stage {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.browser {
|
||||
background: var(--ifm-background-color);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 30px 60px -20px rgba(22, 93, 255, 0.25),
|
||||
0 0 0 1px var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .browser {
|
||||
box-shadow:
|
||||
0 30px 60px -20px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.browserBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .browserBar {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-bottom-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.browserDot {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.browserDotRed { background: #ff5f56; }
|
||||
.browserDotYellow { background: #ffbd2e; }
|
||||
.browserDotGreen { background: #27c93f; }
|
||||
|
||||
.browserUrl {
|
||||
margin: 0 auto;
|
||||
padding: 3px 14px;
|
||||
background: var(--ifm-background-color);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-family: 'SFMono-Regular', Menlo, monospace;
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .browserUrl {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.screenshot {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
}
|
||||
|
||||
.caption {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 996px) {
|
||||
.caption {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.captionTitle {
|
||||
font-size: 1.7rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem;
|
||||
color: var(--ifm-heading-color);
|
||||
}
|
||||
|
||||
.captionDesc {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
|
||||
.captionLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-weight: 500;
|
||||
color: var(--ifm-color-primary);
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.captionLink:hover {
|
||||
color: var(--ifm-color-primary-dark);
|
||||
}
|
||||
234
docs-site/src/css/custom.css
Normal file
234
docs-site/src/css/custom.css
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* BackupX 官方文档站样式
|
||||
* 灵感:Ant Design / Arco Design
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Primary palette (Arco blue) */
|
||||
--ifm-color-primary: #165dff;
|
||||
--ifm-color-primary-dark: #0e4fe6;
|
||||
--ifm-color-primary-darker: #0b4bd9;
|
||||
--ifm-color-primary-darkest: #093eb3;
|
||||
--ifm-color-primary-light: #2f6cff;
|
||||
--ifm-color-primary-lighter: #3d75ff;
|
||||
--ifm-color-primary-lightest: #668eff;
|
||||
|
||||
/* Surfaces */
|
||||
--ifm-background-color: #ffffff;
|
||||
--ifm-background-surface-color: #ffffff;
|
||||
--ifm-color-emphasis-100: #f7f9fc;
|
||||
--ifm-color-emphasis-200: #eef1f6;
|
||||
--ifm-color-emphasis-300: #dde3ec;
|
||||
|
||||
/* Typography */
|
||||
--ifm-font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
--ifm-font-family-monospace: 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
--ifm-heading-font-weight: 600;
|
||||
--ifm-code-font-size: 92%;
|
||||
--ifm-h1-font-size: 2.25rem;
|
||||
--ifm-h2-font-size: 1.75rem;
|
||||
--ifm-h3-font-size: 1.35rem;
|
||||
--ifm-line-height-base: 1.7;
|
||||
|
||||
--ifm-color-content: #1d2129;
|
||||
--ifm-color-content-secondary: #4e5969;
|
||||
--ifm-heading-color: #1d2129;
|
||||
|
||||
/* Navbar */
|
||||
--ifm-navbar-height: 64px;
|
||||
--ifm-navbar-background-color: rgba(255, 255, 255, 0.82);
|
||||
--ifm-navbar-link-color: #4e5969;
|
||||
--ifm-navbar-link-hover-color: var(--ifm-color-primary);
|
||||
|
||||
/* Sidebar */
|
||||
--ifm-menu-color: #4e5969;
|
||||
--ifm-menu-color-background-active: rgba(22, 93, 255, 0.08);
|
||||
--ifm-menu-color-background-hover: var(--ifm-color-emphasis-100);
|
||||
|
||||
/* Code */
|
||||
--ifm-code-background: rgba(22, 93, 255, 0.06);
|
||||
--docusaurus-highlighted-code-line-bg: rgba(22, 93, 255, 0.08);
|
||||
|
||||
/* Hero background helper (consumed in index.module.css) */
|
||||
--bx-hero-bg: transparent;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--ifm-color-primary: #4080ff;
|
||||
--ifm-color-primary-dark: #3371f2;
|
||||
--ifm-color-primary-darker: #2c6ae6;
|
||||
--ifm-color-primary-darkest: #2359c7;
|
||||
--ifm-color-primary-light: #5a93ff;
|
||||
--ifm-color-primary-lighter: #74a5ff;
|
||||
--ifm-color-primary-lightest: #9dbfff;
|
||||
|
||||
--ifm-background-color: #0f1115;
|
||||
--ifm-background-surface-color: #16181d;
|
||||
--ifm-color-emphasis-100: #1a1d23;
|
||||
--ifm-color-emphasis-200: #23272f;
|
||||
--ifm-color-emphasis-300: #2e343d;
|
||||
|
||||
--ifm-color-content: #e6e9ef;
|
||||
--ifm-color-content-secondary: #9aa3b2;
|
||||
--ifm-heading-color: #f0f2f5;
|
||||
|
||||
--ifm-navbar-background-color: rgba(15, 17, 21, 0.82);
|
||||
--ifm-navbar-link-color: #c9d1db;
|
||||
|
||||
--ifm-menu-color: #c9d1db;
|
||||
--ifm-menu-color-background-active: rgba(64, 128, 255, 0.15);
|
||||
--ifm-menu-color-background-hover: rgba(255, 255, 255, 0.04);
|
||||
|
||||
--ifm-code-background: rgba(64, 128, 255, 0.14);
|
||||
--docusaurus-highlighted-code-line-bg: rgba(64, 128, 255, 0.18);
|
||||
}
|
||||
|
||||
/* Frosted-glass navbar */
|
||||
.navbar {
|
||||
backdrop-filter: saturate(180%) blur(10px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(10px);
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .navbar {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.navbar__title {
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.navbar__link {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Sidebar tweaks */
|
||||
.menu__link {
|
||||
font-size: 14px;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.menu__link--active,
|
||||
.menu__link--active:hover {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.theme-doc-sidebar-container {
|
||||
border-right: 1px solid var(--ifm-color-emphasis-200) !important;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .theme-doc-sidebar-container {
|
||||
border-right-color: rgba(255, 255, 255, 0.06) !important;
|
||||
}
|
||||
|
||||
/* Article: better heading rhythm */
|
||||
.markdown h2 {
|
||||
margin-top: 2.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .markdown h2 {
|
||||
border-top-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.markdown h3 {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.markdown table {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 0 1px var(--ifm-color-emphasis-200);
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.markdown table thead tr {
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
}
|
||||
|
||||
.markdown table th,
|
||||
.markdown table td {
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.markdown table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
code {
|
||||
background: var(--ifm-code-background);
|
||||
border: none;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
/* Admonitions: softer */
|
||||
.theme-admonition {
|
||||
border-radius: 8px;
|
||||
border-width: 1px;
|
||||
border-left-width: 4px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
--ifm-footer-background-color: #141720;
|
||||
--ifm-footer-color: #9aa3b2;
|
||||
--ifm-footer-link-color: #c9d1db;
|
||||
--ifm-footer-link-hover-color: #ffffff;
|
||||
--ifm-footer-title-color: #f0f2f5;
|
||||
padding: 3.5rem 0 2.5rem;
|
||||
}
|
||||
|
||||
.footer__title {
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.footer__link-item {
|
||||
font-size: 14px;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.footer__bottom {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding-top: 2rem;
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
|
||||
.footer__copyright {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--ifm-color-emphasis-300);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--ifm-color-emphasis-400, #adb5bd);
|
||||
}
|
||||
|
||||
[data-theme='dark'] ::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
273
docs-site/src/pages/index.module.css
Normal file
273
docs-site/src/pages/index.module.css
Normal file
@@ -0,0 +1,273 @@
|
||||
/* ── Hero ───────────────────────────────────────────── */
|
||||
.hero {
|
||||
position: relative;
|
||||
padding: 7rem 0 6rem;
|
||||
overflow: hidden;
|
||||
background: var(--bx-hero-bg);
|
||||
}
|
||||
|
||||
.heroBg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 15% 20%, rgba(104, 127, 255, 0.18) 0%, transparent 45%),
|
||||
radial-gradient(circle at 85% 70%, rgba(22, 93, 255, 0.15) 0%, transparent 50%),
|
||||
linear-gradient(180deg, #f7f9ff 0%, #ffffff 100%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .heroBg {
|
||||
background:
|
||||
radial-gradient(circle at 15% 20%, rgba(96, 126, 255, 0.22) 0%, transparent 45%),
|
||||
radial-gradient(circle at 85% 70%, rgba(118, 70, 255, 0.18) 0%, transparent 50%),
|
||||
linear-gradient(180deg, #0f1115 0%, #0b0d10 100%);
|
||||
}
|
||||
|
||||
.heroInner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
gap: 4rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 996px) {
|
||||
.hero {
|
||||
padding: 4rem 0 3rem;
|
||||
}
|
||||
.heroInner {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.heroContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 14px;
|
||||
background: rgba(22, 93, 255, 0.08);
|
||||
border: 1px solid rgba(22, 93, 255, 0.15);
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
color: var(--ifm-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .badge {
|
||||
background: rgba(96, 126, 255, 0.15);
|
||||
border-color: rgba(96, 126, 255, 0.3);
|
||||
color: var(--ifm-color-primary-lighter);
|
||||
}
|
||||
|
||||
.badgeDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--ifm-color-primary);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 4px rgba(22, 93, 255, 0.18);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: clamp(2.25rem, 4vw, 3.4rem);
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.025em;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--ifm-heading-color);
|
||||
}
|
||||
|
||||
.heroTitleAccent {
|
||||
display: block;
|
||||
background: linear-gradient(90deg, #4080ff 0%, #8f4bff 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.65;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
max-width: 540px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
background: linear-gradient(90deg, #165dff 0%, #4080ff 100%);
|
||||
border: none;
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 6px 20px rgba(22, 93, 255, 0.3);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.primaryBtn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 25px rgba(22, 93, 255, 0.4);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btnArrow {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.primaryBtn:hover .btnArrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
background: var(--ifm-background-color);
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
color: var(--ifm-font-color-base);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.secondaryBtn:hover {
|
||||
border-color: var(--ifm-color-primary);
|
||||
color: var(--ifm-color-primary);
|
||||
background: var(--ifm-background-color);
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.75rem;
|
||||
padding-top: 1.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.metricValue {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--ifm-heading-color);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.metricLabel {
|
||||
font-size: 12px;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.metricDivider {
|
||||
width: 1px;
|
||||
height: 30px;
|
||||
background: var(--ifm-color-emphasis-300);
|
||||
}
|
||||
|
||||
/* ── Code window (macOS-style) ─────────────────────── */
|
||||
.heroCode {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.codeWindow {
|
||||
background: #0f1622;
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 20px 50px -10px rgba(15, 22, 34, 0.35),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
[data-theme='light'] .codeWindow {
|
||||
box-shadow: 0 20px 50px -10px rgba(22, 93, 255, 0.2), 0 0 0 1px rgba(22, 93, 255, 0.06);
|
||||
}
|
||||
|
||||
.codeHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
background: #161f2e;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.codeDot {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.codeDotRed { background: #ff5f56; }
|
||||
.codeDotYellow { background: #ffbd2e; }
|
||||
.codeDotGreen { background: #27c93f; }
|
||||
|
||||
.codeTitle {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #7b8696;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.codeBody {
|
||||
margin: 0;
|
||||
padding: 18px 20px;
|
||||
font-family: 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: #e1e7ef;
|
||||
background: transparent;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.codeBody code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.codePrompt {
|
||||
color: #4080ff;
|
||||
margin-right: 6px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.codeComment {
|
||||
color: #6e7889;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.codeString {
|
||||
color: #82d1ff;
|
||||
}
|
||||
112
docs-site/src/pages/index.tsx
Normal file
112
docs-site/src/pages/index.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import Link from '@docusaurus/Link';
|
||||
import Translate, {translate} from '@docusaurus/Translate';
|
||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||
import Layout from '@theme/Layout';
|
||||
import Heading from '@theme/Heading';
|
||||
import HomepageFeatures from '@site/src/components/HomepageFeatures';
|
||||
import HomepageShowcase from '@site/src/components/HomepageShowcase';
|
||||
|
||||
import styles from './index.module.css';
|
||||
|
||||
function HomepageHeader() {
|
||||
return (
|
||||
<header className={styles.hero}>
|
||||
<div className={styles.heroBg} aria-hidden="true" />
|
||||
<div className={clsx('container', styles.heroInner)}>
|
||||
<div className={styles.heroContent}>
|
||||
<div className={styles.badge}>
|
||||
<span className={styles.badgeDot} />
|
||||
<Translate id="home.badge">Open-source · v1.6.0</Translate>
|
||||
</div>
|
||||
<Heading as="h1" className={styles.heroTitle}>
|
||||
<Translate id="home.title.part1">Self-hosted backup management</Translate>
|
||||
<span className={styles.heroTitleAccent}>
|
||||
<Translate id="home.title.part2">for every server.</Translate>
|
||||
</span>
|
||||
</Heading>
|
||||
<p className={styles.heroSubtitle}>
|
||||
<Translate id="home.tagline">
|
||||
One binary, one command. File / database / SAP HANA backups routed to 70+ storage backends.
|
||||
</Translate>
|
||||
</p>
|
||||
<div className={styles.actions}>
|
||||
<Link className={clsx('button button--primary button--lg', styles.primaryBtn)} to="/docs/getting-started/quick-start">
|
||||
<Translate id="home.getStarted">Get Started</Translate>
|
||||
<span className={styles.btnArrow} aria-hidden="true">→</span>
|
||||
</Link>
|
||||
<Link className={clsx('button button--lg', styles.secondaryBtn)} to="https://github.com/Awuqing/BackupX">
|
||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style={{marginRight: 6}}>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 005.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
GitHub
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.metrics}>
|
||||
<div className={styles.metric}>
|
||||
<div className={styles.metricValue}>70+</div>
|
||||
<div className={styles.metricLabel}>
|
||||
<Translate id="home.metric.backends">Storage backends</Translate>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.metricDivider} />
|
||||
<div className={styles.metric}>
|
||||
<div className={styles.metricValue}>5</div>
|
||||
<div className={styles.metricLabel}>
|
||||
<Translate id="home.metric.backupTypes">Backup types</Translate>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.metricDivider} />
|
||||
<div className={styles.metric}>
|
||||
<div className={styles.metricValue}>Apache 2.0</div>
|
||||
<div className={styles.metricLabel}>
|
||||
<Translate id="home.metric.license">License</Translate>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.heroCode}>
|
||||
<div className={styles.codeWindow}>
|
||||
<div className={styles.codeHeader}>
|
||||
<span className={clsx(styles.codeDot, styles.codeDotRed)} />
|
||||
<span className={clsx(styles.codeDot, styles.codeDotYellow)} />
|
||||
<span className={clsx(styles.codeDot, styles.codeDotGreen)} />
|
||||
<span className={styles.codeTitle}>bash</span>
|
||||
</div>
|
||||
<pre className={styles.codeBody}>
|
||||
<code>
|
||||
<span className={styles.codeComment}># Docker one-liner</span>{'\n'}
|
||||
<span className={styles.codePrompt}>$</span> docker run -d --name backupx \{'\n'}
|
||||
{' '}-p 8340:8340 \{'\n'}
|
||||
{' '}-v backupx-data:/app/data \{'\n'}
|
||||
{' '}awuqing/backupx:latest{'\n'}
|
||||
{'\n'}
|
||||
<span className={styles.codeComment}># Open http://localhost:8340</span>{'\n'}
|
||||
<span className={styles.codeComment}># Deploy an Agent on a remote host</span>{'\n'}
|
||||
<span className={styles.codePrompt}>$</span> backupx agent \{'\n'}
|
||||
{' '}--master <span className={styles.codeString}>http://master:8340</span> \{'\n'}
|
||||
{' '}--token <span className={styles.codeString}><token></span>
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home(): ReactNode {
|
||||
const {siteConfig} = useDocusaurusContext();
|
||||
return (
|
||||
<Layout
|
||||
title={translate({id: 'home.pageTitle', message: 'Self-hosted backup management'})}
|
||||
description={siteConfig.tagline}>
|
||||
<HomepageHeader />
|
||||
<main>
|
||||
<HomepageFeatures />
|
||||
<HomepageShowcase />
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
0
docs-site/static/.nojekyll
Normal file
0
docs-site/static/.nojekyll
Normal file
BIN
docs-site/static/img/favicon.ico
Normal file
BIN
docs-site/static/img/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
11
docs-site/static/img/logo.svg
Normal file
11
docs-site/static/img/logo.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<defs>
|
||||
<linearGradient id="bxg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#2f6cff"/>
|
||||
<stop offset="100%" stop-color="#0b3eb3"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="4" y="4" width="40" height="40" rx="8" fill="url(#bxg)"/>
|
||||
<path d="M16 14h10a5 5 0 0 1 0 10H16V14z" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16 24h12a5 5 0 0 1 0 10H16V24z" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 639 B |
BIN
docs-site/static/img/screenshots/backup-tasks.png
Normal file
BIN
docs-site/static/img/screenshots/backup-tasks.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
BIN
docs-site/static/img/screenshots/dashboard.png
Normal file
BIN
docs-site/static/img/screenshots/dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
BIN
docs-site/static/img/screenshots/nodes.png
Normal file
BIN
docs-site/static/img/screenshots/nodes.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
BIN
docs-site/static/img/screenshots/storage-targets.png
Normal file
BIN
docs-site/static/img/screenshots/storage-targets.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
12
docs-site/tsconfig.json
Normal file
12
docs-site/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
// This file is not used by "docusaurus start/build" commands.
|
||||
// It is here to improve your IDE experience (type-checking, autocompletion...),
|
||||
// and can also run the package.json "typecheck" script manually.
|
||||
{
|
||||
"extends": "@docusaurus/tsconfig",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"strict": true
|
||||
},
|
||||
"exclude": [".docusaurus", "build"]
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/backup"
|
||||
)
|
||||
|
||||
// Agent 是 Agent 进程的主控制器。
|
||||
@@ -131,6 +133,12 @@ func (a *Agent) pollAndHandleOnce(ctx context.Context) {
|
||||
a.handleRunTask(ctx, cmd)
|
||||
case "list_dir":
|
||||
a.handleListDir(ctx, cmd)
|
||||
case "restore_record":
|
||||
a.handleRestoreRecord(ctx, cmd)
|
||||
case "discover_db":
|
||||
a.handleDiscoverDB(ctx, cmd)
|
||||
case "delete_storage_object":
|
||||
a.handleDeleteStorageObject(ctx, cmd)
|
||||
default:
|
||||
msg := fmt.Sprintf("unknown command type: %s", cmd.Type)
|
||||
log.Printf("[agent] %s", msg)
|
||||
@@ -158,6 +166,83 @@ func (a *Agent) handleRunTask(ctx context.Context, cmd *CommandPayload) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleRestoreRecord 处理 restore_record 命令
|
||||
func (a *Agent) handleRestoreRecord(ctx context.Context, cmd *CommandPayload) {
|
||||
var payload struct {
|
||||
RestoreRecordID uint `json:"restoreRecordId"`
|
||||
}
|
||||
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
if payload.RestoreRecordID == 0 {
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "restoreRecordId is required", nil)
|
||||
return
|
||||
}
|
||||
if err := a.executor.ExecuteRestore(ctx, payload.RestoreRecordID); err != nil {
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{
|
||||
"restoreRecordId": payload.RestoreRecordID,
|
||||
})
|
||||
}
|
||||
|
||||
// handleDeleteStorageObject 处理 delete_storage_object 命令:在 Agent 侧删除指定存储对象。
|
||||
// 用于跨节点 local_disk 场景下的远程备份文件清理。
|
||||
func (a *Agent) handleDeleteStorageObject(ctx context.Context, cmd *CommandPayload) {
|
||||
var payload struct {
|
||||
TargetType string `json:"targetType"`
|
||||
TargetConfig map[string]any `json:"targetConfig"`
|
||||
StoragePath string `json:"storagePath"`
|
||||
}
|
||||
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(payload.StoragePath) == "" {
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "storagePath is required", nil)
|
||||
return
|
||||
}
|
||||
provider, err := a.executor.storageRegistry.Create(ctx, payload.TargetType, payload.TargetConfig)
|
||||
if err != nil {
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "create provider: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
if err := provider.Delete(ctx, payload.StoragePath); err != nil {
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "delete object: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{"deleted": true})
|
||||
}
|
||||
|
||||
// handleDiscoverDB 处理 discover_db 命令:在 Agent 本机执行 mysql/psql 列出数据库。
|
||||
func (a *Agent) handleDiscoverDB(ctx context.Context, cmd *CommandPayload) {
|
||||
var payload struct {
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
databases, err := backup.DiscoverDatabases(ctx, backup.NewOSCommandExecutor(), backup.DiscoverRequest{
|
||||
Type: payload.Type,
|
||||
Host: payload.Host,
|
||||
Port: payload.Port,
|
||||
User: payload.User,
|
||||
Password: payload.Password,
|
||||
})
|
||||
if err != nil {
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{"databases": databases})
|
||||
}
|
||||
|
||||
// handleListDir 处理 list_dir 命令(阶段四实现)
|
||||
func (a *Agent) handleListDir(ctx context.Context, cmd *CommandPayload) {
|
||||
var payload struct {
|
||||
|
||||
@@ -158,6 +158,52 @@ func (c *MasterClient) UpdateRecord(ctx context.Context, recordID uint, update R
|
||||
return c.do(ctx, http.MethodPost, path, update, nil)
|
||||
}
|
||||
|
||||
// RestoreSpec 与 service.AgentRestoreSpec 对齐
|
||||
type RestoreSpec struct {
|
||||
RestoreRecordID uint `json:"restoreRecordId"`
|
||||
BackupRecordID uint `json:"backupRecordId"`
|
||||
TaskID uint `json:"taskId"`
|
||||
TaskName string `json:"taskName"`
|
||||
Type string `json:"type"`
|
||||
SourcePath string `json:"sourcePath,omitempty"`
|
||||
SourcePaths []string `json:"sourcePaths,omitempty"`
|
||||
DBHost string `json:"dbHost,omitempty"`
|
||||
DBPort int `json:"dbPort,omitempty"`
|
||||
DBUser string `json:"dbUser,omitempty"`
|
||||
DBPassword string `json:"dbPassword,omitempty"`
|
||||
DBName string `json:"dbName,omitempty"`
|
||||
DBPath string `json:"dbPath,omitempty"`
|
||||
ExtraConfig string `json:"extraConfig,omitempty"`
|
||||
Compression string `json:"compression"`
|
||||
Encrypt bool `json:"encrypt"`
|
||||
Storage StorageTargetConfig `json:"storage"`
|
||||
StoragePath string `json:"storagePath"`
|
||||
FileName string `json:"fileName"`
|
||||
}
|
||||
|
||||
// RestoreUpdate 与 service.AgentRestoreUpdate 对齐
|
||||
type RestoreUpdate struct {
|
||||
Status string `json:"status,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
LogAppend string `json:"logAppend,omitempty"`
|
||||
}
|
||||
|
||||
// GetRestoreSpec 拉取恢复规格
|
||||
func (c *MasterClient) GetRestoreSpec(ctx context.Context, restoreRecordID uint) (*RestoreSpec, error) {
|
||||
var spec RestoreSpec
|
||||
path := fmt.Sprintf("/api/agent/restores/%d/spec", restoreRecordID)
|
||||
if err := c.do(ctx, http.MethodGet, path, nil, &spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &spec, nil
|
||||
}
|
||||
|
||||
// UpdateRestore 上报恢复记录的状态/日志
|
||||
func (c *MasterClient) UpdateRestore(ctx context.Context, restoreRecordID uint, update RestoreUpdate) error {
|
||||
path := fmt.Sprintf("/api/agent/restores/%d", restoreRecordID)
|
||||
return c.do(ctx, http.MethodPost, path, update, nil)
|
||||
}
|
||||
|
||||
// do 是通用 HTTP 调用。所有 Agent API 都统一走 JSON + X-Agent-Token。
|
||||
func (c *MasterClient) do(ctx context.Context, method, path string, body any, out any) error {
|
||||
var reqBody io.Reader
|
||||
|
||||
@@ -238,6 +238,180 @@ func (l *recordLogger) WriteLine(message string) {
|
||||
_ = l.client.UpdateRecord(l.ctx, l.recordID, RecordUpdate{LogAppend: message + "\n"})
|
||||
}
|
||||
|
||||
// restoreLogger 把 runner 日志回传到 Master 恢复记录。
|
||||
type restoreLogger struct {
|
||||
ctx context.Context
|
||||
client *MasterClient
|
||||
restoreID uint
|
||||
}
|
||||
|
||||
func newRestoreLogger(ctx context.Context, client *MasterClient, restoreID uint) *restoreLogger {
|
||||
return &restoreLogger{ctx: ctx, client: client, restoreID: restoreID}
|
||||
}
|
||||
|
||||
func (l *restoreLogger) WriteLine(message string) {
|
||||
_ = l.client.UpdateRestore(l.ctx, l.restoreID, RestoreUpdate{LogAppend: message + "\n"})
|
||||
}
|
||||
|
||||
// DeleteStorageObject 在 Agent 本机上删除指定存储对象(供跨节点清理调用)。
|
||||
func (e *Executor) DeleteStorageObject(ctx context.Context, targetType string, targetConfig map[string]any, storagePath string) error {
|
||||
provider, err := e.storageRegistry.Create(ctx, targetType, targetConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create provider: %w", err)
|
||||
}
|
||||
return provider.Delete(ctx, storagePath)
|
||||
}
|
||||
|
||||
// ExecuteRestore 处理 restore_record 命令:拉规格 → 下载 → 解压 → 执行 runner.Restore → 上报结果。
|
||||
//
|
||||
// 与 ExecuteRunTask 对称,但方向相反:
|
||||
// - 下载:通过 spec.Storage 创建 provider → Download(spec.StoragePath)
|
||||
// - 解密:当前 Agent 不支持加密恢复(密钥未下发),spec.Encrypt=true 会直接失败
|
||||
// - 执行:backup.Registry.Runner(spec.Type).Restore
|
||||
// - 上报:通过 UpdateRestore(status/logAppend)
|
||||
func (e *Executor) ExecuteRestore(ctx context.Context, restoreRecordID uint) error {
|
||||
spec, err := e.client.GetRestoreSpec(ctx, restoreRecordID)
|
||||
if err != nil {
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("拉取恢复规格失败: %v", err))
|
||||
return err
|
||||
}
|
||||
if spec.Encrypt {
|
||||
msg := "Agent 不支持加密恢复(加密密钥仅在 Master 端持有)"
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, msg)
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
e.appendRestoreLog(ctx, restoreRecordID, fmt.Sprintf("[agent] 开始恢复 %s (type=%s)\n", spec.TaskName, spec.Type))
|
||||
|
||||
if err := os.MkdirAll(e.tempDir, 0o755); err != nil {
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("创建临时目录失败: %v", err))
|
||||
return err
|
||||
}
|
||||
tmpDir, err := os.MkdirTemp(e.tempDir, "restore-*")
|
||||
if err != nil {
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("创建恢复临时目录失败: %v", err))
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// 1) 创建 storage provider
|
||||
var rawConfig map[string]any
|
||||
if len(spec.Storage.Config) > 0 {
|
||||
if err := jsonUnmarshalMap(spec.Storage.Config, &rawConfig); err != nil {
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("解析存储配置失败: %v", err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
provider, err := e.storageRegistry.Create(ctx, spec.Storage.Type, rawConfig)
|
||||
if err != nil {
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("创建存储客户端失败: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// 2) 下载
|
||||
fileName := spec.FileName
|
||||
if strings.TrimSpace(fileName) == "" {
|
||||
fileName = filepath.Base(spec.StoragePath)
|
||||
}
|
||||
artifactPath := filepath.Join(tmpDir, filepath.Base(fileName))
|
||||
e.appendRestoreLog(ctx, restoreRecordID, fmt.Sprintf("[agent] 下载备份文件 %s\n", spec.StoragePath))
|
||||
reader, err := provider.Download(ctx, spec.StoragePath)
|
||||
if err != nil {
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("下载备份失败: %v", err))
|
||||
return err
|
||||
}
|
||||
if err := writeReaderToLocal(artifactPath, reader); err != nil {
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("写入备份文件失败: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// 3) 解压(Agent 不支持加密,遇到 .enc 会直接失败)
|
||||
preparedPath := artifactPath
|
||||
if strings.HasSuffix(strings.ToLower(preparedPath), ".enc") {
|
||||
msg := "检测到加密后缀,Agent 不支持加密恢复"
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, msg)
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(preparedPath), ".gz") {
|
||||
e.appendRestoreLog(ctx, restoreRecordID, "[agent] 解压 gzip 压缩\n")
|
||||
decompressed, err := compress.GunzipFile(preparedPath)
|
||||
if err != nil {
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("解压失败: %v", err))
|
||||
return err
|
||||
}
|
||||
preparedPath = decompressed
|
||||
}
|
||||
|
||||
// 4) 运行 runner.Restore
|
||||
taskSpec := buildRestoreBackupTaskSpec(spec, time.Now().UTC(), tmpDir)
|
||||
runner, err := e.backupRegistry.Runner(taskSpec.Type)
|
||||
if err != nil {
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("不支持的备份类型: %v", err))
|
||||
return err
|
||||
}
|
||||
logger := newRestoreLogger(ctx, e.client, restoreRecordID)
|
||||
if err := runner.Restore(ctx, taskSpec, preparedPath, logger); err != nil {
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// 5) 上报成功
|
||||
return e.client.UpdateRestore(ctx, restoreRecordID, RestoreUpdate{
|
||||
Status: "success",
|
||||
LogAppend: "[agent] 恢复执行完成\n",
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Executor) appendRestoreLog(ctx context.Context, restoreID uint, line string) {
|
||||
_ = e.client.UpdateRestore(ctx, restoreID, RestoreUpdate{LogAppend: line})
|
||||
}
|
||||
|
||||
func (e *Executor) reportRestoreFailure(ctx context.Context, restoreID uint, msg string) {
|
||||
_ = e.client.UpdateRestore(ctx, restoreID, RestoreUpdate{
|
||||
Status: "failed",
|
||||
ErrorMessage: msg,
|
||||
LogAppend: fmt.Sprintf("[agent] 错误: %s\n", msg),
|
||||
})
|
||||
}
|
||||
|
||||
// buildRestoreBackupTaskSpec 把 RestoreSpec 转成 backup.TaskSpec。
|
||||
func buildRestoreBackupTaskSpec(spec *RestoreSpec, startedAt time.Time, tempDir string) backup.TaskSpec {
|
||||
return backup.TaskSpec{
|
||||
ID: spec.TaskID,
|
||||
Name: spec.TaskName,
|
||||
Type: spec.Type,
|
||||
SourcePath: spec.SourcePath,
|
||||
SourcePaths: spec.SourcePaths,
|
||||
ExcludePatterns: nil,
|
||||
Database: backup.DatabaseSpec{
|
||||
Host: spec.DBHost,
|
||||
Port: spec.DBPort,
|
||||
User: spec.DBUser,
|
||||
Password: spec.DBPassword,
|
||||
Path: spec.DBPath,
|
||||
Names: splitCommaOrNewline(spec.DBName),
|
||||
},
|
||||
Compression: spec.Compression,
|
||||
Encrypt: spec.Encrypt,
|
||||
StartedAt: startedAt,
|
||||
TempDir: tempDir,
|
||||
}
|
||||
}
|
||||
|
||||
// writeReaderToLocal 把 reader 写到本地文件(Agent 侧工具函数)。
|
||||
func writeReaderToLocal(targetPath string, reader io.ReadCloser) error {
|
||||
defer reader.Close()
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.Create(targetPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
_, err = io.Copy(file, reader)
|
||||
return err
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
|
||||
func computeFileSHA256(path string) (string, error) {
|
||||
|
||||
@@ -80,6 +80,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
storageTargetService.SetBackupRecordRepository(backupRecordRepo)
|
||||
backupTaskService := service.NewBackupTaskService(backupTaskRepo, storageTargetRepo, configCipher)
|
||||
backupTaskService.SetRecordsAndStorage(backupRecordRepo, storageRegistry)
|
||||
// nodeRepo 在下方 Cluster 节点管理区块才实例化,这里延后注入
|
||||
backupRunnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil), backup.NewSAPHANARunner(nil))
|
||||
logHub := backup.NewLogHub()
|
||||
retentionService := backupretention.NewService(backupRecordRepo)
|
||||
@@ -97,6 +98,9 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
backupTaskService.SetScheduler(schedulerService)
|
||||
// 审计日志注入延迟到 auditService 创建后(见下方)
|
||||
backupRecordService := service.NewBackupRecordService(backupRecordRepo, backupExecutionService, logHub)
|
||||
// 恢复服务:使用独立 LogHub 避免恢复记录与备份记录 ID 命名空间冲突
|
||||
restoreRecordRepo := repository.NewRestoreRecordRepository(db)
|
||||
restoreLogHub := backup.NewLogHub()
|
||||
dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo)
|
||||
settingsService := service.NewSettingsService(systemConfigRepo)
|
||||
|
||||
@@ -106,11 +110,13 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
authService.SetAuditService(auditService)
|
||||
schedulerService.SetAuditRecorder(auditService)
|
||||
|
||||
// Database discovery
|
||||
// Database discovery(集群依赖在 agentService 创建后注入)
|
||||
databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor())
|
||||
|
||||
// Cluster: Node management
|
||||
nodeRepo := repository.NewNodeRepository(db)
|
||||
backupTaskService.SetNodeRepository(nodeRepo)
|
||||
schedulerService.SetNodeRepository(nodeRepo)
|
||||
nodeService := service.NewNodeService(nodeRepo, version)
|
||||
nodeService.SetTaskRepository(backupTaskRepo)
|
||||
if err := nodeService.EnsureLocalNode(ctx); err != nil {
|
||||
@@ -122,13 +128,106 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
// Agent 协议服务:命令队列 + 任务下发 + 记录上报
|
||||
agentCmdRepo := repository.NewAgentCommandRepository(db)
|
||||
agentService := service.NewAgentService(nodeRepo, backupTaskRepo, backupRecordRepo, storageTargetRepo, agentCmdRepo, configCipher)
|
||||
agentService.SetRestoreRepository(restoreRecordRepo)
|
||||
agentService.StartCommandTimeoutMonitor(ctx, 30*time.Second, 10*time.Minute)
|
||||
|
||||
// 一键部署:install token service + 后台 GC
|
||||
installTokenRepo := repository.NewAgentInstallTokenRepository(db)
|
||||
installTokenService := service.NewInstallTokenService(installTokenRepo, nodeRepo)
|
||||
installTokenService.StartGC(ctx, time.Hour)
|
||||
|
||||
// 把 Agent 下发能力注入到备份执行服务,实现多节点路由
|
||||
backupExecutionService.SetClusterDependencies(nodeRepo, agentService)
|
||||
// 启用远程目录浏览:NodeService 通过 AgentService 做同步 RPC
|
||||
nodeService.SetAgentRPC(agentService)
|
||||
// 启用远程数据库发现:远程节点任务配置时 DatabasePicker 拿到的是节点视角的 DB 列表
|
||||
databaseDiscoveryService.SetClusterDependencies(nodeRepo, agentService)
|
||||
|
||||
// 恢复服务:集群感知(本地/远程路由),依赖 agentService 入队
|
||||
restoreService := service.NewRestoreService(
|
||||
restoreRecordRepo,
|
||||
backupRecordRepo,
|
||||
backupTaskRepo,
|
||||
storageTargetRepo,
|
||||
nodeRepo,
|
||||
storageRegistry,
|
||||
backupRunnerRegistry,
|
||||
restoreLogHub,
|
||||
configCipher,
|
||||
agentService,
|
||||
cfg.Backup.TempDir,
|
||||
cfg.Backup.MaxConcurrent,
|
||||
)
|
||||
|
||||
// 验证服务:定期校验备份可恢复性(企业合规刚需)
|
||||
verificationRecordRepo := repository.NewVerificationRecordRepository(db)
|
||||
verifyLogHub := backup.NewLogHub()
|
||||
verificationService := service.NewVerificationService(
|
||||
verificationRecordRepo,
|
||||
backupRecordRepo,
|
||||
backupTaskRepo,
|
||||
storageTargetRepo,
|
||||
nodeRepo,
|
||||
storageRegistry,
|
||||
verifyLogHub,
|
||||
configCipher,
|
||||
cfg.Backup.TempDir,
|
||||
cfg.Backup.MaxConcurrent,
|
||||
)
|
||||
// 验证失败通知:通过 NotificationService 的事件总线派发 verify_failed
|
||||
verificationService.SetNotifier(service.NewVerificationEventNotifier(notificationService))
|
||||
// 恢复完成/失败事件派发(restore_success / restore_failed)
|
||||
restoreService.SetEventDispatcher(notificationService)
|
||||
// 调度器接入验证演练 cron
|
||||
schedulerService.SetVerifyRunner(verificationService)
|
||||
|
||||
// 用户管理与 API Key 服务(企业级 RBAC)
|
||||
userService := service.NewUserService(userRepo)
|
||||
apiKeyRepo := repository.NewApiKeyRepository(db)
|
||||
apiKeyService := service.NewApiKeyService(apiKeyRepo)
|
||||
|
||||
// SLA 后台扫描:每 15 分钟扫描违约任务,同任务 6 小时内不重复派发
|
||||
dashboardService.StartSLAMonitor(ctx, notificationService, 15*time.Minute, 6*time.Hour)
|
||||
// 存储目标健康扫描:每 5 分钟测试启用目标,掉线即告警
|
||||
storageTargetService.StartHealthMonitor(ctx, notificationService, 5*time.Minute)
|
||||
|
||||
// 备份复制服务(3-2-1 规则核心)
|
||||
replicationRecordRepo := repository.NewReplicationRecordRepository(db)
|
||||
replicationService := service.NewReplicationService(
|
||||
replicationRecordRepo, backupRecordRepo, storageTargetRepo,
|
||||
nodeRepo, storageRegistry, configCipher,
|
||||
cfg.Backup.TempDir, cfg.Backup.MaxConcurrent,
|
||||
)
|
||||
replicationService.SetEventDispatcher(notificationService)
|
||||
backupExecutionService.SetReplicationTrigger(replicationService)
|
||||
// 备份成功后触发下游依赖任务(任务依赖链工作流)
|
||||
backupExecutionService.SetDependentsResolver(backupTaskService)
|
||||
|
||||
// 任务模板(批量创建)
|
||||
taskTemplateRepo := repository.NewTaskTemplateRepository(db)
|
||||
taskTemplateService := service.NewTaskTemplateService(taskTemplateRepo, backupTaskService)
|
||||
|
||||
// 任务配置导入/导出(JSON,集群迁移 & 灾备)
|
||||
taskExportService := service.NewTaskExportService(backupTaskService, backupTaskRepo, storageTargetRepo, nodeRepo)
|
||||
|
||||
// 全局搜索(跨任务/存储/节点/最近记录)
|
||||
searchService := service.NewSearchService(backupTaskRepo, backupRecordRepo, storageTargetRepo, nodeRepo)
|
||||
|
||||
// 实时事件广播器(SSE 推送给前端 Dashboard)
|
||||
// 注入 notification 后,每次 DispatchEvent 同时 broadcast 到所有 SSE 订阅者
|
||||
eventBroadcaster := service.NewEventBroadcaster()
|
||||
notificationService.SetBroadcaster(eventBroadcaster)
|
||||
|
||||
// 集群版本监控:每 30 分钟扫描,节点 24 小时内只告警一次
|
||||
clusterVersionMonitor := service.NewClusterVersionMonitor(nodeRepo, version)
|
||||
clusterVersionMonitor.SetEventDispatcher(notificationService)
|
||||
clusterVersionMonitor.Start(ctx, 30*time.Minute, 24*time.Hour)
|
||||
|
||||
// Dashboard 集群概览依赖注入
|
||||
dashboardService.SetClusterDependencies(nodeRepo, version)
|
||||
|
||||
router := aphttp.NewRouter(aphttp.RouterDependencies{
|
||||
Context: ctx,
|
||||
Config: cfg,
|
||||
Version: version,
|
||||
Logger: appLogger,
|
||||
@@ -138,6 +237,15 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
BackupTaskService: backupTaskService,
|
||||
BackupExecutionService: backupExecutionService,
|
||||
BackupRecordService: backupRecordService,
|
||||
RestoreService: restoreService,
|
||||
VerificationService: verificationService,
|
||||
ReplicationService: replicationService,
|
||||
TaskTemplateService: taskTemplateService,
|
||||
TaskExportService: taskExportService,
|
||||
SearchService: searchService,
|
||||
EventBroadcaster: eventBroadcaster,
|
||||
UserService: userService,
|
||||
ApiKeyService: apiKeyService,
|
||||
NotificationService: notificationService,
|
||||
DashboardService: dashboardService,
|
||||
SettingsService: settingsService,
|
||||
@@ -146,8 +254,11 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
DatabaseDiscoveryService: databaseDiscoveryService,
|
||||
AuditService: auditService,
|
||||
JWTManager: jwtManager,
|
||||
UserRepository: userRepo,
|
||||
SystemConfigRepo: systemConfigRepo,
|
||||
UserRepository: userRepo,
|
||||
SystemConfigRepo: systemConfigRepo,
|
||||
InstallTokenService: installTokenService,
|
||||
MasterExternalURL: "", // 如需覆盖 URL,可扩展 cfg.Server 增字段;目前留空依赖 X-Forwarded-* / Request.Host
|
||||
DB: db,
|
||||
})
|
||||
|
||||
httpServer := &stdhttp.Server{
|
||||
|
||||
119
server/internal/backup/discover.go
Normal file
119
server/internal/backup/discover.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DiscoverRequest 数据库发现请求参数。
|
||||
// Type 取 "mysql" 或 "postgresql"。
|
||||
type DiscoverRequest struct {
|
||||
Type string
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
Password string
|
||||
}
|
||||
|
||||
// DiscoverDatabases 通过本机 mysql/psql 客户端连接目标数据库并列出非系统库。
|
||||
// 5 秒命令超时。调用方负责传入 CommandExecutor(Master 用 OSCommandExecutor,
|
||||
// Agent 同理)。此函数不依赖 service / apperror,便于在 agent 包复用。
|
||||
func DiscoverDatabases(ctx context.Context, executor CommandExecutor, req DiscoverRequest) ([]string, error) {
|
||||
switch strings.TrimSpace(strings.ToLower(req.Type)) {
|
||||
case "mysql":
|
||||
return discoverMySQLDatabases(ctx, executor, req)
|
||||
case "postgresql":
|
||||
return discoverPostgreSQLDatabases(ctx, executor, req)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database type: %s", req.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func discoverMySQLDatabases(ctx context.Context, executor CommandExecutor, req DiscoverRequest) ([]string, error) {
|
||||
mysqlPath, err := executor.LookPath("mysql")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("系统未安装 mysql 客户端")
|
||||
}
|
||||
timeout, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
var stdout, stderr bytes.Buffer
|
||||
args := []string{
|
||||
fmt.Sprintf("--host=%s", req.Host),
|
||||
fmt.Sprintf("--port=%d", req.Port),
|
||||
fmt.Sprintf("--user=%s", req.User),
|
||||
"-e", "SHOW DATABASES",
|
||||
"--skip-column-names",
|
||||
}
|
||||
env := []string{fmt.Sprintf("MYSQL_PWD=%s", req.Password)}
|
||||
if err := executor.Run(timeout, mysqlPath, args, CommandOptions{
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Env: env,
|
||||
}); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg == "" {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
return nil, fmt.Errorf("连接 MySQL 失败:%s", errMsg)
|
||||
}
|
||||
systemDBs := map[string]bool{
|
||||
"information_schema": true,
|
||||
"performance_schema": true,
|
||||
"mysql": true,
|
||||
"sys": true,
|
||||
}
|
||||
var databases []string
|
||||
for _, line := range strings.Split(stdout.String(), "\n") {
|
||||
db := strings.TrimSpace(line)
|
||||
if db == "" || systemDBs[db] {
|
||||
continue
|
||||
}
|
||||
databases = append(databases, db)
|
||||
}
|
||||
return databases, nil
|
||||
}
|
||||
|
||||
func discoverPostgreSQLDatabases(ctx context.Context, executor CommandExecutor, req DiscoverRequest) ([]string, error) {
|
||||
psqlPath, err := executor.LookPath("psql")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("系统未安装 psql 客户端")
|
||||
}
|
||||
timeout, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
var stdout, stderr bytes.Buffer
|
||||
args := []string{
|
||||
"-h", req.Host,
|
||||
"-p", fmt.Sprintf("%d", req.Port),
|
||||
"-U", req.User,
|
||||
"-d", "postgres",
|
||||
"-t", "-A",
|
||||
"-c", "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname",
|
||||
}
|
||||
env := []string{fmt.Sprintf("PGPASSWORD=%s", req.Password)}
|
||||
if err := executor.Run(timeout, psqlPath, args, CommandOptions{
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Env: env,
|
||||
}); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg == "" {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
return nil, fmt.Errorf("连接 PostgreSQL 失败:%s", errMsg)
|
||||
}
|
||||
skipDBs := map[string]bool{
|
||||
"postgres": true,
|
||||
}
|
||||
var databases []string
|
||||
for _, line := range strings.Split(stdout.String(), "\n") {
|
||||
db := strings.TrimSpace(line)
|
||||
if db == "" || skipDBs[db] || strings.HasPrefix(db, "template") {
|
||||
continue
|
||||
}
|
||||
databases = append(databases, db)
|
||||
}
|
||||
return databases, nil
|
||||
}
|
||||
179
server/internal/backup/verify.go
Normal file
179
server/internal/backup/verify.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// VerifyReport 是 quick 模式的验证结果摘要。
|
||||
type VerifyReport struct {
|
||||
TotalEntries int `json:"totalEntries,omitempty"`
|
||||
FileBytes int64 `json:"fileBytes,omitempty"`
|
||||
ChecksumOK bool `json:"checksumOk,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// VerifyTarArchive 遍历 tar 归档的每个 header + reader,不写盘。
|
||||
// 能检测归档截断、条目损坏、层级不对等常见问题。
|
||||
// expectedChecksum 非空时额外对整个文件校验 SHA-256(不做解压)。
|
||||
func VerifyTarArchive(artifactPath string, expectedChecksum string) (*VerifyReport, error) {
|
||||
file, err := os.Open(artifactPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open tar artifact: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
report := &VerifyReport{}
|
||||
h := sha256.New()
|
||||
reader := io.TeeReader(file, h)
|
||||
tr := tar.NewReader(reader)
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("read tar entry: %w", err)
|
||||
}
|
||||
report.TotalEntries++
|
||||
// 读完条目数据以触发完整性校验(tar 内部 CRC 不严格,但断流会报错)
|
||||
if header.Typeflag == tar.TypeReg || header.Typeflag == tar.TypeRegA {
|
||||
n, copyErr := io.Copy(io.Discard, tr)
|
||||
if copyErr != nil {
|
||||
return report, fmt.Errorf("read entry %s: %w", header.Name, copyErr)
|
||||
}
|
||||
report.FileBytes += n
|
||||
}
|
||||
}
|
||||
// 读完 tar 后继续把剩余字节喂给 hash(tar 结束后可能有零填充尾)
|
||||
if _, err := io.Copy(io.Discard, reader); err != nil {
|
||||
return report, fmt.Errorf("drain remainder: %w", err)
|
||||
}
|
||||
actual := hex.EncodeToString(h.Sum(nil))
|
||||
if strings.TrimSpace(expectedChecksum) != "" {
|
||||
report.ChecksumOK = strings.EqualFold(actual, expectedChecksum)
|
||||
if !report.ChecksumOK {
|
||||
return report, fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actual)
|
||||
}
|
||||
} else {
|
||||
report.ChecksumOK = true
|
||||
}
|
||||
report.Detail = fmt.Sprintf("tar 包完整(%d 条目,有效字节 %d)", report.TotalEntries, report.FileBytes)
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// VerifySQLiteFile 校验 SQLite 文件头魔数。
|
||||
// 官方格式:前 16 字节为 "SQLite format 3\000"。
|
||||
func VerifySQLiteFile(artifactPath string) (*VerifyReport, error) {
|
||||
file, err := os.Open(artifactPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite artifact: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
header := make([]byte, 16)
|
||||
if _, err := io.ReadFull(file, header); err != nil {
|
||||
return nil, fmt.Errorf("read sqlite header: %w", err)
|
||||
}
|
||||
const magic = "SQLite format 3\x00"
|
||||
if string(header) != magic {
|
||||
return &VerifyReport{Detail: "非法的 SQLite 文件头"}, fmt.Errorf("invalid sqlite magic header")
|
||||
}
|
||||
info, _ := file.Stat()
|
||||
var size int64
|
||||
if info != nil {
|
||||
size = info.Size()
|
||||
}
|
||||
return &VerifyReport{
|
||||
FileBytes: size,
|
||||
Detail: fmt.Sprintf("SQLite 文件头合法(总大小 %d 字节)", size),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyMySQLDump 校验 MySQL dump 文件头部是否为合法 mysqldump 输出。
|
||||
// 头部 1024 字节包含以下任一关键字即通过:
|
||||
// - "-- MySQL dump"
|
||||
// - "-- Server version"
|
||||
// - "-- MariaDB dump"
|
||||
func VerifyMySQLDump(artifactPath string) (*VerifyReport, error) {
|
||||
return verifyDumpHeader(artifactPath, []string{"-- MySQL dump", "-- Server version", "-- MariaDB dump"}, "MySQL/MariaDB")
|
||||
}
|
||||
|
||||
// VerifyPostgreSQLDump 校验 PostgreSQL plain text dump 头部。
|
||||
// 典型标记:"-- PostgreSQL database dump" 或 "-- Dumped from database version"。
|
||||
func VerifyPostgreSQLDump(artifactPath string) (*VerifyReport, error) {
|
||||
return verifyDumpHeader(artifactPath, []string{"-- PostgreSQL database dump", "-- Dumped from database version", "SET statement_timeout"}, "PostgreSQL")
|
||||
}
|
||||
|
||||
func verifyDumpHeader(artifactPath string, markers []string, label string) (*VerifyReport, error) {
|
||||
file, err := os.Open(artifactPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open dump artifact: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
reader := bufio.NewReader(file)
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := io.ReadFull(reader, buf)
|
||||
sample := string(buf[:n])
|
||||
matched := ""
|
||||
for _, m := range markers {
|
||||
if strings.Contains(sample, m) {
|
||||
matched = m
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched == "" {
|
||||
return &VerifyReport{Detail: fmt.Sprintf("未在前 %d 字节中发现 %s dump 特征", n, label)}, fmt.Errorf("no %s dump marker in header", label)
|
||||
}
|
||||
info, _ := file.Stat()
|
||||
var size int64
|
||||
if info != nil {
|
||||
size = info.Size()
|
||||
}
|
||||
return &VerifyReport{
|
||||
FileBytes: size,
|
||||
Detail: fmt.Sprintf("%s dump 头部识别标志: %q(文件 %d 字节)", label, matched, size),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifySAPHANAArchive 校验 SAP HANA 归档 tar 中是否包含 databackup/logbackup 标志文件。
|
||||
func VerifySAPHANAArchive(artifactPath string) (*VerifyReport, error) {
|
||||
file, err := os.Open(artifactPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open hana archive: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
tr := tar.NewReader(file)
|
||||
report := &VerifyReport{}
|
||||
var foundDataBackup bool
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("read tar entry: %w", err)
|
||||
}
|
||||
report.TotalEntries++
|
||||
name := strings.ToLower(header.Name)
|
||||
if strings.Contains(name, "databackup") || strings.Contains(name, "logbackup") || strings.HasPrefix(name, "hana_") {
|
||||
foundDataBackup = true
|
||||
}
|
||||
if header.Typeflag == tar.TypeReg || header.Typeflag == tar.TypeRegA {
|
||||
n, copyErr := io.Copy(io.Discard, tr)
|
||||
if copyErr != nil {
|
||||
return report, fmt.Errorf("read entry %s: %w", header.Name, copyErr)
|
||||
}
|
||||
report.FileBytes += n
|
||||
}
|
||||
}
|
||||
if !foundDataBackup {
|
||||
return report, fmt.Errorf("HANA archive missing databackup/logbackup markers")
|
||||
}
|
||||
report.Detail = fmt.Sprintf("HANA 归档包含 %d 条目(%d 字节),已识别备份标志文件", report.TotalEntries, report.FileBytes)
|
||||
return report, nil
|
||||
}
|
||||
121
server/internal/backup/verify_test.go
Normal file
121
server/internal/backup/verify_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 构造一个最小的 tar 归档文件供测试使用
|
||||
func writeTestTar(t *testing.T, entries map[string][]byte) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "test.tar")
|
||||
buf := new(bytes.Buffer)
|
||||
tw := tar.NewWriter(buf)
|
||||
for name, body := range entries {
|
||||
header := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg}
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
t.Fatalf("write tar header: %v", err)
|
||||
}
|
||||
if _, err := tw.Write(body); err != nil {
|
||||
t.Fatalf("write tar body: %v", err)
|
||||
}
|
||||
}
|
||||
_ = tw.Close()
|
||||
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
|
||||
t.Fatalf("write tar file: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestVerifyTarArchive_Valid(t *testing.T) {
|
||||
path := writeTestTar(t, map[string][]byte{
|
||||
"readme.md": []byte("hello"),
|
||||
"data.bin": []byte("world!!!"),
|
||||
})
|
||||
report, err := VerifyTarArchive(path, "")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyTarArchive returned error: %v", err)
|
||||
}
|
||||
if report.TotalEntries != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", report.TotalEntries)
|
||||
}
|
||||
if report.FileBytes == 0 {
|
||||
t.Fatalf("expected non-zero file bytes")
|
||||
}
|
||||
if !report.ChecksumOK {
|
||||
t.Fatalf("checksumOK should be true when expected checksum empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyTarArchive_Truncated(t *testing.T) {
|
||||
// 构造带多个大 entry 的 tar,在 entry 数据中间截断,使 io.Copy 触发 UnexpectedEOF
|
||||
path := filepath.Join(t.TempDir(), "big.tar")
|
||||
buf := new(bytes.Buffer)
|
||||
tw := tar.NewWriter(buf)
|
||||
body := bytes.Repeat([]byte("x"), 4096)
|
||||
_ = tw.WriteHeader(&tar.Header{Name: "big.bin", Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg})
|
||||
_, _ = tw.Write(body)
|
||||
_ = tw.Close()
|
||||
data := buf.Bytes()
|
||||
// 保留 header 完整(512),破坏 body 中间使 tar.Reader 在 io.Copy 时遇到 EOF
|
||||
truncated := data[:512+1024]
|
||||
if err := os.WriteFile(path, truncated, 0o644); err != nil {
|
||||
t.Fatalf("write truncated: %v", err)
|
||||
}
|
||||
if _, err := VerifyTarArchive(path, ""); err == nil {
|
||||
t.Fatalf("expected error on truncated tar, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifySQLiteFile_Valid(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "ok.db")
|
||||
content := []byte("SQLite format 3\x00" + string(make([]byte, 100)))
|
||||
if err := os.WriteFile(path, content, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
report, err := VerifySQLiteFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifySQLiteFile: %v", err)
|
||||
}
|
||||
if report.FileBytes == 0 {
|
||||
t.Fatalf("expected non-zero size")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifySQLiteFile_Invalid(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bad.db")
|
||||
if err := os.WriteFile(path, []byte("not sqlite at all, some other text"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
if _, err := VerifySQLiteFile(path); err == nil {
|
||||
t.Fatalf("expected error on non-sqlite file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyMySQLDump(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "dump.sql")
|
||||
content := "-- MySQL dump 10.13 Distrib 8.0.33\n-- Host: localhost\nINSERT INTO foo VALUES (1);\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
report, err := VerifyMySQLDump(path)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyMySQLDump: %v", err)
|
||||
}
|
||||
if report.Detail == "" {
|
||||
t.Fatalf("expected Detail in report")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPostgreSQLDump_Invalid(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "notpg.sql")
|
||||
if err := os.WriteFile(path, []byte("some random text without header markers"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
if _, err := VerifyPostgreSQLDump(path); err == nil {
|
||||
t.Fatalf("expected error on non-pg dump")
|
||||
}
|
||||
}
|
||||
180
server/internal/backup/window.go
Normal file
180
server/internal/backup/window.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MaintenanceWindow 描述一个允许执行备份的时段。
|
||||
// 格式语义:
|
||||
// - Days 为 "0..6" 的字符串集合(0=周日,6=周六);空 = 每天
|
||||
// - StartMinutes / EndMinutes 为"午夜起计算的分钟数",0 ≤ v < 1440
|
||||
// - 跨午夜窗口:Start > End 表示跨夜(如 22:00-06:00)
|
||||
//
|
||||
// 多个窗口是 OR 语义:只要 now 落入任一窗口即允许执行。
|
||||
type MaintenanceWindow struct {
|
||||
Days map[int]bool
|
||||
StartMinutes int
|
||||
EndMinutes int
|
||||
}
|
||||
|
||||
// ParseMaintenanceWindows 解析用户配置(CSV 每项形如 "days=mon,tue|time=22:00-06:00")。
|
||||
// 简化语法:多个窗口以 ';' 分隔,每个窗口按 "[days=xxx;]time=HH:MM-HH:MM" 格式。
|
||||
// Days 缺省 = 全周;若不合法,跳过该段而非抛错(让调用方尽力工作)。
|
||||
// 示例:
|
||||
// "time=01:00-05:00" 每天 1 点到 5 点
|
||||
// "days=sat,sun;time=00:00-23:59" 仅周末全天
|
||||
// "time=22:00-06:00" 每天跨夜
|
||||
// "days=mon,tue,wed,thu,fri;time=22:00-06:00" 工作日跨夜
|
||||
func ParseMaintenanceWindows(value string) []MaintenanceWindow {
|
||||
v := strings.TrimSpace(value)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
segments := strings.Split(v, ";")
|
||||
var windows []MaintenanceWindow
|
||||
for _, segment := range segments {
|
||||
segment = strings.TrimSpace(segment)
|
||||
if segment == "" {
|
||||
continue
|
||||
}
|
||||
window, ok := parseSingleWindow(segment)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
windows = append(windows, window)
|
||||
}
|
||||
return windows
|
||||
}
|
||||
|
||||
func parseSingleWindow(segment string) (MaintenanceWindow, bool) {
|
||||
// "days=xxx,time=HH:MM-HH:MM" 或 "time=..."
|
||||
fields := strings.Split(segment, ",")
|
||||
days := map[int]bool{}
|
||||
var timeExpr string
|
||||
for _, field := range fields {
|
||||
field = strings.TrimSpace(field)
|
||||
if field == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(field, "days=") {
|
||||
daysPart := strings.TrimPrefix(field, "days=")
|
||||
for _, day := range strings.Split(daysPart, "|") {
|
||||
if idx := parseDayToken(strings.TrimSpace(day)); idx >= 0 {
|
||||
days[idx] = true
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(field, "time=") {
|
||||
timeExpr = strings.TrimPrefix(field, "time=")
|
||||
}
|
||||
}
|
||||
start, end, ok := parseTimeRange(strings.TrimSpace(timeExpr))
|
||||
if !ok {
|
||||
return MaintenanceWindow{}, false
|
||||
}
|
||||
return MaintenanceWindow{Days: days, StartMinutes: start, EndMinutes: end}, true
|
||||
}
|
||||
|
||||
var dayTokens = map[string]int{
|
||||
"sun": 0, "sunday": 0, "0": 0,
|
||||
"mon": 1, "monday": 1, "1": 1,
|
||||
"tue": 2, "tuesday": 2, "2": 2,
|
||||
"wed": 3, "wednesday": 3, "3": 3,
|
||||
"thu": 4, "thursday": 4, "4": 4,
|
||||
"fri": 5, "friday": 5, "5": 5,
|
||||
"sat": 6, "saturday": 6, "6": 6,
|
||||
}
|
||||
|
||||
func parseDayToken(value string) int {
|
||||
v := strings.ToLower(strings.TrimSpace(value))
|
||||
if v == "" {
|
||||
return -1
|
||||
}
|
||||
if idx, ok := dayTokens[v]; ok {
|
||||
return idx
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// parseTimeRange 解析 "HH:MM-HH:MM",返回起止分钟数。
|
||||
func parseTimeRange(value string) (int, int, bool) {
|
||||
parts := strings.SplitN(value, "-", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
start, ok := parseHHMM(parts[0])
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
end, ok := parseHHMM(parts[1])
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
func parseHHMM(value string) (int, bool) {
|
||||
parts := strings.Split(strings.TrimSpace(value), ":")
|
||||
if len(parts) != 2 {
|
||||
return 0, false
|
||||
}
|
||||
h, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
if err != nil || h < 0 || h > 23 {
|
||||
return 0, false
|
||||
}
|
||||
m, err := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if err != nil || m < 0 || m > 59 {
|
||||
return 0, false
|
||||
}
|
||||
return h*60 + m, true
|
||||
}
|
||||
|
||||
// IsWithinWindow 判断 t 是否落入任一窗口。windows 为空或 nil 时总是返回 true(不限制)。
|
||||
func IsWithinWindow(t time.Time, windows []MaintenanceWindow) bool {
|
||||
if len(windows) == 0 {
|
||||
return true
|
||||
}
|
||||
minutes := t.Hour()*60 + t.Minute()
|
||||
weekday := int(t.Weekday())
|
||||
for _, w := range windows {
|
||||
if len(w.Days) > 0 && !w.Days[weekday] {
|
||||
continue
|
||||
}
|
||||
if w.StartMinutes == w.EndMinutes {
|
||||
continue
|
||||
}
|
||||
if w.StartMinutes < w.EndMinutes {
|
||||
// 同日窗口
|
||||
if minutes >= w.StartMinutes && minutes < w.EndMinutes {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
// 跨午夜:[start, 1440) ∪ [0, end)
|
||||
if minutes >= w.StartMinutes || minutes < w.EndMinutes {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidateMaintenanceWindows 用户输入合法性校验(返回人可读的错误)。
|
||||
func ValidateMaintenanceWindows(value string) error {
|
||||
v := strings.TrimSpace(value)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
segments := strings.Split(v, ";")
|
||||
for _, segment := range segments {
|
||||
segment = strings.TrimSpace(segment)
|
||||
if segment == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := parseSingleWindow(segment); !ok {
|
||||
return fmt.Errorf("无效的维护窗口配置: %q(期望格式如 time=22:00-06:00 或 days=sat,sun,time=00:00-23:59)", segment)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
110
server/internal/backup/window_test.go
Normal file
110
server/internal/backup/window_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseAndCheck_SingleSameDayWindow(t *testing.T) {
|
||||
windows := ParseMaintenanceWindows("time=01:00-05:00")
|
||||
if len(windows) != 1 {
|
||||
t.Fatalf("expected 1 window, got %d", len(windows))
|
||||
}
|
||||
// 周一 03:00 UTC(天数不限制)
|
||||
at := time.Date(2026, 4, 20, 3, 0, 0, 0, time.UTC)
|
||||
if !IsWithinWindow(at, windows) {
|
||||
t.Fatalf("expected 03:00 to be inside 01:00-05:00")
|
||||
}
|
||||
at = time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC)
|
||||
if IsWithinWindow(at, windows) {
|
||||
t.Fatalf("expected 06:00 to be outside 01:00-05:00")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndCheck_CrossMidnight(t *testing.T) {
|
||||
windows := ParseMaintenanceWindows("time=22:00-06:00")
|
||||
if len(windows) != 1 {
|
||||
t.Fatalf("expected 1 window")
|
||||
}
|
||||
tests := []struct {
|
||||
hour, minute int
|
||||
inside bool
|
||||
}{
|
||||
{22, 30, true},
|
||||
{23, 59, true},
|
||||
{0, 0, true},
|
||||
{3, 0, true},
|
||||
{5, 59, true},
|
||||
{6, 0, false},
|
||||
{7, 0, false},
|
||||
{21, 59, false},
|
||||
}
|
||||
base := time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC)
|
||||
for _, tc := range tests {
|
||||
at := base.Add(time.Duration(tc.hour)*time.Hour + time.Duration(tc.minute)*time.Minute)
|
||||
if got := IsWithinWindow(at, windows); got != tc.inside {
|
||||
t.Errorf("%02d:%02d expected inside=%v, got %v", tc.hour, tc.minute, tc.inside, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndCheck_DaysFilter(t *testing.T) {
|
||||
// 周末全天
|
||||
windows := ParseMaintenanceWindows("days=sat|sun,time=00:00-23:59")
|
||||
if len(windows) != 1 {
|
||||
t.Fatalf("expected 1 window")
|
||||
}
|
||||
sat := time.Date(2026, 4, 18, 12, 0, 0, 0, time.UTC) // Saturday
|
||||
sun := time.Date(2026, 4, 19, 12, 0, 0, 0, time.UTC) // Sunday
|
||||
mon := time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC) // Monday
|
||||
if !IsWithinWindow(sat, windows) {
|
||||
t.Fatalf("saturday should be inside")
|
||||
}
|
||||
if !IsWithinWindow(sun, windows) {
|
||||
t.Fatalf("sunday should be inside")
|
||||
}
|
||||
if IsWithinWindow(mon, windows) {
|
||||
t.Fatalf("monday should be outside")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndCheck_Multiple(t *testing.T) {
|
||||
// 两段:工作日跨夜 + 周末全天
|
||||
windows := ParseMaintenanceWindows("days=mon|tue|wed|thu|fri,time=22:00-06:00;days=sat|sun,time=00:00-23:59")
|
||||
if len(windows) != 2 {
|
||||
t.Fatalf("expected 2 windows, got %d", len(windows))
|
||||
}
|
||||
monAfternoon := time.Date(2026, 4, 20, 15, 0, 0, 0, time.UTC)
|
||||
if IsWithinWindow(monAfternoon, windows) {
|
||||
t.Fatalf("mon 15:00 should be outside both windows")
|
||||
}
|
||||
monNight := time.Date(2026, 4, 20, 23, 0, 0, 0, time.UTC)
|
||||
if !IsWithinWindow(monNight, windows) {
|
||||
t.Fatalf("mon 23:00 should be inside weekday-night window")
|
||||
}
|
||||
sunNoon := time.Date(2026, 4, 19, 12, 0, 0, 0, time.UTC)
|
||||
if !IsWithinWindow(sunNoon, windows) {
|
||||
t.Fatalf("sun 12:00 should be inside weekend window")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMaintenanceWindows(t *testing.T) {
|
||||
if err := ValidateMaintenanceWindows(""); err != nil {
|
||||
t.Fatalf("empty should be valid, got %v", err)
|
||||
}
|
||||
if err := ValidateMaintenanceWindows("time=01:00-05:00"); err != nil {
|
||||
t.Fatalf("valid format rejected: %v", err)
|
||||
}
|
||||
if err := ValidateMaintenanceWindows("bad-input"); err == nil {
|
||||
t.Fatalf("invalid format should return error")
|
||||
}
|
||||
if err := ValidateMaintenanceWindows("time=25:00-30:00"); err == nil {
|
||||
t.Fatalf("invalid hour should return error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWithinWindow_NoWindows(t *testing.T) {
|
||||
if !IsWithinWindow(time.Now(), nil) {
|
||||
t.Fatalf("no windows should always be inside")
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func Open(cfg config.DatabaseConfig, logger *zap.Logger) (*gorm.DB, error) {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&model.User{}, &model.SystemConfig{}, &model.StorageTarget{}, &model.OAuthSession{}, &model.BackupTask{}, &model.BackupRecord{}, &model.Notification{}, &model.Node{}, &model.BackupTaskStorageTarget{}, &model.AuditLog{}, &model.AgentCommand{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.User{}, &model.SystemConfig{}, &model.StorageTarget{}, &model.OAuthSession{}, &model.BackupTask{}, &model.BackupRecord{}, &model.Notification{}, &model.Node{}, &model.BackupTaskStorageTarget{}, &model.AuditLog{}, &model.AgentCommand{}, &model.AgentInstallToken{}, &model.RestoreRecord{}, &model.VerificationRecord{}, &model.ApiKey{}, &model.ReplicationRecord{}, &model.TaskTemplate{}); err != nil {
|
||||
return nil, fmt.Errorf("migrate schema: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,12 +14,13 @@ import (
|
||||
// AgentHandler 实现 Agent 调用 Master 的 HTTP API。
|
||||
// 全部端点通过 X-Agent-Token 头做节点认证,不使用 JWT。
|
||||
type AgentHandler struct {
|
||||
agentService *service.AgentService
|
||||
nodeService *service.NodeService
|
||||
agentService *service.AgentService
|
||||
nodeService *service.NodeService
|
||||
restoreService *service.RestoreService
|
||||
}
|
||||
|
||||
func NewAgentHandler(agentService *service.AgentService, nodeService *service.NodeService) *AgentHandler {
|
||||
return &AgentHandler{agentService: agentService, nodeService: nodeService}
|
||||
func NewAgentHandler(agentService *service.AgentService, nodeService *service.NodeService, restoreService *service.RestoreService) *AgentHandler {
|
||||
return &AgentHandler{agentService: agentService, nodeService: nodeService, restoreService: restoreService}
|
||||
}
|
||||
|
||||
// extractToken 从请求头或 JSON body 中提取 Agent Token。
|
||||
@@ -154,3 +155,70 @@ func (h *AgentHandler) UpdateRecord(c *gin.Context) {
|
||||
}
|
||||
response.Success(c, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// GetRestoreSpec Agent 拉取恢复规格。
|
||||
func (h *AgentHandler) GetRestoreSpec(c *gin.Context) {
|
||||
if h.restoreService == nil {
|
||||
c.JSON(stdhttp.StatusServiceUnavailable, gin.H{"code": "RESTORE_SERVICE_DISABLED", "message": "restore service is not enabled"})
|
||||
return
|
||||
}
|
||||
node, err := h.agentService.AuthenticatedNode(c.Request.Context(), extractToken(c))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
spec, err := h.restoreService.GetAgentRestoreSpec(c.Request.Context(), node, uint(id))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, spec)
|
||||
}
|
||||
|
||||
// UpdateRestore Agent 上报恢复记录的状态/日志。
|
||||
func (h *AgentHandler) UpdateRestore(c *gin.Context) {
|
||||
if h.restoreService == nil {
|
||||
c.JSON(stdhttp.StatusServiceUnavailable, gin.H{"code": "RESTORE_SERVICE_DISABLED", "message": "restore service is not enabled"})
|
||||
return
|
||||
}
|
||||
node, err := h.agentService.AuthenticatedNode(c.Request.Context(), extractToken(c))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
var input service.AgentRestoreUpdate
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.restoreService.UpdateAgentRestore(c.Request.Context(), node, uint(id), input); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// Self 返回当前 Agent token 所属节点的状态,供安装脚本末尾探活。
|
||||
func (h *AgentHandler) Self(c *gin.Context) {
|
||||
node, err := h.agentService.AuthenticatedNode(c.Request.Context(), extractToken(c))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
status, err := h.agentService.SelfStatus(c.Request.Context(), node)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, status)
|
||||
}
|
||||
|
||||
93
server/internal/http/api_key_handler.go
Normal file
93
server/internal/http/api_key_handler.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ApiKeyHandler 管理 API Key(admin 专属)。
|
||||
type ApiKeyHandler struct {
|
||||
service *service.ApiKeyService
|
||||
auditService *service.AuditService
|
||||
}
|
||||
|
||||
func NewApiKeyHandler(apiKeyService *service.ApiKeyService, auditService *service.AuditService) *ApiKeyHandler {
|
||||
return &ApiKeyHandler{service: apiKeyService, auditService: auditService}
|
||||
}
|
||||
|
||||
func (h *ApiKeyHandler) List(c *gin.Context) {
|
||||
items, err := h.service.List(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, items)
|
||||
}
|
||||
|
||||
func (h *ApiKeyHandler) Create(c *gin.Context) {
|
||||
var input service.ApiKeyCreateInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("API_KEY_INVALID", "API Key 参数不合法", err))
|
||||
return
|
||||
}
|
||||
creator := ""
|
||||
if username, exists := c.Get(contextUsernameKey); exists {
|
||||
if v, ok := username.(string); ok {
|
||||
creator = v
|
||||
}
|
||||
}
|
||||
result, err := h.service.Create(c.Request.Context(), creator, input)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "api_key", "create", "api_key", fmt.Sprintf("%d", result.ApiKey.ID), result.ApiKey.Name,
|
||||
fmt.Sprintf("创建 API Key: %s (角色: %s)", result.ApiKey.Name, result.ApiKey.Role))
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *ApiKeyHandler) Revoke(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.Revoke(c.Request.Context(), id); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "api_key", "revoke", "api_key", fmt.Sprintf("%d", id), "",
|
||||
fmt.Sprintf("撤销 API Key (ID: %d)", id))
|
||||
response.Success(c, gin.H{"revoked": true})
|
||||
}
|
||||
|
||||
func (h *ApiKeyHandler) Toggle(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("API_KEY_INVALID", "参数不合法", err))
|
||||
return
|
||||
}
|
||||
if err := h.service.ToggleDisabled(c.Request.Context(), id, input.Disabled); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
action := "enable"
|
||||
label := "启用"
|
||||
if input.Disabled {
|
||||
action = "disable"
|
||||
label = "停用"
|
||||
}
|
||||
recordAudit(c, h.auditService, "api_key", action, "api_key", fmt.Sprintf("%d", id), "",
|
||||
fmt.Sprintf("%s API Key (ID: %d)", label, id))
|
||||
response.Success(c, gin.H{"disabled": input.Disabled})
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
stdhttp "net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -17,24 +24,97 @@ func NewAuditHandler(auditService *service.AuditService) *AuditHandler {
|
||||
return &AuditHandler{auditService: auditService}
|
||||
}
|
||||
|
||||
// List 多字段筛选分页查询审计日志。
|
||||
// 支持参数:category, action, username, targetId, keyword, dateFrom, dateTo, limit, offset。
|
||||
// 向后兼容:若仅传 category + limit + offset,行为与旧版一致。
|
||||
func (h *AuditHandler) List(c *gin.Context) {
|
||||
category := strings.TrimSpace(c.Query("category"))
|
||||
limit := 50
|
||||
offset := 0
|
||||
if v := strings.TrimSpace(c.Query("limit")); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
||||
limit = parsed
|
||||
}
|
||||
opts, err := parseAuditFilter(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("offset")); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil && parsed >= 0 {
|
||||
offset = parsed
|
||||
}
|
||||
}
|
||||
result, err := h.auditService.List(c.Request.Context(), category, limit, offset)
|
||||
result, err := h.auditService.ListAdvanced(c.Request.Context(), opts)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
// Export 导出 CSV。同筛选参数,最多 10000 行。
|
||||
// 文件名带时间戳避免浏览器缓存覆盖。
|
||||
func (h *AuditHandler) Export(c *gin.Context) {
|
||||
opts, err := parseAuditFilter(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
// 导出不分页:覆盖掉 List 的默认 limit
|
||||
opts.Limit = 0
|
||||
opts.Offset = 0
|
||||
items, err := h.auditService.ExportAll(c.Request.Context(), opts)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
filename := fmt.Sprintf("backupx-audit-%s.csv", time.Now().UTC().Format("20060102-150405"))
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
// UTF-8 BOM 让 Excel 正确识别中文
|
||||
_, _ = c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||
writer := csv.NewWriter(c.Writer)
|
||||
_ = writer.Write([]string{"时间", "用户", "类别", "动作", "目标类型", "目标 ID", "目标名", "详情", "客户端 IP"})
|
||||
for _, item := range items {
|
||||
_ = writer.Write([]string{
|
||||
item.CreatedAt.UTC().Format(time.RFC3339),
|
||||
item.Username,
|
||||
item.Category,
|
||||
item.Action,
|
||||
item.TargetType,
|
||||
item.TargetID,
|
||||
item.TargetName,
|
||||
item.Detail,
|
||||
item.ClientIP,
|
||||
})
|
||||
}
|
||||
writer.Flush()
|
||||
if err := writer.Error(); err != nil {
|
||||
c.Writer.WriteHeader(stdhttp.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// parseAuditFilter 解析查询参数为 repository 选项。
|
||||
func parseAuditFilter(c *gin.Context) (repository.AuditLogListOptions, error) {
|
||||
opts := repository.AuditLogListOptions{
|
||||
Category: strings.TrimSpace(c.Query("category")),
|
||||
Action: strings.TrimSpace(c.Query("action")),
|
||||
Username: strings.TrimSpace(c.Query("username")),
|
||||
TargetID: strings.TrimSpace(c.Query("targetId")),
|
||||
Keyword: strings.TrimSpace(c.Query("keyword")),
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("limit")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
opts.Limit = n
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("offset")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
opts.Offset = n
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("dateFrom")); v != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
return opts, apperror.BadRequest("AUDIT_FILTER_INVALID", "dateFrom 必须为 RFC3339 时间格式", err)
|
||||
}
|
||||
opts.DateFrom = &parsed
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("dateTo")); v != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
return opts, apperror.BadRequest("AUDIT_FILTER_INVALID", "dateTo 必须为 RFC3339 时间格式", err)
|
||||
}
|
||||
opts.DateTo = &parsed
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
@@ -16,12 +16,13 @@ import (
|
||||
)
|
||||
|
||||
type BackupRecordHandler struct {
|
||||
service *service.BackupRecordService
|
||||
auditService *service.AuditService
|
||||
service *service.BackupRecordService
|
||||
restoreService *service.RestoreService
|
||||
auditService *service.AuditService
|
||||
}
|
||||
|
||||
func NewBackupRecordHandler(recordService *service.BackupRecordService, auditService *service.AuditService) *BackupRecordHandler {
|
||||
return &BackupRecordHandler{service: recordService, auditService: auditService}
|
||||
func NewBackupRecordHandler(recordService *service.BackupRecordService, restoreService *service.RestoreService, auditService *service.AuditService) *BackupRecordHandler {
|
||||
return &BackupRecordHandler{service: recordService, restoreService: restoreService, auditService: auditService}
|
||||
}
|
||||
|
||||
func (h *BackupRecordHandler) List(c *gin.Context) {
|
||||
@@ -121,18 +122,29 @@ func (h *BackupRecordHandler) Download(c *gin.Context) {
|
||||
_, _ = io.Copy(c.Writer, result.Reader)
|
||||
}
|
||||
|
||||
// Restore 启动一次异步恢复并返回 restoreRecordId;实际执行路由由 RestoreService
|
||||
// 根据 task.NodeID 决定(本地 Master or 远程 Agent)。
|
||||
func (h *BackupRecordHandler) Restore(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.Restore(c.Request.Context(), id); err != nil {
|
||||
if h.restoreService == nil {
|
||||
response.Error(c, apperror.Internal("RESTORE_SERVICE_DISABLED", "恢复服务未启用", nil))
|
||||
return
|
||||
}
|
||||
triggeredBy := ""
|
||||
if subject, exists := c.Get(contextUserSubjectKey); exists {
|
||||
triggeredBy = strings.TrimSpace(fmt.Sprintf("%v", subject))
|
||||
}
|
||||
detail, err := h.restoreService.Start(c.Request.Context(), id, triggeredBy)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "backup_record", "restore", "backup_record", fmt.Sprintf("%d", id), "",
|
||||
fmt.Sprintf("恢复备份记录 (ID: %d)", id))
|
||||
response.Success(c, gin.H{"restored": true})
|
||||
fmt.Sprintf("启动恢复 (备份记录 ID: %d, 恢复记录 ID: %d)", id, detail.ID))
|
||||
response.Success(c, detail)
|
||||
}
|
||||
|
||||
func (h *BackupRecordHandler) Delete(c *gin.Context) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package http
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/pkg/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -30,3 +31,37 @@ func (h *BackupRunHandler) Run(c *gin.Context) {
|
||||
recordAudit(c, h.auditService, "backup_task", "run", "backup_task", fmt.Sprintf("%d", id), "", "手动触发备份")
|
||||
response.Success(c, record)
|
||||
}
|
||||
|
||||
// BatchRun 批量触发备份任务。best-effort:单个失败不影响其他。
|
||||
// Body: {"ids": [1,2,3]}
|
||||
func (h *BackupRunHandler) BatchRun(c *gin.Context) {
|
||||
var input struct {
|
||||
IDs []uint `json:"ids" binding:"required,min=1"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("BACKUP_TASK_BATCH_INVALID", "批量执行参数不合法", err))
|
||||
return
|
||||
}
|
||||
results := make([]service.BatchResult, 0, len(input.IDs))
|
||||
succ := 0
|
||||
for _, id := range input.IDs {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
_, err := h.service.RunTaskByID(c.Request.Context(), id)
|
||||
item := service.BatchResult{ID: id, Success: err == nil}
|
||||
if err != nil {
|
||||
if appErr, ok := err.(*apperror.AppError); ok {
|
||||
item.Error = appErr.Message
|
||||
} else {
|
||||
item.Error = err.Error()
|
||||
}
|
||||
} else {
|
||||
succ++
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
recordAudit(c, h.auditService, "backup_task", "batch_run", "backup_task", "", "",
|
||||
fmt.Sprintf("批量触发备份 %d/%d", succ, len(results)))
|
||||
response.Success(c, results)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,16 @@ func (h *BackupTaskHandler) List(c *gin.Context) {
|
||||
response.Success(c, items)
|
||||
}
|
||||
|
||||
// ListTags 返回系统内所有任务用过的唯一标签列表,供前端标签选择器的建议词。
|
||||
func (h *BackupTaskHandler) ListTags(c *gin.Context) {
|
||||
tags, err := h.service.ListTags(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, tags)
|
||||
}
|
||||
|
||||
func (h *BackupTaskHandler) Get(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
@@ -106,6 +116,55 @@ func (h *BackupTaskHandler) Delete(c *gin.Context) {
|
||||
response.Success(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
// BatchToggle / BatchDelete 批量操作。
|
||||
// Body: {"ids": [1,2,3], "enabled": true} (enabled 仅 toggle 用)
|
||||
func (h *BackupTaskHandler) BatchToggle(c *gin.Context) {
|
||||
var input struct {
|
||||
IDs []uint `json:"ids" binding:"required,min=1"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("BACKUP_TASK_BATCH_INVALID", "批量操作参数不合法", err))
|
||||
return
|
||||
}
|
||||
results := h.service.BatchToggle(c.Request.Context(), input.IDs, input.Enabled)
|
||||
succ := 0
|
||||
for _, r := range results {
|
||||
if r.Success {
|
||||
succ++
|
||||
}
|
||||
}
|
||||
action := "batch_enable"
|
||||
label := "启用"
|
||||
if !input.Enabled {
|
||||
action = "batch_disable"
|
||||
label = "停用"
|
||||
}
|
||||
recordAudit(c, h.auditService, "backup_task", action, "backup_task", "", "",
|
||||
fmt.Sprintf("批量%s %d/%d 个任务", label, succ, len(results)))
|
||||
response.Success(c, results)
|
||||
}
|
||||
|
||||
func (h *BackupTaskHandler) BatchDelete(c *gin.Context) {
|
||||
var input struct {
|
||||
IDs []uint `json:"ids" binding:"required,min=1"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("BACKUP_TASK_BATCH_INVALID", "批量删除参数不合法", err))
|
||||
return
|
||||
}
|
||||
results := h.service.BatchDeleteTasks(c.Request.Context(), input.IDs)
|
||||
succ := 0
|
||||
for _, r := range results {
|
||||
if r.Success {
|
||||
succ++
|
||||
}
|
||||
}
|
||||
recordAudit(c, h.auditService, "backup_task", "batch_delete", "backup_task", "", "",
|
||||
fmt.Sprintf("批量删除 %d/%d 个任务", succ, len(results)))
|
||||
response.Success(c, results)
|
||||
}
|
||||
|
||||
func (h *BackupTaskHandler) Toggle(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
package http
|
||||
|
||||
const contextUserSubjectKey = "userSubject"
|
||||
const (
|
||||
contextUserSubjectKey = "userSubject"
|
||||
contextUserRoleKey = "userRole"
|
||||
contextUsernameKey = "username"
|
||||
// contextAuthSubjectKey 标识认证主体来源(user | api_key),便于审计追踪。
|
||||
contextAuthSubjectKey = "authSubject"
|
||||
)
|
||||
|
||||
@@ -27,6 +27,58 @@ func (h *DashboardHandler) Stats(c *gin.Context) {
|
||||
response.Success(c, payload)
|
||||
}
|
||||
|
||||
// SLA 返回所有启用任务的 SLA 合规视图。用于 Dashboard 企业合规卡片。
|
||||
func (h *DashboardHandler) SLA(c *gin.Context) {
|
||||
payload, err := h.service.SLACompliance(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, payload)
|
||||
}
|
||||
|
||||
// Cluster 返回集群节点概览(在线/离线/过期 Agent 等),用于 Dashboard 卡片。
|
||||
func (h *DashboardHandler) Cluster(c *gin.Context) {
|
||||
payload, err := h.service.ClusterOverview(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, payload)
|
||||
}
|
||||
|
||||
// NodePerformance 返回各节点近 N 天的执行表现(成功率/字节数/平均耗时)。
|
||||
func (h *DashboardHandler) NodePerformance(c *gin.Context) {
|
||||
days := 30
|
||||
if v := strings.TrimSpace(c.Query("days")); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
||||
days = parsed
|
||||
}
|
||||
}
|
||||
payload, err := h.service.NodePerformance(c.Request.Context(), days)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, payload)
|
||||
}
|
||||
|
||||
// Breakdown 返回按类型/状态/节点/存储分组的统计。
|
||||
func (h *DashboardHandler) Breakdown(c *gin.Context) {
|
||||
days := 30
|
||||
if v := strings.TrimSpace(c.Query("days")); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
||||
days = parsed
|
||||
}
|
||||
}
|
||||
payload, err := h.service.Breakdown(c.Request.Context(), days)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, payload)
|
||||
}
|
||||
|
||||
func (h *DashboardHandler) Timeline(c *gin.Context) {
|
||||
days := 30
|
||||
if value := strings.TrimSpace(c.Query("days")); value != "" {
|
||||
|
||||
81
server/internal/http/events_handler.go
Normal file
81
server/internal/http/events_handler.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// EventsHandler 实时事件推送(SSE)。
|
||||
// 前端通过 EventSource 订阅 /api/events/stream,实时接收系统事件,
|
||||
// 用于 Dashboard 免刷新更新 / 桌面 Toast / 实时告警。
|
||||
type EventsHandler struct {
|
||||
broadcaster *service.EventBroadcaster
|
||||
}
|
||||
|
||||
func NewEventsHandler(broadcaster *service.EventBroadcaster) *EventsHandler {
|
||||
return &EventsHandler{broadcaster: broadcaster}
|
||||
}
|
||||
|
||||
// Stream SSE 长连接。JWT/API Key 中间件之后。
|
||||
// 心跳:每 25s 发一条 comment 行(: keepalive)保持连接不被代理断开。
|
||||
func (h *EventsHandler) Stream(c *gin.Context) {
|
||||
if h.broadcaster == nil {
|
||||
response.Error(c, apperror.Internal("EVENTS_DISABLED", "事件广播器未启用", nil))
|
||||
return
|
||||
}
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Header().Set("X-Accel-Buffering", "no") // 禁用 nginx 缓冲
|
||||
flusher, ok := c.Writer.(interface{ Flush() })
|
||||
if !ok {
|
||||
response.Error(c, apperror.Internal("EVENTS_STREAM_UNSUPPORTED", "当前连接不支持 SSE", nil))
|
||||
return
|
||||
}
|
||||
// 首先发送一次 hello 让客户端确认连通
|
||||
_, _ = fmt.Fprintf(c.Writer, ": connected %d\n\n", time.Now().Unix())
|
||||
flusher.Flush()
|
||||
|
||||
ch, cancel := h.broadcaster.Subscribe(32)
|
||||
defer cancel()
|
||||
|
||||
heartbeat := time.NewTicker(25 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
case <-heartbeat.C:
|
||||
if _, err := fmt.Fprintf(c.Writer, ": heartbeat %d\n\n", time.Now().Unix()); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
case envelope, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := writeEventEnvelope(c.Writer, envelope); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeEventEnvelope(writer io.Writer, envelope service.EventEnvelope) error {
|
||||
data, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(writer, "event: %s\ndata: %s\n\n", envelope.Type, data)
|
||||
return err
|
||||
}
|
||||
75
server/internal/http/health_handler.go
Normal file
75
server/internal/http/health_handler.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
stdhttp "net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// HealthHandler 提供 K8s/Swarm 风格的健康检查端点。
|
||||
//
|
||||
// - /health :liveness 探针。进程存活即 200(不检查任何依赖)。
|
||||
// - /ready :readiness 探针。检查数据库连通,不通则返回 503。
|
||||
//
|
||||
// 两者均为公开端点(无认证中间件),供外部编排系统探测。
|
||||
// 输出最少信息,避免泄露内部结构。
|
||||
type HealthHandler struct {
|
||||
db *gorm.DB
|
||||
startedAt time.Time
|
||||
version string
|
||||
}
|
||||
|
||||
func NewHealthHandler(db *gorm.DB, version string) *HealthHandler {
|
||||
return &HealthHandler{db: db, startedAt: time.Now().UTC(), version: version}
|
||||
}
|
||||
|
||||
// Live 用于 liveness:只要进程能响应就返回 200。
|
||||
func (h *HealthHandler) Live(c *gin.Context) {
|
||||
c.JSON(stdhttp.StatusOK, gin.H{
|
||||
"status": "live",
|
||||
"version": h.version,
|
||||
"uptime": int(time.Since(h.startedAt).Seconds()),
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// Ready 用于 readiness:依赖(数据库)不可用时返回 503。
|
||||
// 新实例启动或数据库短暂失联时,编排系统据此停止转发流量。
|
||||
func (h *HealthHandler) Ready(c *gin.Context) {
|
||||
checks := map[string]string{}
|
||||
overallOK := true
|
||||
if h.db != nil {
|
||||
sqlDB, err := h.db.DB()
|
||||
if err != nil {
|
||||
checks["database"] = "error: " + err.Error()
|
||||
overallOK = false
|
||||
} else {
|
||||
ctx, cancel := c.Request.Context(), func() {}
|
||||
_ = cancel
|
||||
if err := sqlDB.PingContext(ctx); err != nil {
|
||||
checks["database"] = "ping failed: " + err.Error()
|
||||
overallOK = false
|
||||
} else {
|
||||
checks["database"] = "ok"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
checks["database"] = "not configured"
|
||||
overallOK = false
|
||||
}
|
||||
status := stdhttp.StatusOK
|
||||
state := "ready"
|
||||
if !overallOK {
|
||||
status = stdhttp.StatusServiceUnavailable
|
||||
state = "not_ready"
|
||||
}
|
||||
c.JSON(status, gin.H{
|
||||
"status": state,
|
||||
"version": h.version,
|
||||
"uptime": int(time.Since(h.startedAt).Seconds()),
|
||||
"checks": checks,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
331
server/internal/http/install_flow_test.go
Normal file
331
server/internal/http/install_flow_test.go
Normal file
@@ -0,0 +1,331 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/config"
|
||||
"backupx/server/internal/database"
|
||||
"backupx/server/internal/logger"
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/security"
|
||||
"backupx/server/internal/service"
|
||||
)
|
||||
|
||||
// setupInstallFlowRouter 构造一个 Node + Agent + InstallToken 全量依赖的 router,
|
||||
// 并返回已登录管理员 JWT。
|
||||
func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
|
||||
t.Helper()
|
||||
tempDir := t.TempDir()
|
||||
cfg := config.Config{
|
||||
Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test"},
|
||||
Database: config.DatabaseConfig{Path: filepath.Join(tempDir, "backupx.db")},
|
||||
Security: config.SecurityConfig{JWTExpire: "24h"},
|
||||
Log: config.LogConfig{Level: "error"},
|
||||
}
|
||||
log, err := logger.New(cfg.Log)
|
||||
if err != nil {
|
||||
t.Fatalf("logger: %v", err)
|
||||
}
|
||||
db, err := database.Open(cfg.Database, log)
|
||||
if err != nil {
|
||||
t.Fatalf("db: %v", err)
|
||||
}
|
||||
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
systemConfigRepo := repository.NewSystemConfigRepository(db)
|
||||
resolved, err := service.ResolveSecurity(context.Background(), cfg.Security, systemConfigRepo)
|
||||
if err != nil {
|
||||
t.Fatalf("security: %v", err)
|
||||
}
|
||||
jwtMgr := security.NewJWTManager(resolved.JWTSecret, time.Hour)
|
||||
authSvc := service.NewAuthService(userRepo, systemConfigRepo, jwtMgr, security.NewLoginRateLimiter(5, time.Minute))
|
||||
systemSvc := service.NewSystemService(cfg, "test", time.Now().UTC())
|
||||
|
||||
nodeRepo := repository.NewNodeRepository(db)
|
||||
nodeSvc := service.NewNodeService(nodeRepo, "test")
|
||||
if err := nodeSvc.EnsureLocalNode(context.Background()); err != nil {
|
||||
t.Fatalf("ensure local: %v", err)
|
||||
}
|
||||
|
||||
installTokenRepo := repository.NewAgentInstallTokenRepository(db)
|
||||
installTokenSvc := service.NewInstallTokenService(installTokenRepo, nodeRepo)
|
||||
|
||||
auditLogRepo := repository.NewAuditLogRepository(db)
|
||||
auditSvc := service.NewAuditService(auditLogRepo)
|
||||
|
||||
// 用 cancelable ctx,测试结束时停掉 handler 启动的后台 GC 协程,
|
||||
// 避免 goroutine 持有 map 导致 tempdir 清理失败。
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
router := NewRouter(RouterDependencies{
|
||||
Context: ctx,
|
||||
Config: cfg,
|
||||
Version: "test",
|
||||
Logger: log,
|
||||
AuthService: authSvc,
|
||||
SystemService: systemSvc,
|
||||
NodeService: nodeSvc,
|
||||
InstallTokenService: installTokenSvc,
|
||||
AuditService: auditSvc,
|
||||
JWTManager: jwtMgr,
|
||||
UserRepository: userRepo,
|
||||
SystemConfigRepo: systemConfigRepo,
|
||||
})
|
||||
|
||||
// setup 管理员并登录拿 JWT
|
||||
setupBody, _ := json.Marshal(map[string]string{
|
||||
"username": "admin", "password": "password-123", "displayName": "admin",
|
||||
})
|
||||
setupReq := httptest.NewRequest(http.MethodPost, "/api/auth/setup", bytes.NewBuffer(setupBody))
|
||||
setupReq.Header.Set("Content-Type", "application/json")
|
||||
setupRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(setupRec, setupReq)
|
||||
if setupRec.Code != 200 {
|
||||
t.Fatalf("setup failed: %d %s", setupRec.Code, setupRec.Body.String())
|
||||
}
|
||||
var setupResp struct {
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(setupRec.Body.Bytes(), &setupResp); err != nil {
|
||||
t.Fatalf("unmarshal setup: %v", err)
|
||||
}
|
||||
|
||||
return router, setupResp.Data.Token
|
||||
}
|
||||
|
||||
func TestOneClickInstallFlow(t *testing.T) {
|
||||
router, jwt := setupInstallFlowRouter(t)
|
||||
|
||||
// 1. 批量创建
|
||||
batchBody, _ := json.Marshal(map[string][]string{"names": {"prod-a", "prod-b"}})
|
||||
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody))
|
||||
batchReq.Header.Set("Content-Type", "application/json")
|
||||
batchReq.Header.Set("Authorization", "Bearer "+jwt)
|
||||
batchRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(batchRec, batchReq)
|
||||
if batchRec.Code != 200 {
|
||||
t.Fatalf("batch create failed: %d %s", batchRec.Code, batchRec.Body.String())
|
||||
}
|
||||
var batchResp struct {
|
||||
Data []struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(batchRec.Body.Bytes(), &batchResp); err != nil {
|
||||
t.Fatalf("unmarshal batch: %v", err)
|
||||
}
|
||||
if len(batchResp.Data) != 2 {
|
||||
t.Fatalf("expected 2 nodes, got %d", len(batchResp.Data))
|
||||
}
|
||||
nodeID := batchResp.Data[0].ID
|
||||
|
||||
// 2. 生成 install token
|
||||
genBody, _ := json.Marshal(map[string]any{
|
||||
"mode": "systemd",
|
||||
"arch": "auto",
|
||||
"agentVersion": "v1.7.0",
|
||||
"downloadSrc": "github",
|
||||
"ttlSeconds": 900,
|
||||
})
|
||||
genReq := httptest.NewRequest(http.MethodPost,
|
||||
"/api/nodes/"+formatUint(nodeID)+"/install-tokens", bytes.NewBuffer(genBody))
|
||||
genReq.Header.Set("Content-Type", "application/json")
|
||||
genReq.Header.Set("Authorization", "Bearer "+jwt)
|
||||
genRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(genRec, genReq)
|
||||
if genRec.Code != 200 {
|
||||
t.Fatalf("install-tokens failed: %d %s", genRec.Code, genRec.Body.String())
|
||||
}
|
||||
var genResp struct {
|
||||
Data struct {
|
||||
InstallToken string `json:"installToken"`
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(genRec.Body.Bytes(), &genResp); err != nil {
|
||||
t.Fatalf("unmarshal gen: %v", err)
|
||||
}
|
||||
if genResp.Data.InstallToken == "" {
|
||||
t.Fatalf("missing installToken")
|
||||
}
|
||||
|
||||
// 3. 公开端点消费
|
||||
scriptReq := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
|
||||
scriptRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(scriptRec, scriptReq)
|
||||
if scriptRec.Code != 200 {
|
||||
t.Fatalf("script fetch failed: %d %s", scriptRec.Code, scriptRec.Body.String())
|
||||
}
|
||||
if !strings.Contains(scriptRec.Body.String(), "systemctl enable --now backupx-agent") {
|
||||
t.Fatalf("script missing systemctl enable:\n%s", scriptRec.Body.String())
|
||||
}
|
||||
|
||||
// 4. 再次消费应 410
|
||||
scriptReq2 := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
|
||||
scriptRec2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(scriptRec2, scriptReq2)
|
||||
if scriptRec2.Code != http.StatusGone {
|
||||
t.Fatalf("second consume should be 410, got %d: %s", scriptRec2.Code, scriptRec2.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallTokenRateLimit(t *testing.T) {
|
||||
router, jwt := setupInstallFlowRouter(t)
|
||||
|
||||
batchBody, _ := json.Marshal(map[string][]string{"names": {"rl-test"}})
|
||||
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody))
|
||||
batchReq.Header.Set("Content-Type", "application/json")
|
||||
batchReq.Header.Set("Authorization", "Bearer "+jwt)
|
||||
batchRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(batchRec, batchReq)
|
||||
if batchRec.Code != 200 {
|
||||
t.Fatalf("batch: %d %s", batchRec.Code, batchRec.Body.String())
|
||||
}
|
||||
var batchResp struct {
|
||||
Data []struct {
|
||||
ID uint `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(batchRec.Body.Bytes(), &batchResp)
|
||||
nodeID := batchResp.Data[0].ID
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"mode": "systemd", "arch": "auto", "agentVersion": "v1",
|
||||
"downloadSrc": "github", "ttlSeconds": 300,
|
||||
})
|
||||
for i := 0; i < 5; i++ {
|
||||
req := httptest.NewRequest(http.MethodPost,
|
||||
"/api/nodes/"+formatUint(nodeID)+"/install-tokens", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+jwt)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("iter %d expected 200, got %d: %s", i, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost,
|
||||
"/api/nodes/"+formatUint(nodeID)+"/install-tokens", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+jwt)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected 429, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateTokenFlow(t *testing.T) {
|
||||
router, jwt := setupInstallFlowRouter(t)
|
||||
|
||||
batchBody, _ := json.Marshal(map[string][]string{"names": {"rot-x"}})
|
||||
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody))
|
||||
batchReq.Header.Set("Content-Type", "application/json")
|
||||
batchReq.Header.Set("Authorization", "Bearer "+jwt)
|
||||
batchRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(batchRec, batchReq)
|
||||
var batchResp struct {
|
||||
Data []struct {
|
||||
ID uint `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(batchRec.Body.Bytes(), &batchResp)
|
||||
nodeID := batchResp.Data[0].ID
|
||||
|
||||
rotReq := httptest.NewRequest(http.MethodPost,
|
||||
"/api/nodes/"+formatUint(nodeID)+"/rotate-token", nil)
|
||||
rotReq.Header.Set("Authorization", "Bearer "+jwt)
|
||||
rotRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rotRec, rotReq)
|
||||
if rotRec.Code != 200 {
|
||||
t.Fatalf("rotate failed: %d %s", rotRec.Code, rotRec.Body.String())
|
||||
}
|
||||
var rotResp struct {
|
||||
Data struct {
|
||||
NewToken string `json:"newToken"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(rotRec.Body.Bytes(), &rotResp)
|
||||
if len(rotResp.Data.NewToken) != 64 {
|
||||
t.Fatalf("new token wrong length: %s", rotResp.Data.NewToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallFlowComposeModeMismatch(t *testing.T) {
|
||||
router, jwt := setupInstallFlowRouter(t)
|
||||
|
||||
batchBody, _ := json.Marshal(map[string][]string{"names": {"cm"}})
|
||||
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody))
|
||||
batchReq.Header.Set("Content-Type", "application/json")
|
||||
batchReq.Header.Set("Authorization", "Bearer "+jwt)
|
||||
batchRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(batchRec, batchReq)
|
||||
var batchResp struct {
|
||||
Data []struct {
|
||||
ID uint `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(batchRec.Body.Bytes(), &batchResp)
|
||||
nodeID := batchResp.Data[0].ID
|
||||
|
||||
// 生成 systemd 模式的 token
|
||||
genBody, _ := json.Marshal(map[string]any{
|
||||
"mode": "systemd", "arch": "auto", "agentVersion": "v1",
|
||||
"downloadSrc": "github", "ttlSeconds": 300,
|
||||
})
|
||||
genReq := httptest.NewRequest(http.MethodPost,
|
||||
"/api/nodes/"+formatUint(nodeID)+"/install-tokens", bytes.NewBuffer(genBody))
|
||||
genReq.Header.Set("Content-Type", "application/json")
|
||||
genReq.Header.Set("Authorization", "Bearer "+jwt)
|
||||
genRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(genRec, genReq)
|
||||
var genResp struct {
|
||||
Data struct {
|
||||
InstallToken string `json:"installToken"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(genRec.Body.Bytes(), &genResp)
|
||||
|
||||
// 访问 compose.yml 应 400
|
||||
req := httptest.NewRequest(http.MethodGet,
|
||||
"/install/"+genResp.Data.InstallToken+"/compose.yml", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for mode mismatch, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// systemd token 未被消费(Peek 不消费)→ 应仍可通过 /install/:token 消费成功
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
|
||||
rec2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec2, req2)
|
||||
if rec2.Code != 200 {
|
||||
t.Fatalf("original script fetch should still work: %d %s", rec2.Code, rec2.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// formatUint 小工具:uint → 十进制字符串(无需引入 strconv)。
|
||||
func formatUint(u uint) string {
|
||||
if u == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for u > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + u%10)
|
||||
u /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
221
server/internal/http/install_handler.go
Normal file
221
server/internal/http/install_handler.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdhttp "net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/installscript"
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// InstallHandler 公开路由(不走 JWT 中间件):/install/:token 与 /install/:token/compose.yml。
|
||||
type InstallHandler struct {
|
||||
tokenService *service.InstallTokenService
|
||||
auditService *service.AuditService
|
||||
externalURL string
|
||||
limiter *ipLimiter
|
||||
}
|
||||
|
||||
// NewInstallHandler 构造 handler 并启动限流器的后台 GC 协程。
|
||||
// gcCtx 控制 GC 协程生命周期,建议传入 app context。
|
||||
func NewInstallHandler(gcCtx context.Context, tokenService *service.InstallTokenService, auditService *service.AuditService, externalURL string) *InstallHandler {
|
||||
limiter := newIPLimiter(20, time.Minute)
|
||||
limiter.startGC(gcCtx)
|
||||
return &InstallHandler{
|
||||
tokenService: tokenService,
|
||||
auditService: auditService,
|
||||
externalURL: externalURL,
|
||||
limiter: limiter,
|
||||
}
|
||||
}
|
||||
|
||||
// Script 消费 install token 并返回 shell 脚本;Mode 由 token 存储决定(systemd/docker/foreground 均返回 shell)。
|
||||
func (h *InstallHandler) Script(c *gin.Context) {
|
||||
if !h.limiter.allow(c.ClientIP()) {
|
||||
c.String(stdhttp.StatusTooManyRequests, "请求过于频繁,请稍后再试\n")
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(c.Param("token"))
|
||||
consumed, err := h.tokenService.Consume(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
c.String(stdhttp.StatusInternalServerError, "server error\n")
|
||||
return
|
||||
}
|
||||
if consumed == nil {
|
||||
c.String(stdhttp.StatusGone, "install token 不存在、已过期或已消费\n")
|
||||
return
|
||||
}
|
||||
h.recordConsumeAudit(c, consumed, "script")
|
||||
script, err := installscript.RenderScript(installscript.Context{
|
||||
MasterURL: resolveMasterURL(c, h.externalURL),
|
||||
AgentToken: consumed.Node.Token,
|
||||
AgentVersion: consumed.Record.AgentVer,
|
||||
Mode: consumed.Record.Mode,
|
||||
Arch: consumed.Record.Arch,
|
||||
DownloadBase: installscript.DownloadBaseFor(consumed.Record.DownloadSrc),
|
||||
InstallPrefix: "/opt/backupx-agent",
|
||||
NodeID: consumed.Node.ID,
|
||||
})
|
||||
if err != nil {
|
||||
c.String(stdhttp.StatusInternalServerError, "render error\n")
|
||||
return
|
||||
}
|
||||
c.Data(stdhttp.StatusOK, "text/x-shellscript; charset=utf-8", []byte(script))
|
||||
}
|
||||
|
||||
// Compose 消费 install token 并返回 docker-compose YAML,仅 Mode=docker 有效。
|
||||
// 注意:/install/:token 与 /install/:token/compose.yml 共享同一 token 的消费状态,任一首次命中即消费。
|
||||
func (h *InstallHandler) Compose(c *gin.Context) {
|
||||
if !h.limiter.allow(c.ClientIP()) {
|
||||
c.String(stdhttp.StatusTooManyRequests, "请求过于频繁,请稍后再试\n")
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(c.Param("token"))
|
||||
// 先 Peek 看 Mode(不消费),若非 docker 直接 400
|
||||
record, err := h.tokenService.Peek(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
c.String(stdhttp.StatusInternalServerError, "server error\n")
|
||||
return
|
||||
}
|
||||
if record == nil {
|
||||
c.String(stdhttp.StatusGone, "install token 不存在\n")
|
||||
return
|
||||
}
|
||||
if record.Mode != model.InstallModeDocker {
|
||||
c.String(stdhttp.StatusBadRequest, "该 install token 的模式不是 docker\n")
|
||||
return
|
||||
}
|
||||
// 消费
|
||||
consumed, err := h.tokenService.Consume(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
c.String(stdhttp.StatusInternalServerError, "server error\n")
|
||||
return
|
||||
}
|
||||
if consumed == nil {
|
||||
c.String(stdhttp.StatusGone, "install token 已过期或已消费\n")
|
||||
return
|
||||
}
|
||||
h.recordConsumeAudit(c, consumed, "compose")
|
||||
yaml, err := installscript.RenderComposeYaml(installscript.Context{
|
||||
MasterURL: resolveMasterURL(c, h.externalURL),
|
||||
AgentToken: consumed.Node.Token,
|
||||
AgentVersion: consumed.Record.AgentVer,
|
||||
Mode: model.InstallModeDocker,
|
||||
NodeID: consumed.Node.ID,
|
||||
})
|
||||
if err != nil {
|
||||
c.String(stdhttp.StatusInternalServerError, "render error\n")
|
||||
return
|
||||
}
|
||||
c.Data(stdhttp.StatusOK, "text/yaml; charset=utf-8", []byte(yaml))
|
||||
}
|
||||
|
||||
func (h *InstallHandler) recordConsumeAudit(c *gin.Context, consumed *service.ConsumedInstallToken, kind string) {
|
||||
if h.auditService == nil {
|
||||
return
|
||||
}
|
||||
h.auditService.Record(service.AuditEntry{
|
||||
Category: "install_token",
|
||||
Action: "consume",
|
||||
TargetType: "node",
|
||||
TargetID: strconv.FormatUint(uint64(consumed.Node.ID), 10),
|
||||
TargetName: consumed.Node.Name,
|
||||
Detail: "install token 消费 (" + kind + ")",
|
||||
ClientIP: c.ClientIP(),
|
||||
})
|
||||
}
|
||||
|
||||
// resolveMasterURL 按优先级推导 Master URL:外部配置 > X-Forwarded-* > Request.Host。
|
||||
// 此为包级 helper,供 install_handler 和 node_handler 共用。
|
||||
func resolveMasterURL(c *gin.Context, externalURL string) string {
|
||||
if strings.TrimSpace(externalURL) != "" {
|
||||
return strings.TrimRight(externalURL, "/")
|
||||
}
|
||||
scheme := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto"))
|
||||
if scheme == "" {
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
} else {
|
||||
scheme = "http"
|
||||
}
|
||||
}
|
||||
host := strings.TrimSpace(c.GetHeader("X-Forwarded-Host"))
|
||||
if host == "" {
|
||||
host = c.Request.Host
|
||||
}
|
||||
return scheme + "://" + host
|
||||
}
|
||||
|
||||
// ipLimiter 简单内存滑动窗口限流,按 client IP 维度。
|
||||
type ipLimiter struct {
|
||||
mu sync.Mutex
|
||||
events map[string][]time.Time
|
||||
limit int
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
func newIPLimiter(limit int, window time.Duration) *ipLimiter {
|
||||
return &ipLimiter{events: make(map[string][]time.Time), limit: limit, window: window}
|
||||
}
|
||||
|
||||
func (l *ipLimiter) allow(ip string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-l.window)
|
||||
keep := l.events[ip][:0]
|
||||
for _, t := range l.events[ip] {
|
||||
if t.After(cutoff) {
|
||||
keep = append(keep, t)
|
||||
}
|
||||
}
|
||||
if len(keep) >= l.limit {
|
||||
l.events[ip] = keep
|
||||
return false
|
||||
}
|
||||
l.events[ip] = append(keep, now)
|
||||
return true
|
||||
}
|
||||
|
||||
// gc 清理窗口外所有过期的 IP 条目,防止公网扫描导致 map 无界增长。
|
||||
// 由后台 goroutine 周期性调用。
|
||||
func (l *ipLimiter) gc(now time.Time) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
cutoff := now.Add(-l.window)
|
||||
for k, v := range l.events {
|
||||
stale := true
|
||||
for _, t := range v {
|
||||
if t.After(cutoff) {
|
||||
stale = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if stale {
|
||||
delete(l.events, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startGC 启动后台清理协程,每 window 周期清扫一次 map。
|
||||
// ctx 取消时协程退出。
|
||||
func (l *ipLimiter) startGC(ctx context.Context) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(l.window)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case t := <-ticker.C:
|
||||
l.gc(t)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdhttp "net/http"
|
||||
"strings"
|
||||
|
||||
@@ -26,28 +27,94 @@ func CORSMiddleware() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func AuthMiddleware(jwtManager *security.JWTManager) gin.HandlerFunc {
|
||||
// ApiKeyAuthenticator 抽象 API Key 验证能力,避免 middleware 直接依赖 service 包。
|
||||
// 实现方:service.ApiKeyService。未注入时 AuthMiddleware 仍然支持 JWT。
|
||||
type ApiKeyAuthenticator interface {
|
||||
Authenticate(ctx context.Context, rawKey string) (subject string, role string, err error)
|
||||
}
|
||||
|
||||
// AuthMiddleware 支持两种认证方式:
|
||||
// - JWT (Authorization: Bearer <jwt>):交互式用户
|
||||
// - API Key (Authorization: Bearer bax_xxx 或 X-Api-Key: bax_xxx):第三方脚本
|
||||
//
|
||||
// JWT 会在 context 中写入 userSubject / userRole / username;
|
||||
// API Key 会写入 authSubject=api_key:<id> / userRole=<key role>。
|
||||
func AuthMiddleware(jwtManager *security.JWTManager, apiKeyAuth ApiKeyAuthenticator) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
header := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if !strings.HasPrefix(header, "Bearer ") {
|
||||
rawToken := extractAuthToken(c)
|
||||
if rawToken == "" {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_REQUIRED", "请先登录", nil))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||
claims, err := jwtManager.Parse(tokenString)
|
||||
if apiKeyAuth != nil && strings.HasPrefix(rawToken, "bax_") {
|
||||
subject, role, err := apiKeyAuth.Authenticate(c.Request.Context(), rawToken)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(contextAuthSubjectKey, subject)
|
||||
c.Set(contextUserRoleKey, role)
|
||||
c.Set(contextUserSubjectKey, subject)
|
||||
c.Set(contextUsernameKey, subject)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
claims, err := jwtManager.Parse(rawToken)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_TOKEN", "登录状态已失效,请重新登录", err))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(contextUserSubjectKey, claims.Subject)
|
||||
c.Set(contextUserRoleKey, claims.Role)
|
||||
c.Set(contextUsernameKey, claims.Username)
|
||||
c.Set(contextAuthSubjectKey, "user:"+claims.Subject)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// extractAuthToken 从 Authorization: Bearer 或 X-Api-Key 中提取原始 token。
|
||||
func extractAuthToken(c *gin.Context) string {
|
||||
header := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if strings.HasPrefix(header, "Bearer ") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||
}
|
||||
if key := strings.TrimSpace(c.GetHeader("X-Api-Key")); key != "" {
|
||||
return key
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RequireRole 仅放行指定角色,否则返回 403。
|
||||
// 必须用在 AuthMiddleware 之后。viewer 只读保护、admin 管理端都靠它。
|
||||
func RequireRole(roles ...string) gin.HandlerFunc {
|
||||
allowed := make(map[string]bool, len(roles))
|
||||
for _, r := range roles {
|
||||
allowed[strings.ToLower(r)] = true
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
role, _ := c.Get(contextUserRoleKey)
|
||||
roleStr := ""
|
||||
if v, ok := role.(string); ok {
|
||||
roleStr = strings.ToLower(v)
|
||||
}
|
||||
if !allowed[roleStr] {
|
||||
response.Error(c, apperror.New(403, "AUTH_FORBIDDEN", "当前角色无权执行此操作", nil))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireNotViewer 是 RequireRole(admin, operator) 的快捷方式,
|
||||
// 用于任何"写入/变更"类端点,禁止 viewer 触发。
|
||||
func RequireNotViewer() gin.HandlerFunc {
|
||||
return RequireRole("admin", "operator")
|
||||
}
|
||||
|
||||
func ClientKey(c *gin.Context) string {
|
||||
ip := strings.TrimSpace(c.ClientIP())
|
||||
if ip == "" {
|
||||
|
||||
@@ -5,18 +5,59 @@ import (
|
||||
stdhttp "net/http"
|
||||
"strconv"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/installscript"
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/pkg/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type NodeHandler struct {
|
||||
service *service.NodeService
|
||||
auditService *service.AuditService
|
||||
service *service.NodeService
|
||||
auditService *service.AuditService
|
||||
installTokenSvc *service.InstallTokenService
|
||||
userRepo repository.UserRepository
|
||||
externalURL string
|
||||
}
|
||||
|
||||
func NewNodeHandler(service *service.NodeService, auditService *service.AuditService) *NodeHandler {
|
||||
return &NodeHandler{service: service, auditService: auditService}
|
||||
// NewNodeHandler 构造 handler。
|
||||
// userRepo 用于把 JWT subject(用户名)解析为 user.ID,填入 install_token.created_by_id 做审计追溯;
|
||||
// 传 nil 时 created_by_id 记为 0(仍可用,不阻断)。
|
||||
func NewNodeHandler(
|
||||
nodeService *service.NodeService,
|
||||
auditService *service.AuditService,
|
||||
installTokenSvc *service.InstallTokenService,
|
||||
userRepo repository.UserRepository,
|
||||
externalURL string,
|
||||
) *NodeHandler {
|
||||
return &NodeHandler{
|
||||
service: nodeService,
|
||||
auditService: auditService,
|
||||
installTokenSvc: installTokenSvc,
|
||||
userRepo: userRepo,
|
||||
externalURL: externalURL,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveCurrentUserID 从 JWT subject 解析出 user.ID,失败返回 0。
|
||||
func (h *NodeHandler) resolveCurrentUserID(c *gin.Context) uint {
|
||||
if h.userRepo == nil {
|
||||
return 0
|
||||
}
|
||||
subjectValue, ok := c.Get(contextUserSubjectKey)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil || subject == "" {
|
||||
return 0
|
||||
}
|
||||
user, err := h.userRepo.FindByUsername(c.Request.Context(), subject)
|
||||
if err != nil || user == nil {
|
||||
return 0
|
||||
}
|
||||
return user.ID
|
||||
}
|
||||
|
||||
func (h *NodeHandler) List(c *gin.Context) {
|
||||
@@ -128,3 +169,135 @@ func (h *NodeHandler) Heartbeat(c *gin.Context) {
|
||||
}
|
||||
response.Success(c, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// BatchCreate 批量创建远程节点。
|
||||
func (h *NodeHandler) BatchCreate(c *gin.Context) {
|
||||
var input struct {
|
||||
Names []string `json:"names" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
results, err := h.service.BatchCreate(c.Request.Context(), input.Names)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "node", "batch_create", "node", "",
|
||||
fmt.Sprintf("%d", len(results)), fmt.Sprintf("批量创建 %d 个节点", len(results)))
|
||||
response.Success(c, results)
|
||||
}
|
||||
|
||||
// RotateToken 轮换节点的 agent token。
|
||||
func (h *NodeHandler) RotateToken(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
tok, err := h.service.RotateToken(c.Request.Context(), uint(id))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "node", "rotate_token", "node",
|
||||
fmt.Sprintf("%d", id), "",
|
||||
fmt.Sprintf("轮换节点 Token (ID: %d)", id))
|
||||
response.Success(c, gin.H{"newToken": tok})
|
||||
}
|
||||
|
||||
// CreateInstallToken 生成一次性安装令牌。
|
||||
func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
|
||||
if h.installTokenSvc == nil {
|
||||
response.Error(c, apperror.New(stdhttp.StatusServiceUnavailable,
|
||||
"INSTALL_TOKEN_DISABLED", "一键部署未启用", nil))
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Mode string `json:"mode"`
|
||||
Arch string `json:"arch"`
|
||||
AgentVersion string `json:"agentVersion"`
|
||||
DownloadSrc string `json:"downloadSrc"`
|
||||
TTLSeconds int `json:"ttlSeconds"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
// 默认值
|
||||
if input.Mode == "" {
|
||||
input.Mode = "systemd"
|
||||
}
|
||||
if input.Arch == "" {
|
||||
input.Arch = "auto"
|
||||
}
|
||||
if input.DownloadSrc == "" {
|
||||
input.DownloadSrc = "github"
|
||||
}
|
||||
if input.TTLSeconds == 0 {
|
||||
input.TTLSeconds = 900
|
||||
}
|
||||
|
||||
out, err := h.installTokenSvc.Create(c.Request.Context(), service.InstallTokenInput{
|
||||
NodeID: uint(id),
|
||||
Mode: input.Mode,
|
||||
Arch: input.Arch,
|
||||
AgentVersion: input.AgentVersion,
|
||||
DownloadSrc: input.DownloadSrc,
|
||||
TTLSeconds: input.TTLSeconds,
|
||||
CreatedByID: h.resolveCurrentUserID(c),
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "install_token", "create", "node",
|
||||
fmt.Sprintf("%d", id), out.Node.Name,
|
||||
fmt.Sprintf("生成 %s/%s install token TTL=%ds", input.Mode, input.Arch, input.TTLSeconds))
|
||||
|
||||
masterURL := resolveMasterURL(c, h.externalURL)
|
||||
body := gin.H{
|
||||
"installToken": out.Token,
|
||||
"expiresAt": out.ExpiresAt,
|
||||
"url": masterURL + "/install/" + out.Token,
|
||||
"composeUrl": "",
|
||||
}
|
||||
if input.Mode == "docker" {
|
||||
body["composeUrl"] = masterURL + "/install/" + out.Token + "/compose.yml"
|
||||
}
|
||||
response.Success(c, body)
|
||||
}
|
||||
|
||||
// PreviewScript 预览安装脚本(token 字段用 <AGENT_TOKEN> 占位,不消费 install token)。
|
||||
// 用于 UI Step 3 展开"脚本预览"。
|
||||
func (h *NodeHandler) PreviewScript(c *gin.Context) {
|
||||
mode := c.DefaultQuery("mode", "systemd")
|
||||
arch := c.DefaultQuery("arch", "auto")
|
||||
ver := c.Query("agentVersion")
|
||||
if ver == "" {
|
||||
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": "agentVersion required"})
|
||||
return
|
||||
}
|
||||
src := c.DefaultQuery("downloadSrc", "github")
|
||||
ctx := installscript.Context{
|
||||
MasterURL: resolveMasterURL(c, h.externalURL),
|
||||
AgentToken: "<AGENT_TOKEN>",
|
||||
AgentVersion: ver,
|
||||
Mode: mode,
|
||||
Arch: arch,
|
||||
DownloadBase: installscript.DownloadBaseFor(src),
|
||||
InstallPrefix: "/opt/backupx-agent",
|
||||
}
|
||||
script, err := installscript.RenderScript(ctx)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
c.Data(stdhttp.StatusOK, "text/x-shellscript; charset=utf-8", []byte(script))
|
||||
}
|
||||
|
||||
128
server/internal/http/replication_handler.go
Normal file
128
server/internal/http/replication_handler.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ReplicationHandler 管理备份复制记录列表 + 手动触发。
|
||||
type ReplicationHandler struct {
|
||||
service *service.ReplicationService
|
||||
auditService *service.AuditService
|
||||
}
|
||||
|
||||
func NewReplicationHandler(replicationService *service.ReplicationService, auditService *service.AuditService) *ReplicationHandler {
|
||||
return &ReplicationHandler{service: replicationService, auditService: auditService}
|
||||
}
|
||||
|
||||
// TriggerByRecord 手动触发:从备份记录复制到指定目标存储。
|
||||
// Body: {"destTargetId": 12}
|
||||
func (h *ReplicationHandler) TriggerByRecord(c *gin.Context) {
|
||||
recordID, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
DestTargetID uint `json:"destTargetId" binding:"required,min=1"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("REPLICATION_INVALID", "复制参数不合法", err))
|
||||
return
|
||||
}
|
||||
triggeredBy := ""
|
||||
if subject, exists := c.Get(contextUsernameKey); exists {
|
||||
if v, ok := subject.(string); ok {
|
||||
triggeredBy = v
|
||||
}
|
||||
}
|
||||
if triggeredBy == "" {
|
||||
triggeredBy = "manual"
|
||||
}
|
||||
result, err := h.service.Start(c.Request.Context(), recordID, input.DestTargetID, triggeredBy)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "replication", "manual_run", "backup_record", fmt.Sprintf("%d", recordID), "",
|
||||
fmt.Sprintf("手动触发复制(备份记录 #%d → 存储 #%d, 复制记录 #%d)", recordID, input.DestTargetID, result.ID))
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *ReplicationHandler) List(c *gin.Context) {
|
||||
filter, err := buildReplicationFilter(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
items, err := h.service.List(c.Request.Context(), filter)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, items)
|
||||
}
|
||||
|
||||
func (h *ReplicationHandler) Get(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.service.Get(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, item)
|
||||
}
|
||||
|
||||
func buildReplicationFilter(c *gin.Context) (service.ReplicationRecordListInput, error) {
|
||||
var filter service.ReplicationRecordListInput
|
||||
if v := strings.TrimSpace(c.Query("taskId")); v != "" {
|
||||
parsed, err := strconv.ParseUint(v, 10, 32)
|
||||
if err != nil {
|
||||
return filter, apperror.BadRequest("REPLICATION_FILTER_INVALID", "taskId 不合法", err)
|
||||
}
|
||||
id := uint(parsed)
|
||||
filter.TaskID = &id
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("backupRecordId")); v != "" {
|
||||
parsed, err := strconv.ParseUint(v, 10, 32)
|
||||
if err != nil {
|
||||
return filter, apperror.BadRequest("REPLICATION_FILTER_INVALID", "backupRecordId 不合法", err)
|
||||
}
|
||||
id := uint(parsed)
|
||||
filter.BackupRecordID = &id
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("destTargetId")); v != "" {
|
||||
parsed, err := strconv.ParseUint(v, 10, 32)
|
||||
if err != nil {
|
||||
return filter, apperror.BadRequest("REPLICATION_FILTER_INVALID", "destTargetId 不合法", err)
|
||||
}
|
||||
id := uint(parsed)
|
||||
filter.DestTargetID = &id
|
||||
}
|
||||
filter.Status = strings.TrimSpace(c.Query("status"))
|
||||
if v := strings.TrimSpace(c.Query("dateFrom")); v != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
return filter, apperror.BadRequest("REPLICATION_FILTER_INVALID", "dateFrom 必须为 RFC3339", err)
|
||||
}
|
||||
filter.DateFrom = &parsed
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("dateTo")); v != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
return filter, apperror.BadRequest("REPLICATION_FILTER_INVALID", "dateTo 必须为 RFC3339", err)
|
||||
}
|
||||
filter.DateTo = &parsed
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
162
server/internal/http/restore_record_handler.go
Normal file
162
server/internal/http/restore_record_handler.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/backup"
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RestoreRecordHandler 提供恢复记录列表/详情/实时日志端点。
|
||||
// 创建恢复由 BackupRecordHandler.Restore 代理到 RestoreService.Start。
|
||||
type RestoreRecordHandler struct {
|
||||
service *service.RestoreService
|
||||
auditService *service.AuditService
|
||||
}
|
||||
|
||||
func NewRestoreRecordHandler(restoreService *service.RestoreService, auditService *service.AuditService) *RestoreRecordHandler {
|
||||
return &RestoreRecordHandler{service: restoreService, auditService: auditService}
|
||||
}
|
||||
|
||||
func (h *RestoreRecordHandler) List(c *gin.Context) {
|
||||
filter, err := buildRestoreFilter(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
items, err := h.service.List(c.Request.Context(), filter)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, items)
|
||||
}
|
||||
|
||||
func (h *RestoreRecordHandler) Get(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.service.Get(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, item)
|
||||
}
|
||||
|
||||
func (h *RestoreRecordHandler) StreamLogs(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
detail, err := h.service.Get(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
events := detail.LogEvents
|
||||
completed := detail.Status != "running"
|
||||
channel, cancel, err := h.service.SubscribeLogs(c.Request.Context(), id, 64)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
defer cancel()
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
flusher, ok := c.Writer.(interface{ Flush() })
|
||||
if !ok {
|
||||
response.Error(c, apperror.Internal("RESTORE_STREAM_UNSUPPORTED", "当前连接不支持日志流", nil))
|
||||
return
|
||||
}
|
||||
for _, event := range events {
|
||||
if err := writeRestoreSSEEvent(c.Writer, event); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
if completed {
|
||||
return
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
case event, ok := <-channel:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := writeRestoreSSEEvent(c.Writer, event); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
if event.Completed {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildRestoreFilter(c *gin.Context) (service.RestoreRecordListInput, error) {
|
||||
var filter service.RestoreRecordListInput
|
||||
if taskIDValue := strings.TrimSpace(c.Query("taskId")); taskIDValue != "" {
|
||||
parsed, err := strconv.ParseUint(taskIDValue, 10, 32)
|
||||
if err != nil {
|
||||
return filter, apperror.BadRequest("RESTORE_RECORD_FILTER_INVALID", "taskId 不合法", err)
|
||||
}
|
||||
v := uint(parsed)
|
||||
filter.TaskID = &v
|
||||
}
|
||||
if backupValue := strings.TrimSpace(c.Query("backupRecordId")); backupValue != "" {
|
||||
parsed, err := strconv.ParseUint(backupValue, 10, 32)
|
||||
if err != nil {
|
||||
return filter, apperror.BadRequest("RESTORE_RECORD_FILTER_INVALID", "backupRecordId 不合法", err)
|
||||
}
|
||||
v := uint(parsed)
|
||||
filter.BackupRecordID = &v
|
||||
}
|
||||
if nodeValue := strings.TrimSpace(c.Query("nodeId")); nodeValue != "" {
|
||||
parsed, err := strconv.ParseUint(nodeValue, 10, 32)
|
||||
if err != nil {
|
||||
return filter, apperror.BadRequest("RESTORE_RECORD_FILTER_INVALID", "nodeId 不合法", err)
|
||||
}
|
||||
v := uint(parsed)
|
||||
filter.NodeID = &v
|
||||
}
|
||||
filter.Status = strings.TrimSpace(c.Query("status"))
|
||||
if dateFrom := strings.TrimSpace(c.Query("dateFrom")); dateFrom != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, dateFrom)
|
||||
if err != nil {
|
||||
return filter, apperror.BadRequest("RESTORE_RECORD_FILTER_INVALID", "dateFrom 必须为 RFC3339 时间格式", err)
|
||||
}
|
||||
filter.DateFrom = &parsed
|
||||
}
|
||||
if dateTo := strings.TrimSpace(c.Query("dateTo")); dateTo != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, dateTo)
|
||||
if err != nil {
|
||||
return filter, apperror.BadRequest("RESTORE_RECORD_FILTER_INVALID", "dateTo 必须为 RFC3339 时间格式", err)
|
||||
}
|
||||
filter.DateTo = &parsed
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func writeRestoreSSEEvent(writer io.Writer, event backup.LogEvent) error {
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(writer, "event: log\ndata: %s\n\n", payload)
|
||||
return err
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
stdhttp "net/http"
|
||||
|
||||
@@ -12,9 +13,13 @@ import (
|
||||
"backupx/server/pkg/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type RouterDependencies struct {
|
||||
// Context 控制 handler 启动的后台协程(如 ipLimiter GC)的生命周期。
|
||||
// app 应传入随进程退出可取消的 ctx;若为 nil 则退化为 context.Background()。
|
||||
Context context.Context
|
||||
Config config.Config
|
||||
Version string
|
||||
Logger *zap.Logger
|
||||
@@ -24,6 +29,15 @@ type RouterDependencies struct {
|
||||
BackupTaskService *service.BackupTaskService
|
||||
BackupExecutionService *service.BackupExecutionService
|
||||
BackupRecordService *service.BackupRecordService
|
||||
RestoreService *service.RestoreService
|
||||
VerificationService *service.VerificationService
|
||||
ReplicationService *service.ReplicationService
|
||||
TaskTemplateService *service.TaskTemplateService
|
||||
TaskExportService *service.TaskExportService
|
||||
SearchService *service.SearchService
|
||||
EventBroadcaster *service.EventBroadcaster
|
||||
UserService *service.UserService
|
||||
ApiKeyService *service.ApiKeyService
|
||||
NotificationService *service.NotificationService
|
||||
DashboardService *service.DashboardService
|
||||
SettingsService *service.SettingsService
|
||||
@@ -34,6 +48,10 @@ type RouterDependencies struct {
|
||||
JWTManager *security.JWTManager
|
||||
UserRepository repository.UserRepository
|
||||
SystemConfigRepo repository.SystemConfigRepository
|
||||
InstallTokenService *service.InstallTokenService
|
||||
MasterExternalURL string
|
||||
// DB 注入给健康检查端点做 liveness/readiness 探测。
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
@@ -48,7 +66,19 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
storageTargetHandler := NewStorageTargetHandler(deps.StorageTargetService, deps.AuditService)
|
||||
backupTaskHandler := NewBackupTaskHandler(deps.BackupTaskService, deps.AuditService)
|
||||
backupRunHandler := NewBackupRunHandler(deps.BackupExecutionService, deps.AuditService)
|
||||
backupRecordHandler := NewBackupRecordHandler(deps.BackupRecordService, deps.AuditService)
|
||||
backupRecordHandler := NewBackupRecordHandler(deps.BackupRecordService, deps.RestoreService, deps.AuditService)
|
||||
restoreRecordHandler := NewRestoreRecordHandler(deps.RestoreService, deps.AuditService)
|
||||
verificationHandler := NewVerificationHandler(deps.VerificationService, deps.AuditService)
|
||||
replicationHandler := NewReplicationHandler(deps.ReplicationService, deps.AuditService)
|
||||
taskTemplateHandler := NewTaskTemplateHandler(deps.TaskTemplateService, deps.AuditService)
|
||||
userHandler := NewUserHandler(deps.UserService, deps.AuditService)
|
||||
apiKeyHandler := NewApiKeyHandler(deps.ApiKeyService, deps.AuditService)
|
||||
// apiKeyAuth:给 AuthMiddleware 注入 API Key 验证能力。
|
||||
// 为 nil 时中间件仅支持 JWT,不影响向后兼容。
|
||||
var apiKeyAuth ApiKeyAuthenticator
|
||||
if deps.ApiKeyService != nil {
|
||||
apiKeyAuth = deps.ApiKeyService
|
||||
}
|
||||
notificationHandler := NewNotificationHandler(deps.NotificationService)
|
||||
dashboardHandler := NewDashboardHandler(deps.DashboardService)
|
||||
settingsHandler := NewSettingsHandler(deps.SettingsService, deps.AuditService)
|
||||
@@ -61,111 +91,237 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
auth.GET("/setup/status", authHandler.SetupStatus)
|
||||
auth.POST("/setup", authHandler.Setup)
|
||||
auth.POST("/login", authHandler.Login)
|
||||
auth.POST("/logout", AuthMiddleware(deps.JWTManager), authHandler.Logout)
|
||||
auth.GET("/profile", AuthMiddleware(deps.JWTManager), authHandler.Profile)
|
||||
auth.PUT("/password", AuthMiddleware(deps.JWTManager), authHandler.ChangePassword)
|
||||
auth.POST("/logout", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.Logout)
|
||||
auth.GET("/profile", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.Profile)
|
||||
auth.PUT("/password", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ChangePassword)
|
||||
}
|
||||
|
||||
system := api.Group("/system")
|
||||
system.Use(AuthMiddleware(deps.JWTManager))
|
||||
system.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
||||
system.GET("/info", systemHandler.Info)
|
||||
system.GET("/update-check", systemHandler.CheckUpdate)
|
||||
|
||||
storageTargets := api.Group("/storage-targets")
|
||||
storageTargets.Use(AuthMiddleware(deps.JWTManager))
|
||||
storageTargets.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
||||
// 静态路由必须在参数路由 /:id 之前注册,避免 Gin 路由冲突
|
||||
storageTargets.GET("", storageTargetHandler.List)
|
||||
storageTargets.POST("", storageTargetHandler.Create)
|
||||
storageTargets.POST("/test", storageTargetHandler.TestConnection)
|
||||
storageTargets.POST("/google-drive/auth-url", storageTargetHandler.StartGoogleDriveOAuth)
|
||||
storageTargets.POST("/google-drive/complete", storageTargetHandler.CompleteGoogleDriveOAuth)
|
||||
storageTargets.POST("", RequireNotViewer(), storageTargetHandler.Create)
|
||||
storageTargets.POST("/test", RequireNotViewer(), storageTargetHandler.TestConnection)
|
||||
storageTargets.POST("/google-drive/auth-url", RequireNotViewer(), storageTargetHandler.StartGoogleDriveOAuth)
|
||||
storageTargets.POST("/google-drive/complete", RequireNotViewer(), storageTargetHandler.CompleteGoogleDriveOAuth)
|
||||
storageTargets.GET("/google-drive/callback", storageTargetHandler.HandleGoogleDriveCallback)
|
||||
rcloneHandler := NewRcloneHandler()
|
||||
storageTargets.GET("/rclone/backends", rcloneHandler.ListBackends)
|
||||
// 参数路由
|
||||
storageTargets.GET("/:id", storageTargetHandler.Get)
|
||||
storageTargets.PUT("/:id", storageTargetHandler.Update)
|
||||
storageTargets.DELETE("/:id", storageTargetHandler.Delete)
|
||||
storageTargets.PUT("/:id/star", storageTargetHandler.ToggleStar)
|
||||
storageTargets.POST("/:id/test", storageTargetHandler.TestSavedConnection)
|
||||
storageTargets.PUT("/:id", RequireNotViewer(), storageTargetHandler.Update)
|
||||
storageTargets.DELETE("/:id", RequireNotViewer(), storageTargetHandler.Delete)
|
||||
storageTargets.PUT("/:id/star", RequireNotViewer(), storageTargetHandler.ToggleStar)
|
||||
storageTargets.POST("/:id/test", RequireNotViewer(), storageTargetHandler.TestSavedConnection)
|
||||
storageTargets.GET("/:id/usage", storageTargetHandler.GetUsage)
|
||||
storageTargets.GET("/:id/google-drive/profile", storageTargetHandler.GoogleDriveProfile)
|
||||
|
||||
backupTasks := api.Group("/backup/tasks")
|
||||
backupTasks.Use(AuthMiddleware(deps.JWTManager))
|
||||
backupTasks.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
||||
backupTasks.GET("", backupTaskHandler.List)
|
||||
backupTasks.GET("/tags", backupTaskHandler.ListTags)
|
||||
backupTasks.GET("/:id", backupTaskHandler.Get)
|
||||
backupTasks.POST("", backupTaskHandler.Create)
|
||||
backupTasks.PUT("/:id", backupTaskHandler.Update)
|
||||
backupTasks.DELETE("/:id", backupTaskHandler.Delete)
|
||||
backupTasks.PUT("/:id/toggle", backupTaskHandler.Toggle)
|
||||
backupTasks.POST("/:id/run", backupRunHandler.Run)
|
||||
backupTasks.POST("", RequireNotViewer(), backupTaskHandler.Create)
|
||||
backupTasks.PUT("/:id", RequireNotViewer(), backupTaskHandler.Update)
|
||||
backupTasks.DELETE("/:id", RequireNotViewer(), backupTaskHandler.Delete)
|
||||
backupTasks.PUT("/:id/toggle", RequireNotViewer(), backupTaskHandler.Toggle)
|
||||
backupTasks.POST("/:id/run", RequireNotViewer(), backupRunHandler.Run)
|
||||
backupTasks.POST("/batch/toggle", RequireNotViewer(), backupTaskHandler.BatchToggle)
|
||||
backupTasks.POST("/batch/delete", RequireNotViewer(), backupTaskHandler.BatchDelete)
|
||||
backupTasks.POST("/batch/run", RequireNotViewer(), backupRunHandler.BatchRun)
|
||||
// 任务配置导入/导出(集群迁移 & 灾备)
|
||||
if deps.TaskExportService != nil {
|
||||
taskExportHandler := NewTaskExportHandler(deps.TaskExportService, deps.AuditService)
|
||||
backupTasks.GET("/export", taskExportHandler.Export)
|
||||
backupTasks.POST("/import", RequireNotViewer(), taskExportHandler.Import)
|
||||
}
|
||||
if deps.VerificationService != nil {
|
||||
backupTasks.POST("/:id/verify", RequireNotViewer(), verificationHandler.TriggerByTask)
|
||||
}
|
||||
|
||||
backupRecords := api.Group("/backup/records")
|
||||
backupRecords.Use(AuthMiddleware(deps.JWTManager))
|
||||
backupRecords.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
||||
backupRecords.GET("", backupRecordHandler.List)
|
||||
backupRecords.GET("/:id", backupRecordHandler.Get)
|
||||
backupRecords.GET("/:id/logs/stream", backupRecordHandler.StreamLogs)
|
||||
backupRecords.GET("/:id/download", backupRecordHandler.Download)
|
||||
backupRecords.POST("/:id/restore", backupRecordHandler.Restore)
|
||||
backupRecords.POST("/batch-delete", backupRecordHandler.BatchDelete)
|
||||
backupRecords.DELETE("/:id", backupRecordHandler.Delete)
|
||||
backupRecords.POST("/:id/restore", RequireNotViewer(), backupRecordHandler.Restore)
|
||||
backupRecords.POST("/batch-delete", RequireNotViewer(), backupRecordHandler.BatchDelete)
|
||||
backupRecords.DELETE("/:id", RequireNotViewer(), backupRecordHandler.Delete)
|
||||
|
||||
// 恢复记录独立命名空间:列表/详情/SSE 日志流。
|
||||
// 创建恢复仍然走 POST /backup/records/:id/restore(以源备份记录为触发点)。
|
||||
if deps.RestoreService != nil {
|
||||
restoreRecords := api.Group("/restore/records")
|
||||
restoreRecords.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
||||
restoreRecords.GET("", restoreRecordHandler.List)
|
||||
restoreRecords.GET("/:id", restoreRecordHandler.Get)
|
||||
restoreRecords.GET("/:id/logs/stream", restoreRecordHandler.StreamLogs)
|
||||
}
|
||||
|
||||
// 备份复制记录(3-2-1 规则)
|
||||
if deps.ReplicationService != nil {
|
||||
replicationRecords := api.Group("/replication/records")
|
||||
replicationRecords.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
||||
replicationRecords.GET("", replicationHandler.List)
|
||||
replicationRecords.GET("/:id", replicationHandler.Get)
|
||||
backupRecords.POST("/:id/replicate", RequireNotViewer(), replicationHandler.TriggerByRecord)
|
||||
}
|
||||
|
||||
// 任务模板(批量创建)
|
||||
if deps.TaskTemplateService != nil {
|
||||
templates := api.Group("/task-templates")
|
||||
templates.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
||||
templates.GET("", taskTemplateHandler.List)
|
||||
templates.GET("/:id", taskTemplateHandler.Get)
|
||||
templates.POST("", RequireNotViewer(), taskTemplateHandler.Create)
|
||||
templates.PUT("/:id", RequireNotViewer(), taskTemplateHandler.Update)
|
||||
templates.DELETE("/:id", RequireNotViewer(), taskTemplateHandler.Delete)
|
||||
templates.POST("/:id/apply", RequireNotViewer(), taskTemplateHandler.Apply)
|
||||
}
|
||||
|
||||
// 备份验证/演练记录
|
||||
if deps.VerificationService != nil {
|
||||
verifyRecords := api.Group("/verify/records")
|
||||
verifyRecords.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
||||
verifyRecords.GET("", verificationHandler.List)
|
||||
verifyRecords.GET("/:id", verificationHandler.Get)
|
||||
verifyRecords.GET("/:id/logs/stream", verificationHandler.StreamLogs)
|
||||
// 基于备份记录的验证入口:与 restore 对称
|
||||
backupRecords.POST("/:id/verify", RequireNotViewer(), verificationHandler.TriggerByRecord)
|
||||
}
|
||||
dashboard := api.Group("/dashboard")
|
||||
dashboard.Use(AuthMiddleware(deps.JWTManager))
|
||||
dashboard.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
||||
dashboard.GET("/stats", dashboardHandler.Stats)
|
||||
dashboard.GET("/timeline", dashboardHandler.Timeline)
|
||||
dashboard.GET("/sla", dashboardHandler.SLA)
|
||||
dashboard.GET("/cluster", dashboardHandler.Cluster)
|
||||
dashboard.GET("/breakdown", dashboardHandler.Breakdown)
|
||||
dashboard.GET("/node-performance", dashboardHandler.NodePerformance)
|
||||
|
||||
notifications := api.Group("/notifications")
|
||||
notifications.Use(AuthMiddleware(deps.JWTManager))
|
||||
notifications.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
||||
notifications.GET("", notificationHandler.List)
|
||||
notifications.GET("/:id", notificationHandler.Get)
|
||||
notifications.POST("", notificationHandler.Create)
|
||||
notifications.PUT("/:id", notificationHandler.Update)
|
||||
notifications.DELETE("/:id", notificationHandler.Delete)
|
||||
notifications.POST("/test", notificationHandler.Test)
|
||||
notifications.POST("/:id/test", notificationHandler.TestSaved)
|
||||
notifications.POST("", RequireNotViewer(), notificationHandler.Create)
|
||||
notifications.PUT("/:id", RequireNotViewer(), notificationHandler.Update)
|
||||
notifications.DELETE("/:id", RequireNotViewer(), notificationHandler.Delete)
|
||||
notifications.POST("/test", RequireNotViewer(), notificationHandler.Test)
|
||||
notifications.POST("/:id/test", RequireNotViewer(), notificationHandler.TestSaved)
|
||||
|
||||
settings := api.Group("/settings")
|
||||
settings.Use(AuthMiddleware(deps.JWTManager))
|
||||
settings.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
||||
settings.GET("", settingsHandler.Get)
|
||||
settings.PUT("", settingsHandler.Update)
|
||||
settings.PUT("", RequireRole("admin"), settingsHandler.Update)
|
||||
|
||||
// 用户管理(admin 专属)
|
||||
if deps.UserService != nil {
|
||||
users := api.Group("/users")
|
||||
users.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth), RequireRole("admin"))
|
||||
users.GET("", userHandler.List)
|
||||
users.POST("", userHandler.Create)
|
||||
users.PUT("/:id", userHandler.Update)
|
||||
users.DELETE("/:id", userHandler.Delete)
|
||||
}
|
||||
|
||||
// API Key 管理(admin 专属)
|
||||
if deps.ApiKeyService != nil {
|
||||
apiKeys := api.Group("/api-keys")
|
||||
apiKeys.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth), RequireRole("admin"))
|
||||
apiKeys.GET("", apiKeyHandler.List)
|
||||
apiKeys.POST("", apiKeyHandler.Create)
|
||||
apiKeys.PUT("/:id/toggle", apiKeyHandler.Toggle)
|
||||
apiKeys.DELETE("/:id", apiKeyHandler.Revoke)
|
||||
}
|
||||
|
||||
auditLogs := api.Group("/audit-logs")
|
||||
auditLogs.Use(AuthMiddleware(deps.JWTManager))
|
||||
auditLogs.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
||||
auditLogs.GET("", auditHandler.List)
|
||||
auditLogs.GET("/export", auditHandler.Export)
|
||||
|
||||
// 实时事件 SSE 流(Dashboard 自刷新、桌面告警)
|
||||
if deps.EventBroadcaster != nil {
|
||||
eventsHandler := NewEventsHandler(deps.EventBroadcaster)
|
||||
events := api.Group("/events")
|
||||
events.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
||||
events.GET("/stream", eventsHandler.Stream)
|
||||
}
|
||||
|
||||
// 全局搜索
|
||||
if deps.SearchService != nil {
|
||||
searchHandler := NewSearchHandler(deps.SearchService)
|
||||
searchGroup := api.Group("/search")
|
||||
searchGroup.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
||||
searchGroup.GET("", searchHandler.Search)
|
||||
}
|
||||
|
||||
if deps.DatabaseDiscoveryService != nil {
|
||||
databaseHandler := NewDatabaseHandler(deps.DatabaseDiscoveryService)
|
||||
database := api.Group("/database")
|
||||
database.Use(AuthMiddleware(deps.JWTManager))
|
||||
database.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
||||
database.POST("/discover", databaseHandler.Discover)
|
||||
}
|
||||
|
||||
nodeHandler := NewNodeHandler(deps.NodeService, deps.AuditService)
|
||||
nodeHandler := NewNodeHandler(deps.NodeService, deps.AuditService, deps.InstallTokenService, deps.UserRepository, deps.MasterExternalURL)
|
||||
nodes := api.Group("/nodes")
|
||||
nodes.Use(AuthMiddleware(deps.JWTManager))
|
||||
nodes.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
||||
nodes.GET("", nodeHandler.List)
|
||||
nodes.GET("/:id", nodeHandler.Get)
|
||||
nodes.POST("", nodeHandler.Create)
|
||||
nodes.PUT("/:id", nodeHandler.Update)
|
||||
nodes.DELETE("/:id", nodeHandler.Delete)
|
||||
nodes.POST("", RequireRole("admin"), nodeHandler.Create)
|
||||
nodes.PUT("/:id", RequireRole("admin"), nodeHandler.Update)
|
||||
nodes.DELETE("/:id", RequireRole("admin"), nodeHandler.Delete)
|
||||
nodes.GET("/:id/fs/list", nodeHandler.ListDirectory)
|
||||
nodes.POST("/batch", RequireRole("admin"), nodeHandler.BatchCreate)
|
||||
nodes.POST("/:id/install-tokens", RequireRole("admin"), nodeHandler.CreateInstallToken)
|
||||
nodes.POST("/:id/rotate-token", RequireRole("admin"), nodeHandler.RotateToken)
|
||||
nodes.GET("/:id/install-script-preview", RequireRole("admin"), nodeHandler.PreviewScript)
|
||||
|
||||
// Agent API(token 认证,无需 JWT)
|
||||
if deps.AgentService != nil {
|
||||
agentHandler := NewAgentHandler(deps.AgentService, deps.NodeService)
|
||||
agentHandler := NewAgentHandler(deps.AgentService, deps.NodeService, deps.RestoreService)
|
||||
agent := api.Group("/agent")
|
||||
agent.POST("/heartbeat", agentHandler.Heartbeat)
|
||||
agent.POST("/commands/poll", agentHandler.Poll)
|
||||
agent.POST("/commands/:id/result", agentHandler.SubmitCommandResult)
|
||||
agent.GET("/tasks/:id", agentHandler.GetTaskSpec)
|
||||
agent.POST("/records/:id", agentHandler.UpdateRecord)
|
||||
agent.GET("/restores/:id/spec", agentHandler.GetRestoreSpec)
|
||||
agent.POST("/restores/:id", agentHandler.UpdateRestore)
|
||||
|
||||
// Agent v1(安装脚本探活用),仅 Self 端点
|
||||
v1Agent := api.Group("/v1/agent")
|
||||
v1Agent.GET("/self", agentHandler.Self)
|
||||
} else {
|
||||
// 未启用 Agent 服务时,保留原有 heartbeat 端点以兼容
|
||||
api.POST("/agent/heartbeat", nodeHandler.Heartbeat)
|
||||
}
|
||||
}
|
||||
|
||||
// 健康检查端点(公开、无认证、低开销)
|
||||
// K8s/Swarm/Nomad 等编排系统使用这些端点做 liveness/readiness 探测。
|
||||
healthHandler := NewHealthHandler(deps.DB, deps.Version)
|
||||
engine.GET("/health", healthHandler.Live)
|
||||
engine.GET("/ready", healthHandler.Ready)
|
||||
// 在 /api 下也暴露一份,方便反向代理按 path 前缀统一路由
|
||||
engine.GET("/api/health", healthHandler.Live)
|
||||
engine.GET("/api/ready", healthHandler.Ready)
|
||||
|
||||
// 公开安装路由(不走 JWT 中间件)
|
||||
if deps.InstallTokenService != nil {
|
||||
gcCtx := deps.Context
|
||||
if gcCtx == nil {
|
||||
gcCtx = context.Background()
|
||||
}
|
||||
installHandler := NewInstallHandler(gcCtx, deps.InstallTokenService, deps.AuditService, deps.MasterExternalURL)
|
||||
engine.GET("/install/:token", installHandler.Script)
|
||||
engine.GET("/install/:token/compose.yml", installHandler.Compose)
|
||||
}
|
||||
|
||||
engine.NoRoute(func(c *gin.Context) {
|
||||
response.Error(c, apperror.New(stdhttp.StatusNotFound, "NOT_FOUND", "接口不存在", errors.New("route not found")))
|
||||
})
|
||||
|
||||
28
server/internal/http/search_handler.go
Normal file
28
server/internal/http/search_handler.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SearchHandler 全局搜索。
|
||||
type SearchHandler struct {
|
||||
service *service.SearchService
|
||||
}
|
||||
|
||||
func NewSearchHandler(s *service.SearchService) *SearchHandler {
|
||||
return &SearchHandler{service: s}
|
||||
}
|
||||
|
||||
// Search GET /search?q=关键字
|
||||
func (h *SearchHandler) Search(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
result, err := h.service.Search(c.Request.Context(), query)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
101
server/internal/http/task_export_handler.go
Normal file
101
server/internal/http/task_export_handler.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
stdhttp "net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TaskExportHandler 提供任务配置 JSON 导入/导出。
|
||||
type TaskExportHandler struct {
|
||||
service *service.TaskExportService
|
||||
auditService *service.AuditService
|
||||
}
|
||||
|
||||
func NewTaskExportHandler(s *service.TaskExportService, audit *service.AuditService) *TaskExportHandler {
|
||||
return &TaskExportHandler{service: s, auditService: audit}
|
||||
}
|
||||
|
||||
// Export GET /api/backup/tasks/export?ids=1,2,3
|
||||
// 无 ids 参数时导出全部任务。返回 application/json + Content-Disposition。
|
||||
func (h *TaskExportHandler) Export(c *gin.Context) {
|
||||
var taskIDs []uint
|
||||
if v := strings.TrimSpace(c.Query("ids")); v != "" {
|
||||
for _, part := range strings.Split(v, ",") {
|
||||
if id, err := strconv.ParseUint(strings.TrimSpace(part), 10, 32); err == nil {
|
||||
taskIDs = append(taskIDs, uint(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
payload, err := h.service.Export(c.Request.Context(), taskIDs)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Internal("TASK_EXPORT_MARSHAL_FAILED", "无法序列化导出内容", err))
|
||||
return
|
||||
}
|
||||
filename := fmt.Sprintf("backupx-tasks-%s.json", time.Now().UTC().Format("20060102-150405"))
|
||||
c.Header("Content-Type", "application/json; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
_, _ = c.Writer.Write(data)
|
||||
recordAudit(c, h.auditService, "backup_task", "export", "backup_task", "", "",
|
||||
fmt.Sprintf("导出 %d 个任务的配置为 JSON", payload.TaskCount))
|
||||
}
|
||||
|
||||
// Import POST /api/backup/tasks/import
|
||||
// Body: ExportPayload JSON。返回每个任务的创建/跳过结果。
|
||||
func (h *TaskExportHandler) Import(c *gin.Context) {
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.BadRequest("TASK_IMPORT_INVALID", "无法读取请求体", err))
|
||||
return
|
||||
}
|
||||
if len(body) == 0 {
|
||||
response.Error(c, apperror.BadRequest("TASK_IMPORT_INVALID", "请求体为空", nil))
|
||||
return
|
||||
}
|
||||
if len(body) > 1024*1024 { // 1MB 上限
|
||||
c.Writer.WriteHeader(stdhttp.StatusRequestEntityTooLarge)
|
||||
response.Error(c, apperror.BadRequest("TASK_IMPORT_TOO_LARGE", "导入文件过大(上限 1MB)", nil))
|
||||
return
|
||||
}
|
||||
var payload service.ExportPayload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
response.Error(c, apperror.BadRequest("TASK_IMPORT_INVALID", "JSON 格式不合法", err))
|
||||
return
|
||||
}
|
||||
if len(payload.Tasks) == 0 {
|
||||
response.Error(c, apperror.BadRequest("TASK_IMPORT_INVALID", "文件中未包含任何任务", nil))
|
||||
return
|
||||
}
|
||||
results, err := h.service.Import(c.Request.Context(), payload)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
succ := 0
|
||||
skipped := 0
|
||||
for _, r := range results {
|
||||
if r.Success && !r.Skipped {
|
||||
succ++
|
||||
} else if r.Skipped {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
recordAudit(c, h.auditService, "backup_task", "import", "backup_task", "", "",
|
||||
fmt.Sprintf("从 JSON 导入任务:创建 %d / 跳过 %d / 失败 %d", succ, skipped, len(results)-succ-skipped))
|
||||
response.Success(c, results)
|
||||
}
|
||||
125
server/internal/http/task_template_handler.go
Normal file
125
server/internal/http/task_template_handler.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type TaskTemplateHandler struct {
|
||||
service *service.TaskTemplateService
|
||||
auditService *service.AuditService
|
||||
}
|
||||
|
||||
func NewTaskTemplateHandler(templateService *service.TaskTemplateService, auditService *service.AuditService) *TaskTemplateHandler {
|
||||
return &TaskTemplateHandler{service: templateService, auditService: auditService}
|
||||
}
|
||||
|
||||
func (h *TaskTemplateHandler) List(c *gin.Context) {
|
||||
items, err := h.service.List(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, items)
|
||||
}
|
||||
|
||||
func (h *TaskTemplateHandler) Get(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.service.Get(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, item)
|
||||
}
|
||||
|
||||
func (h *TaskTemplateHandler) Create(c *gin.Context) {
|
||||
var input service.TaskTemplateUpsertInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("TASK_TEMPLATE_INVALID", "模板参数不合法", err))
|
||||
return
|
||||
}
|
||||
creator := ""
|
||||
if v, ok := c.Get(contextUsernameKey); ok {
|
||||
if s, ok := v.(string); ok {
|
||||
creator = s
|
||||
}
|
||||
}
|
||||
item, err := h.service.Create(c.Request.Context(), creator, input)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "task_template", "create", "task_template", fmt.Sprintf("%d", item.ID), item.Name,
|
||||
fmt.Sprintf("创建任务模板: %s (类型: %s)", item.Name, item.TaskType))
|
||||
response.Success(c, item)
|
||||
}
|
||||
|
||||
func (h *TaskTemplateHandler) Update(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input service.TaskTemplateUpsertInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("TASK_TEMPLATE_INVALID", "模板参数不合法", err))
|
||||
return
|
||||
}
|
||||
item, err := h.service.Update(c.Request.Context(), id, input)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "task_template", "update", "task_template", fmt.Sprintf("%d", item.ID), item.Name,
|
||||
fmt.Sprintf("更新任务模板: %s", item.Name))
|
||||
response.Success(c, item)
|
||||
}
|
||||
|
||||
func (h *TaskTemplateHandler) Delete(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.Delete(c.Request.Context(), id); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "task_template", "delete", "task_template", fmt.Sprintf("%d", id), "",
|
||||
fmt.Sprintf("删除任务模板 (ID: %d)", id))
|
||||
response.Success(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
// Apply 一键批量创建任务。Body: {variables: [{name, sourcePath, ...}, ...]}
|
||||
func (h *TaskTemplateHandler) Apply(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input service.TaskTemplateApplyInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("TASK_TEMPLATE_INVALID", "应用参数不合法", err))
|
||||
return
|
||||
}
|
||||
results, err := h.service.Apply(c.Request.Context(), id, input)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
successCount := 0
|
||||
for _, r := range results {
|
||||
if r.Success {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
recordAudit(c, h.auditService, "task_template", "apply", "task_template", fmt.Sprintf("%d", id), "",
|
||||
fmt.Sprintf("应用模板批量创建任务(成功 %d/%d)", successCount, len(results)))
|
||||
response.Success(c, results)
|
||||
}
|
||||
80
server/internal/http/user_handler.go
Normal file
80
server/internal/http/user_handler.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// UserHandler 管理账号(仅 admin 可访问)。
|
||||
type UserHandler struct {
|
||||
service *service.UserService
|
||||
auditService *service.AuditService
|
||||
}
|
||||
|
||||
func NewUserHandler(userService *service.UserService, auditService *service.AuditService) *UserHandler {
|
||||
return &UserHandler{service: userService, auditService: auditService}
|
||||
}
|
||||
|
||||
func (h *UserHandler) List(c *gin.Context) {
|
||||
items, err := h.service.List(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, items)
|
||||
}
|
||||
|
||||
func (h *UserHandler) Create(c *gin.Context) {
|
||||
var input service.UserUpsertInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("USER_INVALID", "用户参数不合法", err))
|
||||
return
|
||||
}
|
||||
item, err := h.service.Create(c.Request.Context(), input)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "user", "create", "user", fmt.Sprintf("%d", item.ID), item.Username,
|
||||
fmt.Sprintf("创建用户 %s (角色: %s)", item.Username, item.Role))
|
||||
response.Success(c, item)
|
||||
}
|
||||
|
||||
func (h *UserHandler) Update(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input service.UserUpsertInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("USER_INVALID", "用户参数不合法", err))
|
||||
return
|
||||
}
|
||||
item, err := h.service.Update(c.Request.Context(), id, input)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "user", "update", "user", fmt.Sprintf("%d", id), item.Username,
|
||||
fmt.Sprintf("更新用户 %s (角色: %s, 停用: %v)", item.Username, item.Role, item.Disabled))
|
||||
response.Success(c, item)
|
||||
}
|
||||
|
||||
func (h *UserHandler) Delete(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.Delete(c.Request.Context(), id); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "user", "delete", "user", fmt.Sprintf("%d", id), "",
|
||||
fmt.Sprintf("删除用户 (ID: %d)", id))
|
||||
response.Success(c, gin.H{"deleted": true})
|
||||
}
|
||||
207
server/internal/http/verification_handler.go
Normal file
207
server/internal/http/verification_handler.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/backup"
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// VerificationHandler 提供验证记录列表/详情/SSE,以及手动触发入口。
|
||||
type VerificationHandler struct {
|
||||
service *service.VerificationService
|
||||
auditService *service.AuditService
|
||||
}
|
||||
|
||||
func NewVerificationHandler(verifyService *service.VerificationService, auditService *service.AuditService) *VerificationHandler {
|
||||
return &VerificationHandler{service: verifyService, auditService: auditService}
|
||||
}
|
||||
|
||||
// TriggerByTask 接收任务级手动触发。使用最新成功备份为源。
|
||||
func (h *VerificationHandler) TriggerByTask(c *gin.Context) {
|
||||
taskID, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&input)
|
||||
triggeredBy := ""
|
||||
if subject, exists := c.Get(contextUserSubjectKey); exists {
|
||||
triggeredBy = strings.TrimSpace(fmt.Sprintf("%v", subject))
|
||||
}
|
||||
if triggeredBy == "" {
|
||||
triggeredBy = "manual"
|
||||
}
|
||||
detail, err := h.service.StartByTask(c.Request.Context(), taskID, input.Mode, triggeredBy)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "backup_verify", "manual_run", "backup_task", fmt.Sprintf("%d", taskID), "",
|
||||
fmt.Sprintf("手动触发验证(任务 ID: %d, 验证记录 ID: %d, 模式: %s)", taskID, detail.ID, detail.Mode))
|
||||
response.Success(c, detail)
|
||||
}
|
||||
|
||||
// TriggerByRecord 基于指定备份记录触发验证(允许验证历史备份)。
|
||||
func (h *VerificationHandler) TriggerByRecord(c *gin.Context) {
|
||||
recordID, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&input)
|
||||
triggeredBy := ""
|
||||
if subject, exists := c.Get(contextUserSubjectKey); exists {
|
||||
triggeredBy = strings.TrimSpace(fmt.Sprintf("%v", subject))
|
||||
}
|
||||
if triggeredBy == "" {
|
||||
triggeredBy = "manual"
|
||||
}
|
||||
detail, err := h.service.Start(c.Request.Context(), recordID, input.Mode, triggeredBy)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "backup_verify", "manual_run", "backup_record", fmt.Sprintf("%d", recordID), "",
|
||||
fmt.Sprintf("手动触发验证(备份记录 ID: %d, 验证记录 ID: %d, 模式: %s)", recordID, detail.ID, detail.Mode))
|
||||
response.Success(c, detail)
|
||||
}
|
||||
|
||||
func (h *VerificationHandler) List(c *gin.Context) {
|
||||
filter, err := buildVerifyFilter(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
items, err := h.service.List(c.Request.Context(), filter)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, items)
|
||||
}
|
||||
|
||||
func (h *VerificationHandler) Get(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.service.Get(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, item)
|
||||
}
|
||||
|
||||
func (h *VerificationHandler) StreamLogs(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
detail, err := h.service.Get(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
events := detail.LogEvents
|
||||
completed := detail.Status != "running"
|
||||
channel, cancel, err := h.service.SubscribeLogs(c.Request.Context(), id, 64)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
defer cancel()
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
flusher, ok := c.Writer.(interface{ Flush() })
|
||||
if !ok {
|
||||
response.Error(c, apperror.Internal("VERIFY_STREAM_UNSUPPORTED", "当前连接不支持日志流", nil))
|
||||
return
|
||||
}
|
||||
for _, event := range events {
|
||||
if err := writeVerifySSEEvent(c.Writer, event); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
if completed {
|
||||
return
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
case event, ok := <-channel:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := writeVerifySSEEvent(c.Writer, event); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
if event.Completed {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildVerifyFilter(c *gin.Context) (service.VerificationRecordListInput, error) {
|
||||
var filter service.VerificationRecordListInput
|
||||
if value := strings.TrimSpace(c.Query("taskId")); value != "" {
|
||||
parsed, err := strconv.ParseUint(value, 10, 32)
|
||||
if err != nil {
|
||||
return filter, apperror.BadRequest("VERIFY_RECORD_FILTER_INVALID", "taskId 不合法", err)
|
||||
}
|
||||
v := uint(parsed)
|
||||
filter.TaskID = &v
|
||||
}
|
||||
if value := strings.TrimSpace(c.Query("backupRecordId")); value != "" {
|
||||
parsed, err := strconv.ParseUint(value, 10, 32)
|
||||
if err != nil {
|
||||
return filter, apperror.BadRequest("VERIFY_RECORD_FILTER_INVALID", "backupRecordId 不合法", err)
|
||||
}
|
||||
v := uint(parsed)
|
||||
filter.BackupRecordID = &v
|
||||
}
|
||||
filter.Status = strings.TrimSpace(c.Query("status"))
|
||||
if dateFrom := strings.TrimSpace(c.Query("dateFrom")); dateFrom != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, dateFrom)
|
||||
if err != nil {
|
||||
return filter, apperror.BadRequest("VERIFY_RECORD_FILTER_INVALID", "dateFrom 必须为 RFC3339 时间格式", err)
|
||||
}
|
||||
filter.DateFrom = &parsed
|
||||
}
|
||||
if dateTo := strings.TrimSpace(c.Query("dateTo")); dateTo != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, dateTo)
|
||||
if err != nil {
|
||||
return filter, apperror.BadRequest("VERIFY_RECORD_FILTER_INVALID", "dateTo 必须为 RFC3339 时间格式", err)
|
||||
}
|
||||
filter.DateTo = &parsed
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func writeVerifySSEEvent(writer io.Writer, event backup.LogEvent) error {
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(writer, "event: log\ndata: %s\n\n", payload)
|
||||
return err
|
||||
}
|
||||
170
server/internal/installscript/renderer.go
Normal file
170
server/internal/installscript/renderer.go
Normal file
@@ -0,0 +1,170 @@
|
||||
// Package installscript 负责把一次性安装令牌 + 节点配置渲染为可执行 shell 脚本或 docker-compose YAML。
|
||||
//
|
||||
// 模板文件通过 go:embed 嵌入二进制,避免运行时依赖外部资源。
|
||||
package installscript
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"backupx/server/internal/model"
|
||||
)
|
||||
|
||||
//go:embed templates/agent-install.sh.tmpl
|
||||
var installScriptTmpl string
|
||||
|
||||
//go:embed templates/agent-compose.yml.tmpl
|
||||
var composeYamlTmpl string
|
||||
|
||||
// Context 是模板渲染输入。
|
||||
type Context struct {
|
||||
MasterURL string
|
||||
AgentToken string
|
||||
AgentVersion string
|
||||
Mode string // systemd|docker|foreground
|
||||
Arch string // amd64|arm64|auto
|
||||
DownloadBase string
|
||||
InstallPrefix string
|
||||
NodeID uint
|
||||
}
|
||||
|
||||
// DownloadBaseFor 将下载源枚举转换为具体 URL 前缀。
|
||||
func DownloadBaseFor(src string) string {
|
||||
switch src {
|
||||
case model.InstallSourceGhproxy:
|
||||
return "https://ghproxy.com/https://github.com/Awuqing/BackupX/releases/download"
|
||||
default:
|
||||
return "https://github.com/Awuqing/BackupX/releases/download"
|
||||
}
|
||||
}
|
||||
|
||||
// RenderScript 渲染目标机安装脚本。
|
||||
func RenderScript(ctx Context) (string, error) {
|
||||
ctx = withDefaults(ctx)
|
||||
if err := validateContext(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpl, err := template.New("install").Parse(installScriptTmpl)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse template: %w", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, ctx); err != nil {
|
||||
return "", fmt.Errorf("execute template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// RenderComposeYaml 渲染 docker-compose.yml 片段。
|
||||
func RenderComposeYaml(ctx Context) (string, error) {
|
||||
ctx = withDefaults(ctx)
|
||||
if err := validateContext(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpl, err := template.New("compose").Parse(composeYamlTmpl)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse template: %w", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, ctx); err != nil {
|
||||
return "", fmt.Errorf("execute template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// validateContext 对模板变量做安全校验,防止 YAML/shell 注入。
|
||||
// - MasterURL:必须是合法 http(s) URL,无控制字符
|
||||
// - AgentToken:仅允许 hex 字符,最长 128
|
||||
// - AgentVersion:仅允许 tag 常见字符(字母数字、点、连字符、下划线、加号)
|
||||
//
|
||||
// 这些字段被直接写入 shell 双引号字符串和 YAML 双引号值;不做校验会带来
|
||||
// 注入风险(如 MasterURL 含 `"\nCOMMAND:` 可逃逸 YAML 结构)。
|
||||
func validateContext(ctx Context) error {
|
||||
if err := validateMasterURL(ctx.MasterURL); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAgentToken(ctx.AgentToken); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAgentVersion(ctx.AgentVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMasterURL(raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return fmt.Errorf("master URL empty")
|
||||
}
|
||||
if strings.ContainsAny(raw, " \t\r\n\"'`$\\") {
|
||||
return fmt.Errorf("master URL contains illegal characters")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid master URL: %w", err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("master URL scheme must be http or https, got %q", u.Scheme)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return fmt.Errorf("master URL missing host")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAgentToken 允许占位符 <AGENT_TOKEN>(PreviewScript 使用),
|
||||
// 或 32 字节 hex(64 字符)+ 小幅兼容(16-128 hex 字符)
|
||||
func validateAgentToken(tok string) error {
|
||||
if tok == "<AGENT_TOKEN>" {
|
||||
return nil
|
||||
}
|
||||
if len(tok) < 8 || len(tok) > 128 {
|
||||
return fmt.Errorf("agent token length out of range")
|
||||
}
|
||||
for _, c := range tok {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
case c >= 'a' && c <= 'f':
|
||||
case c >= 'A' && c <= 'F':
|
||||
default:
|
||||
return fmt.Errorf("agent token must be hex")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAgentVersion(v string) error {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return fmt.Errorf("agent version empty")
|
||||
}
|
||||
if len(v) > 64 {
|
||||
return fmt.Errorf("agent version too long")
|
||||
}
|
||||
for _, c := range v {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
case c >= 'a' && c <= 'z':
|
||||
case c >= 'A' && c <= 'Z':
|
||||
case c == '.' || c == '-' || c == '_' || c == '+':
|
||||
default:
|
||||
return fmt.Errorf("agent version contains illegal char %q", c)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func withDefaults(ctx Context) Context {
|
||||
if ctx.InstallPrefix == "" {
|
||||
ctx.InstallPrefix = "/opt/backupx-agent"
|
||||
}
|
||||
if ctx.DownloadBase == "" {
|
||||
ctx.DownloadBase = DownloadBaseFor(model.InstallSourceGitHub)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
176
server/internal/installscript/renderer_test.go
Normal file
176
server/internal/installscript/renderer_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package installscript
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"backupx/server/internal/model"
|
||||
)
|
||||
|
||||
// 使用合法 hex token(32 字节 = 64 字符)以通过 validateAgentToken 校验
|
||||
var testCtx = Context{
|
||||
MasterURL: "https://master.example.com",
|
||||
AgentToken: "deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
AgentVersion: "v1.7.0",
|
||||
Mode: model.InstallModeSystemd,
|
||||
Arch: model.InstallArchAuto,
|
||||
DownloadBase: "https://github.com/Awuqing/BackupX/releases/download",
|
||||
InstallPrefix: "/opt/backupx-agent",
|
||||
NodeID: 42,
|
||||
}
|
||||
|
||||
func TestRenderScriptSystemd(t *testing.T) {
|
||||
got, err := RenderScript(testCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("render err: %v", err)
|
||||
}
|
||||
mustContain := []string{
|
||||
"BACKUPX_AGENT_MASTER=${MASTER_URL}",
|
||||
`Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"`,
|
||||
"systemctl daemon-reload",
|
||||
"systemctl enable --now backupx-agent",
|
||||
"X-Agent-Token: ${AGENT_TOKEN}",
|
||||
"MASTER_URL=\"https://master.example.com\"",
|
||||
"AGENT_TOKEN=\"deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef\"",
|
||||
}
|
||||
for _, s := range mustContain {
|
||||
if !strings.Contains(got, s) {
|
||||
t.Errorf("systemd script missing %q", s)
|
||||
}
|
||||
}
|
||||
mustNotContain := []string{"docker run", `exec "${INSTALL_PREFIX}/backupx" agent --temp-dir`}
|
||||
for _, s := range mustNotContain {
|
||||
if strings.Contains(got, s) {
|
||||
t.Errorf("systemd script unexpectedly contains %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderScriptForeground(t *testing.T) {
|
||||
ctx := testCtx
|
||||
ctx.Mode = model.InstallModeForeground
|
||||
got, err := RenderScript(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("render err: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, `exec "${INSTALL_PREFIX}/backupx" agent`) {
|
||||
t.Errorf("foreground script missing exec line:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "systemctl daemon-reload") {
|
||||
t.Errorf("foreground script should not reference systemctl:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "docker run") {
|
||||
t.Errorf("foreground script should not reference docker:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderScriptDocker(t *testing.T) {
|
||||
ctx := testCtx
|
||||
ctx.Mode = model.InstallModeDocker
|
||||
got, err := RenderScript(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("render err: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, "docker run") {
|
||||
t.Errorf("docker script missing `docker run`:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "awuqing/backupx:${AGENT_VERSION}") {
|
||||
t.Errorf("docker script missing image tag reference:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "systemctl daemon-reload") {
|
||||
t.Errorf("docker script should not reference systemctl:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderComposeYaml(t *testing.T) {
|
||||
ctx := testCtx
|
||||
ctx.Mode = model.InstallModeDocker
|
||||
got, err := RenderComposeYaml(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("render err: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, "image: awuqing/backupx:v1.7.0") {
|
||||
t.Errorf("compose missing image:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, `BACKUPX_AGENT_TOKEN: "deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef"`) {
|
||||
t.Errorf("compose missing token env:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderScriptRejectsInjectedMasterURL(t *testing.T) {
|
||||
bad := []string{
|
||||
"https://example.com\" other: inject", // 含引号和空格
|
||||
"javascript:alert(1)", // scheme 非法
|
||||
"https://example.com\n- privileged", // 含换行,YAML 注入经典 payload
|
||||
"", // 空
|
||||
}
|
||||
for _, u := range bad {
|
||||
ctx := testCtx
|
||||
ctx.MasterURL = u
|
||||
if _, err := RenderScript(ctx); err == nil {
|
||||
t.Errorf("RenderScript should reject MasterURL %q", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderComposeYamlRejectsInjectedMasterURL(t *testing.T) {
|
||||
ctx := testCtx
|
||||
ctx.Mode = model.InstallModeDocker
|
||||
ctx.MasterURL = "https://example.com\n- privileged: true"
|
||||
if _, err := RenderComposeYaml(ctx); err == nil {
|
||||
t.Errorf("RenderComposeYaml should reject injected MasterURL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderScriptRejectsBadToken(t *testing.T) {
|
||||
ctx := testCtx
|
||||
ctx.AgentToken = "not-hex-token" // 非 hex
|
||||
if _, err := RenderScript(ctx); err == nil {
|
||||
t.Errorf("should reject non-hex agent token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderScriptAcceptsPlaceholderToken(t *testing.T) {
|
||||
ctx := testCtx
|
||||
ctx.AgentToken = "<AGENT_TOKEN>" // Preview 占位符
|
||||
if _, err := RenderScript(ctx); err != nil {
|
||||
t.Errorf("should accept placeholder token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderScriptRejectsBadVersion(t *testing.T) {
|
||||
ctx := testCtx
|
||||
ctx.AgentVersion = "v1.7 && rm -rf /" // 含非法字符
|
||||
if _, err := RenderScript(ctx); err == nil {
|
||||
t.Errorf("should reject version with shell metacharacters")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadBaseMapping(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
model.InstallSourceGitHub: "https://github.com/Awuqing/BackupX/releases/download",
|
||||
model.InstallSourceGhproxy: "https://ghproxy.com/https://github.com/Awuqing/BackupX/releases/download",
|
||||
}
|
||||
for src, want := range cases {
|
||||
got := DownloadBaseFor(src)
|
||||
if got != want {
|
||||
t.Errorf("src=%s want=%s got=%s", src, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderScriptDefaultsApplied(t *testing.T) {
|
||||
ctx := testCtx
|
||||
ctx.InstallPrefix = "" // 应被默认为 /opt/backupx-agent
|
||||
ctx.DownloadBase = "" // 应被默认为 github
|
||||
got, err := RenderScript(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("render err: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, "INSTALL_PREFIX=\"/opt/backupx-agent\"") {
|
||||
t.Errorf("default InstallPrefix not applied:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "DOWNLOAD_BASE=\"https://github.com/Awuqing/BackupX/releases/download\"") {
|
||||
t.Errorf("default DownloadBase not applied:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# BackupX Agent docker-compose 片段
|
||||
# 生成于 {{.MasterURL}} · 节点 ID {{.NodeID}}
|
||||
version: "3.8"
|
||||
services:
|
||||
backupx-agent:
|
||||
image: awuqing/backupx:{{.AgentVersion}}
|
||||
command: ["agent"]
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
BACKUPX_AGENT_MASTER: "{{.MasterURL}}"
|
||||
BACKUPX_AGENT_TOKEN: "{{.AgentToken}}"
|
||||
volumes:
|
||||
- /var/lib/backupx-agent:/tmp/backupx-agent
|
||||
108
server/internal/installscript/templates/agent-install.sh.tmpl
Normal file
108
server/internal/installscript/templates/agent-install.sh.tmpl
Normal file
@@ -0,0 +1,108 @@
|
||||
#!/bin/sh
|
||||
# BackupX Agent 一键安装脚本(由 Master 动态渲染)
|
||||
# 模式: {{.Mode}} | 架构: {{.Arch}} | 版本: {{.AgentVersion}}
|
||||
set -eu
|
||||
|
||||
MASTER_URL="{{.MasterURL}}"
|
||||
AGENT_TOKEN="{{.AgentToken}}"
|
||||
AGENT_VERSION="{{.AgentVersion}}"
|
||||
DOWNLOAD_BASE="{{.DownloadBase}}"
|
||||
INSTALL_PREFIX="{{.InstallPrefix}}"
|
||||
ARCH="{{.Arch}}"
|
||||
|
||||
# 1. 前置检查
|
||||
[ "$(id -u)" -eq 0 ] || { echo "请使用 root 或 sudo 执行" >&2; exit 1; }
|
||||
command -v curl >/dev/null || command -v wget >/dev/null \
|
||||
|| { echo "需要 curl 或 wget" >&2; exit 1; }
|
||||
{{if eq .Mode "systemd"}}command -v systemctl >/dev/null || { echo "不支持非 systemd 系统" >&2; exit 1; }
|
||||
{{end}}{{if eq .Mode "docker"}}command -v docker >/dev/null || { echo "需要先安装 docker" >&2; exit 1; }
|
||||
{{end}}
|
||||
# 2. 架构检测
|
||||
if [ "$ARCH" = "auto" ]; then
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) ARCH=amd64 ;;
|
||||
aarch64|arm64) ARCH=arm64 ;;
|
||||
*) echo "不支持的架构: $(uname -m)" >&2; exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
{{if ne .Mode "docker"}}
|
||||
# 3. 下载二进制(systemd / foreground 模式)
|
||||
ARCHIVE="backupx-${AGENT_VERSION}-linux-${ARCH}.tar.gz"
|
||||
URL="${DOWNLOAD_BASE}/${AGENT_VERSION}/${ARCHIVE}"
|
||||
TMPDIR="$(mktemp -d)"; trap 'rm -rf "$TMPDIR"' EXIT
|
||||
echo "[1/4] 下载 ${URL}"
|
||||
if command -v curl >/dev/null; then
|
||||
curl -fsSL "$URL" -o "$TMPDIR/pkg.tar.gz"
|
||||
else
|
||||
wget -qO "$TMPDIR/pkg.tar.gz" "$URL"
|
||||
fi
|
||||
tar xzf "$TMPDIR/pkg.tar.gz" -C "$TMPDIR"
|
||||
|
||||
# 4. 安装二进制 + 用户
|
||||
echo "[2/4] 安装到 ${INSTALL_PREFIX}"
|
||||
id backupx >/dev/null 2>&1 || useradd --system --home-dir "$INSTALL_PREFIX" --shell /usr/sbin/nologin backupx
|
||||
install -d -o backupx -g backupx "$INSTALL_PREFIX" /var/lib/backupx-agent
|
||||
install -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx"
|
||||
{{end}}
|
||||
|
||||
{{if eq .Mode "systemd"}}
|
||||
# 5. systemd unit
|
||||
echo "[3/4] 配置 systemd"
|
||||
cat > /etc/systemd/system/backupx-agent.service <<UNIT
|
||||
[Unit]
|
||||
Description=BackupX Agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=backupx
|
||||
Environment="BACKUPX_AGENT_MASTER=${MASTER_URL}"
|
||||
Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"
|
||||
ExecStart=${INSTALL_PREFIX}/backupx agent --temp-dir /var/lib/backupx-agent
|
||||
Restart=on-failure
|
||||
RestartSec=10s
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now backupx-agent
|
||||
|
||||
# 6. 等待上线
|
||||
echo "[4/4] 等待节点上线"
|
||||
for i in $(seq 1 15); do
|
||||
sleep 2
|
||||
if curl -fsSL -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null \
|
||||
| grep -q '"status":"online"'; then
|
||||
echo "✓ 节点已上线"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
echo "⚠ 30s 内未收到上线心跳,请检查防火墙或 journalctl -u backupx-agent"
|
||||
exit 2
|
||||
{{end}}
|
||||
|
||||
{{if eq .Mode "foreground"}}
|
||||
# 5. 前台运行
|
||||
echo "[3/3] 前台启动 agent(Ctrl+C 退出)"
|
||||
export BACKUPX_AGENT_MASTER="${MASTER_URL}"
|
||||
export BACKUPX_AGENT_TOKEN="${AGENT_TOKEN}"
|
||||
exec "${INSTALL_PREFIX}/backupx" agent --temp-dir /var/lib/backupx-agent
|
||||
{{end}}
|
||||
|
||||
{{if eq .Mode "docker"}}
|
||||
# Docker 模式:直接用镜像启动容器
|
||||
echo "[1/2] 拉取镜像 awuqing/backupx:${AGENT_VERSION}"
|
||||
docker pull "awuqing/backupx:${AGENT_VERSION}"
|
||||
echo "[2/2] 启动容器 backupx-agent"
|
||||
docker rm -f backupx-agent >/dev/null 2>&1 || true
|
||||
docker run -d --name backupx-agent --restart=unless-stopped \
|
||||
-e "BACKUPX_AGENT_MASTER=${MASTER_URL}" \
|
||||
-e "BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}" \
|
||||
-v /var/lib/backupx-agent:/tmp/backupx-agent \
|
||||
"awuqing/backupx:${AGENT_VERSION}" agent
|
||||
echo "✓ 容器已启动"
|
||||
{{end}}
|
||||
@@ -20,6 +20,19 @@ const (
|
||||
// Payload: {"path": "/var/log"}
|
||||
// Result: {"entries": [{"name":"...", "path":"...", "isDir":true, "size":0}]}
|
||||
AgentCommandTypeListDir = "list_dir"
|
||||
// AgentCommandTypeRestoreRecord 在 Agent 节点上恢复指定备份记录
|
||||
// Payload: {"restoreRecordId": 789}
|
||||
// Agent 拉 /api/agent/restores/:id/spec 获取完整规格后执行恢复
|
||||
AgentCommandTypeRestoreRecord = "restore_record"
|
||||
// AgentCommandTypeDiscoverDB 在 Agent 节点上发现数据库列表
|
||||
// Payload: {"type": "mysql", "host": "...", "port": 3306, "user": "...", "password": "..."}
|
||||
// Result: {"databases": ["db1", "db2"]}
|
||||
AgentCommandTypeDiscoverDB = "discover_db"
|
||||
// AgentCommandTypeDeleteStorageObject 在 Agent 节点上删除指定存储对象
|
||||
// Payload: {"targetType": "local_disk", "targetConfig": {...}, "storagePath": "tasks/1/x.tar.gz"}
|
||||
// 用于跨节点 local_disk 场景:Master 删记录时请求 Agent 清理其本地备份文件。
|
||||
// Agent 需具备对应存储 provider 的执行能力。best-effort:失败仅影响 Agent 侧文件残留。
|
||||
AgentCommandTypeDeleteStorageObject = "delete_storage_object"
|
||||
)
|
||||
|
||||
// AgentCommand 代表 Master 发给某个 Agent 节点的待执行命令。
|
||||
|
||||
36
server/internal/model/agent_install_token.go
Normal file
36
server/internal/model/agent_install_token.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// AgentInstallToken 一次性安装令牌,用于 /install/:token 公开端点。
|
||||
//
|
||||
// 生命周期:创建 → 消费(ConsumedAt 非空即作废)→ 超过 ExpiresAt 后被 GC 硬删除。
|
||||
type AgentInstallToken struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Token string `gorm:"size:64;uniqueIndex;not null" json:"token"`
|
||||
NodeID uint `gorm:"not null;index" json:"nodeId"`
|
||||
Mode string `gorm:"size:16;not null" json:"mode"` // systemd|docker|foreground
|
||||
Arch string `gorm:"size:16;not null" json:"arch"` // amd64|arm64|auto
|
||||
AgentVer string `gorm:"size:32;not null" json:"agentVersion"`
|
||||
DownloadSrc string `gorm:"size:16;not null;default:'github'" json:"downloadSrc"`
|
||||
ExpiresAt time.Time `gorm:"not null;index" json:"expiresAt"`
|
||||
ConsumedAt *time.Time `json:"consumedAt,omitempty"`
|
||||
CreatedByID uint `gorm:"not null" json:"createdById"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (AgentInstallToken) TableName() string { return "agent_install_tokens" }
|
||||
|
||||
// 合法模式/架构/下载源常量
|
||||
const (
|
||||
InstallModeSystemd = "systemd"
|
||||
InstallModeDocker = "docker"
|
||||
InstallModeForeground = "foreground"
|
||||
|
||||
InstallArchAmd64 = "amd64"
|
||||
InstallArchArm64 = "arm64"
|
||||
InstallArchAuto = "auto"
|
||||
|
||||
InstallSourceGitHub = "github"
|
||||
InstallSourceGhproxy = "ghproxy"
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user