Compare commits

...

24 Commits

Author SHA1 Message Date
Wu Qing
7084d47c4b feat(BackupX): harden agent cluster backup workflow
Squash merge PR #61
2026-05-13 14:24:45 +08:00
Wu Qing
7a6ffd4ddd feat(BackupX): 修复跨节点备份恢复终态处理 (#60)
* feat(BackupX): 修复集群部署管理逻辑

* feat(BackupX): 修复节点池任务运行归属

* feat(BackupX): 修复跨节点恢复路由

* feat(BackupX): 修复跨节点备份恢复终态处理

* test(BackupX): 稳定安装流HTTP测试
2026-05-09 23:03:25 +08:00
Wu Qing
61709dd4c9 fix(cluster): support external master URL
- add server.external_url / BACKUPX_SERVER_EXTERNAL_URL for Agent install URL generation
- pass the configured external Master URL into install script and compose rendering
- document cluster deployment requirements for Docker, bare-metal, and multi-node setups

Fixes #55
2026-05-09 07:41:51 +08:00
Wu Qing
f6bd185b9f feat: improve agent install release layout support
- fix bare-metal Agent install config and executor path handling
- support release package layout in deploy/install.sh and release workflow
- add regression tests for Agent execution and deploy install script behavior
2026-05-09 00:00:53 +08:00
Wu Qing
af0e8f5c1f fix: respect local timezone for scheduler (#54) 2026-05-01 14:39:16 +08:00
Wu Qing
63fde903d2 feat: add complete MFA support
Add complete MFA support with TOTP, recovery codes, WebAuthn, trusted-device cookie flow, and email/SMS OTP delivery via notification channels. Security follow-up: trusted device tokens are stored in HttpOnly cookies, and SMS OTP reuses the existing Webhook notifier to avoid introducing a new dynamic URL sink.
2026-04-25 22:14:50 +08:00
Wu Qing
67a42b09ba fix: make agent install command proxy independent (#50) 2026-04-25 13:43:30 +08:00
Wu Qing
bc8742977e 功能: v2.2 节点池调度 + Grafana Dashboard + 版本漂移 UI (#49)
节点池动态调度(企业集群核心需求):
- model.Node 新增 Labels CSV;Node.HasLabel / LabelSet 辅助方法
- model.BackupTask 新增 NodePoolTag;与 NodeID 互斥(校验层拒绝同时设置)
- BackupExecutionService.selectPoolNode:匹配标签的在线节点中选"运行中任务最少"
  并列按 ID 升序稳定;空池返回 NODE_POOL_EMPTY 让用户立即感知
- 选中节点仅写 BackupRecord,不回写 task.NodeID —— 每次执行重选实现真轮转均衡

Grafana Dashboard(v2.1 指标的可视化闭环):
- deploy/grafana/backupx-dashboard.json:11 个面板覆盖概览/时序/容量/集群
- deploy/grafana/README.md:Prometheus 抓取配置 + 告警建议
- release workflow 打包 grafana/ + nginx.conf 到 tar.gz

前端:
- 节点列表:Agent 版本 vs Master 不一致时橙红 Tag + Tooltip 提示升级
- 节点列表新增"标签/节点池"列,支持 CSV 编辑 + 并发/带宽一起改
- 任务表单新增 NodePoolTag 输入框,与节点选择器互斥禁用

测试:
- model/node_label_test.go:HasLabel / LabelSet / nil 安全
- service/node_pool_scheduler_test.go:负载最低优先 / 空池错误 / nil repo 降级
- go test ./... + npm run build 全绿
2026-04-21 14:05:48 +08:00
Wu Qing
1a699da8d6 修复: #46 Agent 一键安装脚本在 Debian dash 下执行失败 (#48)
* 修复: #46 Agent 一键安装脚本在 Debian dash 下执行失败

根因(多因素,任何一个都可能导致用户复现的 "sh: 2: Syntax error: newline unexpected"):
- Debian/Ubuntu 默认 /bin/sh → dash;pipe 方式下 shebang 被忽略
- Content-Type: text/x-shellscript 会触发部分 CDN/反向代理的脚本识别与改写
- 如果响应被改写为 HTML,sh 在第 2 行(<html>)即报此语法错误

修复:
1. 前端命令改为 `curl -fsSL URL | sudo bash`(避开 dash)
2. 命令面板增加"先下载再执行"备用命令(代理过滤场景兜底)
3. install handler Content-Type 改为 text/plain;加 nosniff / no-store /
   Content-Disposition 三头,减少中间层改写的概率
4. 脚本模板加 magic marker `BACKUPX_AGENT_INSTALL_V1`,用户可通过
   `head -3` 自查响应完整性;加 bash 自举段,文件执行时优先切到 bash

测试:
- installscript/issue46_test.go 断言 magic + bash-bootstrap 存在于三种模式
- install_flow_test.go 断言新 headers 与 marker
- go test ./... 全绿,前端 build 通过

* 修复: #46 用户截图证实 nginx SPA fallback 返回 index.html

用户反馈截图显示 curl 下载到的是 BackupX 前端 HTML,而非 shell 脚本——
说明 /install/:token 未被反向代理转发到后端,nginx 按 try_files fallback
到 /index.html,sh 读第 2 行 <html> 报语法错误。

真正的根因修复:
1. 后端 install 端点额外暴露 /api/install/:token 别名,让反向代理
   已有的 /api/ 转发规则自动接管
2. 节点创建时返回的 url/composeUrl 统一使用 /api/install/ 前缀
3. 更新 deploy/nginx.conf 模板:
   - 新增 location /install/ 转发(兼容旧版本生成的命令)
   - 新增 /health /ready /metrics 单独转发,避免 SPA fallback

测试:
- install_flow_test.go 新增 TestInstallScriptAliasUnderAPI 断言
  /api/install/:token 路径可用 + 新生成的 url 用 /api/install/ 前缀
2026-04-20 23:35:39 +08:00
Wu Qing
1b73f19eb1 功能: v2.1 可观测性与流控 (#47)
* 功能: v2.1 可观测性与流控 — Prometheus + 节点带宽 + 审计 Webhook

核心能力:
- Prometheus /metrics 端点:11 类指标(任务/存储/节点/SLA/验证/恢复/复制)
- 节点级带宽限速生效:model.Node.BandwidthLimit 覆盖全局默认
- 审计日志 Webhook 外输:HMAC-SHA256 签名,配合 SIEM 合规留档

实现:
- server/internal/metrics/  独立 Registry + 异步 Gauge Collector(30s)
- backup/restore/verify/replication 服务注入 metrics 钩子,nil 安全
- resolveProviderForNode() 按 task.NodeID 解析 BandwidthLimit
- AuditService.SetWebhook + 动态 settings 推送,无需重启

测试:
- metrics/registry_test.go: 注册/采集/nil safety/HTTP handler
- service/audit_service_webhook_test.go: 签名正确性/异步投递/禁用路径
- go test ./... 全部通过

* chore: 触发 CodeQL 扫描
2026-04-20 23:26:04 +08:00
Wu Qing
539e9e64c4 功能: v2.0.0 企业级备份管理平台 — 11 项核心能力 (#45)
* 功能: v2.0.0 企业级备份管理平台 — 11 项核心能力

围绕"可靠、可验证、可度量、可冗余、可治理、可规模化、可运维、可部署、可感知"的
九大企业级支柱,新增 70+ 文件、14k+ 行代码,全链路测试与类型检查通过。

## 集群能力

- 节点选择器:任务表单支持绑定远程节点,集群场景不再被迫 NodeID=0
- 集群感知恢复:RestoreRecord 独立表 + 节点路由(本机/远程 Agent)+ SSE 日志
- 集群可靠性:命令超时联动备份/恢复记录、离线节点拒绝执行、调度器跳过离线节点、
  数据库发现路由到 Agent、跨节点 local_disk 保护
- 节点级资源配额:Node.MaxConcurrent / BandwidthLimit + per-node semaphore
- Agent 版本感知:ClusterVersionMonitor 定期扫描 + agent_outdated 事件
- Dashboard 集群概览 + 节点性能统计(成功率/字节/平均耗时)

## 企业功能

- 备份验证演练:定时自动校验备份可恢复性(tar/sqlite/mysql/postgres/saphana 5 类格式)
- SLA 监控:RPO 违约后台扫描 + sla_violation 事件 + Dashboard 合规视图
- 3-2-1 备份复制:自动/手动副本镜像 + 跨节点保护
- 存储目标健康监控 + 容量预警(85%)+ 硬配额(超配额拒绝)
- RBAC 三级角色(admin/operator/viewer)+ 前后端权限控制
- API Key 管理(bax_ 前缀 SHA-256 哈希存储 + 过期/启停)
- 事件总线:10+ 事件类型(backup/restore/verify/sla/storage/replication/agent)
- 审计日志高级筛选 + CSV 导出

## 规模化运维

- 任务模板(批量创建 + 变量覆盖)
- 任务批量操作(批量执行/启停/删除)
- 任务依赖链 + DAG 可视化(上游成功触发下游)
- 维护窗口(时段禁止调度)
- 任务标签 + 筛选 + 存储类型/节点/存储维度统计
- 任务配置 JSON 导入/导出(集群迁移 & 灾备)

## 体验 & 可达性

- 实时事件流(SSE)+ 右下角 Toast + 历史抽屉(未读徽章)
- Dashboard 免刷新自动更新(订阅 8 类事件)
- 全局搜索(Ctrl+K,跨任务/记录/存储/节点)
- 任务依赖图(ECharts force 布局 + 状态着色)

## 合规 & 可部署

- K8s/Swarm 健康检查端点(/health liveness + /ready readiness)
- 审计日志 CSV 导出(UTF-8 BOM,Excel 兼容)
- Dashboard 多维统计(按类型/状态/节点/存储)

## 破坏性变更

- POST /backup/records/:id/restore 返回格式变更为 {restoreRecordId, ...}
  (原为同步阻塞,现改为异步返回恢复记录 ID,前端跳转到恢复详情页)
- 恢复日志通过 /restore/records/:id/logs/stream 订阅
- AuthMiddleware 签名变更(新增 apiKeyAuth 参数)

* 修复: CodeQL 安全扫描告警

- 所有 strconv.ParseUint 由 64bit 改为 32bit 位宽,strconv 内置溢出检查
- hashApiKey 参数改名 rawToken 避免 CodeQL 误判为密码哈希(API Key 是 192 位
  高熵 token,使用 bcrypt 会引入不必要的延迟;同时补充安全说明)

* 修复: API Key 哈希改用 HMAC-SHA256 + 应用级 pepper

- 符合 RFC 2104 标准,业界 API token 存储的推荐方案
- 数据库泄漏场景下增加离线反推难度(需同时获取二进制 pepper)
- 规避 CodeQL go/weak-sensitive-data-hashing 对裸 SHA-256 的误判
2026-04-20 13:04:13 +08:00
Wu Qing
83bf5ec656 功能: 一键部署 Agent 向导 (#44) 2026-04-19 17:25:34 +08:00
Wu Qing
66373fa8e4 修复: 中文 i18n 目录名从 zh-Hans 改为 zh-CN,首页 SSR 翻译现已生效 (#42)
Docusaurus 3.10 会把 locale id 'zh-Hans' 规范化为 BCP 47 的 'zh-CN' 来
读取 i18n/ 目录。之前手工创建的 i18n/zh-Hans/ 目录 Docusaurus 识别不到,
导致中文版 SSR 输出仍是英文字符串,只有 URL 路由 /zh-Hans/ 生效。

同时修复 index.tsx 中 <Translate id={labelId}> 动态 id 问题:
write-translations 工具要求静态字符串,已拆分为三个独立的 Translate 元素。
2026-04-17 13:52:16 +08:00
Wu Qing
3a4c2edd9b 文档: 按 Ant/Arco Design 风格重构官网首页,修正 API 参考,完善 i18n (#41)
重构:
- 首页 Hero 重设计:双列布局(标题+CTA+指标 / macOS 风代码窗口)
- 引入渐变文字、pulse 徽章、悬停带动画的主按钮
- 功能卡片加 SVG 图标、悬停提升效果、部分卡片变成可点击链接
- 新增 HomepageShowcase 截图轮播区:Tab 切换四个核心页面(仪表盘/任务/存储/多节点)
- 全站换 Arco 蓝 (#165dff) 作为主色,紫色 (#8f4bff) 作为辅助
- 导航栏加毛玻璃效果、表格加圆角与边框、菜单项圆角化
- 深色模式配色整体收敛

内容修正:
- API 参考补全遗漏的端点:auth logout/profile、records batch-delete、
  storage-targets star/usage/google-drive、notifications test、dashboard timeline、settings
- 把 API 表格改为"方法/端点/说明"三列,加响应结构说明
- 中英文 API 文档同步更新

i18n:
- code.json 补充 Hero、Features、Showcase 全部新翻译键
- 校对:16 个中英文档 frontmatter 完全对齐,无漏译

构建:双语 build 通过、产物 3.3MB
2026-04-17 13:39:27 +08:00
Wu Qing
a6dd8033ed 文档: 新增 Docusaurus 官网与双语文档,README 切换为英文默认 (#39)
- 新建 docs-site/ Docusaurus 项目,支持 en + zh-Hans 双语
- 从 README 迁移内容为独立文档页面:
  - Getting Started(安装、快速开始)
  - Deployment(Docker、裸机、Nginx、配置参考)
  - Features(备份类型、存储后端、SAP HANA、多节点集群、通知)
  - Reference(API、CLI)
  - Development(开发、贡献)
- 自定义 BackupX 主题色、logo、落地页组件
- 新增 .github/workflows/docs.yml,Actions 自动构建并发布到 GitHub Pages
- README.md 切换为英文,中文版挪到 README.zh-CN.md,两者均精简为导航型
- 配置站点 URL:https://awuqing.github.io/BackupX/
2026-04-17 13:19:41 +08:00
Wu Qing
81c9c042d6 功能: 修复并实现多节点集群部署 (#38)
基础修复:
- 新增节点离线检测:每 15s 扫描,超 45s 未心跳的远程节点自动置离线
- 节点删除前检查关联任务,避免孤立备份任务
- BackupTaskRepository 新增 CountByNodeID/ListByNodeID

Master 端 Agent 协议:
- 新增 AgentCommand 模型与命令队列仓储(pending/dispatched/succeeded/failed/timeout)
- 新增 AgentService:任务下发、命令轮询、结果回收、超时扫描
- 新增专用 Agent HTTP API(X-Agent-Token 认证):
  /api/agent/heartbeat
  /api/agent/commands/poll
  /api/agent/commands/:id/result
  /api/agent/tasks/:id
  /api/agent/records/:id
- BackupExecutionService 支持 node 路由:task.NodeID 指向远程节点时自动入队派发

Agent CLI(backupx agent 子命令):
- 配置:YAML 文件 / 环境变量 / CLI 参数,优先级 CLI > 文件 > 环境
- 心跳循环 + 命令轮询循环 + 优雅退出
- 本地复用 BackupRunner 与 storage registry 执行备份并直接上传
- 支持 run_task 和 list_dir 两种命令

远程目录浏览:
- NodeService 支持通过 Agent RPC 列出远程节点目录(15s 超时)

前端:
- NodesPage 添加节点后展示 Agent 启动命令和环境变量配置

文档:
- README 中英文重写"多节点集群"章节,含架构图、步骤、限制、CLI 参考
2026-04-17 12:29:08 +08:00
Wu Qing
3e90e0f8a8 功能: 新增 SAP HANA 完整备份支持与 Backint 协议代理 (#37)
* chore: ignore web/dist directory in git repository

* 功能: 新增 SAP HANA 完整备份支持与 Backint 协议代理

- 修复 service 层校验 bug,使 SAP HANA 类型可正常创建
- 增强 hdbsql Runner:支持完整/增量/差异/日志备份、并行通道、失败重试
- 新增 Backint 协议代理(backupx backint 子命令),HANA 原生接口直连 BackupX 存储后端
- 新增本地 SQLite 目录维护 EBID↔对象键映射
- 前端新增 SAP HANA 扩展字段表单(备份类型/级别/通道数/重试次数/实例编号)
- README 中英文补充 SAP HANA 两种模式的使用说明
2026-04-16 23:43:46 +08:00
Wu Qing
827a5a2181 文档: 更新 README 中英文文档 (#35)
- 存储后端描述更新为 70+ rclone 集成
- API 参考补充新增端点(节点编辑、Rclone 后端列表、版本检查、Agent 心跳)
- 技术栈补充 rclone
- 多节点集群章节补充 IP 检测、节点编辑等新功能描述
- 存储目标添加指南补充 Rclone 类型配置项分层说明
- 任务删除行为说明(清理远端文件、保留记录)
- 版本升级指引从一键更新改为手动 docker compose pull
- 发版示例更新为 v1.4.3
2026-04-05 11:33:56 +08:00
Wu Qing
970eb154e1 优化: 多模块功能修复与体验改进 (#34)
1. 保留策略清理后自动删除空文件夹(新增 StorageDirCleaner 接口)
2. 备份任务删除时清理远端文件但保留备份记录
3. 节点管理修复:本机 IP/版本检测、Heartbeat OS/Arch 修正、新增编辑功能
4. 审计日志规范化:统一格式、丰富详情、节点操作增加审计记录
5. 系统设置移除一键更新操作,仅保留版本检查
6. Rclone 配置项分层展示(必填 + 高级可选折叠)
7. DirectoryPicker 目录选择器样式优化
2026-04-05 11:23:46 +08:00
Wu Qing
d26753c44a 优化: 存储类型下拉框分类中文标注去重 (#33)
优化: 存储类型下拉框分类中文标注去重
2026-04-02 13:43:37 +08:00
Awuqing
4251eb9e15 优化: 存储类型下拉框分类中文标注 + 去重
问题:API 返回的 rclone 后端纯英文技术名难辨别,且和内置类型存在重复
(如 rclone 的 drive 和内置的 google_drive)。

修复:
- 前端静态定义分类+中文标注(常用/云存储/网盘/文件传输/企业存储/自建存储)
- 排除工具类后端(alias/cache/http/archive 等)和重复后端(drive→用google_drive)
- Select 使用 OptGroup 按分组渲染,搜索仍支持英文/中文关键词
- 常用类型(S3/阿里云/SFTP 等)置顶,其余按分类排列
2026-04-02 13:39:43 +08:00
Wu Qing
94d5fb7286 功能: Docker 一键自动更新 (#32)
功能: Docker 一键自动更新
2026-04-01 23:47:43 +08:00
Awuqing
8eb93b3dd9 功能: Docker 一键自动更新
- 新增 POST /api/system/update-apply,执行 docker pull + docker compose up -d
- 前端系统设置页新增「一键更新(Docker)」按钮,点击后自动拉取新镜像并重启容器
- Dockerfile 安装 docker-cli + docker-cli-compose
- docker-compose.yml 挂载 /var/run/docker.sock 以支持容器内操作 Docker
- 自动检测是否为 Docker 环境,非 Docker 环境引导下载二进制
2026-04-01 23:43:12 +08:00
Wu Qing
df5c8aa80d 功能: 系统更新检查 (#31)
功能: 系统更新检查(GitHub Release + Docker)
2026-04-01 23:18:21 +08:00
290 changed files with 55172 additions and 1431 deletions

63
.github/workflows/docs.yml vendored Normal file
View 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

View File

@@ -110,13 +110,21 @@ jobs:
cp -r web/dist "${ARCHIVE_NAME}/web"
cp server/config.example.yaml "${ARCHIVE_NAME}/"
cp deploy/install.sh "${ARCHIVE_NAME}/" 2>/dev/null || true
# v2.2+: 随发布包提供 Grafana dashboard 与 nginx.conf 模板
if [ -d deploy/grafana ]; then
cp -r deploy/grafana "${ARCHIVE_NAME}/grafana"
fi
cp deploy/nginx.conf "${ARCHIVE_NAME}/nginx.conf" 2>/dev/null || true
tar czf "${ARCHIVE_NAME}.tar.gz" "${ARCHIVE_NAME}"
cp "${ARCHIVE_NAME}.tar.gz" "backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz"
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.VERSION }}
files: backupx-${{ env.VERSION }}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz
files: |
backupx-${{ env.VERSION }}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz
backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz
generate_release_notes: true
# ─── Job 3: Docker 多架构 → Docker Hub ───

5
.gitignore vendored
View File

@@ -1 +1,4 @@
web/node_modules/
web/node_modules/
web/dist/
server/bin/
.claude/

View File

@@ -55,6 +55,7 @@ RUN apk add --no-cache \
nginx \
tzdata \
ca-certificates \
docker-cli docker-cli-compose \
# Required by mysql/postgresql backup tasks
mysql-client \
postgresql16-client \

321
README.md
View File

@@ -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,307 +15,84 @@
<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
| 能力 | 说明 |
|------|------|
| **备份类型** | 文件/目录(多源路径)、MySQLPostgreSQLSQLiteSAP HANA |
| **存储后端** | 阿里云 OSS、腾讯云 COS、七牛云、S3 兼容(AWS/MinIO/R2)、Google Drive、WebDAV、FTP/FTPS、本地磁盘 |
| **自动调度** | Cron 定时 + 可视化编辑器 + 自动保留策略(按天数/份数清理) |
| **多节点** | Master-Agent 集群,统一管理多台服务器的备份 |
| **安全** | JWT + bcrypt + AES-256-GCM 加密配置 + 可选备份文件加密 + 审计日志 |
| **通知** | 邮件 / Webhook / Telegram备份成功或失败时自动推送 |
| **部署** | 单二进制 + 内嵌 SQLiteDocker 一键启动,零外部依赖 |
| 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 |
| **Observability** | Prometheus `/metrics` endpoint + `/health` + `/ready` probes + SLA breach gauge |
| **Audit Webhook** | HMAC-SHA256 signed forwarding to SIEM / WORM storage for compliance (SOC2 / GDPR) |
| **Flow Control** | Per-node bandwidth cap + per-node concurrency limit — tune big/small nodes independently |
| **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。
For ARM64 hosts, use `backupx-linux-arm64.tar.gz`. The archive contains `backupx`, `web/`, `config.example.yaml`, and `install.sh`; run `install.sh` from the extracted directory.
<details>
<summary>docker-compose.yml 参考</summary>
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).
```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
## Documentation
volumes:
backupx-data:
```
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.
</details>
Quick links:
**预编译包(裸机部署):**
- [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 # 或用国内镜像构建 Dockergoproxy.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 | 主机 + 端口 + 用户名/密码 |
| 本地磁盘 | 目标目录路径 |
> 国内云厂商只需填 Region 和 AccessKey系统自动组装 Endpoint。
添加后点击 **测试连接** 确认配置正确。
### 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
```
### 裸机部署
```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
```
---
## 多节点集群
BackupX 支持 Master-Agent 模式管理多台服务器:
1. Web 控制台 → **节点管理****添加节点**,系统生成 Token
2. 在远程服务器部署 Agent 并使用 Token 连接 Master
3. 创建备份任务时选择对应节点Master 自动下发任务
创建文件备份任务时,可通过可视化目录浏览器远程选择 Agent 节点上的目录,无需手动输入路径。
---
## 开发指南
**环境要求:** 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.2.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\|POST /nodes` | 列表 / 添加 |
| | `GET /nodes/:id/fs/list` | 目录浏览 |
| **通知** | `GET\|POST /notifications` | 列表 / 添加 |
| **仪表盘** | `GET /dashboard/stats` | 概览统计 |
| **审计日志** | `GET /audit-logs` | 操作审计 |
| **系统** | `GET /system/info` | 系统信息 |
---
## 技术栈
| 组件 | 技术 |
|------|------|
| **后端** | Go · Gin · GORM · SQLite · robfig/cron |
| **前端** | React 18 · TypeScript · ArcoDesign · Vite · Zustand · ECharts |
| **存储** | AWS SDK v2 · Google Drive API v3 · gowebdav · jlaffaye/ftp |
| **安全** | 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

99
README.zh-CN.md Normal file
View File

@@ -0,0 +1,99 @@
<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备份成功或失败时自动推送 |
| **可观测性** | Prometheus `/metrics` 端点 + `/health` + `/ready` 探针 + SLA 违约监控 |
| **审计外输** | HMAC-SHA256 签名 Webhook对接 SIEM / WORM 存储满足 SOC2 / GDPR 合规 |
| **流控** | 节点级带宽限速 + 节点级并发控制,大小节点分别配置,避免小内存 Agent 被挤爆 |
| **部署** | 单二进制 + 内嵌 SQLiteDocker 一键启动,零外部依赖 |
## 快速开始
```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
```
ARM64 主机请下载 `backupx-linux-arm64.tar.gz`。预编译包内包含 `backupx``web/``config.example.yaml``install.sh`,请在解压后的目录内执行 `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)

View File

@@ -1,318 +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 |
| **Storage Backends** | Alibaba Cloud OSS, Tencent COS, Qiniu Kodo, S3-compatible (AWS/MinIO/R2), Google Drive, WebDAV, FTP/FTPS, Local Disk |
| **Scheduling** | Cron-based scheduling + visual editor + auto-retention policy (by days/count) |
| **Multi-Node** | Master-Agent cluster for managing backups across multiple servers |
| **Security** | JWT + bcrypt + AES-256-GCM encrypted config + optional backup encryption + 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 |
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), compression, retention days, encryption toggle
Save, then click **Run Now** to test. View real-time logs in **Backup Records**.
### 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
```
### 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
```
---
## Multi-Node Cluster
BackupX supports Master-Agent mode for managing multiple servers:
1. Web Console → **Node Management****Add Node** — system generates a Token
2. Deploy Agent on remote server, connect using the Token
3. Create backup tasks and assign to specific nodes — Master dispatches automatically
The visual directory browser lets you pick directories on remote Agent nodes — no manual path typing.
---
## 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.2.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 |
| **Nodes** | `GET\|POST /nodes` | List / Add |
| | `GET /nodes/:id/fs/list` | Directory browser |
| **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 |
---
## Tech Stack
| Component | Technology |
|-----------|-----------|
| **Backend** | Go · Gin · GORM · SQLite · robfig/cron |
| **Frontend** | React 18 · TypeScript · ArcoDesign · Vite · Zustand · ECharts |
| **Storage** | AWS SDK v2 · Google Drive API v3 · gowebdav · jlaffaye/ftp |
| **Security** | JWT · bcrypt · AES-256-GCM |
## Contributing
Issues and Pull Requests are welcome!
## License
[Apache License 2.0](LICENSE)

View File

@@ -1,6 +1,10 @@
#!/bin/sh
set -e
if [ "${1:-}" = "agent" ]; then
exec /app/bin/backupx "$@"
fi
# Backend listens on internal port 8341, Nginx exposes 8340
export BACKUPX_SERVER_PORT="${BACKUPX_SERVER_PORT_INTERNAL:-8341}"

View File

@@ -19,6 +19,25 @@ server {
proxy_read_timeout 3600s;
}
# Agent one-click install endpoints.
# Some external reverse proxies strip the /api prefix before reaching this
# container, so /install/ must be proxied here instead of falling through to
# the SPA index.html.
location /install/ {
proxy_pass http://127.0.0.1:8341/install/;
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;
proxy_buffering off;
proxy_cache off;
}
location = /health { proxy_pass http://127.0.0.1:8341/health; }
location = /ready { proxy_pass http://127.0.0.1:8341/ready; }
location = /metrics { proxy_pass http://127.0.0.1:8341/metrics; }
# SPA fallback
location / {
try_files $uri $uri/ /index.html;

34
deploy/grafana/README.md Normal file
View File

@@ -0,0 +1,34 @@
# BackupX Grafana Dashboard
对接 BackupX v2.1+ 暴露的 Prometheus `/metrics` 端点。
## 导入步骤
1. 在 Grafana 配置 Prometheus 数据源指向你的 Prometheus例如 `http://prometheus:9090`
2. 在 Prometheus 配置抓取 BackupX
```yaml
scrape_configs:
- job_name: 'backupx'
scrape_interval: 30s
static_configs:
- targets: ['backupx-master:8340']
```
3. Grafana → Dashboards → Import → 上传 `backupx-dashboard.json` → 选 Prometheus 数据源 → Import
## 面板内容
- 当前运行任务数 / SLA 违约数 / 在线节点 / 24h 成功率 / 应用版本
- 任务执行速率(按 success/failed 堆叠)
- 任务耗时 P50/P95/P99按任务类型
- 任务产出字节速率
- 存储目标用量 TopN 柱状图
- 节点在线状态表(红/绿标色)
- 验证 / 恢复 / 复制的成功率时间线
## 自定义建议
-`backupx_sla_breach_tasks > 0` 配为 AlertManager 告警
- `sum(backupx_node_online) < N` 触发集群容量告警N 为你集群的最少节点数)
- P99 任务耗时突变可用于发现慢任务和资源压力

View File

@@ -0,0 +1,193 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {"type": "grafana", "uid": "-- Grafana --"},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"description": "BackupX v2.1+ 核心指标面板。对接 /metrics 端点,抓取周期建议 30s与服务端 Gauge collector 同步)。",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [
{
"title": "BackupX 文档",
"url": "https://awuqing.github.io/BackupX/",
"type": "link",
"targetBlank": true
}
],
"liveNow": false,
"panels": [
{
"type": "stat",
"title": "正在运行的任务",
"gridPos": {"h": 4, "w": 4, "x": 0, "y": 0},
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"targets": [{"expr": "backupx_task_running", "refId": "A"}],
"fieldConfig": {
"defaults": {
"unit": "short",
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "yellow", "value": 5}]}
}
},
"options": {"colorMode": "value", "graphMode": "area", "textMode": "auto"}
},
{
"type": "stat",
"title": "SLA 违约任务数",
"gridPos": {"h": 4, "w": 4, "x": 4, "y": 0},
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"targets": [{"expr": "backupx_sla_breach_tasks", "refId": "A"}],
"fieldConfig": {
"defaults": {
"unit": "short",
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "red", "value": 1}]}
}
}
},
{
"type": "stat",
"title": "在线节点",
"gridPos": {"h": 4, "w": 4, "x": 8, "y": 0},
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"targets": [{"expr": "sum(backupx_node_online)", "refId": "A"}],
"fieldConfig": {
"defaults": {"unit": "short", "color": {"mode": "thresholds"}, "thresholds": {"steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]}}
}
},
{
"type": "stat",
"title": "24h 任务成功率",
"gridPos": {"h": 4, "w": 6, "x": 12, "y": 0},
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"targets": [{
"expr": "sum(rate(backupx_task_run_total{status=\"success\"}[24h])) / sum(rate(backupx_task_run_total[24h])) * 100",
"refId": "A"
}],
"fieldConfig": {
"defaults": {
"unit": "percent", "decimals": 2,
"thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "yellow", "value": 95}, {"color": "green", "value": 99}]}
}
}
},
{
"type": "stat",
"title": "应用版本",
"gridPos": {"h": 4, "w": 6, "x": 18, "y": 0},
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"targets": [{"expr": "backupx_app_info", "refId": "A", "format": "table", "instant": true}],
"options": {"textMode": "value_and_name", "reduceOptions": {"calcs": ["last"], "fields": "/^version$/"}}
},
{
"type": "timeseries",
"title": "任务执行速率(按状态)",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 4},
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"targets": [{
"expr": "sum by (status) (rate(backupx_task_run_total[5m]))",
"refId": "A",
"legendFormat": "{{status}}"
}],
"fieldConfig": {
"defaults": {
"unit": "ops",
"custom": {"drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 10, "stacking": {"mode": "normal"}}
},
"overrides": [
{"matcher": {"id": "byName", "options": "success"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}}]},
{"matcher": {"id": "byName", "options": "failed"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "red"}}]}
]
}
},
{
"type": "timeseries",
"title": "任务耗时 P50 / P95 / P99",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 4},
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"targets": [
{"expr": "histogram_quantile(0.50, sum(rate(backupx_task_run_duration_seconds_bucket[10m])) by (le, task_type))", "refId": "A", "legendFormat": "P50 {{task_type}}"},
{"expr": "histogram_quantile(0.95, sum(rate(backupx_task_run_duration_seconds_bucket[10m])) by (le, task_type))", "refId": "B", "legendFormat": "P95 {{task_type}}"},
{"expr": "histogram_quantile(0.99, sum(rate(backupx_task_run_duration_seconds_bucket[10m])) by (le, task_type))", "refId": "C", "legendFormat": "P99 {{task_type}}"}
],
"fieldConfig": {"defaults": {"unit": "s"}}
},
{
"type": "timeseries",
"title": "任务产出字节速率",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 12},
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"targets": [{"expr": "sum by (task_type) (rate(backupx_task_bytes_total[5m]))", "refId": "A", "legendFormat": "{{task_type}}"}],
"fieldConfig": {"defaults": {"unit": "Bps"}}
},
{
"type": "bargauge",
"title": "存储目标用量 TopN",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 12},
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"targets": [{"expr": "topk(10, backupx_storage_used_bytes)", "refId": "A", "legendFormat": "{{target_name}} ({{target_type}})"}],
"fieldConfig": {"defaults": {"unit": "bytes"}},
"options": {"orientation": "horizontal", "displayMode": "gradient"}
},
{
"type": "table",
"title": "节点在线状态",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 20},
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"targets": [{"expr": "backupx_node_online", "refId": "A", "format": "table", "instant": true}],
"transformations": [
{"id": "organize", "options": {"excludeByName": {"Time": true, "__name__": true, "job": true, "instance": true}, "indexByName": {"node_name": 0, "role": 1, "Value": 2}, "renameByName": {"Value": "online"}}}
],
"fieldConfig": {
"overrides": [{
"matcher": {"id": "byName", "options": "online"},
"properties": [{"id": "mappings", "value": [{"type": "value", "options": {"0": {"text": "离线", "color": "red"}, "1": {"text": "在线", "color": "green"}}}]}]
}]
}
},
{
"type": "timeseries",
"title": "验证 / 恢复 / 复制成功率",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 20},
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"targets": [
{"expr": "sum by (status) (rate(backupx_verify_run_total[15m]))", "refId": "A", "legendFormat": "verify {{status}}"},
{"expr": "sum by (status) (rate(backupx_restore_run_total[15m]))", "refId": "B", "legendFormat": "restore {{status}}"},
{"expr": "sum by (status) (rate(backupx_replication_run_total[15m]))", "refId": "C", "legendFormat": "replication {{status}}"}
],
"fieldConfig": {"defaults": {"unit": "ops"}}
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": ["backupx", "backup", "sre"],
"templating": {
"list": [
{
"current": {"selected": false, "text": "Prometheus", "value": "Prometheus"},
"label": "Datasource",
"name": "DS_PROMETHEUS",
"query": "prometheus",
"refresh": 1,
"regex": "",
"type": "datasource"
}
]
},
"time": {"from": "now-6h", "to": "now"},
"timepicker": {},
"timezone": "",
"title": "BackupX Overview",
"uid": "backupx-overview",
"version": 1,
"weekStart": ""
}

View File

@@ -1,17 +1,25 @@
#!/bin/sh
set -eu
PROJECT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
PREFIX="${PREFIX:-/opt/backupx}"
ETC_DIR="${ETC_DIR:-/etc/backupx}"
SERVICE_NAME="backupx"
APP_USER="backupx"
APP_GROUP="backupx"
BIN_SOURCE="${BIN_SOURCE:-$PROJECT_ROOT/server/backupx}"
WEB_SOURCE="${WEB_SOURCE:-$PROJECT_ROOT/web/dist}"
CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$PROJECT_ROOT/server/config.example.yaml}"
if [ -f "$SCRIPT_DIR/backupx" ] && [ -d "$SCRIPT_DIR/web" ]; then
BIN_SOURCE="${BIN_SOURCE:-$SCRIPT_DIR/backupx}"
WEB_SOURCE="${WEB_SOURCE:-$SCRIPT_DIR/web}"
CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$SCRIPT_DIR/config.example.yaml}"
NGINX_SOURCE="${NGINX_SOURCE:-$SCRIPT_DIR/nginx.conf}"
else
BIN_SOURCE="${BIN_SOURCE:-$PROJECT_ROOT/server/backupx}"
WEB_SOURCE="${WEB_SOURCE:-$PROJECT_ROOT/web/dist}"
CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$PROJECT_ROOT/server/config.example.yaml}"
NGINX_SOURCE="${NGINX_SOURCE:-$PROJECT_ROOT/deploy/nginx.conf}"
fi
SERVICE_SOURCE="${SERVICE_SOURCE:-$PROJECT_ROOT/deploy/backupx.service}"
NGINX_SOURCE="${NGINX_SOURCE:-$PROJECT_ROOT/deploy/nginx.conf}"
if [ "$(id -u)" -ne 0 ]; then
echo "请使用 root 或 sudo 执行安装脚本。" >&2
@@ -20,13 +28,20 @@ fi
if [ ! -f "$BIN_SOURCE" ]; then
echo "未找到后端二进制:$BIN_SOURCE" >&2
echo "请先执行cd \"$PROJECT_ROOT/server\" && go build -o backupx ./cmd/backupx" >&2
echo "源码树安装请先执行cd \"$PROJECT_ROOT/server\" && go build -o backupx ./cmd/backupx" >&2
echo "发布包安装请确认当前目录包含 ./backupx、./web 和 ./install.sh。" >&2
exit 1
fi
if [ ! -d "$WEB_SOURCE" ]; then
echo "未找到前端构建产物:$WEB_SOURCE" >&2
echo "请先执行cd \"$PROJECT_ROOT/web\" && npm run build" >&2
echo "源码树安装请先执行cd \"$PROJECT_ROOT/web\" && npm run build" >&2
echo "发布包安装请确认当前目录包含 ./web。" >&2
exit 1
fi
if [ ! -f "$CONFIG_TEMPLATE" ]; then
echo "未找到配置模板:$CONFIG_TEMPLATE" >&2
exit 1
fi
@@ -47,11 +62,34 @@ if [ ! -f "$ETC_DIR/config.yaml" ]; then
install -m 0640 "$CONFIG_TEMPLATE" "$ETC_DIR/config.yaml"
fi
install -m 0644 "$SERVICE_SOURCE" "/etc/systemd/system/$SERVICE_NAME.service"
if [ -f "$SERVICE_SOURCE" ]; then
install -m 0644 "$SERVICE_SOURCE" "/etc/systemd/system/$SERVICE_NAME.service"
else
cat > "/etc/systemd/system/$SERVICE_NAME.service" <<UNIT
[Unit]
Description=BackupX API Service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$APP_USER
Group=$APP_GROUP
WorkingDirectory=$PREFIX
ExecStart=$PREFIX/bin/backupx -config $ETC_DIR/config.yaml
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
UNIT
fi
systemctl daemon-reload
systemctl enable --now "$SERVICE_NAME"
if [ -d "/etc/nginx/conf.d" ]; then
if [ -d "/etc/nginx/conf.d" ] && [ -f "$NGINX_SOURCE" ]; then
install -m 0644 "$NGINX_SOURCE" "/etc/nginx/conf.d/$SERVICE_NAME.conf"
if command -v nginx >/dev/null 2>&1; then
nginx -t

View File

@@ -18,6 +18,22 @@ server {
proxy_read_timeout 3600s;
}
# Agent 一键安装脚本路径(兼容 v2.0 及之前生成的命令)。
# v2.1+ 新生成的命令走 /api/install/... 自动命中上面的 /api/ 代理。
location /install/ {
proxy_pass http://127.0.0.1:8340/install/;
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;
}
# 健康检查端点同样不走 SPA fallback。
location = /health { proxy_pass http://127.0.0.1:8340/health; }
location = /ready { proxy_pass http://127.0.0.1:8340/ready; }
location = /metrics { proxy_pass http://127.0.0.1:8340/metrics; }
location / {
try_files $uri $uri/ /index.html;
}

View File

@@ -15,12 +15,15 @@ services:
- "8340:8340"
volumes:
- backupx-data:/app/data
- /var/run/docker.sock:/var/run/docker.sock # 支持 Web 一键更新
# 挂载需要备份的宿主机目录(按需添加,:ro 表示只读):
# - /var/www:/mnt/www:ro
# - /etc/nginx:/mnt/nginx-conf:ro
# - /home/user/data:/mnt/data:ro
environment:
- TZ=Asia/Shanghai
# 远程 Agent 需要通过公网或可路由地址连接 Master 时,取消注释并改成真实 URL
# - BACKUPX_SERVER_EXTERNAL_URL=https://backup.example.com
# 通过 BACKUPX_ 前缀环境变量覆盖配置:
# - BACKUPX_LOG_LEVEL=debug
# - BACKUPX_BACKUP_MAX_CONCURRENT=4

20
docs-site/.gitignore vendored Normal file
View 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
View 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.

View File

@@ -0,0 +1,99 @@
---
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)
For multi-node clusters, edit `/etc/backupx/config.yaml` after installation and set the Master URL that remote Agents can reach:
```yaml
server:
external_url: "https://backup.example.com"
```
Restart BackupX after changing it:
```bash
sudo systemctl restart backupx
```
## 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'
```

View File

@@ -0,0 +1,66 @@
---
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
external_url: "" # BACKUPX_SERVER_EXTERNAL_URL — public Master URL for Agent install scripts
database:
path: "./data/backupx.db" # BACKUPX_DATABASE_PATH — embedded SQLite
security:
jwt_secret: "" # BACKUPX_SECURITY_JWT_SECRET — auto-generated if empty
jwt_expire: "24h" # BACKUPX_SECURITY_JWT_EXPIRE
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` |
| `server.external_url` | `BACKUPX_SERVER_EXTERNAL_URL` |
| `security.jwt_expire` | `BACKUPX_SECURITY_JWT_EXPIRE` |
| `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` |
## Master external URL
Set `server.external_url` when BackupX is behind Docker, Nginx, a load balancer, or any reverse proxy whose internal Host is not reachable by remote Agents:
```yaml
server:
external_url: "https://backup.example.com"
```
This value is used when BackupX renders one-click Agent install scripts and docker-compose snippets. It must be reachable from every Agent host. Leave it empty only when `X-Forwarded-Proto` / `X-Forwarded-Host` are reliable and point to the same URL that Agents can access.

View File

@@ -0,0 +1,81 @@
---
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
# Required when remote Agents must connect through a public or routed URL:
# - BACKUPX_SERVER_EXTERNAL_URL=https://backup.example.com
- 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.
## Multi-node clusters
When deploying Agents on other machines, set `BACKUPX_SERVER_EXTERNAL_URL` on the Master container to the URL that those Agents can reach:
```yaml
environment:
- BACKUPX_SERVER_EXTERNAL_URL=https://backup.example.com
```
Use an HTTPS URL if Agents cross untrusted networks. The generated one-click install scripts and docker-compose snippets use this value as `BACKUPX_AGENT_MASTER`.
## 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.

View 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).
:::

View 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

View 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
```

View File

@@ -0,0 +1,44 @@
---
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.
When a task is routed to a remote Agent, the source tools and paths are resolved on that Agent host. Multi-target uploads are still tracked per storage target; if at least one target succeeds, the backup record is marked successful and the per-target result table shows partial failures.
## 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.

View File

@@ -0,0 +1,134 @@
---
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
### 0. Set the Master URL for production clusters
Before generating Agent install commands, make sure the Master URL shown to Agents is stable and reachable from every target host.
If BackupX runs behind Docker, Nginx, a load balancer, or an outer reverse proxy, configure `server.external_url` or `BACKUPX_SERVER_EXTERNAL_URL` on the Master:
```yaml title="config.yaml"
server:
external_url: "https://backup.example.com"
```
This URL is baked into systemd units, foreground commands, and docker-compose snippets. If it is wrong, Agents will install successfully but stay offline because they keep polling an internal or browser-only address.
### 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 one-line install command is shown with a live countdown. Click copy, paste into the target machine, and run with root privileges. The default command embeds the rendered installer, so the target host does not need to fetch `/api/install/:token` through your reverse proxy. The public install URL is still available as a fallback.
### 2. One-line install on the target host
Use the command generated by the Web Console. It writes the installer to a temporary file, validates the `BACKUPX_AGENT_INSTALL_V1` marker, then runs it with root privileges.
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)
Docker mode uses the same `BACKUPX_AGENT_MASTER`, `BACKUPX_AGENT_TOKEN`, and `BACKUPX_AGENT_TEMP_DIR=/var/lib/backupx-agent/tmp` environment contract. After starting the container, the installer also probes `/api/v1/agent/self`; if the node does not come online, it prints `docker ps` and `docker logs --tail=100 backupx-agent` diagnostics before exiting non-zero.
If you choose the URL-based fallback command and `curl` prints HTML or the shell reports `Syntax error: newline unexpected`, the install URL is being served by the web console instead of the backend. Ensure either `/api/install/` or `/install/` is forwarded to the BackupX backend, or use the embedded command generated by the console.
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
The node table shows the Agent health and command queue state: pending/dispatched depth, running long commands, timeouts, oldest active command age, and the latest Agent-side error. The same queue depth, running-command, and timeout snapshots are exported as Prometheus metrics:
- `backupx_agent_command_queue_depth`
- `backupx_agent_command_running`
- `backupx_agent_command_timeout_total`
## Known limitations
- **Encrypted backups are Master-only** — the Agent doesn't hold Master's AES-256 key. Creating or updating a task with `encrypt: true` and a remote node or node pool is rejected up front
- **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
```

View 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

View 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`).

View 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).

View 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.

View File

@@ -0,0 +1,61 @@
---
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
For Agent-routed tasks, encryption must stay off because the Agent never receives the Master's encryption key. BackupX rejects remote-node or node-pool tasks with encryption enabled during create/update.
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
View 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).

View 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
}
```

View 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) |

View File

@@ -0,0 +1,155 @@
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 backup orchestration for servers, databases, storage targets and remote agents',
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',
},
{
to: '/community',
label: 'Community',
position: 'left',
},
{
to: '/sponsors',
label: 'Sponsors',
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'},
],
},
{
title: 'Community',
items: [
{label: 'Contributors', href: 'https://github.com/Awuqing/BackupX/graphs/contributors'},
{label: 'Pull Requests', href: 'https://github.com/Awuqing/BackupX/pulls'},
{label: 'Sponsor', to: '/sponsors'},
],
},
{
title: 'Sponsors',
items: [
{label: 'Sponsor BackupX', href: 'https://github.com/sponsors/Awuqing'},
{label: 'Partnership', href: 'https://github.com/Awuqing/BackupX/issues/new/choose'},
{label: 'Sponsor tiers', to: '/sponsors'},
],
},
],
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;

View File

@@ -0,0 +1,160 @@
{
"home.badge": {
"message": "开源备份控制平面 · v2.2.1",
"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 和远程节点备份。控制平面自己掌握,存储后端灵活选择。",
"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"
},
"home.visual.eyebrow": {"message": "BackupX 控制台"},
"home.visual.title": {"message": "运维概览"},
"home.visual.status": {"message": "健康"},
"home.visual.success": {"message": "成功率"},
"home.visual.nodes": {"message": "活跃节点"},
"home.visual.targets": {"message": "存储目标"},
"home.visual.row1.title": {"message": "PostgreSQL 夜间备份"},
"home.visual.row1.desc": {"message": "加密归档已上传至 S3"},
"home.visual.row2.title": {"message": "SAP HANA 快照"},
"home.visual.row2.desc": {"message": "正在 agent-shanghai-02 上运行"},
"home.visual.row3.title": {"message": "保留策略清理"},
"home.visual.row3.desc": {"message": "下一次执行在 4 小时后"},
"home.command.title": {"message": "使用 Docker 启动"},
"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": "开始阅读文档"},
"community.tag": {"message": "社区"},
"community.pageTitle": {"message": "社区、赞助商与贡献者"},
"community.pageDescription": {"message": "赞助 BackupX了解贡献者并找到务实的参与方式。"},
"community.title": {"message": "开放协作,面向长期运维"},
"community.subtitle": {"message": "备份软件的信任来自透明发布、真实部署反馈,以及足够务实的贡献路径。"},
"community.sponsor.kicker": {"message": "赞助商"},
"community.sponsor.wallTitle": {"message": "赞助商"},
"community.sponsor.title": {"message": "支持你依赖的备份基础设施"},
"community.sponsor.cta": {"message": "赞助 BackupX"},
"community.sponsor.openSlot": {"message": "赞助席位开放"},
"community.sponsor.logo.project": {"message": "项目赞助"},
"community.sponsor.logo.cloud": {"message": "云服务伙伴"},
"community.sponsor.logo.object": {"message": "对象存储"},
"community.sponsor.logo.cdn": {"message": "CDN 伙伴"},
"community.sponsor.logo.database": {"message": "数据库伙伴"},
"community.sponsor.logo.security": {"message": "安全审计"},
"community.sponsor.logo.agent": {"message": "远程节点实验室"},
"community.sponsor.logo.docs": {"message": "文档赞助"},
"community.sponsor.logo.release": {"message": "发布赞助"},
"community.sponsor.logo.s3": {"message": "S3 兼容"},
"community.sponsor.logo.webdav": {"message": "WebDAV 伙伴"},
"community.sponsor.logo.sftp": {"message": "SFTP 伙伴"},
"community.sponsor.logo.docker": {"message": "容器伙伴"},
"community.sponsor.logo.mirror": {"message": "镜像伙伴"},
"community.sponsor.logo.restore": {"message": "恢复演练"},
"community.sponsor.logo.qa": {"message": "测试实验室"},
"community.sponsor.logo.oss": {"message": "开源支持"},
"community.sponsor.logo.open": {"message": "赞助席位开放"},
"community.sponsor.infrastructure.label": {"message": "基础设施"},
"community.sponsor.infrastructure.title": {"message": "云与存储生态伙伴"},
"community.sponsor.infrastructure.desc": {"message": "帮助 BackupX 覆盖对象存储、WebDAV、SFTP 以及区域云平台的真实验证。"},
"community.sponsor.security.label": {"message": "安全"},
"community.sponsor.security.title": {"message": "审计与可靠性支持者"},
"community.sponsor.security.desc": {"message": "支持加密、恢复演练、发布签名和运维检查等强化工作。"},
"community.sponsor.community.label": {"message": "社区"},
"community.sponsor.community.title": {"message": "开源支持者"},
"community.sponsor.community.desc": {"message": "支持文档、示例、平台测试和贡献者引导。"},
"community.sponsor.tier.backer.name": {"message": "Backer"},
"community.sponsor.tier.backer.amount": {"message": "适合个人与小团队"},
"community.sponsor.tier.backer.desc": {"message": "支持文档、Issue 分流、兼容性测试和小型体验改进。"},
"community.sponsor.tier.partner.name": {"message": "Partner"},
"community.sponsor.tier.partner.amount": {"message": "适合存储与基础设施厂商"},
"community.sponsor.tier.partner.desc": {"message": "支持 Provider 验证、部署示例、基准说明和集成指南。"},
"community.sponsor.tier.enterprise.name": {"message": "Enterprise"},
"community.sponsor.tier.enterprise.amount": {"message": "适合生产环境使用方"},
"community.sponsor.tier.enterprise.desc": {"message": "赞助恢复演练、发布加固、审计和长期维护等可靠性工作。"},
"community.contributor.kicker": {"message": "贡献者"},
"community.contributor.all": {"message": "查看全部"},
"community.contributor.source": {"message": "浏览器端通过 GitHub contributors API 获取。"},
"community.contributor.botRole": {"message": "自动化贡献者"},
"community.contributor.githubRole": {"message": "GitHub 贡献者"},
"community.contributor.contributions": {"message": "{count} 次贡献"},
"community.path.kicker": {"message": "贡献路径"},
"community.path.issues.title": {"message": "反馈生产问题"},
"community.path.issues.desc": {"message": "提交日志、部署拓扑和恢复预期。"},
"community.path.docs.title": {"message": "完善文档与示例"},
"community.path.docs.desc": {"message": "贡献存储、Agent 和数据库部署指南。"},
"community.path.code.title": {"message": "提交聚焦的 PR"},
"community.path.code.desc": {"message": "保持改动小而可测,并贴合现有架构。"},
"sponsors.pageTitle": {"message": "赞助商"},
"sponsors.pageDescription": {"message": "赞助 BackupX 的可靠性、文档、存储兼容性和长期维护。"},
"sponsors.tag": {"message": "赞助商"},
"sponsors.title": {"message": "赞助 BackupX 生态"},
"sponsors.subtitle": {"message": "赞助帮助 BackupX 更贴近真实运维:经过验证的存储 Provider、可靠发布、恢复信心和更完善的文档。"}
}

View File

@@ -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": "开发"}
}

View File

@@ -0,0 +1,99 @@
---
sidebar_position: 2
title: 裸机部署
description: 从预编译包或源码部署 BackupXsystemd + 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)
如果要部署多节点集群,安装后请编辑 `/etc/backupx/config.yaml`,设置远程 Agent 可访问到的 Master URL
```yaml
server:
external_url: "https://backup.example.com"
```
修改后重启 BackupX
```bash
sudo systemctl restart backupx
```
## 从源码构建
```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'
```

View File

@@ -0,0 +1,66 @@
---
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
external_url: "" # BACKUPX_SERVER_EXTERNAL_URL — Agent 安装脚本使用的 Master 对外 URL
database:
path: "./data/backupx.db" # BACKUPX_DATABASE_PATH — 内嵌 SQLite
security:
jwt_secret: "" # BACKUPX_SECURITY_JWT_SECRET — 留空自动生成
jwt_expire: "24h" # BACKUPX_SECURITY_JWT_EXPIRE
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` |
| `server.external_url` | `BACKUPX_SERVER_EXTERNAL_URL` |
| `security.jwt_expire` | `BACKUPX_SECURITY_JWT_EXPIRE` |
| `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` |
## Master 对外 URL
当 BackupX 部署在 Docker、Nginx、负载均衡或多层反向代理后面且后端收到的内部 Host 不是远程 Agent 可访问地址时,请配置 `server.external_url`
```yaml
server:
external_url: "https://backup.example.com"
```
BackupX 会用这个地址渲染一键 Agent 安装脚本和 docker-compose 片段。该地址必须能被所有 Agent 主机访问。只有在 `X-Forwarded-Proto` / `X-Forwarded-Host` 可靠且正好指向 Agent 可访问地址时,才建议留空。

View File

@@ -0,0 +1,81 @@
---
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
# 远程 Agent 需要通过公网或可路由地址连接 Master 时必须配置:
# - BACKUPX_SERVER_EXTERNAL_URL=https://backup.example.com
- BACKUPX_LOG_LEVEL=info
- BACKUPX_BACKUP_MAX_CONCURRENT=2
volumes:
backupx-data:
```
启动:
```bash
docker compose up -d
```
## 备份宿主机目录
想备份宿主机上的文件,需要将对应路径挂载进容器。在 Web UI 创建文件类型任务时,把源路径指向挂载后的容器内路径(如 `/mnt/www`)。
## 多节点集群
如果要在其他机器部署 Agent请在 Master 容器上设置 `BACKUPX_SERVER_EXTERNAL_URL`,值为所有 Agent 都能访问到的 URL
```yaml
environment:
- BACKUPX_SERVER_EXTERNAL_URL=https://backup.example.com
```
Agent 跨不可信网络访问时建议使用 HTTPS。控制台生成的一键安装脚本和 docker-compose 片段会把这个值写成 `BACKUPX_AGENT_MASTER`。
## 环境变量
所有配置项都可以通过 `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。

View File

@@ -0,0 +1,53 @@
---
sidebar_position: 3
title: Nginx 反向代理
description: 通过 Nginx 发布 BackupXHTTPS + 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`(仅供测试)。
:::

View File

@@ -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 一件事,不要把顺手的小修改和功能代码混在一起

View File

@@ -0,0 +1,83 @@
---
sidebar_position: 1
title: 开发环境
description: 搭建 BackupX 本地开发环境 — 后端、前端、测试。
---
# 开发环境
**环境要求:** Go ≥ 1.25Node.js ≥ 20npm。
## 克隆与依赖
```bash
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
cd web && npm install && cd ..
```
## 开发服务
开两个终端分别跑后端和前端:
```bash
# 终端 1后端监听 :8340
make dev-server
# 终端 2Vite 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 |
| **存储** | rclone70+ 后端)· 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/ # 备份 runnerfile / mysql / postgres / sqlite / saphana
│ │ ├── backint/ # SAP HANA Backint 协议
│ │ ├── http/ # HTTP handler + router
│ │ ├── model/ # GORM 模型
│ │ ├── repository/ # 数据访问
│ │ ├── service/ # 业务逻辑
│ │ └── storage/ # 存储 providerrclone + 直接 SDK
│ └── pkg/ # 通用工具
├── web/ # React 前端Vite
│ └── src/
│ ├── components/
│ ├── pages/
│ ├── services/
│ └── types/
├── docs-site/ # 文档站Docusaurus
├── deploy/ # install.sh / systemd unit / nginx config
└── Makefile
```

View File

@@ -0,0 +1,44 @@
---
sidebar_position: 1
title: 备份类型
description: 文件、MySQL、PostgreSQL、SQLite 和 SAP HANA — 各自的能力与配置说明。
---
# 备份类型
BackupX 支持五种内置备份类型,类型决定了用哪个 runner 执行。
当任务路由到远程 Agent 时,源路径和外部工具都会在该 Agent 主机上解析。多存储目标上传仍会逐目标记录结果;只要至少一个目标上传成功,备份记录即为成功,详情中的目标结果表会展示部分失败。
## 文件 / 目录
打包(可选 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 定时调度。

View File

@@ -0,0 +1,134 @@
---
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 复用 BackupRunnerfile / mysql / postgresql / sqlite / saphana并直接上传到存储
- **安全** — 每个节点独立 TokenAgent 不持有 Master 的 JWT 密钥或 AES-256 加密密钥
## 一键部署步骤
### 0. 为生产集群设置 Master 对外 URL
生成 Agent 安装命令前,请先确认 Master URL 对所有目标主机稳定可达。
如果 BackupX 部署在 Docker、Nginx、负载均衡或外层反向代理后面请在 Master 配置 `server.external_url` 或环境变量 `BACKUPX_SERVER_EXTERNAL_URL`
```yaml title="config.yaml"
server:
external_url: "https://backup.example.com"
```
该 URL 会写入 systemd 单元、前台运行命令和 docker-compose 片段。如果地址不正确Agent 可能安装成功但始终离线,因为它会持续轮询一个内网地址或仅浏览器可访问的地址。
### 1. 打开安装向导
Web 控制台 → **节点管理** → **添加节点**,打开三步向导:
- **第一步 · 节点信息**:填写节点名称;或切换"批量创建"粘贴多行名称(每行一个,最多 50 个)
- **第二步 · 部署参数**:选择安装模式(`systemd` 推荐、`Docker`、`前台运行` 调试用、架构默认自动检测、Agent 版本(默认跟随 Master 版本、有效期5 分钟 / 15 分钟 / 1 小时 / 24 小时)、下载源(`GitHub` 直连或 `ghproxy` 镜像,国内服务器建议后者)
- **第三步 · 安装命令**:一条一键安装命令 + 实时倒计时。点击复制,粘贴到目标机以 root 权限执行。默认命令会嵌入已渲染的安装脚本,目标机无需再通过反向代理访问 `/api/install/:token`;公开安装 URL 仍作为备用路径保留。
### 2. 目标机一条命令完成
请直接使用 Web 控制台生成的命令。该命令会把安装脚本写入临时文件,校验 `BACKUPX_AGENT_INSTALL_V1` 魔数,再以 root 权限执行。
脚本会自动:
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 秒)
Docker 模式使用同一组环境变量约定:`BACKUPX_AGENT_MASTER`、`BACKUPX_AGENT_TOKEN` 和 `BACKUPX_AGENT_TEMP_DIR=/var/lib/backupx-agent/tmp`。容器启动后,安装脚本同样会探测 `/api/v1/agent/self`;如果节点没有上线,会输出 `docker ps` 与 `docker logs --tail=100 backupx-agent` 排查命令,并以非零状态退出。
如果使用 URL 备用命令时 `curl` 输出 HTML或 shell 报 `Syntax error: newline unexpected`,说明安装 URL 被 Web 控制台接管而不是转发到后端。需要确保 `/api/install/` 或 `/install/` 至少一个路径能转发到 BackupX 后端,或改用控制台生成的嵌入式命令。
脚本是幂等的:升级或重装只需重新生成一条安装命令再跑一次。一次性安装链接在 TTL 到期或被首次消费后立即作废。
### 3. 随时轮换 Agent Token
节点操作列(︙)→ **重新生成 Token**。新 Token 一次性显示,旧 Token 24 小时内仍有效便于滚动替换无需停机。24 小时后旧 Token 被拒绝。
### 4. 批量部署
第一步选"批量创建"粘贴节点名(每行一个,最多 50 个)。第三步显示每个节点对应的命令表格,底部「导出 .sh」可打包为单个 shell 文件,方便 SSH 循环或 Ansible 任务。
### 5. 把任务路由到该节点
在 **备份任务** 页面新建任务时选择对应节点。任务触发时:
- 本机 / 未指定(`nodeId=0`Master 进程内直接执行
- 远程节点Master 写入命令队列 → Agent 拉取 → Agent 本地执行 → 上传 → 回报
节点列表会展示 Agent 健康与命令队列状态pending/dispatched 深度、运行中的长任务、超时数、最旧活跃命令年龄和最近 Agent 错误。同样的队列深度、运行中命令数和超时快照会导出为 Prometheus 指标:
- `backupx_agent_command_queue_depth`
- `backupx_agent_command_running`
- `backupx_agent_command_timeout_total`
## 已知限制
- **加密备份仅支持 Master 本机执行**Agent 不持有 Master 的 AES-256 密钥。创建或更新任务时,如果 `encrypt: true` 且选择了远程节点或节点池,会在入口直接拒绝
- **目录浏览超时**:远程目录浏览通过命令队列做同步 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
```

View File

@@ -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` 获取 |
## 事件规则
每个通知配置可以指定触发范围:
- **仅成功** — 正常运行时静默
- **仅失败** — 适合高噪敏感通道
- **全部** — 初始化配置时用于验证链路

View File

@@ -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 AgentSAP 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`)。

View File

@@ -0,0 +1,38 @@
---
sidebar_position: 2
title: 存储后端
description: 70+ 存储后端 — 内置云服务商 + 任意 rclone 后端。
---
# 存储后端
BackupX 的目标是接入任何你想放置备份文件的地方。
## 内置后端
| 类型 | 必填字段 |
|------|---------|
| **阿里云 OSS** | Region + AccessKey ID/Secret + Bucketendpoint 自动组装) |
| **腾讯云 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`,但已成功的目标产物会被保留(不回滚)。

View File

@@ -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.25Node.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`,会进入初始化管理员账户页面。

View File

@@ -0,0 +1,61 @@
---
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. **存储与策略** — 选择存储目标(支持多个)、压缩策略、保留天数、是否加密
对于路由到 Agent 的任务,加密必须关闭,因为 Agent 不会拿到 Master 的加密密钥。BackupX 会在创建/更新阶段拒绝开启加密的远程节点或节点池任务。
保存后可点击 **立即执行** 测试,**备份记录** 页面实时查看执行日志。
:::note
删除备份任务时会自动清理远端存储上的备份文件,但保留备份记录以供审计追溯。
:::
## 4. 配置通知(可选)
**通知配置** 页面支持邮件、Webhook、Telegram 三种方式,可分别配置成功/失败时是否推送。
## 继续阅读
- 了解 [备份类型](/docs/features/backup-types) 和 [存储后端](/docs/features/storage-backends)
- 使用 SAP HANA参考 [SAP HANA 支持](/docs/features/sap-hana)
- 管理多台服务器?参考 [多节点集群](/docs/features/multi-node)

View File

@@ -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)

View File

@@ -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
}
```

View File

@@ -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` | 配置文件路径(用于定位数据库文件) |

View File

@@ -0,0 +1,23 @@
{
"link.title.Docs": {"message": "文档"},
"link.title.Features": {"message": "功能"},
"link.title.More": {"message": "更多"},
"link.title.Community": {"message": "社区"},
"link.title.Sponsors": {"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"},
"link.item.label.Contributors": {"message": "贡献者"},
"link.item.label.Pull Requests": {"message": "Pull Requests"},
"link.item.label.Sponsor": {"message": "赞助"},
"link.item.label.Sponsor BackupX": {"message": "赞助 BackupX"},
"link.item.label.Partnership": {"message": "合作伙伴"},
"link.item.label.Sponsor tiers": {"message": "赞助层级"}
}

View File

@@ -0,0 +1,22 @@
{
"item.label.Docs": {
"message": "文档",
"description": "Navbar item: Docs"
},
"item.label.Downloads": {
"message": "下载",
"description": "Navbar item: Downloads"
},
"item.label.Community": {
"message": "社区",
"description": "Navbar item: Community"
},
"item.label.Sponsors": {
"message": "赞助商",
"description": "Navbar item: Sponsors"
},
"item.label.GitHub": {
"message": "GitHub",
"description": "Navbar item: GitHub"
}
}

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
View 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
View 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;

View File

@@ -0,0 +1,329 @@
import type {ReactNode} from 'react';
import {useEffect, useState} from 'react';
import Heading from '@theme/Heading';
import Translate from '@docusaurus/Translate';
import Link from '@docusaurus/Link';
import styles from './styles.module.css';
type SponsorSlot = {
brand: ReactNode;
name: ReactNode;
href?: string;
};
type Contributor = {
login: string;
avatarUrl?: string;
contributions: number;
type: string;
href: string;
};
type GitHubContributor = {
login: string;
avatar_url?: string;
contributions?: number;
html_url?: string;
type?: string;
};
type CommunityPath = {
title: ReactNode;
description: ReactNode;
href: string;
};
const SPONSOR_SLOTS: SponsorSlot[] = [
{
brand: 'BackupX',
name: <Translate id="community.sponsor.logo.project">Project backer</Translate>,
href: 'https://github.com/sponsors/Awuqing',
},
{
brand: 'Cloud',
name: <Translate id="community.sponsor.logo.cloud">Cloud partner</Translate>,
},
{
brand: 'Object',
name: <Translate id="community.sponsor.logo.object">Object storage</Translate>,
},
{
brand: 'CDN',
name: <Translate id="community.sponsor.logo.cdn">CDN partner</Translate>,
},
{
brand: 'DB',
name: <Translate id="community.sponsor.logo.database">Database partner</Translate>,
},
{
brand: 'Security',
name: <Translate id="community.sponsor.logo.security">Security audit</Translate>,
},
{
brand: 'Agent',
name: <Translate id="community.sponsor.logo.agent">Remote node lab</Translate>,
},
{
brand: 'Docs',
name: <Translate id="community.sponsor.logo.docs">Docs sponsor</Translate>,
},
{
brand: 'Release',
name: <Translate id="community.sponsor.logo.release">Release sponsor</Translate>,
},
{
brand: 'S3',
name: <Translate id="community.sponsor.logo.s3">S3 compatible</Translate>,
},
{
brand: 'WebDAV',
name: <Translate id="community.sponsor.logo.webdav">WebDAV partner</Translate>,
},
{
brand: 'SFTP',
name: <Translate id="community.sponsor.logo.sftp">SFTP partner</Translate>,
},
{
brand: 'Docker',
name: <Translate id="community.sponsor.logo.docker">Container partner</Translate>,
},
{
brand: 'Mirror',
name: <Translate id="community.sponsor.logo.mirror">Mirror partner</Translate>,
},
{
brand: 'Restore',
name: <Translate id="community.sponsor.logo.restore">Restore drill</Translate>,
},
{
brand: 'QA',
name: <Translate id="community.sponsor.logo.qa">Test lab</Translate>,
},
{
brand: 'OSS',
name: <Translate id="community.sponsor.logo.oss">Open source</Translate>,
},
{
brand: 'Open Slot',
name: <Translate id="community.sponsor.logo.open">Sponsor slot open</Translate>,
},
];
const FALLBACK_CONTRIBUTORS: Contributor[] = [
{
login: 'Awuqing',
contributions: 0,
type: 'User',
href: 'https://github.com/Awuqing',
},
{
login: 'dependabot[bot]',
contributions: 0,
type: 'Bot',
href: 'https://github.com/dependabot',
},
];
const COMMUNITY_PATHS: CommunityPath[] = [
{
title: <Translate id="community.path.issues.title">Report production issues</Translate>,
description: <Translate id="community.path.issues.desc">Share logs, deployment topology and restore expectations.</Translate>,
href: 'https://github.com/Awuqing/BackupX/issues',
},
{
title: <Translate id="community.path.docs.title">Improve docs and examples</Translate>,
description: <Translate id="community.path.docs.desc">Contribute deployment guides for storage, agents and databases.</Translate>,
href: '/docs/development/contributing',
},
{
title: <Translate id="community.path.code.title">Ship focused PRs</Translate>,
description: <Translate id="community.path.code.desc">Keep changes small, tested and aligned with the existing architecture.</Translate>,
href: 'https://github.com/Awuqing/BackupX/pulls',
},
];
function SponsorLogoCard({brand, name, href}: SponsorSlot) {
return (
<Link className={styles.sponsorLogoTile} to={href ?? 'https://github.com/sponsors/Awuqing'}>
<span className={styles.sponsorLogoMark}>{brand}</span>
<span className={styles.sponsorLogoName}>{name}</span>
</Link>
);
}
function getInitials(login: string): string {
return login
.replace(/\[bot\]$/i, '')
.split(/[-_\s]/)
.filter(Boolean)
.slice(0, 2)
.map(part => part[0]?.toUpperCase())
.join('') || login.slice(0, 2).toUpperCase();
}
function normalizeContributor(contributor: GitHubContributor): Contributor | null {
if (!contributor.login) {
return null;
}
return {
login: contributor.login,
avatarUrl: contributor.avatar_url,
contributions: contributor.contributions ?? 0,
type: contributor.type ?? 'User',
href: contributor.html_url ?? `https://github.com/${contributor.login}`,
};
}
function useGitHubContributors(): Contributor[] {
const [contributors, setContributors] = useState<Contributor[]>(FALLBACK_CONTRIBUTORS);
useEffect(() => {
const controller = new AbortController();
fetch('https://api.github.com/repos/Awuqing/BackupX/contributors?per_page=12', {
signal: controller.signal,
headers: {
Accept: 'application/vnd.github+json',
},
})
.then(response => {
if (!response.ok) {
throw new Error(`GitHub contributors request failed: ${response.status}`);
}
return response.json() as Promise<GitHubContributor[]>;
})
.then(payload => {
const nextContributors = payload
.map(normalizeContributor)
.filter((contributor): contributor is Contributor => Boolean(contributor));
if (nextContributors.length > 0) {
setContributors(nextContributors);
}
})
.catch(error => {
if (error instanceof Error && error.name !== 'AbortError') {
console.warn(error.message);
}
});
return () => controller.abort();
}, []);
return contributors;
}
function ContributorCard({login, avatarUrl, contributions, type, href}: Contributor) {
return (
<Link className={styles.contributorCard} to={href}>
{avatarUrl ? (
<img className={styles.avatarImage} src={avatarUrl} alt="" loading="lazy" />
) : (
<span className={styles.avatar} aria-hidden="true">{getInitials(login)}</span>
)}
<span className={styles.contributorBody}>
<strong>{login}</strong>
<span>
{type === 'Bot' ? (
<Translate id="community.contributor.botRole">Automation contributor</Translate>
) : (
<Translate id="community.contributor.githubRole">GitHub contributor</Translate>
)}
</span>
<em>
<Translate id="community.contributor.contributions" values={{count: contributions}}>
{'{count} contributions'}
</Translate>
</em>
</span>
</Link>
);
}
export function HomepageSponsors(): ReactNode {
return (
<div className={styles.sponsorWall}>
<div className={styles.sponsorWallHeader}>
<Heading as="h3" className={styles.sponsorWallTitle}>
<Translate id="community.sponsor.wallTitle">Sponsors</Translate>
</Heading>
<Link className={styles.sponsorWallAction} to="https://github.com/sponsors/Awuqing">
<Translate id="community.sponsor.cta">Sponsor BackupX</Translate>
<span aria-hidden="true">-&gt;</span>
</Link>
</div>
<div className={styles.sponsorLogoGrid}>
{SPONSOR_SLOTS.map((slot, index) => (
<SponsorLogoCard key={index} {...slot} />
))}
</div>
</div>
);
}
export default function HomepageCommunity(): ReactNode {
const contributors = useGitHubContributors();
return (
<section id="community" className={styles.section}>
<div className="container">
<div className={styles.sectionHead}>
<div className={styles.sectionTag}>
<Translate id="community.tag">COMMUNITY</Translate>
</div>
<Heading as="h2" className={styles.sectionTitle}>
<Translate id="community.title">Built in the open, ready for long-term operators</Translate>
</Heading>
<p className={styles.sectionSubtitle}>
<Translate id="community.subtitle">
Backup software earns trust through transparent releases, real deployment feedback and a contributor path that stays practical.
</Translate>
</p>
</div>
<HomepageSponsors />
<div className={styles.communityGrid}>
<div className={styles.panel}>
<div className={styles.panelHeader}>
<span>
<Translate id="community.contributor.kicker">Contributors</Translate>
</span>
<Link to="https://github.com/Awuqing/BackupX/graphs/contributors">
<Translate id="community.contributor.all">View all</Translate>
</Link>
</div>
<div className={styles.panelNote}>
<Translate id="community.contributor.source">Loaded from GitHub contributors API in the browser.</Translate>
</div>
<div className={styles.contributorList}>
{contributors.map(contributor => (
<ContributorCard key={contributor.login} {...contributor} />
))}
</div>
</div>
<div className={styles.panel}>
<div className={styles.panelHeader}>
<span>
<Translate id="community.path.kicker">Contributor paths</Translate>
</span>
</div>
<div className={styles.pathList}>
{COMMUNITY_PATHS.map((path, index) => (
<Link key={index} className={styles.pathItem} to={path.href}>
<span className={styles.pathIndex}>{String(index + 1).padStart(2, '0')}</span>
<span>
<strong>{path.title}</strong>
<em>{path.description}</em>
</span>
</Link>
))}
</div>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,429 @@
.section {
padding: 5.5rem 0 6rem;
background:
linear-gradient(180deg, rgba(245, 247, 250, 0) 0%, rgba(245, 247, 250, 0.86) 100%),
var(--ifm-background-color);
}
[data-theme='dark'] .section {
background:
linear-gradient(180deg, rgba(15, 17, 21, 0) 0%, rgba(255, 255, 255, 0.03) 100%),
var(--ifm-background-color);
}
.sectionHead {
max-width: 760px;
margin: 0 auto 2.5rem;
text-align: center;
}
.sectionTag {
display: inline-flex;
align-items: center;
min-height: 28px;
margin-bottom: 1rem;
padding: 4px 10px;
color: #00a870;
background: rgba(0, 180, 42, 0.1);
border: 1px solid rgba(0, 180, 42, 0.18);
border-radius: 8px;
font-size: 12px;
font-weight: 750;
letter-spacing: 0;
}
.sectionTitle {
margin: 0 0 1rem;
color: var(--ifm-heading-color);
font-size: 2.35rem;
font-weight: 750;
letter-spacing: 0;
line-height: 1.2;
}
.sectionSubtitle {
margin: 0;
color: var(--ifm-color-content-secondary);
font-size: 1.04rem;
line-height: 1.7;
}
.sponsorWall {
overflow: hidden;
margin-bottom: 1rem;
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
box-shadow: 0 12px 28px rgba(29, 33, 41, 0.06);
}
[data-theme='dark'] .sponsorWall {
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.08);
box-shadow: none;
}
.sponsorWallHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
min-height: 60px;
padding: 0 1.25rem;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .sponsorWallHeader {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.sponsorWallTitle {
position: relative;
margin: 0;
padding-left: 14px;
color: var(--ifm-heading-color);
font-size: 1.05rem;
font-weight: 750;
letter-spacing: 0;
}
.sponsorWallTitle::before {
position: absolute;
top: 50%;
left: 0;
width: 3px;
height: 18px;
content: "";
background: #52c41a;
border-radius: 3px;
transform: translateY(-50%);
}
.sponsorWallAction {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 36px;
padding: 0 12px;
color: #52c41a;
background: rgba(82, 196, 26, 0.08);
border: 1px solid rgba(82, 196, 26, 0.2);
border-radius: 8px;
font-size: 13px;
font-weight: 700;
text-decoration: none !important;
white-space: nowrap;
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
.sponsorWallAction:hover,
.sponsorWallAction:focus-visible {
color: #389e0d;
background: rgba(82, 196, 26, 0.14);
border-color: #52c41a;
transform: translateY(-1px);
}
.sponsorLogoGrid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
background: var(--ifm-color-emphasis-200);
gap: 1px;
padding: 1px;
}
[data-theme='dark'] .sponsorLogoGrid {
background: rgba(255, 255, 255, 0.08);
}
.sponsorLogoTile {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 106px;
padding: 14px 10px;
flex-direction: column;
color: inherit;
background: var(--ifm-background-color);
text-align: center;
text-decoration: none !important;
transition: background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
[data-theme='dark'] .sponsorLogoTile {
background: rgba(15, 17, 21, 0.78);
}
.sponsorLogoTile:hover,
.sponsorLogoTile:focus-visible {
z-index: 1;
color: inherit;
background: rgba(82, 196, 26, 0.04);
box-shadow: inset 0 0 0 1px rgba(82, 196, 26, 0.5);
transform: translateY(-1px);
}
.sponsorLogoMark {
display: block;
max-width: 100%;
overflow-wrap: anywhere;
color: var(--ifm-color-primary);
font-size: 1.45rem;
font-weight: 850;
letter-spacing: 0;
line-height: 1.1;
}
.sponsorLogoTile:nth-child(2n) .sponsorLogoMark {
color: #ff7d00;
}
.sponsorLogoTile:nth-child(3n) .sponsorLogoMark {
color: #14c9c9;
}
.sponsorLogoTile:nth-child(4n) .sponsorLogoMark {
color: #722ed1;
}
.sponsorLogoTile:nth-child(5n) .sponsorLogoMark {
color: #52c41a;
}
.sponsorLogoName {
display: block;
max-width: 100%;
margin-top: 10px;
color: var(--ifm-color-content-secondary);
font-size: 0.86rem;
font-weight: 600;
line-height: 1.35;
}
.panel {
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
box-shadow: 0 12px 28px rgba(29, 33, 41, 0.06);
}
[data-theme='dark'] .panel {
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.08);
box-shadow: none;
}
.communityGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.panel {
min-width: 0;
padding: 1.25rem;
}
.panelHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
color: var(--ifm-color-content-secondary);
font-size: 12px;
font-weight: 750;
letter-spacing: 0;
text-transform: uppercase;
}
.panelHeader a {
color: var(--ifm-color-primary);
text-decoration: none !important;
}
.panelNote {
margin: -0.35rem 0 1rem;
color: var(--ifm-color-content-secondary);
font-size: 0.82rem;
line-height: 1.5;
}
.contributorList,
.pathList {
display: grid;
gap: 10px;
}
.contributorCard,
.pathItem {
display: grid;
min-width: 0;
color: inherit;
background: var(--ifm-color-emphasis-100);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
text-decoration: none !important;
transition: border-color 0.2s ease, transform 0.2s ease, background 0.2s ease;
}
.contributorCard:hover,
.contributorCard:focus-visible,
.pathItem:hover,
.pathItem:focus-visible {
color: inherit;
background: var(--ifm-background-color);
border-color: var(--ifm-color-primary);
transform: translateY(-1px);
}
[data-theme='dark'] .contributorCard,
[data-theme='dark'] .pathItem {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.08);
}
.contributorCard {
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
align-items: center;
padding: 12px;
}
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
color: #fff;
background: #165dff;
border-radius: 8px;
font-size: 13px;
font-weight: 800;
}
.avatarImage {
width: 44px;
height: 44px;
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
object-fit: cover;
}
.contributorCard:nth-child(2) .avatar {
background: #00a870;
}
.contributorCard:nth-child(3) .avatar {
background: #ff7d00;
}
.contributorBody {
display: grid;
min-width: 0;
gap: 2px;
}
.contributorBody strong {
color: var(--ifm-heading-color);
font-size: 0.98rem;
}
.contributorBody span {
color: var(--ifm-color-content);
font-size: 0.88rem;
}
.contributorBody em,
.pathItem em {
color: var(--ifm-color-content-secondary);
font-size: 0.82rem;
font-style: normal;
line-height: 1.45;
}
.pathItem {
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
padding: 14px;
}
.pathIndex {
color: var(--ifm-color-primary);
font-family: var(--ifm-font-family-monospace);
font-size: 0.86rem;
font-weight: 800;
}
.pathItem strong {
display: block;
margin-bottom: 4px;
color: var(--ifm-heading-color);
font-size: 0.96rem;
}
@media (max-width: 996px) {
.section {
padding: 4rem 0;
}
.sectionTitle {
font-size: 2rem;
}
.communityGrid {
grid-template-columns: 1fr;
}
.sponsorLogoGrid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.sponsorLogoTile {
min-height: 96px;
}
}
@media (max-width: 640px) {
.section {
padding: 3.25rem 0;
}
.sectionTitle {
font-size: 1.75rem;
}
.sponsorWallHeader {
display: grid;
min-height: auto;
padding: 1rem;
}
.sponsorWallAction {
justify-content: center;
width: 100%;
}
.sponsorLogoGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sponsorLogoMark {
font-size: 1.15rem;
}
.panel {
padding: 1rem;
}
}
@media (prefers-reduced-motion: reduce) {
.sponsorWallAction,
.sponsorLogoTile,
.contributorCard,
.pathItem {
transition: none;
}
}

View 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">-&gt;</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>
);
}

View File

@@ -0,0 +1,169 @@
.section {
padding: 5.5rem 0 4.25rem;
background: var(--ifm-background-color);
}
.sectionHead {
text-align: center;
max-width: 720px;
margin: 0 auto 3rem;
}
.sectionTag {
display: inline-flex;
align-items: center;
min-height: 28px;
font-size: 12px;
font-weight: 750;
letter-spacing: 0;
color: var(--ifm-color-primary);
padding: 4px 12px;
background: rgba(22, 93, 255, 0.08);
border: 1px solid rgba(22, 93, 255, 0.16);
border-radius: 8px;
margin-bottom: 1rem;
}
[data-theme='dark'] .sectionTag {
background: rgba(96, 126, 255, 0.18);
color: var(--ifm-color-primary-lighter);
}
.sectionTitle {
font-size: 2.35rem;
line-height: 1.2;
letter-spacing: 0;
font-weight: 750;
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;
}
.sectionTitle {
font-size: 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: 8px;
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(-2px);
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: 8px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(22, 93, 255, 0.1) 0%, rgba(20, 201, 201, 0.12) 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(20, 201, 201, 0.12) 100%);
color: var(--ifm-color-primary-lighter);
}
.featureTitle {
font-size: 1.15rem;
font-weight: 700;
margin: 0 0 0.6rem;
color: var(--ifm-heading-color);
letter-spacing: 0;
}
.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);
}
@media (max-width: 640px) {
.sectionTitle {
font-size: 1.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.featureCard,
.featureCardLink,
.featureArrow {
transition: none;
}
}

View 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"> -&gt;</span>
</Link>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,247 @@
.section {
padding: 4.5rem 0 5.5rem;
background:
linear-gradient(180deg, rgba(245, 247, 250, 0) 0%, rgba(245, 247, 250, 0.72) 100%),
var(--ifm-background-color);
}
[data-theme='dark'] .section {
background:
linear-gradient(180deg, rgba(15, 17, 21, 0) 0%, rgba(255, 255, 255, 0.03) 100%),
var(--ifm-background-color);
}
.sectionHead {
text-align: center;
max-width: 720px;
margin: 0 auto 2.5rem;
}
.sectionTag {
display: inline-flex;
align-items: center;
min-height: 28px;
font-size: 12px;
font-weight: 750;
letter-spacing: 0;
color: #0e7490;
padding: 4px 12px;
background: rgba(20, 201, 201, 0.1);
border: 1px solid rgba(20, 201, 201, 0.2);
border-radius: 8px;
margin-bottom: 1rem;
}
[data-theme='dark'] .sectionTag {
background: rgba(20, 201, 201, 0.16);
color: #67e8f9;
}
.sectionTitle {
font-size: 2.35rem;
line-height: 1.2;
letter-spacing: 0;
font-weight: 750;
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: 6px;
margin-bottom: 2rem;
flex-wrap: wrap;
padding: 6px;
background: var(--ifm-color-emphasis-100);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
}
.tabBtn {
min-height: 40px;
padding: 8px 18px;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
color: var(--ifm-color-content-secondary);
font-size: 14px;
font-weight: 650;
cursor: pointer;
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
}
.tabBtn:hover {
color: var(--ifm-color-primary);
background: var(--ifm-background-color);
}
.tabBtnActive,
.tabBtnActive:hover {
background: var(--ifm-background-color);
color: var(--ifm-color-primary) !important;
border-color: rgba(22, 93, 255, 0.18);
box-shadow: 0 6px 16px rgba(22, 93, 255, 0.12);
}
/* 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: 8px;
overflow: hidden;
box-shadow:
0 24px 58px -22px rgba(22, 93, 255, 0.28),
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: 8px;
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;
font-weight: 750;
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;
min-height: 40px;
padding: 0 12px;
border: 1px solid rgba(22, 93, 255, 0.18);
border-radius: 8px;
font-weight: 650;
color: var(--ifm-color-primary);
text-decoration: none !important;
transition: border-color 0.2s ease, background 0.2s ease;
}
.captionLink:hover {
color: var(--ifm-color-primary-dark);
background: rgba(22, 93, 255, 0.06);
border-color: var(--ifm-color-primary);
}
@media (max-width: 996px) {
.sectionTitle {
font-size: 2rem;
}
}
@media (max-width: 640px) {
.section {
padding: 3.25rem 0 4rem;
}
.sectionTitle {
font-size: 1.75rem;
}
.tabs {
justify-content: stretch;
}
.tabBtn {
flex: 1 1 130px;
}
}
@media (prefers-reduced-motion: reduce) {
.tabBtn,
.captionLink {
transition: none;
}
}

View File

@@ -0,0 +1,264 @@
/**
* 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: #f5f7fa;
--ifm-color-emphasis-200: #e5e6eb;
--ifm-color-emphasis-300: #c9cdd4;
--ifm-color-emphasis-400: #a9aeb8;
/* 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: 700;
--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;
--ifm-global-radius: 8px;
/* Navbar */
--ifm-navbar-height: 64px;
--ifm-navbar-background-color: rgba(255, 255, 255, 0.9);
--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: #1d2129;
--ifm-color-emphasis-200: #272e3b;
--ifm-color-emphasis-300: #384252;
--ifm-color-emphasis-400: #4e5969;
--ifm-color-content: #e6e9ef;
--ifm-color-content-secondary: #9aa3b2;
--ifm-heading-color: #f0f2f5;
--ifm-navbar-background-color: rgba(15, 17, 21, 0.9);
--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;
}
.navbar__link {
font-weight: 500;
font-size: 14px;
}
.navbar__link,
.button,
a {
transition: color 0.2s ease, background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.button {
border-radius: 8px;
font-weight: 650;
}
:focus-visible {
outline: 2px solid var(--ifm-color-primary);
outline-offset: 2px;
}
/* Sidebar tweaks */
.menu__link {
font-size: 14px;
border-radius: 8px;
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);
}
[data-theme='dark'] ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -0,0 +1,19 @@
import type {ReactNode} from 'react';
import {translate} from '@docusaurus/Translate';
import Layout from '@theme/Layout';
import HomepageCommunity from '@site/src/components/HomepageCommunity';
export default function Community(): ReactNode {
return (
<Layout
title={translate({id: 'community.pageTitle', message: 'Community, sponsors and contributors'})}
description={translate({
id: 'community.pageDescription',
message: 'Sponsor BackupX, meet contributors, and find practical ways to contribute.',
})}>
<main>
<HomepageCommunity />
</main>
</Layout>
);
}

View File

@@ -0,0 +1,470 @@
/* Hero */
.hero {
position: relative;
overflow: hidden;
padding: 7rem 0 5.5rem;
background:
linear-gradient(180deg, rgba(22, 93, 255, 0.08) 0%, rgba(255, 255, 255, 0) 72%),
linear-gradient(90deg, rgba(20, 201, 201, 0.08) 0%, rgba(250, 173, 20, 0.08) 100%),
var(--ifm-background-color);
}
.hero::before {
position: absolute;
inset: 0;
content: "";
pointer-events: none;
background-image:
linear-gradient(rgba(22, 93, 255, 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(22, 93, 255, 0.06) 1px, transparent 1px);
background-size: 44px 44px;
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.75), transparent 82%);
}
[data-theme='dark'] .hero {
background:
linear-gradient(180deg, rgba(64, 128, 255, 0.16) 0%, rgba(15, 17, 21, 0) 72%),
linear-gradient(90deg, rgba(20, 201, 201, 0.1) 0%, rgba(250, 173, 20, 0.08) 100%),
var(--ifm-background-color);
}
.heroInner {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(420px, 0.9fr);
gap: 4rem;
align-items: center;
}
.heroContent {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1.25rem;
}
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 5px 12px;
color: var(--ifm-color-primary);
background: rgba(22, 93, 255, 0.09);
border: 1px solid rgba(22, 93, 255, 0.2);
border-radius: 8px;
font-size: 13px;
font-weight: 600;
}
[data-theme='dark'] .badge {
background: rgba(64, 128, 255, 0.16);
border-color: rgba(64, 128, 255, 0.3);
color: var(--ifm-color-primary-lighter);
}
.badgeDot {
width: 7px;
height: 7px;
background: #00b42a;
border-radius: 50%;
box-shadow: 0 0 0 4px rgba(0, 180, 42, 0.12);
}
.heroTitle {
margin: 0;
color: var(--ifm-heading-color);
font-size: 3.45rem;
font-weight: 750;
letter-spacing: 0;
line-height: 1.08;
}
.heroTitleAccent {
display: block;
margin-top: 8px;
color: var(--ifm-color-primary);
}
.heroSubtitle {
max-width: 640px;
margin: 0;
color: var(--ifm-color-content-secondary);
font-size: 1.15rem;
line-height: 1.72;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 8px;
}
.primaryBtn,
.secondaryBtn {
min-height: 46px;
border-radius: 8px;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.primaryBtn {
display: inline-flex;
align-items: center;
gap: 8px;
color: #fff;
background: #165dff;
border: 1px solid #165dff;
box-shadow: 0 10px 24px rgba(22, 93, 255, 0.24);
font-weight: 650;
}
.primaryBtn:hover,
.primaryBtn:focus-visible {
color: #fff;
background: #0e4fe6;
border-color: #0e4fe6;
box-shadow: 0 14px 30px rgba(22, 93, 255, 0.3);
transform: translateY(-1px);
}
.btnArrow {
transition: transform 0.2s ease;
}
.primaryBtn:hover .btnArrow,
.primaryBtn:focus-visible .btnArrow {
transform: translateX(3px);
}
.secondaryBtn {
display: inline-flex;
align-items: center;
color: var(--ifm-font-color-base);
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-300);
font-weight: 600;
}
.secondaryBtn:hover,
.secondaryBtn:focus-visible {
color: var(--ifm-color-primary);
border-color: var(--ifm-color-primary);
background: var(--ifm-background-color);
transform: translateY(-1px);
}
.metrics {
display: flex;
align-items: center;
gap: 1.5rem;
margin-top: 0.5rem;
padding-top: 1.25rem;
}
.metric {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.metricValue {
color: var(--ifm-heading-color);
font-size: 1.35rem;
font-weight: 750;
letter-spacing: 0;
line-height: 1.1;
white-space: nowrap;
}
.metricLabel {
color: var(--ifm-color-content-secondary);
font-size: 12px;
font-weight: 600;
letter-spacing: 0;
line-height: 1.35;
text-transform: uppercase;
}
.metricDivider {
width: 1px;
height: 30px;
background: var(--ifm-color-emphasis-300);
}
/* Product visual */
.heroVisual {
display: grid;
gap: 1rem;
}
.consolePanel {
overflow: hidden;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(22, 93, 255, 0.16);
border-radius: 8px;
box-shadow: 0 24px 60px rgba(29, 33, 41, 0.12);
}
[data-theme='dark'] .consolePanel {
background: rgba(22, 24, 29, 0.9);
border-color: rgba(255, 255, 255, 0.08);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.34);
}
.consoleHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .consoleHeader {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.consoleHeader strong {
display: block;
margin-top: 4px;
color: var(--ifm-heading-color);
font-size: 1.2rem;
}
.consoleEyebrow {
color: var(--ifm-color-content-secondary);
font-size: 12px;
font-weight: 650;
letter-spacing: 0;
text-transform: uppercase;
}
.consoleStatus {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 4px 10px;
color: #00a870;
background: rgba(0, 180, 42, 0.1);
border: 1px solid rgba(0, 180, 42, 0.2);
border-radius: 8px;
font-size: 12px;
font-weight: 700;
}
.consoleGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .consoleGrid {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.consoleGrid > div {
min-width: 0;
padding: 1.1rem 1.25rem;
border-right: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .consoleGrid > div {
border-right-color: rgba(255, 255, 255, 0.08);
}
.consoleGrid > div:last-child {
border-right: 0;
}
.consoleGrid strong {
display: block;
margin-top: 6px;
color: var(--ifm-heading-color);
font-size: 1.45rem;
line-height: 1.1;
}
.consoleLabel {
display: block;
color: var(--ifm-color-content-secondary);
font-size: 12px;
font-weight: 650;
}
.timeline {
display: grid;
}
.timelineRow {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .timelineRow {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.timelineRow:last-child {
border-bottom: 0;
}
.timelineRow strong,
.timelineRow span {
display: block;
}
.timelineRow strong {
color: var(--ifm-heading-color);
font-size: 0.95rem;
font-weight: 700;
}
.timelineRow span {
color: var(--ifm-color-content-secondary);
font-size: 0.85rem;
line-height: 1.5;
}
.timelineRow em {
color: var(--ifm-color-content-secondary);
font-size: 0.8rem;
font-style: normal;
font-weight: 650;
white-space: nowrap;
}
.timelineDotOk,
.timelineDotInfo,
.timelineDotWarn {
width: 10px;
height: 10px;
border-radius: 50%;
}
.timelineDotOk {
background: #00b42a;
}
.timelineDotInfo {
background: #165dff;
}
.timelineDotWarn {
background: #ff7d00;
}
.commandCard {
display: grid;
gap: 8px;
padding: 1rem 1.1rem;
background: #111827;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
box-shadow: 0 16px 34px rgba(17, 24, 39, 0.18);
}
.commandTitle {
color: #9ca3af;
font-size: 12px;
font-weight: 650;
letter-spacing: 0;
text-transform: uppercase;
}
.commandCard code {
overflow-x: auto;
color: #e5e7eb;
background: transparent;
border: 0;
padding: 0;
font-size: 13px;
white-space: nowrap;
}
@media (max-width: 996px) {
.hero {
padding: 4.5rem 0 3.5rem;
}
.heroInner {
grid-template-columns: 1fr;
gap: 2.25rem;
}
.heroTitle {
font-size: 2.45rem;
}
}
@media (max-width: 640px) {
.hero {
padding: 3.75rem 0 2.75rem;
}
.heroTitle {
font-size: 2.05rem;
}
.heroSubtitle {
font-size: 1rem;
}
.actions {
width: 100%;
}
.primaryBtn,
.secondaryBtn {
width: 100%;
justify-content: center;
}
.metrics {
width: 100%;
align-items: stretch;
gap: 0.85rem;
flex-direction: column;
}
.metricDivider {
width: 100%;
height: 1px;
}
.consoleHeader,
.timelineRow {
padding: 1rem;
}
.consoleGrid {
grid-template-columns: 1fr;
}
.consoleGrid > div {
border-right: 0;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
.consoleGrid > div:last-child {
border-bottom: 0;
}
[data-theme='dark'] .consoleGrid > div {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
}
@media (prefers-reduced-motion: reduce) {
.primaryBtn,
.secondaryBtn,
.btnArrow {
transition: none;
}
}

View File

@@ -0,0 +1,169 @@
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 HomepageCommunity from '@site/src/components/HomepageCommunity';
import styles from './index.module.css';
function HomepageHeader() {
return (
<header className={styles.hero}>
<div className={clsx('container', styles.heroInner)}>
<div className={styles.heroContent}>
<div className={styles.badge}>
<span className={styles.badgeDot} />
<Translate id="home.badge">Open-source backup control plane · v2.2.1</Translate>
</div>
<Heading as="h1" className={styles.heroTitle}>
<Translate id="home.title.part1">Backup orchestration</Translate>
<span className={styles.heroTitleAccent}>
<Translate id="home.title.part2">for self-hosted servers.</Translate>
</span>
</Heading>
<p className={styles.heroSubtitle}>
<Translate id="home.tagline">
Run file, database, SAP HANA and remote-node backups from one clean console. Keep the control plane yours, keep the storage flexible.
</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">-&gt;</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}>Agent</div>
<div className={styles.metricLabel}>
<Translate id="home.metric.backupTypes">Remote execution</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.heroVisual}>
<div className={styles.consolePanel}>
<div className={styles.consoleHeader}>
<div>
<span className={styles.consoleEyebrow}>
<Translate id="home.visual.eyebrow">BackupX Console</Translate>
</span>
<strong>
<Translate id="home.visual.title">Operations overview</Translate>
</strong>
</div>
<span className={styles.consoleStatus}>
<Translate id="home.visual.status">Healthy</Translate>
</span>
</div>
<div className={styles.consoleGrid}>
<div>
<span className={styles.consoleLabel}>
<Translate id="home.visual.success">Success rate</Translate>
</span>
<strong>99.4%</strong>
</div>
<div>
<span className={styles.consoleLabel}>
<Translate id="home.visual.nodes">Active nodes</Translate>
</span>
<strong>12</strong>
</div>
<div>
<span className={styles.consoleLabel}>
<Translate id="home.visual.targets">Storage targets</Translate>
</span>
<strong>8</strong>
</div>
</div>
<div className={styles.timeline}>
<div className={styles.timelineRow}>
<span className={styles.timelineDotOk} />
<div>
<strong>
<Translate id="home.visual.row1.title">PostgreSQL nightly</Translate>
</strong>
<span>
<Translate id="home.visual.row1.desc">Encrypted archive uploaded to S3</Translate>
</span>
</div>
<em>02:10</em>
</div>
<div className={styles.timelineRow}>
<span className={styles.timelineDotInfo} />
<div>
<strong>
<Translate id="home.visual.row2.title">SAP HANA snapshot</Translate>
</strong>
<span>
<Translate id="home.visual.row2.desc">Running on agent-shanghai-02</Translate>
</span>
</div>
<em>68%</em>
</div>
<div className={styles.timelineRow}>
<span className={styles.timelineDotWarn} />
<div>
<strong>
<Translate id="home.visual.row3.title">Retention cleanup</Translate>
</strong>
<span>
<Translate id="home.visual.row3.desc">Next run in 4 hours</Translate>
</span>
</div>
<em>queued</em>
</div>
</div>
</div>
<div className={styles.commandCard}>
<div className={styles.commandTitle}>
<Translate id="home.command.title">Start with Docker</Translate>
</div>
<code>docker run -d -p 8340:8340 awuqing/backupx:v2.2.1</code>
</div>
</div>
</div>
</header>
);
}
export default function Home(): ReactNode {
const {siteConfig} = useDocusaurusContext();
return (
<Layout
title={translate({id: 'home.pageTitle', message: 'Backup orchestration for self-hosted servers'})}
description={siteConfig.tagline}>
<HomepageHeader />
<main>
<HomepageFeatures />
<HomepageShowcase />
<HomepageCommunity />
</main>
</Layout>
);
}

View File

@@ -0,0 +1,39 @@
import type {ReactNode} from 'react';
import {translate} from '@docusaurus/Translate';
import Translate from '@docusaurus/Translate';
import Layout from '@theme/Layout';
import Heading from '@theme/Heading';
import {HomepageSponsors} from '@site/src/components/HomepageCommunity';
import styles from '@site/src/components/HomepageCommunity/styles.module.css';
export default function Sponsors(): ReactNode {
return (
<Layout
title={translate({id: 'sponsors.pageTitle', message: 'Sponsors'})}
description={translate({
id: 'sponsors.pageDescription',
message: 'Sponsor BackupX reliability, documentation, storage compatibility and long-term maintenance.',
})}>
<main>
<section className={styles.section}>
<div className="container">
<div className={styles.sectionHead}>
<div className={styles.sectionTag}>
<Translate id="sponsors.tag">SPONSORS</Translate>
</div>
<Heading as="h1" className={styles.sectionTitle}>
<Translate id="sponsors.title">Sponsor the BackupX ecosystem</Translate>
</Heading>
<p className={styles.sectionSubtitle}>
<Translate id="sponsors.subtitle">
Sponsorship helps keep BackupX practical for real operators: tested storage providers, reliable releases, restore confidence and better documentation.
</Translate>
</p>
</div>
<HomepageSponsors />
</div>
</section>
</main>
</Layout>
);
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

12
docs-site/tsconfig.json Normal file
View 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"]
}

View File

@@ -0,0 +1,70 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"backupx/server/internal/agent"
)
// runAgent 是 `backupx agent` 子命令入口。
//
// 用法:
//
// backupx agent --master http://master:8340 --token <token>
// backupx agent --config /etc/backupx-agent.yaml
//
// 配置优先级CLI 参数 > 配置文件 > 环境变量
func runAgent(args []string) {
fs := flag.NewFlagSet("agent", flag.ExitOnError)
configPath := fs.String("config", "", "path to agent config YAML (optional)")
master := fs.String("master", "", "master URL, e.g. http://master.example.com:8340")
token := fs.String("token", "", "agent authentication token")
tempDir := fs.String("temp-dir", "", "local temp directory for backup artifacts")
insecureTLS := fs.Bool("insecure-tls", false, "skip TLS verification (testing only)")
if err := fs.Parse(args); err != nil {
os.Exit(2)
}
cfg, err := loadAgentConfig(*configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "agent: load config: %v\n", err)
os.Exit(2)
}
cfg.MergeWithFlags(*master, *token, *tempDir)
if *insecureTLS {
cfg.InsecureSkipTLSVerify = true
}
if err := cfg.Validate(); err != nil {
fmt.Fprintf(os.Stderr, "agent: %v\n", err)
os.Exit(2)
}
a, err := agent.New(cfg, version)
if err != nil {
fmt.Fprintf(os.Stderr, "agent: init: %v\n", err)
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
fmt.Fprintf(os.Stderr, "backupx agent %s starting (master=%s)\n", version, cfg.Master)
if err := a.Run(ctx); err != nil && err != context.Canceled {
fmt.Fprintf(os.Stderr, "agent: %v\n", err)
os.Exit(1)
}
}
// loadAgentConfig 按优先级加载配置:如果提供了 --config 就用文件,否则走环境变量。
func loadAgentConfig(configPath string) (*agent.Config, error) {
if configPath != "" {
return agent.LoadConfigFile(configPath)
}
return agent.LoadConfigFromEnv()
}

View File

@@ -0,0 +1,98 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"backupx/server/internal/backint"
)
// runBackint 是 `backupx backint` 子命令入口。
//
// CLI 参数遵循 SAP HANA Backint 规范:
//
// backupx backint -f <function> -i <input-file> -o <output-file> -p <param-file>
// [-u <user>] [-c <config-prefix>] [-l <log-file>] [-v <version>]
//
// 除 -f / -i / -o / -p 外其余参数接受但忽略(兼容 SAP 调用约定)。
func runBackint(args []string) {
fs := flag.NewFlagSet("backint", flag.ExitOnError)
fnStr := fs.String("f", "", "function: backup | restore | inquire | delete")
inputPath := fs.String("i", "", "input file path")
outputPath := fs.String("o", "", "output file path")
paramFile := fs.String("p", "", "parameter file path")
// 以下参数仅为兼容 SAP 调用约定,当前未使用
_ = fs.String("u", "", "user (ignored)")
_ = fs.String("c", "", "config-prefix (ignored)")
_ = fs.String("l", "", "log file override (ignored, use LOG_FILE in params)")
_ = fs.String("v", "", "backint version (ignored)")
if err := fs.Parse(args); err != nil {
os.Exit(2)
}
if *fnStr == "" || *inputPath == "" || *outputPath == "" || *paramFile == "" {
fmt.Fprintln(os.Stderr, "backint: -f, -i, -o, -p are required")
fs.Usage()
os.Exit(2)
}
fn, err := backint.ParseFunction(*fnStr)
if err != nil {
fmt.Fprintf(os.Stderr, "backint: %v\n", err)
os.Exit(2)
}
cfg, err := backint.LoadConfigFile(*paramFile)
if err != nil {
fmt.Fprintf(os.Stderr, "backint: load config: %v\n", err)
os.Exit(2)
}
// 配置日志重定向(如果指定 LOG_FILE
restoreLog, err := redirectStderr(cfg.LogFile)
if err != nil {
fmt.Fprintf(os.Stderr, "backint: open log: %v\n", err)
os.Exit(2)
}
defer restoreLog()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
agent, err := backint.NewAgent(ctx, cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "backint: init agent: %v\n", err)
os.Exit(1)
}
defer func() { _ = agent.Close() }()
if err := agent.Run(ctx, fn, *inputPath, *outputPath); err != nil {
fmt.Fprintf(os.Stderr, "backint: run: %v\n", err)
os.Exit(1)
}
}
// redirectStderr 将 stderr 重定向到指定日志文件,返回恢复函数。
// 空字符串表示保持原样。
func redirectStderr(path string) (func(), error) {
if path == "" {
return func() {}, nil
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return nil, err
}
orig := os.Stderr
os.Stderr = f
return func() {
os.Stderr = orig
_ = f.Close()
}, nil
}

View File

@@ -24,6 +24,16 @@ func main() {
runResetPassword(os.Args[2:])
return
}
// 子命令分发backintSAP HANA Backint Agent 模式)
if len(os.Args) > 1 && os.Args[1] == "backint" {
runBackint(os.Args[2:])
return
}
// 子命令分发agent远程节点 Agent 模式)
if len(os.Args) > 1 && os.Args[1] == "agent" {
runAgent(os.Args[2:])
return
}
var configPath string
var showVersion bool

View File

@@ -3,6 +3,7 @@ server:
host: "0.0.0.0"
port: 8340
mode: "release" # debug | release
external_url: "" # 可选Master 对 Agent 可达的 URL例如 https://backup.example.com
database:
path: "./data/backupx.db" # SQLite 数据库路径

View File

@@ -7,6 +7,8 @@ require (
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/natefinch/lumberjack v2.0.0+incompatible
github.com/prometheus/client_golang v1.23.2
github.com/pquerna/otp v1.5.0
github.com/rclone/rclone v1.73.3
github.com/robfig/cron/v3 v3.0.1
github.com/spf13/viper v1.20.0
@@ -14,6 +16,7 @@ require (
golang.org/x/crypto v0.48.0
golang.org/x/oauth2 v0.34.0
google.golang.org/api v0.255.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/gorm v1.25.12
)
@@ -179,8 +182,6 @@ require (
github.com/pkg/xattr v0.4.12 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.2 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
@@ -245,7 +246,6 @@ require (
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/validator.v2 v2.0.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect

View File

@@ -0,0 +1,288 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"os"
"runtime"
"strings"
"sync"
"time"
"backupx/server/internal/backup"
)
// Agent 是 Agent 进程的主控制器。
type Agent struct {
cfg *Config
client *MasterClient
executor *Executor
version string
mu sync.Mutex
started bool
}
// New 构造 Agent。
func New(cfg *Config, version string) (*Agent, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
client := NewMasterClient(cfg.Master, cfg.Token, cfg.InsecureSkipTLSVerify)
executor := NewExecutor(client, cfg.TempDir)
return &Agent{
cfg: cfg,
client: client,
executor: executor,
version: version,
}, nil
}
// Run 启动 Agent 主循环,阻塞直到 ctx 被取消。
func (a *Agent) Run(ctx context.Context) error {
a.mu.Lock()
if a.started {
a.mu.Unlock()
return fmt.Errorf("agent already started")
}
a.started = true
a.mu.Unlock()
hbInterval := parseDuration(a.cfg.HeartbeatInterval, 15*time.Second)
pollInterval := parseDuration(a.cfg.PollInterval, 5*time.Second)
// 首次握手:通过一次心跳确认 token 有效
if err := a.heartbeatOnce(ctx); err != nil {
return fmt.Errorf("initial heartbeat failed: %w", err)
}
log.Printf("[agent] connected to master %s", a.cfg.Master)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
a.heartbeatLoop(ctx, hbInterval)
}()
go func() {
defer wg.Done()
a.pollLoop(ctx, pollInterval)
}()
wg.Wait()
return ctx.Err()
}
// heartbeatLoop 定期发送心跳。
func (a *Agent) heartbeatLoop(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := a.heartbeatOnce(ctx); err != nil {
log.Printf("[agent] heartbeat failed: %v", err)
}
}
}
}
func (a *Agent) heartbeatOnce(ctx context.Context) error {
hostname, _ := os.Hostname()
req := HeartbeatRequest{
Token: a.cfg.Token,
Hostname: hostname,
IPAddress: detectLocalIP(),
AgentVersion: a.version,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
}
_, err := a.client.Heartbeat(ctx, req)
return err
}
// pollLoop 定期拉取并处理待执行命令。
func (a *Agent) pollLoop(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
a.pollAndHandleOnce(ctx)
}
}
}
func (a *Agent) pollAndHandleOnce(ctx context.Context) {
cmd, err := a.client.PollCommand(ctx)
if err != nil {
log.Printf("[agent] poll command failed: %v", err)
return
}
if cmd == nil {
return
}
log.Printf("[agent] received command #%d type=%s", cmd.ID, cmd.Type)
switch cmd.Type {
case "run_task":
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)
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, msg, nil)
}
}
// handleRunTask 处理 run_task 命令
func (a *Agent) handleRunTask(ctx context.Context, cmd *CommandPayload) {
var payload struct {
TaskID uint `json:"taskId"`
RecordID uint `json:"recordId"`
}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
return
}
if err := a.executor.ExecuteRunTask(ctx, payload.TaskID, payload.RecordID); err != nil {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
return
}
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{
"taskId": payload.TaskID,
"recordId": payload.RecordID,
})
}
// 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 {
Path string `json:"path"`
}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
return
}
entries, err := listLocalDir(payload.Path)
if err != nil {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
return
}
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{"entries": entries})
}
// 辅助函数
func parseDuration(s string, fallback time.Duration) time.Duration {
if strings.TrimSpace(s) == "" {
return fallback
}
if d, err := time.ParseDuration(s); err == nil {
return d
}
return fallback
}
func detectLocalIP() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return ""
}
for _, addr := range addrs {
if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
if ipNet.IP.To4() != nil {
return ipNet.IP.String()
}
}
}
return ""
}

View File

@@ -0,0 +1,265 @@
package agent
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// MasterClient 是 Agent 调用 Master HTTP API 的封装。
type MasterClient struct {
baseURL string
token string
httpClient *http.Client
}
// NewMasterClient 构造 Master 客户端。
func NewMasterClient(baseURL, token string, insecureTLS bool) *MasterClient {
transport := &http.Transport{}
if insecureTLS {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
return &MasterClient{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
httpClient: &http.Client{
Timeout: 120 * time.Second,
Transport: transport,
},
}
}
// HeartbeatRequest Agent 上报心跳的请求
type HeartbeatRequest struct {
Token string `json:"token"`
Hostname string `json:"hostname,omitempty"`
IPAddress string `json:"ipAddress,omitempty"`
AgentVersion string `json:"agentVersion,omitempty"`
OS string `json:"os,omitempty"`
Arch string `json:"arch,omitempty"`
}
// HeartbeatResponse Master 返回的心跳响应
type HeartbeatResponse struct {
Status string `json:"status"`
NodeID uint `json:"nodeId"`
Name string `json:"name"`
}
// Heartbeat 上报心跳并获取节点元信息
func (c *MasterClient) Heartbeat(ctx context.Context, req HeartbeatRequest) (*HeartbeatResponse, error) {
var resp HeartbeatResponse
if err := c.do(ctx, http.MethodPost, "/api/agent/heartbeat", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// CommandPayload 与 service.AgentCommandPayload 对齐
type CommandPayload struct {
ID uint `json:"id"`
Type string `json:"type"`
Payload json.RawMessage `json:"payload,omitempty"`
}
// PollCommandResponse 轮询响应:无命令时 Command 为 nil
type PollCommandResponse struct {
Command *CommandPayload `json:"command"`
}
// PollCommand 拉取下一条待执行命令
func (c *MasterClient) PollCommand(ctx context.Context) (*CommandPayload, error) {
var resp PollCommandResponse
if err := c.do(ctx, http.MethodPost, "/api/agent/commands/poll", nil, &resp); err != nil {
return nil, err
}
return resp.Command, nil
}
// SubmitCommandResult 上报命令执行结果
func (c *MasterClient) SubmitCommandResult(ctx context.Context, cmdID uint, success bool, errorMsg string, result any) error {
var resultJSON json.RawMessage
if result != nil {
data, err := json.Marshal(result)
if err != nil {
return fmt.Errorf("marshal result: %w", err)
}
resultJSON = data
}
payload := map[string]any{
"success": success,
"errorMessage": errorMsg,
}
if resultJSON != nil {
payload["result"] = resultJSON
}
path := fmt.Sprintf("/api/agent/commands/%d/result", cmdID)
return c.do(ctx, http.MethodPost, path, payload, nil)
}
// TaskSpec 与 service.AgentTaskSpec 对齐
type TaskSpec struct {
TaskID uint `json:"taskId"`
Name string `json:"name"`
Type string `json:"type"`
SourcePath string `json:"sourcePath"`
SourcePaths string `json:"sourcePaths"`
ExcludePatterns string `json:"excludePatterns"`
DBHost string `json:"dbHost"`
DBPort int `json:"dbPort"`
DBUser string `json:"dbUser"`
DBPassword string `json:"dbPassword"`
DBName string `json:"dbName"`
DBPath string `json:"dbPath"`
ExtraConfig string `json:"extraConfig"`
Compression string `json:"compression"`
Encrypt bool `json:"encrypt"`
StorageTargets []StorageTargetConfig `json:"storageTargets"`
}
// StorageTargetConfig 与 service.AgentStorageTargetConfig 对齐
type StorageTargetConfig struct {
ID uint `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Config json.RawMessage `json:"config"`
}
// GetTaskSpec 拉取任务规格
func (c *MasterClient) GetTaskSpec(ctx context.Context, taskID uint) (*TaskSpec, error) {
var spec TaskSpec
path := fmt.Sprintf("/api/agent/tasks/%d", taskID)
if err := c.do(ctx, http.MethodGet, path, nil, &spec); err != nil {
return nil, err
}
return &spec, nil
}
// RecordUpdate 与 service.AgentRecordUpdate 对齐
type RecordUpdate struct {
Status string `json:"status,omitempty"`
FileName string `json:"fileName,omitempty"`
FileSize int64 `json:"fileSize,omitempty"`
Checksum string `json:"checksum,omitempty"`
StoragePath string `json:"storagePath,omitempty"`
StorageTargetID uint `json:"storageTargetId,omitempty"`
StorageUploadResults []StorageResultItem `json:"storageUploadResults,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
LogAppend string `json:"logAppend,omitempty"`
}
type StorageResultItem struct {
StorageTargetID uint `json:"storageTargetId"`
StorageTargetName string `json:"storageTargetName"`
Status string `json:"status"`
StoragePath string `json:"storagePath,omitempty"`
FileSize int64 `json:"fileSize,omitempty"`
Error string `json:"error,omitempty"`
}
// UpdateRecord 上报备份记录的状态/日志
func (c *MasterClient) UpdateRecord(ctx context.Context, recordID uint, update RecordUpdate) error {
path := fmt.Sprintf("/api/agent/records/%d", recordID)
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
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
reqBody = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody)
if err != nil {
return err
}
req.Header.Set("X-Agent-Token", c.token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("%s %s: %w", method, path, err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%s %s: http %d: %s", method, path, resp.StatusCode, string(data))
}
if out == nil {
return nil
}
// BackupX API 统一封装成 {code, data, message} 形式,需要解出 data 字段
var envelope struct {
Code string `json:"code"`
Data json.RawMessage `json:"data"`
Message string `json:"message"`
}
if err := json.Unmarshal(data, &envelope); err == nil && envelope.Data != nil {
if err := json.Unmarshal(envelope.Data, out); err != nil {
return fmt.Errorf("decode data: %w", err)
}
return nil
}
// 兼容直接返回对象的情况
return json.Unmarshal(data, out)
}

View File

@@ -0,0 +1,105 @@
// Package agent 实现 BackupX 远程 Agent。
//
// Agent 是一个独立的 Go 进程,部署在远程服务器上,通过 HTTP 轮询的方式
// 与 Master 通信:定期上报心跳、拉取 Master 下发的命令、本地执行备份、
// 把执行结果和日志回报给 Master。
//
// 通信协议见 server/internal/http/agent_handler.go。
package agent
import (
"errors"
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
// Config 是 Agent 的运行时配置。
type Config struct {
// Master BackupX Master 的 HTTP 基础地址,例如 http://master.example.com:8340
Master string `yaml:"master"`
// Token 节点认证令牌(在 Master 创建节点时生成)
Token string `yaml:"token"`
// HeartbeatInterval 心跳间隔,默认 15s
HeartbeatInterval string `yaml:"heartbeatInterval"`
// PollInterval 命令轮询间隔,默认 5s
PollInterval string `yaml:"pollInterval"`
// TempDir 备份临时目录,默认 /var/lib/backupx-agent/tmp
TempDir string `yaml:"tempDir"`
// InsecureSkipTLSVerify 测试环境允许跳过 TLS 证书校验
InsecureSkipTLSVerify bool `yaml:"insecureSkipTlsVerify"`
}
// LoadConfigFile 从 YAML 文件加载 Agent 配置。
func LoadConfigFile(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read agent config: %w", err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse agent config: %w", err)
}
return applyConfigDefaults(&cfg)
}
// LoadConfigFromEnv 从环境变量加载 Agent 配置。优先级低于 --config 文件。
//
// 支持的环境变量:
// - BACKUPX_AGENT_MASTER Master URL
// - BACKUPX_AGENT_TOKEN 节点认证令牌
// - BACKUPX_AGENT_HEARTBEAT 心跳间隔(如 15s
// - BACKUPX_AGENT_POLL 命令轮询间隔(如 5s
// - BACKUPX_AGENT_TEMP_DIR 临时目录
// - BACKUPX_AGENT_INSECURE_TLS true / 1 跳过 TLS 校验
func LoadConfigFromEnv() (*Config, error) {
cfg := &Config{
Master: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_MASTER")),
Token: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_TOKEN")),
HeartbeatInterval: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_HEARTBEAT")),
PollInterval: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_POLL")),
TempDir: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_TEMP_DIR")),
InsecureSkipTLSVerify: strings.EqualFold(os.Getenv("BACKUPX_AGENT_INSECURE_TLS"), "true") || os.Getenv("BACKUPX_AGENT_INSECURE_TLS") == "1",
}
return applyConfigDefaults(cfg)
}
// MergeWithFlags 把命令行覆盖值合并入配置(非空覆盖)。
func (c *Config) MergeWithFlags(master, token, tempDir string) {
if strings.TrimSpace(master) != "" {
c.Master = master
}
if strings.TrimSpace(token) != "" {
c.Token = token
}
if strings.TrimSpace(tempDir) != "" {
c.TempDir = tempDir
}
}
// Validate 校验必填字段。
func (c *Config) Validate() error {
if strings.TrimSpace(c.Master) == "" {
return errors.New("master url is required (set via --master, BACKUPX_AGENT_MASTER or config file)")
}
if strings.TrimSpace(c.Token) == "" {
return errors.New("token is required (set via --token, BACKUPX_AGENT_TOKEN or config file)")
}
return nil
}
func applyConfigDefaults(cfg *Config) (*Config, error) {
if cfg.HeartbeatInterval == "" {
cfg.HeartbeatInterval = "15s"
}
if cfg.PollInterval == "" {
cfg.PollInterval = "5s"
}
if cfg.TempDir == "" {
cfg.TempDir = "/var/lib/backupx-agent/tmp"
}
cfg.Master = strings.TrimRight(strings.TrimSpace(cfg.Master), "/")
return cfg, nil
}

View File

@@ -0,0 +1,101 @@
package agent
import (
"os"
"path/filepath"
"testing"
)
func TestLoadConfigFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "agent.yaml")
content := `master: http://master.example.com:8340/
token: abc123
heartbeatInterval: 20s
pollInterval: 3s
tempDir: /var/backupx-agent
insecureSkipTlsVerify: true
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
cfg, err := LoadConfigFile(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Master != "http://master.example.com:8340" {
t.Errorf("trailing slash should be trimmed: %q", cfg.Master)
}
if cfg.Token != "abc123" {
t.Errorf("token: %q", cfg.Token)
}
if cfg.HeartbeatInterval != "20s" || cfg.PollInterval != "3s" {
t.Errorf("intervals: %+v", cfg)
}
if !cfg.InsecureSkipTLSVerify {
t.Errorf("insecure should be true")
}
}
func TestLoadConfigDefaults(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "agent.yaml")
if err := os.WriteFile(path, []byte("master: http://m\ntoken: t\n"), 0644); err != nil {
t.Fatal(err)
}
cfg, err := LoadConfigFile(path)
if err != nil {
t.Fatal(err)
}
if cfg.HeartbeatInterval != "15s" || cfg.PollInterval != "5s" {
t.Errorf("default intervals not applied: %+v", cfg)
}
if cfg.TempDir != "/var/lib/backupx-agent/tmp" {
t.Errorf("default tempdir: %q", cfg.TempDir)
}
}
func TestConfigValidate(t *testing.T) {
cases := []struct {
name string
cfg Config
wantErr bool
}{
{"valid", Config{Master: "http://m", Token: "t"}, false},
{"missing master", Config{Token: "t"}, true},
{"missing token", Config{Master: "http://m"}, true},
}
for _, c := range cases {
err := c.cfg.Validate()
if (err != nil) != c.wantErr {
t.Errorf("%s: err=%v wantErr=%v", c.name, err, c.wantErr)
}
}
}
func TestMergeWithFlags(t *testing.T) {
cfg := &Config{Master: "http://old", Token: "old"}
cfg.MergeWithFlags("http://new", "", "/tmp/x")
if cfg.Master != "http://new" {
t.Errorf("master not overridden: %q", cfg.Master)
}
if cfg.Token != "old" {
t.Errorf("empty flag should not override: %q", cfg.Token)
}
if cfg.TempDir != "/tmp/x" {
t.Errorf("tempDir: %q", cfg.TempDir)
}
}
func TestLoadConfigFromEnv(t *testing.T) {
t.Setenv("BACKUPX_AGENT_MASTER", "http://env-master")
t.Setenv("BACKUPX_AGENT_TOKEN", "env-token")
t.Setenv("BACKUPX_AGENT_INSECURE_TLS", "true")
cfg, err := LoadConfigFromEnv()
if err != nil {
t.Fatal(err)
}
if cfg.Master != "http://env-master" || cfg.Token != "env-token" || !cfg.InsecureSkipTLSVerify {
t.Errorf("env not picked up: %+v", cfg)
}
}

View File

@@ -0,0 +1,495 @@
package agent
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"backupx/server/internal/backup"
"backupx/server/internal/storage"
storageRclone "backupx/server/internal/storage/rclone"
"backupx/server/pkg/compress"
)
// Executor 负责在 Agent 本地执行命令。
type Executor struct {
client *MasterClient
tempDir string
backupRegistry *backup.Registry
storageRegistry *storage.Registry
}
// NewExecutor 构造执行器。预先初始化 backup runner 与 storage registry。
func NewExecutor(client *MasterClient, tempDir string) *Executor {
backupRegistry := backup.NewRegistry(
backup.NewFileRunner(),
backup.NewSQLiteRunner(),
backup.NewMySQLRunner(nil),
backup.NewPostgreSQLRunner(nil),
backup.NewSAPHANARunner(nil),
)
storageRegistry := storage.NewRegistry(
storageRclone.NewLocalDiskFactory(),
storageRclone.NewS3Factory(),
storageRclone.NewWebDAVFactory(),
storageRclone.NewGoogleDriveFactory(),
storageRclone.NewAliyunOSSFactory(),
storageRclone.NewTencentCOSFactory(),
storageRclone.NewQiniuKodoFactory(),
storageRclone.NewFTPFactory(),
storageRclone.NewRcloneFactory(),
)
storageRclone.RegisterAllBackends(storageRegistry)
return &Executor{
client: client,
tempDir: tempDir,
backupRegistry: backupRegistry,
storageRegistry: storageRegistry,
}
}
// ExecuteRunTask 处理 run_task 命令:拉规格 → 执行 runner → 压缩 → 上传 → 上报记录。
//
// 注意Agent 当前不支持 Encrypt=true加密密钥不下发到 Agent避免密钥扩散
// 遇到启用加密的任务会向 Master 上报失败并返回错误。
func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) error {
if err := e.ensureTempDir(); err != nil {
e.reportRecordFailure(ctx, recordID, err.Error())
return err
}
// 1) 拉取任务规格
spec, err := e.client.GetTaskSpec(ctx, taskID)
if err != nil {
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("拉取任务规格失败: %v", err))
return err
}
if spec.Encrypt {
msg := "Agent 不支持加密备份(加密密钥仅在 Master 端持有)"
e.reportRecordFailure(ctx, recordID, msg)
return fmt.Errorf("%s", msg)
}
e.appendLog(ctx, recordID, fmt.Sprintf("[agent] 开始执行任务 %s (type=%s)\n", spec.Name, spec.Type))
// 2) 构造 backup.TaskSpec 并找对应 runner
startedAt := time.Now().UTC()
backupSpec := buildBackupTaskSpec(spec, startedAt, e.tempDir)
runner, err := e.backupRegistry.Runner(backupSpec.Type)
if err != nil {
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("不支持的备份类型: %v", err))
return err
}
// 3) 运行 runner
logger := newRecordLogger(ctx, e.client, recordID)
result, err := runner.Run(ctx, backupSpec, logger)
if err != nil {
e.reportRecordFailure(ctx, recordID, err.Error())
return err
}
defer os.RemoveAll(result.TempDir)
// 4) 可选 gzip 压缩
finalPath := result.ArtifactPath
if strings.EqualFold(spec.Compression, "gzip") && !strings.HasSuffix(strings.ToLower(finalPath), ".gz") {
e.appendLog(ctx, recordID, "[agent] 开始压缩备份文件\n")
compressedPath, compressErr := compress.GzipFile(finalPath)
if compressErr != nil {
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("压缩失败: %v", compressErr))
return compressErr
}
finalPath = compressedPath
}
info, err := os.Stat(finalPath)
if err != nil {
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("获取文件信息失败: %v", err))
return err
}
fileName := filepath.Base(finalPath)
fileSize := info.Size()
storagePath := backup.BuildStorageKey(spec.Type, startedAt, fileName)
// 5) 计算 checksum一次读一次并上传到所有目标
checksum, err := computeFileSHA256(finalPath)
if err != nil {
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("计算 checksum 失败: %v", err))
return err
}
if len(spec.StorageTargets) == 0 {
e.reportRecordFailure(ctx, recordID, "没有关联的存储目标")
return fmt.Errorf("no storage targets")
}
uploadResults := make([]StorageResultItem, 0, len(spec.StorageTargets))
selectedStorageTargetID := uint(0)
var uploadErrors []string
for _, target := range spec.StorageTargets {
if err := e.uploadToTarget(ctx, recordID, target, finalPath, storagePath, fileSize, spec.TaskID); err != nil {
uploadResults = append(uploadResults, StorageResultItem{
StorageTargetID: target.ID,
StorageTargetName: target.Name,
Status: "failed",
Error: err.Error(),
})
uploadErrors = append(uploadErrors, fmt.Sprintf("%s: %v", target.Name, err))
e.appendLog(ctx, recordID, fmt.Sprintf("[agent] 上传到存储目标 %s 失败: %v\n", target.Name, err))
continue
}
if selectedStorageTargetID == 0 {
selectedStorageTargetID = target.ID
}
uploadResults = append(uploadResults, StorageResultItem{
StorageTargetID: target.ID,
StorageTargetName: target.Name,
Status: "success",
StoragePath: storagePath,
FileSize: fileSize,
})
e.appendLog(ctx, recordID, fmt.Sprintf("[agent] 已上传到存储目标 %s\n", target.Name))
}
if selectedStorageTargetID == 0 {
msg := strings.Join(uploadErrors, "; ")
if msg == "" {
msg = "所有存储目标上传均失败"
}
e.reportRecordFailureWithUploadResults(ctx, recordID, msg, uploadResults)
return fmt.Errorf("%s", msg)
}
// 6) 上报最终成功
return e.client.UpdateRecord(ctx, recordID, RecordUpdate{
Status: "success",
FileName: fileName,
FileSize: fileSize,
Checksum: checksum,
StoragePath: storagePath,
StorageTargetID: selectedStorageTargetID,
StorageUploadResults: uploadResults,
LogAppend: fmt.Sprintf("[agent] 任务完成,总计 %d 字节\n", fileSize),
})
}
// uploadToTarget 上传单个目标。为保持简化不做上传级重试rclone 本身已有 low-level 重试)。
func (e *Executor) uploadToTarget(ctx context.Context, recordID uint, target StorageTargetConfig, filePath, objectKey string, fileSize int64, taskID uint) error {
var rawConfig map[string]any
if len(target.Config) > 0 {
// DecodeRawConfig 通过 json 解析
if err := jsonUnmarshalMap(target.Config, &rawConfig); err != nil {
return fmt.Errorf("parse storage config: %w", err)
}
}
provider, err := e.storageRegistry.Create(ctx, target.Type, rawConfig)
if err != nil {
return fmt.Errorf("create provider: %w", err)
}
f, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("open artifact: %w", err)
}
defer f.Close()
meta := map[string]string{
"taskId": fmt.Sprintf("%d", taskID),
"recordId": fmt.Sprintf("%d", recordID),
}
return provider.Upload(ctx, objectKey, f, fileSize, meta)
}
// appendLog 追加日志到 Master 记录(尽力而为,失败不中断主流程)
func (e *Executor) appendLog(ctx context.Context, recordID uint, line string) {
_ = e.client.UpdateRecord(ctx, recordID, RecordUpdate{LogAppend: line})
}
// reportRecordFailure 上报失败状态
func (e *Executor) reportRecordFailure(ctx context.Context, recordID uint, msg string) {
e.reportRecordFailureWithUploadResults(ctx, recordID, msg, nil)
}
func (e *Executor) reportRecordFailureWithUploadResults(ctx context.Context, recordID uint, msg string, uploadResults []StorageResultItem) {
_ = e.client.UpdateRecord(ctx, recordID, RecordUpdate{
Status: "failed",
ErrorMessage: msg,
StorageUploadResults: uploadResults,
LogAppend: fmt.Sprintf("[agent] 错误: %s\n", msg),
})
}
// buildBackupTaskSpec 把 AgentTaskSpec 转换为 backup.TaskSpec。
func buildBackupTaskSpec(spec *TaskSpec, startedAt time.Time, tempDir string) backup.TaskSpec {
sourcePaths := parseStringListField(spec.SourcePaths)
excludes := parseStringListField(spec.ExcludePatterns)
return backup.TaskSpec{
ID: spec.TaskID,
Name: spec.Name,
Type: spec.Type,
SourcePath: spec.SourcePath,
SourcePaths: sourcePaths,
ExcludePatterns: excludes,
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,
}
}
func (e *Executor) ensureTempDir() error {
if err := os.MkdirAll(e.tempDir, 0o755); err != nil {
return fmt.Errorf("create agent temp dir: %w", err)
}
return nil
}
func parseStringListField(value string) []string {
trimmed := strings.TrimSpace(value)
if trimmed == "" || trimmed == "[]" {
return nil
}
var jsonItems []string
if err := json.Unmarshal([]byte(trimmed), &jsonItems); err == nil {
return compactStringList(jsonItems)
}
return compactStringList(strings.FieldsFunc(trimmed, func(r rune) bool {
return r == '\n' || r == '\r'
}))
}
func compactStringList(items []string) []string {
result := make([]string, 0, len(items))
for _, item := range items {
if trimmed := strings.TrimSpace(item); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// recordLogger 把 runner 日志回传到 Master 记录。
// 实现 backup.LogWriter每条日志追加到 record.log_content。
type recordLogger struct {
ctx context.Context
client *MasterClient
recordID uint
}
func newRecordLogger(ctx context.Context, client *MasterClient, recordID uint) *recordLogger {
return &recordLogger{ctx: ctx, client: client, recordID: recordID}
}
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
// - 上报:通过 UpdateRestorestatus/logAppend
func (e *Executor) ExecuteRestore(ctx context.Context, restoreRecordID uint) error {
if err := e.ensureTempDir(); err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, err.Error())
return err
}
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))
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) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func splitCommaOrNewline(s string) []string {
var result []string
for _, part := range strings.FieldsFunc(s, func(r rune) bool {
return r == ',' || r == '\n' || r == ';'
}) {
if p := strings.TrimSpace(part); p != "" {
result = append(result, p)
}
}
return result
}

View File

@@ -0,0 +1,233 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"backupx/server/internal/storage"
)
func TestBuildBackupTaskSpecParsesJSONSourcePaths(t *testing.T) {
spec := &TaskSpec{
TaskID: 7,
Name: "root-files",
Type: "file",
SourcePaths: `["/root","/etc"]`,
ExcludePatterns: `["*.log","tmp"]`,
}
got := buildBackupTaskSpec(spec, time.Unix(0, 0), "/var/lib/backupx-agent/tmp")
if !reflect.DeepEqual(got.SourcePaths, []string{"/root", "/etc"}) {
t.Fatalf("source paths = %#v", got.SourcePaths)
}
if !reflect.DeepEqual(got.ExcludePatterns, []string{"*.log", "tmp"}) {
t.Fatalf("exclude patterns = %#v", got.ExcludePatterns)
}
}
func TestParseStringListFieldKeepsLegacyLineFormat(t *testing.T) {
got := parseStringListField("/root\n /etc \n")
want := []string{"/root", "/etc"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("paths = %#v, want %#v", got, want)
}
}
func TestExecuteRunTaskRecordsPerTargetUploadResults(t *testing.T) {
sourceDir := t.TempDir()
if err := os.WriteFile(filepath.Join(sourceDir, "index.html"), []byte("hello"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
var finalUpdate RecordUpdate
var updates []RecordUpdate
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/agent/tasks/1":
writeAgentEnvelope(t, w, TaskSpec{
TaskID: 1,
Name: "site",
Type: "file",
SourcePath: sourceDir,
Compression: "gzip",
StorageTargets: []StorageTargetConfig{
{ID: 11, Name: "broken", Type: "agent_test_storage", Config: json.RawMessage(`{"name":"broken"}`)},
{ID: 12, Name: "ok", Type: "agent_test_storage", Config: json.RawMessage(`{"name":"ok"}`)},
},
})
case r.Method == http.MethodPost && r.URL.Path == "/api/agent/records/99":
var update RecordUpdate
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
t.Fatalf("Decode update returned error: %v", err)
}
updates = append(updates, update)
if update.Status != "" {
finalUpdate = update
}
writeAgentEnvelope(t, w, map[string]string{"status": "ok"})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
executor := NewExecutor(NewMasterClient(server.URL, "token", false), filepath.Join(t.TempDir(), "tmp"))
executor.storageRegistry = storage.NewRegistry(&agentTestStorageFactory{
providers: map[string]*agentTestStorageProvider{
"broken": {name: "broken", failUpload: true},
"ok": {name: "ok", objects: map[string][]byte{}},
},
})
if err := executor.ExecuteRunTask(context.Background(), 1, 99); err != nil {
t.Fatalf("ExecuteRunTask returned error: %v", err)
}
if len(updates) == 0 || finalUpdate.Status != "success" {
t.Fatalf("expected final success update, got updates=%#v final=%#v", updates, finalUpdate)
}
if finalUpdate.StorageTargetID != 12 {
t.Fatalf("expected first successful target 12, got %d", finalUpdate.StorageTargetID)
}
if len(finalUpdate.StorageUploadResults) != 2 {
t.Fatalf("expected two upload results, got %#v", finalUpdate.StorageUploadResults)
}
if finalUpdate.StorageUploadResults[0].Status != "failed" || finalUpdate.StorageUploadResults[1].Status != "success" {
t.Fatalf("unexpected upload results: %#v", finalUpdate.StorageUploadResults)
}
if finalUpdate.StoragePath == "" || finalUpdate.FileSize <= 0 || finalUpdate.Checksum == "" {
t.Fatalf("expected artifact metadata in final update, got %#v", finalUpdate)
}
}
func TestExecuteRunTaskReportsPerTargetUploadResultsWhenAllTargetsFail(t *testing.T) {
sourceDir := t.TempDir()
if err := os.WriteFile(filepath.Join(sourceDir, "index.html"), []byte("hello"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
var finalUpdate RecordUpdate
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/agent/tasks/1":
writeAgentEnvelope(t, w, TaskSpec{
TaskID: 1,
Name: "site",
Type: "file",
SourcePath: sourceDir,
Compression: "gzip",
StorageTargets: []StorageTargetConfig{
{ID: 11, Name: "broken-a", Type: "agent_test_storage", Config: json.RawMessage(`{"name":"broken-a"}`)},
{ID: 12, Name: "broken-b", Type: "agent_test_storage", Config: json.RawMessage(`{"name":"broken-b"}`)},
},
})
case r.Method == http.MethodPost && r.URL.Path == "/api/agent/records/99":
var update RecordUpdate
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
t.Fatalf("Decode update returned error: %v", err)
}
if update.Status != "" {
finalUpdate = update
}
writeAgentEnvelope(t, w, map[string]string{"status": "ok"})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
executor := NewExecutor(NewMasterClient(server.URL, "token", false), filepath.Join(t.TempDir(), "tmp"))
executor.storageRegistry = storage.NewRegistry(&agentTestStorageFactory{
providers: map[string]*agentTestStorageProvider{
"broken-a": {name: "broken-a", failUpload: true},
"broken-b": {name: "broken-b", failUpload: true},
},
})
if err := executor.ExecuteRunTask(context.Background(), 1, 99); err == nil {
t.Fatal("expected ExecuteRunTask to return upload failure")
}
if finalUpdate.Status != "failed" {
t.Fatalf("expected final failed update, got %#v", finalUpdate)
}
if len(finalUpdate.StorageUploadResults) != 2 {
t.Fatalf("expected failed update to keep per-target results, got %#v", finalUpdate.StorageUploadResults)
}
for _, item := range finalUpdate.StorageUploadResults {
if item.Status != "failed" || item.Error == "" {
t.Fatalf("unexpected upload result: %#v", item)
}
}
}
type agentTestStorageFactory struct {
providers map[string]*agentTestStorageProvider
}
func (f *agentTestStorageFactory) Type() storage.ProviderType {
return "agent_test_storage"
}
func (f *agentTestStorageFactory) New(_ context.Context, config map[string]any) (storage.StorageProvider, error) {
name, _ := config["name"].(string)
provider := f.providers[name]
if provider == nil {
return nil, fmt.Errorf("unknown provider %q", name)
}
return provider, nil
}
type agentTestStorageProvider struct {
name string
failUpload bool
objects map[string][]byte
}
func (p *agentTestStorageProvider) Type() storage.ProviderType { return "agent_test_storage" }
func (p *agentTestStorageProvider) TestConnection(context.Context) error {
return nil
}
func (p *agentTestStorageProvider) Upload(_ context.Context, objectKey string, reader io.Reader, _ int64, _ map[string]string) error {
if p.failUpload {
return fmt.Errorf("upload failed for %s", p.name)
}
data, err := io.ReadAll(reader)
if err != nil {
return err
}
if p.objects == nil {
p.objects = map[string][]byte{}
}
p.objects[objectKey] = data
return nil
}
func (p *agentTestStorageProvider) Download(_ context.Context, objectKey string) (io.ReadCloser, error) {
data, ok := p.objects[objectKey]
if !ok {
return nil, fmt.Errorf("object %s not found", objectKey)
}
return io.NopCloser(strings.NewReader(string(data))), nil
}
func (p *agentTestStorageProvider) Delete(_ context.Context, objectKey string) error {
delete(p.objects, objectKey)
return nil
}
func (p *agentTestStorageProvider) List(context.Context, string) ([]storage.ObjectInfo, error) {
return nil, nil
}
func writeAgentEnvelope(t *testing.T, w http.ResponseWriter, data any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]any{"code": "OK", "data": data}); err != nil {
t.Fatalf("Encode response returned error: %v", err)
}
}

View File

@@ -0,0 +1,50 @@
package agent
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// DirEntry Agent 返回给 Master 的目录项。
type DirEntry struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"isDir"`
Size int64 `json:"size"`
}
// listLocalDir 列出 Agent 所在机器的指定路径。
func listLocalDir(path string) ([]DirEntry, error) {
cleaned := filepath.Clean(strings.TrimSpace(path))
if strings.TrimSpace(path) == "" || cleaned == "." {
cleaned = "/"
}
entries, err := os.ReadDir(cleaned)
if err != nil {
return nil, fmt.Errorf("read dir: %w", err)
}
result := make([]DirEntry, 0, len(entries))
for _, entry := range entries {
info, _ := entry.Info()
size := int64(0)
if info != nil && !entry.IsDir() {
size = info.Size()
}
result = append(result, DirEntry{
Name: entry.Name(),
Path: filepath.Join(cleaned, entry.Name()),
IsDir: entry.IsDir(),
Size: size,
})
}
sort.Slice(result, func(i, j int) bool {
if result[i].IsDir != result[j].IsDir {
return result[i].IsDir
}
return result[i].Name < result[j].Name
})
return result, nil
}

View File

@@ -0,0 +1,76 @@
package agent
import (
"os"
"path/filepath"
"testing"
)
func TestListLocalDir(t *testing.T) {
dir := t.TempDir()
_ = os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0644)
_ = os.Mkdir(filepath.Join(dir, "sub"), 0755)
_ = os.WriteFile(filepath.Join(dir, "b.txt"), []byte("world!"), 0644)
entries, err := listLocalDir(dir)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(entries) != 3 {
t.Fatalf("expected 3 entries, got %d", len(entries))
}
// 目录排序靠前
if !entries[0].IsDir || entries[0].Name != "sub" {
t.Errorf("directories should sort first: %+v", entries)
}
// 文件大小正确
var a *DirEntry
for i := range entries {
if entries[i].Name == "a.txt" {
a = &entries[i]
break
}
}
if a == nil || a.Size != 5 {
t.Errorf("file size: %+v", a)
}
}
func TestListLocalDirEmptyPathUsesRoot(t *testing.T) {
entries, err := listLocalDir("")
if err != nil {
t.Fatalf("list root: %v", err)
}
if len(entries) == 0 {
t.Fatalf("expected root entries")
}
for _, entry := range entries {
if !filepath.IsAbs(entry.Path) {
t.Fatalf("entry path should be absolute: %+v", entry)
}
}
}
func TestSplitCommaOrNewline(t *testing.T) {
cases := []struct {
in string
out []string
}{
{"", nil},
{"a,b,c", []string{"a", "b", "c"}},
{"a\nb\nc", []string{"a", "b", "c"}},
{"a; b ,\nc\n", []string{"a", "b", "c"}},
}
for _, c := range cases {
got := splitCommaOrNewline(c.in)
if len(got) != len(c.out) {
t.Errorf("%q: got %v want %v", c.in, got, c.out)
continue
}
for i := range got {
if got[i] != c.out[i] {
t.Errorf("%q[%d]: %q vs %q", c.in, i, got[i], c.out[i])
}
}
}
}

View File

@@ -0,0 +1,12 @@
package agent
import "encoding/json"
// jsonUnmarshalMap 把 []byte 或 json.RawMessage 解为 map[string]any。
func jsonUnmarshalMap(data []byte, out *map[string]any) error {
if len(data) == 0 {
*out = map[string]any{}
return nil
}
return json.Unmarshal(data, out)
}

View File

@@ -13,6 +13,7 @@ import (
"backupx/server/internal/database"
aphttp "backupx/server/internal/http"
"backupx/server/internal/logger"
"backupx/server/internal/metrics"
"backupx/server/internal/notify"
"backupx/server/internal/repository"
"backupx/server/internal/scheduler"
@@ -59,9 +60,9 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
jwtManager := security.NewJWTManager(resolvedSecurity.JWTSecret, config.MustJWTDuration(cfg.Security))
rateLimiter := security.NewLoginRateLimiter(5, time.Minute)
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, rateLimiter)
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
configCipher := codec.NewConfigCipher(resolvedSecurity.EncryptionKey)
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, rateLimiter, configCipher)
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
storageRegistry := storage.NewRegistry(
storageRclone.NewLocalDiskFactory(),
storageRclone.NewS3Factory(),
@@ -79,11 +80,14 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
storageTargetService.SetBackupTaskRepository(backupTaskRepo)
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)
notifyRegistry := notify.NewRegistry(notify.NewEmailNotifier(), notify.NewWebhookNotifier(), notify.NewTelegramNotifier())
notificationService := service.NewNotificationService(notificationRepo, notifyRegistry, configCipher)
authService.SetNotificationService(notificationService)
// 初始化 rclone 传输配置(重试 + 带宽限制)
rcloneCtx := storageRclone.ConfiguredContext(ctx, storageRclone.TransferConfig{
LowLevelRetries: cfg.Backup.Retries,
@@ -96,6 +100,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)
@@ -104,36 +111,175 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
auditService := service.NewAuditService(auditLogRepo)
authService.SetAuditService(auditService)
schedulerService.SetAuditRecorder(auditService)
// 审计日志外输:启动时用当前 settings 初始化 webhook后续前端修改立即生效
settingsService.SetAuditWebhookConfigurer(ctx, auditService)
// Database discovery
// Database discovery(集群依赖在 agentService 创建后注入)
databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor())
// Cluster: Node management
nodeRepo := repository.NewNodeRepository(db)
nodeService := service.NewNodeService(nodeRepo)
backupTaskService.SetNodeRepository(nodeRepo)
schedulerService.SetNodeRepository(nodeRepo)
nodeService := service.NewNodeService(nodeRepo, version)
nodeService.SetTaskRepository(backupTaskRepo)
if err := nodeService.EnsureLocalNode(ctx); err != nil {
appLogger.Warn("failed to ensure local node", zap.Error(err))
}
// 启动离线检测:每 15s 扫描一次,超过 45s 未心跳的远程节点标记为离线
nodeService.StartOfflineMonitor(ctx, 15*time.Second)
// Agent 协议服务:命令队列 + 任务下发 + 记录上报
agentCmdRepo := repository.NewAgentCommandRepository(db)
nodeService.SetAgentCommandRepository(agentCmdRepo)
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)
// Prometheus 指标采集Counter/Histogram 由业务服务实时写入;
// Gauge 类存储用量、节点在线、SLA 违约)由 Collector 每 30s 异步刷新,
// 避免 /metrics 请求路径做慢 IO。
appMetrics := metrics.New(version)
backupExecutionService.SetMetrics(appMetrics)
restoreService.SetMetrics(appMetrics)
verificationService.SetMetrics(appMetrics)
replicationService.SetMetrics(appMetrics)
metricsCollector := metrics.NewCollector(
appMetrics,
metrics.NewRepoSource(storageTargetRepo, backupRecordRepo, nodeRepo, backupTaskRepo, agentCmdRepo),
30*time.Second,
)
metricsCollector.Start(ctx)
router := aphttp.NewRouter(aphttp.RouterDependencies{
Config: cfg,
Version: version,
Logger: appLogger,
AuthService: authService,
SystemService: systemService,
StorageTargetService: storageTargetService,
BackupTaskService: backupTaskService,
BackupExecutionService: backupExecutionService,
BackupRecordService: backupRecordService,
NotificationService: notificationService,
DashboardService: dashboardService,
SettingsService: settingsService,
Context: ctx,
Config: cfg,
Version: version,
Logger: appLogger,
AuthService: authService,
SystemService: systemService,
StorageTargetService: storageTargetService,
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,
NodeService: nodeService,
AgentService: agentService,
DatabaseDiscoveryService: databaseDiscoveryService,
AuditService: auditService,
AuditService: auditService,
JWTManager: jwtManager,
UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo,
UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo,
InstallTokenService: installTokenService,
MasterExternalURL: cfg.Server.ExternalURL,
DB: db,
Metrics: appMetrics,
})
httpServer := &stdhttp.Server{

View File

@@ -0,0 +1,360 @@
package backint
import (
"compress/gzip"
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"os"
"path"
"strings"
"time"
"backupx/server/internal/storage"
storageRclone "backupx/server/internal/storage/rclone"
)
// Agent 是 Backint 协议代理主入口。
//
// 职责:
// 1. 根据 -f 指定的功能,从 -i 输入文件解析请求
// 2. 把数据路由到 BackupX storage 后端
// 3. 把结果写回 -o 输出文件(失败使用 #ERROR不中断批次
type Agent struct {
cfg *Config
provider storage.StorageProvider
catalog *Catalog
}
// NewAgent 构造 Agent初始化 storage provider 与 catalog。
func NewAgent(ctx context.Context, cfg *Config) (*Agent, error) {
registry := buildStorageRegistry()
provider, err := registry.Create(ctx, cfg.StorageType, cfg.StorageConfig)
if err != nil {
return nil, fmt.Errorf("create storage provider: %w", err)
}
if err := provider.TestConnection(ctx); err != nil {
return nil, fmt.Errorf("storage provider connection failed: %w", err)
}
cat, err := OpenCatalog(cfg.CatalogDB)
if err != nil {
return nil, err
}
return &Agent{cfg: cfg, provider: provider, catalog: cat}, nil
}
// Close 释放资源。
func (a *Agent) Close() error {
if a.catalog != nil {
return a.catalog.Close()
}
return nil
}
// Run 执行一次 Backint 调用。
//
// HANA 针对 BACKUP 调用时input 是 #PIPE 列表output 需返回 #SAVED 或 #ERROR。
// 批次中任一条目失败不应导致整个进程退出,因此错误被降级为 #ERROR 行。
// 仅在极端错误参数非法、I/O 失败)时返回 error进程以非 0 退出。
func (a *Agent) Run(ctx context.Context, fn Function, inputPath, outputPath string) error {
in, err := os.Open(inputPath)
if err != nil {
return fmt.Errorf("open input: %w", err)
}
defer in.Close()
out, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("create output: %w", err)
}
defer out.Close()
switch fn {
case FunctionBackup:
return a.runBackup(ctx, in, out)
case FunctionRestore:
return a.runRestore(ctx, in, out)
case FunctionInquire:
return a.runInquire(ctx, in, out)
case FunctionDelete:
return a.runDelete(ctx, in, out)
default:
return fmt.Errorf("unsupported function: %s", fn)
}
}
// runBackup 处理 BACKUP 操作:读取每条请求的管道/文件,上传到存储后端。
func (a *Agent) runBackup(ctx context.Context, in io.Reader, out io.Writer) error {
reqs, err := ParseBackupRequests(in)
if err != nil {
return err
}
for _, req := range reqs {
ebid, perr := a.handleBackupOne(ctx, req)
if perr != nil {
fmt.Fprintf(os.Stderr, "backint: backup %q failed: %v\n", req.Path, perr)
_ = WriteError(out, req.Path)
continue
}
_ = WriteSaved(out, ebid, req.Path)
}
return nil
}
// handleBackupOne 上传一条请求,返回分配的 EBID。
func (a *Agent) handleBackupOne(ctx context.Context, req BackupRequest) (string, error) {
src, size, err := openBackupSource(req)
if err != nil {
return "", err
}
defer src.Close()
ebid := generateEBID()
objectKey := a.objectKeyFor(ebid)
reader := io.Reader(src)
// 可选 gzip 压缩
if a.cfg.Compress {
pr, pw := io.Pipe()
go func() {
gw := gzip.NewWriter(pw)
if _, cerr := io.Copy(gw, src); cerr != nil {
_ = gw.Close()
_ = pw.CloseWithError(cerr)
return
}
if cerr := gw.Close(); cerr != nil {
_ = pw.CloseWithError(cerr)
return
}
_ = pw.Close()
}()
reader = pr
size = -1 // 压缩后大小未知
objectKey += ".gz"
}
meta := map[string]string{
"source-path": req.Path,
"ebid": ebid,
"compress": boolStr(a.cfg.Compress),
}
if err := a.provider.Upload(ctx, objectKey, reader, size, meta); err != nil {
return "", fmt.Errorf("upload: %w", err)
}
if err := a.catalog.Put(CatalogEntry{
EBID: ebid,
ObjectKey: objectKey,
SourcePath: req.Path,
Size: size,
}); err != nil {
return "", fmt.Errorf("catalog put: %w", err)
}
return ebid, nil
}
// runRestore 处理 RESTORE 操作:根据 EBID 从存储下载,写入 HANA 指定的管道/文件。
func (a *Agent) runRestore(ctx context.Context, in io.Reader, out io.Writer) error {
reqs, err := ParseRestoreRequests(in)
if err != nil {
return err
}
for _, req := range reqs {
if perr := a.handleRestoreOne(ctx, req); perr != nil {
fmt.Fprintf(os.Stderr, "backint: restore %q failed: %v\n", req.EBID, perr)
_ = WriteError(out, req.Path)
continue
}
_ = WriteRestored(out, req.EBID, req.Path)
}
return nil
}
func (a *Agent) handleRestoreOne(ctx context.Context, req RestoreRequest) error {
entry, err := a.catalog.Get(req.EBID)
if err != nil {
return fmt.Errorf("catalog get: %w", err)
}
if entry == nil {
return fmt.Errorf("ebid not found: %s", req.EBID)
}
rc, err := a.provider.Download(ctx, entry.ObjectKey)
if err != nil {
return fmt.Errorf("download: %w", err)
}
defer rc.Close()
var src io.Reader = rc
if strings.HasSuffix(entry.ObjectKey, ".gz") {
gr, err := gzip.NewReader(rc)
if err != nil {
return fmt.Errorf("gzip reader: %w", err)
}
defer gr.Close()
src = gr
}
dst, err := openRestoreTarget(req)
if err != nil {
return err
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return fmt.Errorf("copy to target: %w", err)
}
return nil
}
// runInquire 处理 INQUIRE 操作:查询 EBID 是否存在,或列出全部备份。
func (a *Agent) runInquire(ctx context.Context, in io.Reader, out io.Writer) error {
reqs, err := ParseInquireRequests(in)
if err != nil {
return err
}
for _, req := range reqs {
if req.All {
entries, err := a.catalog.List()
if err != nil {
fmt.Fprintf(os.Stderr, "backint: inquire list failed: %v\n", err)
_ = WriteError(out, "#NULL")
continue
}
for _, e := range entries {
_ = WriteBackup(out, e.EBID)
}
continue
}
entry, err := a.catalog.Get(req.EBID)
if err != nil {
fmt.Fprintf(os.Stderr, "backint: inquire %q failed: %v\n", req.EBID, err)
_ = WriteError(out, req.EBID)
continue
}
if entry == nil {
_ = WriteNotFound(out, req.EBID)
continue
}
_ = WriteBackup(out, entry.EBID)
}
return nil
}
// runDelete 处理 DELETE 操作:从存储删除对象并移除目录条目。
func (a *Agent) runDelete(ctx context.Context, in io.Reader, out io.Writer) error {
reqs, err := ParseDeleteRequests(in)
if err != nil {
return err
}
for _, req := range reqs {
if perr := a.handleDeleteOne(ctx, req); perr != nil {
fmt.Fprintf(os.Stderr, "backint: delete %q failed: %v\n", req.EBID, perr)
_ = WriteError(out, req.EBID)
continue
}
_ = WriteDeleted(out, req.EBID)
}
return nil
}
func (a *Agent) handleDeleteOne(ctx context.Context, req DeleteRequest) error {
entry, err := a.catalog.Get(req.EBID)
if err != nil {
return fmt.Errorf("catalog get: %w", err)
}
if entry == nil {
return fmt.Errorf("ebid not found: %s", req.EBID)
}
if err := a.provider.Delete(ctx, entry.ObjectKey); err != nil {
// 允许后端返回"不存在"类错误后继续删除目录条目,避免孤立条目
fmt.Fprintf(os.Stderr, "backint: storage delete warning for %s: %v\n", entry.ObjectKey, err)
}
return a.catalog.Delete(req.EBID)
}
// 辅助函数
func (a *Agent) objectKeyFor(ebid string) string {
base := ebid + ".bin"
if a.cfg.KeyPrefix == "" {
return base
}
return path.Join(a.cfg.KeyPrefix, base)
}
// openBackupSource 打开 HANA 提供的数据源。
//
// 对于 #PIPE 模式HANA 写入命名管道Agent 读取。管道是顺序流size 未知 (-1)。
// 对于文件模式HANA 已在指定路径写好完整文件。
func openBackupSource(req BackupRequest) (io.ReadCloser, int64, error) {
if req.IsPipe {
f, err := os.OpenFile(req.Path, os.O_RDONLY, 0)
if err != nil {
return nil, 0, fmt.Errorf("open pipe: %w", err)
}
return f, -1, nil
}
f, err := os.Open(req.Path)
if err != nil {
return nil, 0, fmt.Errorf("open file: %w", err)
}
info, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, 0, fmt.Errorf("stat: %w", err)
}
return f, info.Size(), nil
}
// openRestoreTarget 打开 HANA 指定的恢复目标(管道或文件)。
func openRestoreTarget(req RestoreRequest) (io.WriteCloser, error) {
if req.IsPipe {
return os.OpenFile(req.Path, os.O_WRONLY, 0)
}
return os.Create(req.Path)
}
// generateEBID 生成 Backint 外部备份 ID。
// 格式backupx-<timestamp>-<16 hex chars>
func generateEBID() string {
var buf [8]byte
if _, err := rand.Read(buf[:]); err != nil {
// fallback用纳秒时间戳作为熵
now := time.Now().UnixNano()
for i := 0; i < 8; i++ {
buf[i] = byte(now >> (i * 8))
}
}
return fmt.Sprintf("backupx-%d-%s", time.Now().Unix(), hex.EncodeToString(buf[:]))
}
func boolStr(b bool) string {
if b {
return "true"
}
return "false"
}
// buildStorageRegistry 构造与主程序一致的 storage registry。
//
// Backint Agent 作为独立 CLI 进程运行,不依赖 BackupX HTTP 服务,
// 因此这里直接引用 storage/rclone 包注册所有后端。
func buildStorageRegistry() *storage.Registry {
registry := storage.NewRegistry(
storageRclone.NewLocalDiskFactory(),
storageRclone.NewS3Factory(),
storageRclone.NewWebDAVFactory(),
storageRclone.NewGoogleDriveFactory(),
storageRclone.NewAliyunOSSFactory(),
storageRclone.NewTencentCOSFactory(),
storageRclone.NewQiniuKodoFactory(),
storageRclone.NewFTPFactory(),
storageRclone.NewRcloneFactory(),
)
storageRclone.RegisterAllBackends(registry)
return registry
}

View File

@@ -0,0 +1,217 @@
package backint
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"backupx/server/internal/storage"
storageRclone "backupx/server/internal/storage/rclone"
)
// newTestAgent 构造一个使用本地磁盘后端的 Agent便于集成测试。
func newTestAgent(t *testing.T, compress bool) (*Agent, string) {
t.Helper()
dir := t.TempDir()
storageDir := filepath.Join(dir, "storage")
if err := os.MkdirAll(storageDir, 0755); err != nil {
t.Fatal(err)
}
registry := storage.NewRegistry(storageRclone.NewLocalDiskFactory())
provider, err := registry.Create(context.Background(), "local_disk", map[string]any{
"basePath": storageDir,
})
if err != nil {
t.Fatalf("create provider: %v", err)
}
cat, err := OpenCatalog(filepath.Join(dir, "catalog.db"))
if err != nil {
t.Fatal(err)
}
agent := &Agent{
cfg: &Config{StorageType: "local_disk", KeyPrefix: "backint", Compress: compress, CatalogDB: filepath.Join(dir, "catalog.db")},
provider: provider,
catalog: cat,
}
t.Cleanup(func() { _ = agent.Close() })
return agent, dir
}
func TestAgent_BackupAndRestore_File(t *testing.T) {
agent, dir := newTestAgent(t, false)
ctx := context.Background()
// 准备源文件
src := filepath.Join(dir, "src.bak")
content := []byte("hello backint world")
if err := os.WriteFile(src, content, 0644); err != nil {
t.Fatal(err)
}
// BACKUP
inPath := filepath.Join(dir, "backup.in")
outPath := filepath.Join(dir, "backup.out")
if err := os.WriteFile(inPath, []byte(src+"\n"), 0644); err != nil {
t.Fatal(err)
}
if err := agent.Run(ctx, FunctionBackup, inPath, outPath); err != nil {
t.Fatalf("backup: %v", err)
}
out, _ := os.ReadFile(outPath)
if !bytes.HasPrefix(out, []byte("#SAVED ")) {
t.Fatalf("expected #SAVED, got: %s", out)
}
// 提取 EBID#SAVED <ebid> "<path>"
parts := strings.Fields(string(out))
if len(parts) < 3 {
t.Fatalf("malformed output: %s", out)
}
ebid := parts[1]
// RESTORE
restoreDst := filepath.Join(dir, "restored.bak")
inPath2 := filepath.Join(dir, "restore.in")
outPath2 := filepath.Join(dir, "restore.out")
if err := os.WriteFile(inPath2, []byte(ebid+" \""+restoreDst+"\"\n"), 0644); err != nil {
t.Fatal(err)
}
if err := agent.Run(ctx, FunctionRestore, inPath2, outPath2); err != nil {
t.Fatalf("restore: %v", err)
}
got, err := os.ReadFile(restoreDst)
if err != nil {
t.Fatalf("read restored: %v", err)
}
if !bytes.Equal(got, content) {
t.Errorf("restored content mismatch: %q vs %q", got, content)
}
}
func TestAgent_BackupWithCompression(t *testing.T) {
agent, dir := newTestAgent(t, true)
ctx := context.Background()
src := filepath.Join(dir, "src.bak")
content := bytes.Repeat([]byte("ABCDEFGH"), 1024)
if err := os.WriteFile(src, content, 0644); err != nil {
t.Fatal(err)
}
inPath := filepath.Join(dir, "backup.in")
outPath := filepath.Join(dir, "backup.out")
_ = os.WriteFile(inPath, []byte(src+"\n"), 0644)
if err := agent.Run(ctx, FunctionBackup, inPath, outPath); err != nil {
t.Fatalf("backup: %v", err)
}
parts := strings.Fields(string(mustRead(t, outPath)))
ebid := parts[1]
// 验证 catalog 记录的对象键以 .gz 结尾
entry, _ := agent.catalog.Get(ebid)
if entry == nil || !strings.HasSuffix(entry.ObjectKey, ".gz") {
t.Fatalf("expected .gz suffix: %+v", entry)
}
// RESTORE 应能解压回原始内容
dst := filepath.Join(dir, "restored.bak")
in2 := filepath.Join(dir, "restore.in")
out2 := filepath.Join(dir, "restore.out")
_ = os.WriteFile(in2, []byte(ebid+" \""+dst+"\"\n"), 0644)
if err := agent.Run(ctx, FunctionRestore, in2, out2); err != nil {
t.Fatalf("restore: %v", err)
}
got := mustRead(t, dst)
if !bytes.Equal(got, content) {
t.Errorf("decompressed content mismatch (len=%d vs %d)", len(got), len(content))
}
}
func TestAgent_Inquire(t *testing.T) {
agent, dir := newTestAgent(t, false)
ctx := context.Background()
// 注入两条目录记录
_ = agent.catalog.Put(CatalogEntry{EBID: "bid-a", ObjectKey: "k/a"})
_ = agent.catalog.Put(CatalogEntry{EBID: "bid-b", ObjectKey: "k/b"})
// INQUIRE #NULL 应列出全部
in := filepath.Join(dir, "inq.in")
out := filepath.Join(dir, "inq.out")
_ = os.WriteFile(in, []byte("#NULL\n"), 0644)
if err := agent.Run(ctx, FunctionInquire, in, out); err != nil {
t.Fatalf("inquire: %v", err)
}
text := string(mustRead(t, out))
if !strings.Contains(text, "bid-a") || !strings.Contains(text, "bid-b") {
t.Errorf("expected both ebids, got: %s", text)
}
// INQUIRE 不存在的 ebid → #NOTFOUND
_ = os.WriteFile(in, []byte("bid-missing\n"), 0644)
if err := agent.Run(ctx, FunctionInquire, in, out); err != nil {
t.Fatalf("inquire missing: %v", err)
}
text = string(mustRead(t, out))
if !strings.Contains(text, "#NOTFOUND") {
t.Errorf("expected #NOTFOUND, got: %s", text)
}
}
func TestAgent_Delete(t *testing.T) {
agent, dir := newTestAgent(t, false)
ctx := context.Background()
// 先做一次 BACKUP
src := filepath.Join(dir, "src.bak")
_ = os.WriteFile(src, []byte("data"), 0644)
inPath := filepath.Join(dir, "b.in")
outPath := filepath.Join(dir, "b.out")
_ = os.WriteFile(inPath, []byte(src+"\n"), 0644)
if err := agent.Run(ctx, FunctionBackup, inPath, outPath); err != nil {
t.Fatal(err)
}
ebid := strings.Fields(string(mustRead(t, outPath)))[1]
// DELETE
delIn := filepath.Join(dir, "d.in")
delOut := filepath.Join(dir, "d.out")
_ = os.WriteFile(delIn, []byte(ebid+"\n"), 0644)
if err := agent.Run(ctx, FunctionDelete, delIn, delOut); err != nil {
t.Fatalf("delete: %v", err)
}
if !strings.Contains(string(mustRead(t, delOut)), "#DELETED") {
t.Errorf("expected #DELETED, got: %s", mustRead(t, delOut))
}
// catalog 条目应已删除
if entry, _ := agent.catalog.Get(ebid); entry != nil {
t.Errorf("catalog entry should be removed, got: %+v", entry)
}
}
func TestAgent_RestoreUnknownEBID(t *testing.T) {
agent, dir := newTestAgent(t, false)
ctx := context.Background()
in := filepath.Join(dir, "r.in")
out := filepath.Join(dir, "r.out")
_ = os.WriteFile(in, []byte("bid-unknown \""+filepath.Join(dir, "dst")+"\"\n"), 0644)
if err := agent.Run(ctx, FunctionRestore, in, out); err != nil {
t.Fatalf("run: %v", err)
}
if !strings.Contains(string(mustRead(t, out)), "#ERROR") {
t.Errorf("expected #ERROR for unknown ebid, got: %s", mustRead(t, out))
}
}
func mustRead(t *testing.T, path string) []byte {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return b
}

View File

@@ -0,0 +1,102 @@
package backint
import (
"fmt"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/clause"
gormlogger "gorm.io/gorm/logger"
)
// CatalogEntry 是 Backint 目录条目,建立 BID (备份 ID) 与对象键的映射。
//
// BID 是 Backint Agent 返回给 SAP HANA 的唯一标识HANA 后续用它作为 RESTORE/DELETE
// 的句柄。Agent 用 catalog 查询该 BID 对应的实际存储对象键。
type CatalogEntry struct {
ID uint `gorm:"primaryKey"`
EBID string `gorm:"column:ebid;uniqueIndex;size:128;not null"`
ObjectKey string `gorm:"column:object_key;size:512;not null"`
SourcePath string `gorm:"column:source_path;size:1024"`
Size int64 `gorm:"column:size"`
CreatedAt time.Time `gorm:"column:created_at"`
}
// TableName 指定表名,避免 GORM 自动复数化。
func (CatalogEntry) TableName() string { return "backint_catalog" }
// Catalog 是本地 Backint 目录SQLite 后端)。
type Catalog struct {
db *gorm.DB
}
// OpenCatalog 打开或创建 catalog 数据库。
func OpenCatalog(dbPath string) (*Catalog, error) {
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: gormlogger.Default.LogMode(gormlogger.Silent),
})
if err != nil {
return nil, fmt.Errorf("open catalog: %w", err)
}
if err := db.AutoMigrate(&CatalogEntry{}); err != nil {
return nil, fmt.Errorf("migrate catalog: %w", err)
}
return &Catalog{db: db}, nil
}
// Close 关闭底层连接。
func (c *Catalog) Close() error {
if c.db == nil {
return nil
}
sqlDB, err := c.db.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
// Put 插入或更新一条记录。
func (c *Catalog) Put(entry CatalogEntry) error {
if entry.EBID == "" {
return fmt.Errorf("ebid is required")
}
if entry.CreatedAt.IsZero() {
entry.CreatedAt = time.Now().UTC()
}
// UpsertEBID 冲突时更新 object_key/size/source_path
return c.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "ebid"}},
DoUpdates: clause.AssignmentColumns([]string{
"object_key", "source_path", "size", "created_at",
}),
}).Create(&entry).Error
}
// Get 通过 EBID 查询条目。未找到返回 (nil, nil)。
func (c *Catalog) Get(ebid string) (*CatalogEntry, error) {
var entry CatalogEntry
err := c.db.Where("ebid = ?", ebid).First(&entry).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
return &entry, nil
}
// Delete 删除一条记录。
func (c *Catalog) Delete(ebid string) error {
return c.db.Where("ebid = ?", ebid).Delete(&CatalogEntry{}).Error
}
// List 列出全部条目。
func (c *Catalog) List() ([]CatalogEntry, error) {
var entries []CatalogEntry
if err := c.db.Order("created_at DESC").Find(&entries).Error; err != nil {
return nil, err
}
return entries, nil
}

View File

@@ -0,0 +1,74 @@
package backint
import (
"path/filepath"
"testing"
)
func TestCatalog_CRUD(t *testing.T) {
dir := t.TempDir()
cat, err := OpenCatalog(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer cat.Close()
if err := cat.Put(CatalogEntry{EBID: "bid-1", ObjectKey: "k/1.bin", SourcePath: "/tmp/a", Size: 100}); err != nil {
t.Fatalf("put: %v", err)
}
if err := cat.Put(CatalogEntry{EBID: "bid-2", ObjectKey: "k/2.bin", Size: 200}); err != nil {
t.Fatalf("put: %v", err)
}
got, err := cat.Get("bid-1")
if err != nil || got == nil {
t.Fatalf("get: %v %v", got, err)
}
if got.ObjectKey != "k/1.bin" || got.Size != 100 {
t.Errorf("mismatch: %+v", got)
}
// 不存在的条目
missing, err := cat.Get("bid-999")
if err != nil {
t.Fatalf("get missing: %v", err)
}
if missing != nil {
t.Errorf("expected nil, got %+v", missing)
}
// List
all, err := cat.List()
if err != nil || len(all) != 2 {
t.Fatalf("list: %v %d", err, len(all))
}
// Delete
if err := cat.Delete("bid-1"); err != nil {
t.Fatalf("delete: %v", err)
}
got, _ = cat.Get("bid-1")
if got != nil {
t.Errorf("bid-1 should be deleted")
}
}
func TestCatalog_UpsertSameEBID(t *testing.T) {
dir := t.TempDir()
cat, err := OpenCatalog(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer cat.Close()
if err := cat.Put(CatalogEntry{EBID: "bid-x", ObjectKey: "v1"}); err != nil {
t.Fatal(err)
}
if err := cat.Put(CatalogEntry{EBID: "bid-x", ObjectKey: "v2"}); err != nil {
t.Fatal(err)
}
got, _ := cat.Get("bid-x")
if got == nil || got.ObjectKey != "v2" {
t.Errorf("upsert failed: %+v", got)
}
}

View File

@@ -0,0 +1,140 @@
package backint
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
)
// Config 是 Backint Agent 的运行时配置。
//
// SAP HANA 通过 -p <paramfile> 传入一个参数文件。BackupX Backint Agent 复用 SAP
// 的"#KEY = VALUE"风格(兼容原生 backint 参数文件习惯),不支持 section。
//
// 必填字段:
// - STORAGE_TYPE存储类型s3/webdav/local_disk/...,与 BackupX storage registry 一致)
// - STORAGE_CONFIG_JSON存储配置 JSON 文件路径(或直接 STORAGE_CONFIG = <json>
//
// 可选字段:
// - PARALLEL_FACTOR并行度默认 1
// - COMPRESS是否 gzip 压缩true/false默认 false
// - LOG_FILE日志文件路径默认 stderr
// - CATALOG_DB本地目录数据库路径默认 ./backint_catalog.db
// - KEY_PREFIX对象键前缀默认空最终对象键 = <prefix>/<ebid>
type Config struct {
StorageType string
StorageConfigJSON string // 存储配置 JSON 文件路径
StorageConfigRaw []byte // 也支持直接内联STORAGE_CONFIG
StorageConfig map[string]any // 解析后的存储配置
ParallelFactor int
Compress bool
LogFile string
CatalogDB string
KeyPrefix string
}
// LoadConfigFile 从文件加载配置。
func LoadConfigFile(path string) (*Config, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open backint config: %w", err)
}
defer f.Close()
return ParseConfig(f)
}
// ParseConfig 从 reader 解析配置。
func ParseConfig(r io.Reader) (*Config, error) {
cfg := &Config{ParallelFactor: 1}
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, ";") {
continue
}
// 兼容可选的 "#" 前缀SAP 约定)
line = strings.TrimPrefix(line, "#")
eq := strings.Index(line, "=")
if eq < 0 {
continue
}
key := strings.TrimSpace(line[:eq])
value := strings.TrimSpace(line[eq+1:])
if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
value = value[1 : len(value)-1]
}
switch strings.ToUpper(key) {
case "STORAGE_TYPE":
cfg.StorageType = value
case "STORAGE_CONFIG_JSON":
cfg.StorageConfigJSON = value
case "STORAGE_CONFIG":
cfg.StorageConfigRaw = []byte(value)
case "PARALLEL_FACTOR":
n, err := strconv.Atoi(value)
if err != nil || n <= 0 {
return nil, fmt.Errorf("invalid PARALLEL_FACTOR: %q", value)
}
cfg.ParallelFactor = n
case "COMPRESS":
cfg.Compress = parseBool(value)
case "LOG_FILE":
cfg.LogFile = value
case "CATALOG_DB":
cfg.CatalogDB = value
case "KEY_PREFIX":
cfg.KeyPrefix = strings.Trim(value, "/")
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
if err := cfg.finalize(); err != nil {
return nil, err
}
return cfg, nil
}
func (c *Config) finalize() error {
if c.StorageType == "" {
return errors.New("STORAGE_TYPE is required")
}
if c.CatalogDB == "" {
c.CatalogDB = "./backint_catalog.db"
}
// 加载存储配置 JSON
var raw []byte
switch {
case c.StorageConfigJSON != "":
data, err := os.ReadFile(c.StorageConfigJSON)
if err != nil {
return fmt.Errorf("read STORAGE_CONFIG_JSON: %w", err)
}
raw = data
case len(c.StorageConfigRaw) > 0:
raw = c.StorageConfigRaw
default:
return errors.New("STORAGE_CONFIG_JSON or STORAGE_CONFIG is required")
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return fmt.Errorf("parse storage config JSON: %w", err)
}
c.StorageConfig = m
return nil
}
func parseBool(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}

View File

@@ -0,0 +1,74 @@
package backint
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestParseConfig(t *testing.T) {
dir := t.TempDir()
storagePath := filepath.Join(dir, "storage.json")
if err := os.WriteFile(storagePath, []byte(`{"basePath":"/tmp/backup"}`), 0644); err != nil {
t.Fatal(err)
}
input := `
; 注释
#STORAGE_TYPE = local_disk
#STORAGE_CONFIG_JSON = ` + storagePath + `
#PARALLEL_FACTOR = 4
#COMPRESS = true
#KEY_PREFIX = /hana/backups/
#CATALOG_DB = ` + filepath.Join(dir, "catalog.db") + `
`
cfg, err := ParseConfig(strings.NewReader(input))
if err != nil {
t.Fatalf("parse: %v", err)
}
if cfg.StorageType != "local_disk" {
t.Errorf("StorageType: %q", cfg.StorageType)
}
if cfg.ParallelFactor != 4 {
t.Errorf("ParallelFactor: %d", cfg.ParallelFactor)
}
if !cfg.Compress {
t.Errorf("Compress should be true")
}
if cfg.KeyPrefix != "hana/backups" {
t.Errorf("KeyPrefix should be trimmed: %q", cfg.KeyPrefix)
}
if cfg.StorageConfig["basePath"] != "/tmp/backup" {
t.Errorf("StorageConfig mismatch: %+v", cfg.StorageConfig)
}
}
func TestParseConfig_MissingStorageType(t *testing.T) {
input := `PARALLEL_FACTOR = 1`
if _, err := ParseConfig(strings.NewReader(input)); err == nil {
t.Fatal("expected error for missing STORAGE_TYPE")
}
}
func TestParseConfig_InlineStorageConfig(t *testing.T) {
input := `STORAGE_TYPE = local_disk
STORAGE_CONFIG = {"basePath":"/x"}
`
cfg, err := ParseConfig(strings.NewReader(input))
if err != nil {
t.Fatalf("parse: %v", err)
}
if cfg.StorageConfig["basePath"] != "/x" {
t.Errorf("inline config not parsed: %+v", cfg.StorageConfig)
}
}
func TestParseConfig_InvalidParallel(t *testing.T) {
input := `STORAGE_TYPE = local_disk
STORAGE_CONFIG = {}
PARALLEL_FACTOR = oops
`
if _, err := ParseConfig(strings.NewReader(input)); err == nil {
t.Fatal("expected error for invalid PARALLEL_FACTOR")
}
}

View File

@@ -0,0 +1,267 @@
// Package backint 实现 SAP HANA Backint 协议代理。
//
// Backint 协议是 SAP HANA 与第三方备份工具之间的管道/文件协议。
// SAP HANA 通过 CLI 调用 Backint Agent传入参数文件、输入文件、输出文件
// Agent 根据输入文件中的 #PIPE / #EBID / #NULL 指令读取/写入数据,
// 并在输出文件中返回 #SAVED / #RESTORED / #BACKUP / #NOTFOUND / #DELETED / #ERROR。
//
// 支持的功能BACKUP / RESTORE / INQUIRE / DELETE
// 参考规范SAP HANA Backint Interface for Backup Tools (OSS 1642148)
package backint
import (
"bufio"
"errors"
"fmt"
"io"
"strings"
)
// Function 代表 Backint 操作类型,对应 CLI 的 -f 参数。
type Function string
const (
FunctionBackup Function = "backup"
FunctionRestore Function = "restore"
FunctionInquire Function = "inquire"
FunctionDelete Function = "delete"
)
// BackupRequest 是 BACKUP 操作的单条请求。
//
// 两种形态:
// - Pipe: #PIPE <path> (HANA 通过命名管道传输数据)
// - File: "<path>" (HANA 指向一个已完成的临时文件)
type BackupRequest struct {
IsPipe bool
Path string
}
// RestoreRequest 是 RESTORE 操作的单条请求。
//
// 形态:#PIPE <ebid> "<path>" 或 <ebid> "<path>"
type RestoreRequest struct {
IsPipe bool
EBID string // 之前 BACKUP 返回的备份 ID
Path string
}
// InquireRequest 是 INQUIRE 操作的单条请求。
//
// 形态:
// - #NULL (列出所有备份)
// - "<ebid>" (查询指定 ID 是否存在)
// - #EBID "<ebid>" (带前缀的变体)
type InquireRequest struct {
All bool
EBID string
}
// DeleteRequest 是 DELETE 操作的单条请求。
//
// 形态:<ebid> 或 #EBID <ebid>
type DeleteRequest struct {
EBID string
}
// ParseBackupRequests 解析 BACKUP 输入文件。
func ParseBackupRequests(r io.Reader) ([]BackupRequest, error) {
var items []BackupRequest
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if strings.HasPrefix(line, "#PIPE") {
path := strings.TrimSpace(strings.TrimPrefix(line, "#PIPE"))
if path == "" {
return nil, fmt.Errorf("invalid #PIPE line: %q", line)
}
items = append(items, BackupRequest{IsPipe: true, Path: trimQuotes(path)})
continue
}
items = append(items, BackupRequest{IsPipe: false, Path: trimQuotes(line)})
}
if err := scanner.Err(); err != nil {
return nil, err
}
return items, nil
}
// ParseRestoreRequests 解析 RESTORE 输入文件。
func ParseRestoreRequests(r io.Reader) ([]RestoreRequest, error) {
var items []RestoreRequest
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
isPipe := false
if strings.HasPrefix(line, "#PIPE") {
isPipe = true
line = strings.TrimSpace(strings.TrimPrefix(line, "#PIPE"))
}
if strings.HasPrefix(line, "#EBID") {
line = strings.TrimSpace(strings.TrimPrefix(line, "#EBID"))
}
ebid, rest := splitFirstField(line)
if ebid == "" || rest == "" {
return nil, fmt.Errorf("invalid restore line: %q", line)
}
items = append(items, RestoreRequest{
IsPipe: isPipe,
EBID: trimQuotes(ebid),
Path: trimQuotes(rest),
})
}
if err := scanner.Err(); err != nil {
return nil, err
}
return items, nil
}
// ParseInquireRequests 解析 INQUIRE 输入文件。
func ParseInquireRequests(r io.Reader) ([]InquireRequest, error) {
var items []InquireRequest
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if line == "#NULL" {
items = append(items, InquireRequest{All: true})
continue
}
if strings.HasPrefix(line, "#EBID") {
line = strings.TrimSpace(strings.TrimPrefix(line, "#EBID"))
}
items = append(items, InquireRequest{EBID: trimQuotes(line)})
}
if err := scanner.Err(); err != nil {
return nil, err
}
return items, nil
}
// ParseDeleteRequests 解析 DELETE 输入文件。
func ParseDeleteRequests(r io.Reader) ([]DeleteRequest, error) {
var items []DeleteRequest
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if strings.HasPrefix(line, "#EBID") {
line = strings.TrimSpace(strings.TrimPrefix(line, "#EBID"))
}
ebid := trimQuotes(strings.TrimSpace(line))
if ebid == "" {
return nil, fmt.Errorf("invalid delete line: %q", line)
}
items = append(items, DeleteRequest{EBID: ebid})
}
if err := scanner.Err(); err != nil {
return nil, err
}
return items, nil
}
// 输出写入辅助
// WriteSaved 写入一条 BACKUP 成功响应:#SAVED <ebid> "<path>"
func WriteSaved(w io.Writer, ebid, path string) error {
_, err := fmt.Fprintf(w, "#SAVED %s %s\n", ebid, quote(path))
return err
}
// WriteRestored 写入一条 RESTORE 成功响应:#RESTORED "<ebid>" "<path>"
func WriteRestored(w io.Writer, ebid, path string) error {
_, err := fmt.Fprintf(w, "#RESTORED %s %s\n", quote(ebid), quote(path))
return err
}
// WriteBackup 写入一条 INQUIRE 命中响应:#BACKUP "<ebid>"
func WriteBackup(w io.Writer, ebid string) error {
_, err := fmt.Fprintf(w, "#BACKUP %s\n", quote(ebid))
return err
}
// WriteNotFound 写入一条 INQUIRE/RESTORE 未命中响应:#NOTFOUND "<path-or-ebid>"
func WriteNotFound(w io.Writer, identifier string) error {
_, err := fmt.Fprintf(w, "#NOTFOUND %s\n", quote(identifier))
return err
}
// WriteDeleted 写入一条 DELETE 成功响应:#DELETED "<ebid>"
func WriteDeleted(w io.Writer, ebid string) error {
_, err := fmt.Fprintf(w, "#DELETED %s\n", quote(ebid))
return err
}
// WriteError 写入一条错误响应:#ERROR "<path-or-ebid>"
//
// SAP HANA 会将 #ERROR 视为本条请求失败,但不会终止整个批次。
// 在 stderr 输出错误详情便于排查。
func WriteError(w io.Writer, identifier string) error {
_, err := fmt.Fprintf(w, "#ERROR %s\n", quote(identifier))
return err
}
// 内部工具函数
func trimQuotes(s string) string {
s = strings.TrimSpace(s)
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
return s[1 : len(s)-1]
}
return s
}
func quote(s string) string {
return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"`
}
// splitFirstField 把一行拆分为 "第一个字段" 和 "剩余部分"。
// 支持带引号的字段:`"abc def" "path"` → `abc def` / `"path"`。
func splitFirstField(line string) (first, rest string) {
line = strings.TrimSpace(line)
if line == "" {
return "", ""
}
if line[0] == '"' {
idx := strings.Index(line[1:], `"`)
if idx < 0 {
return line, ""
}
return line[1 : idx+1], strings.TrimSpace(line[idx+2:])
}
idx := strings.IndexAny(line, " \t")
if idx < 0 {
return line, ""
}
return line[:idx], strings.TrimSpace(line[idx+1:])
}
// ParseFunction 将 CLI 的 -f 参数字符串规范化为 Function。
func ParseFunction(s string) (Function, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "backup":
return FunctionBackup, nil
case "restore":
return FunctionRestore, nil
case "inquire":
return FunctionInquire, nil
case "delete":
return FunctionDelete, nil
default:
return "", errors.New("unsupported backint function: " + s)
}
}

View File

@@ -0,0 +1,142 @@
package backint
import (
"bytes"
"strings"
"testing"
)
func TestParseBackupRequests(t *testing.T) {
input := `#PIPE /tmp/pipe1
#PIPE "/tmp/pipe two"
/tmp/file.bak
"/tmp/file two.bak"
`
reqs, err := ParseBackupRequests(strings.NewReader(input))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(reqs) != 4 {
t.Fatalf("expected 4 requests, got %d", len(reqs))
}
if !reqs[0].IsPipe || reqs[0].Path != "/tmp/pipe1" {
t.Errorf("req[0] mismatch: %+v", reqs[0])
}
if !reqs[1].IsPipe || reqs[1].Path != "/tmp/pipe two" {
t.Errorf("req[1] mismatch: %+v", reqs[1])
}
if reqs[2].IsPipe || reqs[2].Path != "/tmp/file.bak" {
t.Errorf("req[2] mismatch: %+v", reqs[2])
}
if reqs[3].Path != "/tmp/file two.bak" {
t.Errorf("req[3] mismatch: %+v", reqs[3])
}
}
func TestParseRestoreRequests(t *testing.T) {
input := `#PIPE backupx-123 "/tmp/pipe1"
#EBID "backupx-456" "/tmp/file.bak"
backupx-789 /tmp/plain.bak
`
reqs, err := ParseRestoreRequests(strings.NewReader(input))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(reqs) != 3 {
t.Fatalf("expected 3, got %d", len(reqs))
}
if !reqs[0].IsPipe || reqs[0].EBID != "backupx-123" || reqs[0].Path != "/tmp/pipe1" {
t.Errorf("req[0] mismatch: %+v", reqs[0])
}
if reqs[1].IsPipe || reqs[1].EBID != "backupx-456" {
t.Errorf("req[1] mismatch: %+v", reqs[1])
}
if reqs[2].EBID != "backupx-789" || reqs[2].Path != "/tmp/plain.bak" {
t.Errorf("req[2] mismatch: %+v", reqs[2])
}
}
func TestParseInquireRequests(t *testing.T) {
input := "#NULL\nbackupx-abc\n#EBID \"backupx-xyz\"\n"
reqs, err := ParseInquireRequests(strings.NewReader(input))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(reqs) != 3 {
t.Fatalf("expected 3, got %d", len(reqs))
}
if !reqs[0].All {
t.Errorf("req[0] should be All")
}
if reqs[1].EBID != "backupx-abc" {
t.Errorf("req[1] mismatch: %+v", reqs[1])
}
if reqs[2].EBID != "backupx-xyz" {
t.Errorf("req[2] mismatch: %+v", reqs[2])
}
}
func TestParseDeleteRequests(t *testing.T) {
input := "backupx-aaa\n#EBID \"backupx-bbb\"\n"
reqs, err := ParseDeleteRequests(strings.NewReader(input))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(reqs) != 2 || reqs[0].EBID != "backupx-aaa" || reqs[1].EBID != "backupx-bbb" {
t.Fatalf("unexpected: %+v", reqs)
}
}
func TestWriteResponses(t *testing.T) {
var buf bytes.Buffer
_ = WriteSaved(&buf, "backupx-1", "/tmp/x")
_ = WriteRestored(&buf, "backupx-2", "/tmp/y")
_ = WriteBackup(&buf, "backupx-3")
_ = WriteNotFound(&buf, "backupx-4")
_ = WriteDeleted(&buf, "backupx-5")
_ = WriteError(&buf, "/tmp/z")
want := "#SAVED backupx-1 \"/tmp/x\"\n" +
"#RESTORED \"backupx-2\" \"/tmp/y\"\n" +
"#BACKUP \"backupx-3\"\n" +
"#NOTFOUND \"backupx-4\"\n" +
"#DELETED \"backupx-5\"\n" +
"#ERROR \"/tmp/z\"\n"
if buf.String() != want {
t.Errorf("output mismatch:\n got: %q\nwant: %q", buf.String(), want)
}
}
func TestParseFunction(t *testing.T) {
cases := map[string]Function{
"backup": FunctionBackup,
"BACKUP": FunctionBackup,
"restore": FunctionRestore,
"inquire": FunctionInquire,
"delete": FunctionDelete,
}
for s, want := range cases {
got, err := ParseFunction(s)
if err != nil || got != want {
t.Errorf("ParseFunction(%q) = %v, %v; want %v", s, got, err, want)
}
}
if _, err := ParseFunction("bogus"); err == nil {
t.Errorf("expected error for bogus function")
}
}
func TestSplitFirstField(t *testing.T) {
cases := []struct{ in, first, rest string }{
{`abc def`, "abc", "def"},
{`"abc def" ghi`, "abc def", "ghi"},
{`"a b" "c d"`, "a b", `"c d"`},
{`lone`, "lone", ""},
{``, "", ""},
}
for _, c := range cases {
f, r := splitFirstField(c.in)
if f != c.first || r != c.rest {
t.Errorf("splitFirstField(%q) = (%q, %q); want (%q, %q)", c.in, f, r, c.first, c.rest)
}
}
}

View 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 秒命令超时。调用方负责传入 CommandExecutorMaster 用 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
}

View File

@@ -11,6 +11,28 @@ import (
"backupx/server/internal/storage"
)
// collectDirPrefixes 从待删除的记录中提取唯一的父目录前缀。
func collectDirPrefixes(records []model.BackupRecord) []string {
seen := make(map[string]struct{})
var prefixes []string
for _, record := range records {
path := strings.TrimSpace(record.StoragePath)
if path == "" {
continue
}
idx := strings.LastIndex(path, "/")
if idx <= 0 {
continue
}
dir := path[:idx]
if _, ok := seen[dir]; !ok {
seen[dir] = struct{}{}
prefixes = append(prefixes, dir)
}
}
return prefixes
}
type CleanupResult struct {
DeletedRecords int
DeletedObjects int
@@ -54,6 +76,17 @@ func (s *Service) Cleanup(ctx context.Context, task *model.BackupTask, provider
}
result.DeletedRecords++
}
// 清理空目录:收集被删除文件的父目录,尝试移除空目录
if dirCleaner, ok := provider.(storage.StorageDirCleaner); ok && result.DeletedObjects > 0 {
prefixes := collectDirPrefixes(candidates)
for _, prefix := range prefixes {
if err := dirCleaner.RemoveEmptyDirs(ctx, prefix); err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("cleanup empty dirs for %s: %v", prefix, err))
}
}
}
return result, nil
}

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