Compare commits

..

43 Commits

Author SHA1 Message Date
Awuqing
b44050ef9b fix(backup): 加固 CDC 仓库恢复安全 2026-08-07 20:55:09 +08:00
Awuqing
755632e19b fix(cluster): 绑定 Agent 中转产物到备份记录 2026-08-07 20:54:57 +08:00
Awuqing
c2805da6df refactor(ui): 统一 SVG 图标组件
建立本地图标出口,统一项目内 Arco SVG 图标依赖并抽取登录页服务器插画。

以可复用的存储目标名称组件替换字符星标,补充可访问语义与组件测试。
2026-08-07 05:56:20 +08:00
Awuqing
f8deafcb00 feat(auth): 完善英文初始化与裸机安装
在登录和首个管理员创建前提供中英文切换,并在初始化状态不可达时展示可重试错误,避免误入登录页。

对齐源码构建产物与安装脚本路径,增加 systemd/API 就绪检查、失败诊断及中英文裸机文档。

Closes #104
2026-08-07 05:55:56 +08:00
Awuqing
cc50637b4b feat(cluster): 支持远程源服务器集中备份
为 Master 本地磁盘增加可选流式中转,远程 Agent 可直接把产物写入中央存储并通过反向通道恢复。

持久化并校验传输模式,兼容既有 Agent 本机磁盘目标,补齐鉴权、完整性、配额、访问保护、前端配置及双向链路测试。

Closes #101
2026-08-07 05:54:34 +08:00
Awuqing
05dd1baa61 feat(backup): 新增 CDC 内容寻址仓库模式
使用内容定义分块、不可变 pack 与快照索引实现跨文件和跨快照去重。

接入多目标上传、软配额、恢复、下载、校验、保留和安全垃圾回收,并补充前端入口、中英文文档及端到端测试。

Closes #94
2026-08-07 05:53:16 +08:00
Wu Qing
50ce6587d8 feat(restore): 恢复到指定目录(文件类型本机恢复 + 确认弹窗输入) (#86)
* feat(restore): 支持恢复到指定目录(文件类型本机恢复)

恢复此前只能覆盖原始源路径。新增「恢复到指定目录」:把文件备份还原到任意目录,
用于测试恢复、迁移、并排恢复而不覆盖现网数据。

- backup.TaskSpec +RestoreTargetPath;FileRunner.Restore 非空时把归档解压到该目录。
- model.RestoreRecord +TargetPath(持久化/审计)。
- RestoreService.Start 增加 targetPath 参数与校验:仅文件类型、需绝对路径、
  远程节点暂不支持(清晰报错);executeLocally 透传到 spec。
- 恢复触发端点接受可选请求体 {targetPath}(无 body 时恢复到原始路径)。
- 测试:恢复到指定目录后文件落在该目录;相对路径被拒。

* feat(restore): 恢复确认弹窗支持指定恢复目录

文件类型 + 本机恢复时,恢复确认弹窗新增「恢复到指定目录」输入(可选、绝对路径、
留空=原位置),并实时反映在「恢复目标」摘要中;经 startRestoreFromBackup 透传 targetPath。
2026-06-01 00:39:17 +08:00
Wu Qing
f7599dd9bd Update README.md (#95) 2026-06-01 00:34:09 +08:00
Wu Qing
bf0e91db57 chore(ci): 为工作流声明最小权限 (contents: read) (#96)
修复 CodeQL actions/missing-workflow-permissions 告警:ci.yml 未显式声明
GITHUB_TOKEN 权限,默认可写。构建/测试仅需读取仓库内容,故收敛为 contents: read。
2026-06-01 00:27:24 +08:00
dependabot[bot]
37092f3167 build(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#93)
Bumps the npm_and_yarn group with 2 updates in the /web directory: [ws](https://github.com/websockets/ws) and [axios](https://github.com/axios/axios).


Updates `ws` from 8.19.0 to 8.21.0
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.19.0...8.21.0)

Updates `axios` from 1.15.2 to 1.16.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.15.2...v1.16.0)

---
updated-dependencies:
- dependency-name: ws
  dependency-version: 8.21.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: axios
  dependency-version: 1.16.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-31 23:37:13 +08:00
Wu Qing
51e4b0b0ce fix(backup): 修复差异/清单设计评审发现的三处问题 (#92)
1) DeleteRecord 拒绝删除仍被成功差异依赖的全量(+CountDependentDifferentials),堵住手动删除孤立差异链的数据完整性缺口;2) 列表查询 Omit(Manifest),差异基线改按需 FindByID 加载,避免清单大列拖累热路径;3) FileRunner.Run 每文件即时关闭句柄,杜绝大目录 FD 泄漏。含仓储层单测。
2026-05-28 13:38:10 +08:00
Wu Qing
493e1faff5 feat(backup): 新增按需(选择性)文件恢复 (#91)
在内容浏览基础上支持仅恢复勾选的文件/目录到原位置。FileRunner.Restore 按选中集合过滤提取与删除;RestoreService.StartSelective(Start 委托,零破坏);恢复端点接受可选 selectedPaths;前端内容弹窗支持勾选恢复。
2026-05-27 19:50:50 +08:00
Wu Qing
68bb964350 feat(backup): 新增备份内容浏览 (#90)
查看每次备份捕获的文件清单(路径/大小/目录),核对完整性、排查遗漏。清单取自全量备份记录,无需下载解压;差异记录回退基线清单。只读端点 + 前端可筛选弹窗。
2026-05-27 19:33:44 +08:00
Wu Qing
65cf3a04d4 feat(backup): 新增 zstd 压缩选项 (#89)
备份压缩在 gzip 之外新增 zstd(更高压缩率、更快解压)。pkg/compress 新增 ZstdFile/UnzstdFile,Master 与 Agent 压缩/解压按后缀分流,任务校验与前端下拉同步;往返单测覆盖。
2026-05-27 19:15:06 +08:00
Wu Qing
90b58d58d6 feat(backup): 新增差异备份(differential)模式 (#88)
文件备份新增差异模式:仅打包自上次全量以来的变更并记录删除,恢复自动按全量+差异链还原。含基线解析、链式恢复、保留链保护与本机文件任务校验;清单/比对/删除/往返/保留保护单测全覆盖。
2026-05-27 19:03:40 +08:00
Wu Qing
f584a0802a feat(backup): 新增 MongoDB 备份与恢复支持 (#87)
通过 mongodump/mongorestore --archive 流式管线接入 MongoDB 数据源,与现有数据库运行器架构一致;注册到 Master 与 Agent,含任务校验、默认端口与前端表单/恢复确认。5 个单测覆盖参数构造、全库、空产物与缺工具分支。
2026-05-27 18:35:10 +08:00
Wu Qing
992fc24150 feat(backup): GFS 分层保留策略(祖父-父-子)+ 任务表单配置 (#85)
新增 GFS 分层保留:按天/周/月/年保留代表性备份,各层级并集;任一>0 启用、全0 维持原策略(兼容),锁定记录豁免。后端 retention 算法+任务字段贯通,前端任务表单 GFS 配置。go test、tsc+vite 通过。
2026-05-27 15:36:58 +08:00
Wu Qing
386f12a11b feat(backup): 备份保留锁定 / 法律保留(豁免清理删除 + 记录页锁定) (#84)
新增保留锁定:锁定的备份豁免保留期清理与手动删除(迁移基线/合规快照/取证)。model+Locked、retention 剔除锁定记录、DeleteRecord 拒绝删除、PUT /backup/records/:id/lock、记录页锁定/解锁操作与标识。go test、tsc+vite、运行时路由验证均通过。
2026-05-27 13:59:05 +08:00
Wu Qing
f807ce10e6 feat(audit): 审计日志保留期清理(后端自动清理 + 审计页配置) (#83)
审计日志新增可配置保留期:AuditLogRepository.DeleteBefore + AuditService 保留期监控(每 6h 读取 audit_retention_days,0/缺省=永久保留);审计页新增管理员保留天数配置控件。后端 go test、前端 tsc+vite 通过。
2026-05-27 08:37:34 +08:00
Wu Qing
a0d1e66199 feat(reports): 企业合规报表(后端聚合 + CSV 导出 + 前端页面) (#82)
新增合规报表:ReportService 逐任务聚合备份合规证据(成功率/最近成功/SLA 判定/加密/受保护量)+ JSON/CSV API;前端新增 /reports 页面(汇总卡片+明细表+CSV 导出)。后端 go test、前端 tsc+vite、端到端路由验证均通过。
2026-05-27 08:14:56 +08:00
dependabot[bot]
74e29a0753 build(deps): bump go_modules group in /server (#71)
dependabot 安全更新:rclone 1.73.3→1.73.5、golang.org/x/crypto 0.48→0.50、otel 1.39→1.41、aws-sdk 等(均为 minor/patch)。#71 CI(go build/test)通过。
2026-05-27 01:23:34 +08:00
dependabot[bot]
01ce536ca8 build(deps): bump npm_and_yarn group in /docs-site (#72)
dependabot 安全更新(docs-site 站点依赖),CI 通过。
2026-05-27 01:23:26 +08:00
Wu Qing
ef2e15f500 test(replication): 为零覆盖的备份复制服务补齐测试 (#80)
新增 replication_service_test.go:备份→复制到目标存储(目标出现对象、源保留、终态 success)+ 同源拒绝。纯测试新增。
2026-05-27 01:19:49 +08:00
Wu Qing
bdf68eef7a test(verify): 为零覆盖的验证服务补齐测试 (#79)
新增 verification_service_test.go:合法压缩备份验证通过(回归保护 #77)+ 损坏对象验证必失败。纯测试新增。
2026-05-27 01:07:12 +08:00
Wu Qing
8747d6a21b fix(security): 节点文件浏览限制为非 viewer (#78)
GET /api/nodes/:id/fs/list 加 RequireNotViewer() 守卫,杜绝只读 viewer 枚举节点文件系统目录(信息泄露);与备份任务配置的权限级别对齐。
2026-05-27 00:54:44 +08:00
Wu Qing
04ad3c29f4 feat(verify): 验证流程同样比对备份 SHA-256,对齐恢复路径强度 (#77)
验证流程在解压前比对下载对象的 SHA-256(复用恢复路径实现),移除 verifyByType 失效的 checksum 形参。备份/恢复/验证三路径完整性校验一致。
2026-05-27 00:49:27 +08:00
Wu Qing
e63b8f0be8 feat(agent): Agent 远程恢复同样校验备份 SHA-256(补全 #75) (#76)
AgentRestoreSpec/RestoreSpec 新增 Checksum 并由 GetAgentRestoreSpec 透传;Agent ExecuteRestore 在解压前比对 SHA-256,不匹配即中止还原。两条恢复路径完整性保证一致。
2026-05-27 00:40:49 +08:00
Wu Qing
45bc210313 feat(restore): 还原前校验备份 SHA-256,拒绝损坏/被篡改的备份 (#75)
恢复路径在解密/解压前比对下载对象的 SHA-256(与备份记录一致),不匹配即中止还原且不触碰源数据;早期无 checksum 的备份跳过(向后兼容)。新增单元+集成测试。
2026-05-27 00:27:41 +08:00
Wu Qing
0f30e7bf52 refactor: 消除集群执行服务冗余逻辑 + 修复节点状态滞后缺陷 (#74)
抽取备份/恢复/验证/复制四服务的复制粘贴逻辑到 execution_helpers.go(净减约 250 行);节点状态改为按 LastSeen 实时推导,消除过期 online 误判;Agent systemd 单元补齐 LimitNOFILE 与单机端一致。go build/test 全绿。
2026-05-26 14:12:39 +08:00
Wu Qing
e4c52fd8f4 docs: 新增 CONTRIBUTING 贡献指南 (#73)
补充开发环境搭建、构建/测试命令、Conventional Commits(中文)规范、
PR 流程与编码约定。内容与仓库实际一致(Go 1.25、make 目标、vitest
前端测试、后端自动托管 web/dist)。

承接 #64 的思路并重写为与代码库一致的准确版本(不含 #63/#64 中关于
`-e PORT` 等不准确的 troubleshooting 内容)。

Co-authored-by: okwn <13228820+okwn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 12:56:43 +08:00
Wu Qing
17f4ec63ae fix: 后端直接托管 Web 控制台修复 #62,并修复 CodeQL 安全告警 (#70)
* fix(server): 后端直接托管 Web 控制台,修复无 nginx 时 404 (#62)

问题 #62:在未安装 nginx 的服务器上,访问 :8340/ 返回
"route not found"(404),Web 控制台完全无法打开;同时 systemd
服务以 backupx 用户启动时因无权读取 root:root 0640 的配置文件
而反复退出(exit 1)。

修复:
- 后端新增 SPA 静态托管:自动探测前端目录(./web、./web/dist、
  /opt/backupx/web 等,或 server.web_root 显式指定),命中后直接
  提供静态文件与 index.html 回退,无需额外 nginx 反向代理即可访问
  控制台。/api、/health、/metrics、/install 等保留前缀仍返回结构化
  JSON 404,不会被 SPA 回退污染(沿用 issue #46 的约定)。
- 含 ".." 的请求路径由文件服务层直接拒绝,叠加 filepath.Rel 容器
  校验,杜绝目录穿越。
- install.sh 以 backupx:backupx 安装配置文件并显式 chown,修复历史
  版本 root:root 0640 导致服务无法读取配置而启动失败的问题;安装
  完成提示同步说明可直接通过 :8340 访问,并给出 journalctl 排查命令。
- 新增 spa_test.go 覆盖目录探测、保留前缀判定、SPA 回退与穿越防护。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): 修复邮件头注入,加固 webhook 与整数转换

CodeQL 静态扫描在 main 上的真实告警修复:
- 邮件通知(email.go):From/To/Subject 头部此前直接拼接用户可控
  内容(备份任务名会进入 Subject),存在 SMTP 头注入风险(可注入
  Bcc 等额外头部或伪造正文)。新增 buildRawMessage/sanitizeHeaderValue
  剔除头部值中的 CR/LF;正文保持原样。新增 email_test.go 覆盖。
- webhook 通知(webhook.go):Validate 增加 URL 解析与 http/https
  协议校验,杜绝 file://、gopher:// 等可用于 SSRF 的协议。
- 整数转换(auth_service.go、storage_target_handler.go、
  backup_record_handler.go):将 ParseUint 的 bitSize 由 64 改为 0
  (即 uint 宽度),消除 uint64→uint 的潜在截断(32 位平台上为越界
  拒绝而非静默截断),并清除 go/incorrect-integer-conversion 告警。

注:archive.go/file_runner.go 的 zipslip 告警为误报(已有 HasPrefix
容器校验且不解压符号链接);node FS 浏览与 webhook 目标主机由设计上
的鉴权用户控制,不在本次行为变更范围内。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 12:50:57 +08:00
dependabot[bot]
5a936ee162 build(deps): bump the npm_and_yarn group across 2 directories with 7 updates (#59)
Bumps the npm_and_yarn group with 1 update in the /docs-site directory: [fast-uri](https://github.com/fastify/fast-uri).
Bumps the npm_and_yarn group with 6 updates in the /web directory:

| Package | From | To |
| --- | --- | --- |
| [follow-redirects](https://github.com/follow-redirects/follow-redirects) | `1.15.11` | `1.16.0` |
| [lodash](https://github.com/lodash/lodash) | `4.17.23` | `4.18.1` |
| [picomatch](https://github.com/micromatch/picomatch) | `4.0.3` | `4.0.4` |
| [postcss](https://github.com/postcss/postcss) | `8.5.8` | `8.5.14` |
| [axios](https://github.com/axios/axios) | `1.13.6` | `1.15.2` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `6.4.1` | `6.4.2` |



Updates `fast-uri` from 3.1.0 to 3.1.2
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2)

Updates `follow-redirects` from 1.15.11 to 1.16.0
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0)

Updates `lodash` from 4.17.23 to 4.18.1
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)

Updates `picomatch` from 4.0.3 to 4.0.4
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

Updates `postcss` from 8.5.8 to 8.5.14
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.14)

Updates `axios` from 1.13.6 to 1.15.2
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.13.6...v1.15.2)

Updates `vite` from 6.4.1 to 6.4.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.4.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.4.2/packages/vite)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: follow-redirects
  dependency-version: 1.16.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: postcss
  dependency-version: 8.5.14
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: axios
  dependency-version: 1.15.2
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: vite
  dependency-version: 6.4.2
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 12:47:09 +08:00
dependabot[bot]
d39335bdde build(deps): bump golang.org/x/image (#36)
Bumps the go_modules group with 1 update in the /server directory: [golang.org/x/image](https://github.com/golang/image).


Updates `golang.org/x/image` from 0.32.0 to 0.38.0
- [Commits](https://github.com/golang/image/compare/v0.32.0...v0.38.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-version: 0.38.0
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 12:43:21 +08:00
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
218 changed files with 20766 additions and 1916 deletions

View File

@@ -6,6 +6,10 @@ on:
pull_request:
branches: [main, master]
# 最小权限:构建/测试仅需读取仓库内容,显式声明以收敛默认的可写令牌。
permissions:
contents: read
jobs:
backend:
name: Go Build & Test

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 ───

106
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,106 @@
# Contributing to BackupX
感谢你对 BackupX 的关注!本指南介绍如何搭建开发环境并提交贡献。
Thanks for your interest in contributing to BackupX! This guide covers how to set up your environment and submit changes.
## 开发环境 / Development Setup
### 依赖 / Prerequisites
- **Go** 1.25+(见 `server/go.mod`
- **Node.js** 20+CI 与 Docker 使用 Node 20
- **npm** 9+
### 快速开始 / Quick Start
分别在两个终端启动前后端(后端 :8340前端 Vite HMR
```bash
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
# 终端 1 —— 后端(默认 http://localhost:8340
make dev-server
# 终端 2 —— 前端Vite 热更新,/api 代理到 8340
make dev-web
```
### 构建 / Building
```bash
make build # 同时构建后端与前端
make build-server # 仅后端 → server/bin/backupx
make build-web # 仅前端 → web/dist
make docker # 构建 Docker 镜像
make docker-cn # 国内镜像源加速构建
```
> 后端会自动托管 `web/dist`(或 `server.web_root` 指定目录),因此本地裸机部署无需额外的反向代理即可访问控制台。
## 测试 / Testing
提交前请确保测试通过:
```bash
make test # 后端 + 前端全部测试
make test-server # 仅后端cd server && go test ./...
make test-web # 仅前端cd web && npm run testvitest
```
新增功能或修复缺陷时,请尽量补充对应测试。
## 提交信息规范 / Commit Messages
本项目采用 **Conventional Commits**,正文用中文撰写:
```
<type>(<scope>): <subject>
<body>
```
| type | 说明 |
|------|------|
| `feat` | 新功能 |
| `fix` | 缺陷修复 |
| `docs` | 文档变更 |
| `style` | 不影响逻辑的格式调整 |
| `refactor` | 重构 |
| `perf` | 性能优化 |
| `test` | 测试相关 |
| `chore` | 构建/依赖/工具链 |
示例:
```
feat(storage): 新增 Wasabi S3 后端支持
fix(cluster): 修复跨节点恢复的终态处理
docs: 补充 CONTRIBUTING 指南
```
## Pull Request 流程
1. **Fork** 仓库并从最新的 `main` 切出特性分支;
2. **开发**功能或修复,必要时补充测试;
3. **自测**:确保 `make test` 通过;
4. **提交**:使用上述 Conventional Commits中文
5. **推送**并对着 `main` 发起 PR。
### PR 描述建议
- 清晰说明本 PR 做了什么;
- 对新功能/修复,补充动机与背景;
- 关联相关 Issue`Closes #62`
- 纯文档 PR 可不附测试。
> 请保持分支基于较新的 `main`:基线过旧的分支容易产生大范围冲突,难以评审与合入。
## 编码规范 / Coding Conventions
- **Go**:所有错误必须处理(禁止 `_ = err`),日志使用项目已有库(`zap`),禁止 `fmt.Println`;提交前执行 `gofmt`
- **前端**:遵循项目 ESLint/Prettier/tsconfig 配置,不擅自引入新的 CSS 框架或 UI 库。
- **包管理**`web/` 使用 npm请提交对应的 `package-lock.json`
## License
向 BackupX 贡献即表示你同意你的贡献以 [Apache License 2.0](LICENSE) 授权。

View File

@@ -46,6 +46,9 @@
| **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 |
## Quick Start
@@ -57,9 +60,15 @@ docker run -d --name backupx -p 8340:8340 -v backupx-data:/app/data awuqing/back
# 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
# Or build and install on bare metal without Docker
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
make build && sudo ./deploy/install.sh
```
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).
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.
Open `http://your-server:8340`, choose English or Chinese on the setup screen, create the first administrator account, then follow the [5-minute Quick Start](https://awuqing.github.io/BackupX/docs/getting-started/quick-start).
## Documentation
@@ -89,6 +98,16 @@ See the [development guide](https://awuqing.github.io/BackupX/docs/development/s
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.
## Star History
<a href="https://www.star-history.com/?repos=Awuqing%2FBackupX&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Awuqing/BackupX&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Awuqing/BackupX&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Awuqing/BackupX&type=date&legend=top-left" />
</picture>
</a>
## License
[Apache License 2.0](LICENSE)

View File

@@ -46,6 +46,9 @@
| **多节点集群** | 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 一键启动,零外部依赖 |
## 快速开始
@@ -57,9 +60,15 @@ docker run -d --name backupx -p 8340:8340 -v backupx-data:/app/data awuqing/back
# 或使用预编译包
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
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
make build && sudo ./deploy/install.sh
```
打开 `http://your-server:8340`,创建管理员账户,按 [5 分钟快速开始](https://awuqing.github.io/BackupX/zh-Hans/docs/getting-started/quick-start) 完成首次备份
ARM64 主机请下载 `backupx-linux-arm64.tar.gz`。预编译包内包含 `backupx``web/``config.example.yaml``install.sh`,请在解压后的目录内执行 `install.sh`
打开 `http://your-server:8340`,在初始化页选择中文或 English 并创建首个管理员账户,按 [5 分钟快速开始](https://awuqing.github.io/BackupX/zh-Hans/docs/getting-started/quick-start) 完成首次备份。
## 文档

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,31 @@
#!/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
SOURCE_BIN_DEFAULT="$PROJECT_ROOT/server/bin/backupx"
# Keep compatibility with contributors who built the historical path by
# hand, while matching the canonical `make build` output first.
if [ ! -f "$SOURCE_BIN_DEFAULT" ] && [ -f "$PROJECT_ROOT/server/backupx" ]; then
SOURCE_BIN_DEFAULT="$PROJECT_ROOT/server/backupx"
fi
BIN_SOURCE="${BIN_SOURCE:-$SOURCE_BIN_DEFAULT}"
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
@@ -19,14 +33,22 @@ if [ "$(id -u)" -ne 0 ]; then
fi
if [ ! -f "$BIN_SOURCE" ]; then
echo "未找到后端二进制:$BIN_SOURCE" >&2
echo "请先执行cd \"$PROJECT_ROOT/server\" && go build -o backupx ./cmd/backupx" >&2
echo "Backend binary not found / 未找到后端二进制:$BIN_SOURCE" >&2
echo "源码树安装请先在仓库根目录执行 make build产物server/bin/backupx)。" >&2
echo "For a source install, run 'make build' in the repository root first." >&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
@@ -44,14 +66,75 @@ cp -R "$WEB_SOURCE/." "$PREFIX/web/"
chown -R "$APP_USER:$APP_GROUP" "$PREFIX"
if [ ! -f "$ETC_DIR/config.yaml" ]; then
install -m 0640 "$CONFIG_TEMPLATE" "$ETC_DIR/config.yaml"
install -o "$APP_USER" -g "$APP_GROUP" -m 0640 "$CONFIG_TEMPLATE" "$ETC_DIR/config.yaml"
fi
# 确保服务账户能读取配置:历史版本曾以 root:root 0640 安装配置,
# 导致以 backupx 身份运行的服务因无权读取配置而启动失败exit 1
chown "$APP_USER:$APP_GROUP" "$ETC_DIR/config.yaml"
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
# systemctl may return before the process has opened its HTTP listener. Verify
# the same unauthenticated endpoint used by the first-administrator screen so a
# broken bare-metal install cannot print a false success message.
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8340/api/auth/setup/status}"
READY=0
ATTEMPT=1
while [ "$ATTEMPT" -le 30 ]; do
if systemctl is-active --quiet "$SERVICE_NAME"; then
if command -v curl >/dev/null 2>&1; then
if curl -fsS --max-time 2 "$HEALTH_URL" >/dev/null 2>&1; then
READY=1
break
fi
elif command -v wget >/dev/null 2>&1; then
if wget -q -T 2 -O /dev/null "$HEALTH_URL"; then
READY=1
break
fi
else
echo "Warning / 警告:未找到 curl 或 wget仅验证 systemd 服务状态。" >&2
READY=1
break
fi
fi
ATTEMPT=$((ATTEMPT + 1))
sleep 1
done
if [ "$READY" -ne 1 ]; then
echo "BackupX did not become ready at $HEALTH_URL / 服务未通过就绪检查。" >&2
systemctl status "$SERVICE_NAME" --no-pager >&2 || true
journalctl -u "$SERVICE_NAME" -n 50 --no-pager >&2 || true
exit 1
fi
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
@@ -67,6 +150,19 @@ cat <<MESSAGE
- 配置文件:$ETC_DIR/config.yaml
- systemd 服务:/etc/systemd/system/$SERVICE_NAME.service
Web 控制台已由后端直接托管,无需额外的 nginx 反向代理即可访问:
http://<本机IP>:8340
首次访问 / First sign-in:
1. 打开上面的地址,并可在登录页右上角选择 中文 或 English。
2. 页面显示“系统初始化 / System setup”时创建首个管理员用户名和密码。
3. 如果未显示初始化表单,请先检查:$HEALTH_URL
(如已安装 nginx脚本会自动写入反向代理配置可继续用 80 端口访问。)
排查:若服务未监听端口,请查看日志:
journalctl -u "$SERVICE_NAME" -n 50 --no-pager
如需修改监听地址、数据库路径或日志级别,请编辑 "$ETC_DIR/config.yaml" 后执行:
systemctl restart "$SERVICE_NAME"
MESSAGE

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

@@ -22,6 +22,8 @@ services:
# - /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

View File

@@ -10,20 +10,34 @@ description: systemd + Nginx deployment from the prebuilt release tarball or sou
```bash
# Download the matching tarball
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-v1.6.0-linux-amd64.tar.gz
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-linux-amd64.tar.gz
# Extract and install
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
tar xzf backupx-linux-amd64.tar.gz && cd backupx-*-linux-amd64
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
2. Copies the binary to `/opt/backupx/bin/backupx` and the web console to `/opt/backupx/web`
3. Installs the default configuration at `/etc/backupx/config.yaml`
4. Installs `backupx.service` (systemd), enabled at boot
5. (Optional) installs an Nginx site file — see [Nginx Reverse Proxy](./nginx)
6. Verifies the first-setup API before reporting success
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
@@ -50,11 +64,13 @@ After=network.target
[Service]
Type=simple
User=backupx
Group=backupx
WorkingDirectory=/opt/backupx
ExecStart=/opt/backupx/backupx --config /opt/backupx/config.yaml
ExecStart=/opt/backupx/bin/backupx -config /etc/backupx/config.yaml
Restart=on-failure
RestartSec=5s
LimitNOFILE=65536
RestartSec=5
NoNewPrivileges=true
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
@@ -66,17 +82,20 @@ Typical operations:
sudo systemctl status backupx
sudo journalctl -u backupx -f # live logs
sudo systemctl restart backupx
curl -fsS http://127.0.0.1:8340/api/auth/setup/status
```
Open `http://your-server:8340`, switch to English if desired, and create the first administrator on the **System setup** screen. For a custom listen port, run the installer with a matching `HEALTH_URL`.
## Password reset
If the admin password is lost:
```bash
/opt/backupx/backupx reset-password \
/opt/backupx/bin/backupx reset-password \
--username admin \
--password 'newpass123' \
--config /opt/backupx/config.yaml
--config /etc/backupx/config.yaml
```
Docker equivalent:

View File

@@ -15,13 +15,14 @@ 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_expires_in: "24h"
jwt_expire: "24h" # BACKUPX_SECURITY_JWT_EXPIRE
encryption_key: "" # AES-256-GCM key for storage config encryption
backup:
@@ -46,7 +47,20 @@ The environment wins when both file and env are set. All dot-paths become unders
| 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

@@ -25,6 +25,8 @@ services:
- /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
@@ -42,6 +44,17 @@ docker compose up -d
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:

View File

@@ -8,14 +8,26 @@ description: File, MySQL, PostgreSQL, SQLite and SAP HANA — what they back up
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.
File tasks offer three backup modes:
- **Full archive** — writes a self-contained tar artifact on every run
- **Differential archive** — writes only changes since the current full baseline and periodically refreshes that baseline
- **CDC repository** — splits content with stable 512 KiB / 1 MiB / 4 MiB boundaries, stores new chunks in immutable 32 MiB packs, and writes a small snapshot manifest for each run
The CDC repository deduplicates identical content across files and snapshots. Restore, selective restore, verification, download-as-tar, retention, and garbage collection all resolve data through the repository index. Compression and encryption are applied per chunk; encrypted repositories use keyed chunk IDs so plaintext hashes are not exposed.
Repository mode currently uses a single-writer index and therefore runs on the Master only. To keep repository copies on multiple backends, select multiple primary storage targets on the task. Object-level replication is intentionally disabled because a snapshot manifest without its shared packs and indexes is not a complete backup.
Common file-task options:
- **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
- Full and differential modes output `.tar`, `.tar.gz`, or `.tar.zst` artifacts
## MySQL

View File

@@ -26,23 +26,53 @@ BackupX supports Master-Agent mode: backup tasks can be routed to specific nodes
- **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
## Centralize backups from servers B/C/D into storage M
Use the Master as the control plane and register every source server as an Agent. A task's **Source server** determines where paths and database tools are resolved; its **Storage targets** determine where the resulting artifact is retained.
BackupX chooses the data path per target:
| Destination | Data path |
| --- | --- |
| S3, WebDAV, FTP, cloud drive, or another network backend | Agent streams directly to the destination |
| `local_disk` with **Relay remote backups through Master** enabled (for example storage server M mounted through NFS) | Agent streams through the authenticated Master API; Master writes to its configured local path |
The relay is streaming: the Master does not create a second temporary copy of the entire artifact. The reverse path is used when restoring a Master-local artifact back to its source Agent. Use HTTPS whenever Agent traffic crosses an untrusted network.
To configure the common `A → {B,C,D} → M` topology:
1. Run BackupX Master on A and mount M on A if M is exposed as NFS or another filesystem.
2. Create a `local_disk` target for that mount and keep **Relay remote backups through Master** enabled, or create an S3/WebDAV target exposed by M. Existing local-disk targets keep their prior Agent-local behavior until this switch is enabled.
3. Install one Agent on B, C, and D from **Node Management**.
4. Create a backup task for each source, choose B/C/D under **Source server**, browse that server's paths, and select M as the storage target. A source-server pool label can route identical tasks dynamically.
5. Verify the per-target result in the backup record. For a Master-local target, the record reports transfer mode `master_relay`; network backends remain `direct`.
## 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 single `curl ... | sudo sh` line is shown with a live countdown. Click copy, paste into the target machine, and run with root privileges.
- **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
Example (systemd mode):
```bash
curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh
```
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:
@@ -53,6 +83,10 @@ The script runs automatically and:
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
@@ -65,14 +99,20 @@ In Step 1 choose "Batch" and paste node names (one per line, max 50). Step 3 sho
### 5. Route a task to the node
In the **Backup Tasks** page, pick the target node when creating the task. When the task runs:
In the **Backup Tasks** page, pick the source server 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 don't work via Agent** — the Agent doesn't hold Master's AES-256 key. Tasks with `encrypt: true` will fail if routed to an Agent
- **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

View File

@@ -19,7 +19,9 @@ BackupX aims to accept any place you'd want to drop a backup file.
| **Google Drive** | Client ID/Secret + OAuth authorization |
| **WebDAV** | URL + username/password |
| **FTP / FTPS** | Host + port + username/password |
| **Local disk** | Target directory (absolute path) |
| **Local disk** | Target directory (absolute path) + optional Master relay for remote Agents |
New local-disk targets enable **Relay remote backups through Master** by default. This makes the configured path belong to the Master, so a storage server mounted there can collect backups from many source Agents. Turn the switch off when the path intentionally belongs to each Agent. Existing targets retain their previous Agent-local behavior until explicitly changed.
## Rclone backends

View File

@@ -55,10 +55,11 @@ sudo ./install.sh # creates system user, installs to /opt/backupx, sets u
The installer:
1. Creates a `backupx` system user
2. Installs binary to `/opt/backupx/backupx`
3. Creates `/opt/backupx/config.yaml` with safe defaults
2. Installs the binary to `/opt/backupx/bin/backupx` and the web console to `/opt/backupx/web`
3. Creates `/etc/backupx/config.yaml` with safe defaults
4. Installs and enables the `backupx.service` systemd unit
5. (Optional) Configures an Nginx reverse proxy
6. Waits for `/api/auth/setup/status`; if startup fails, prints systemd diagnostics and exits non-zero
## From source
@@ -67,16 +68,17 @@ 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
sudo ./deploy/install.sh
```
After `make build`, the binary is at `server/bin/backupx` and the built web UI is at `web/dist/`.
The installer consumes those exact paths, so no Docker runtime is required. If an existing configuration uses a non-default port, set `HEALTH_URL` for the readiness check, for example `sudo HEALTH_URL=http://127.0.0.1:9000/api/auth/setup/status ./deploy/install.sh`.
## Verify the install
```bash
backupx --version # e.g. v1.6.0
/opt/backupx/bin/backupx --version
curl -fsS http://127.0.0.1:8340/api/auth/setup/status
```
Then open `http://your-server:8340` to see the initial admin setup screen.
Then open `http://your-server:8340`. Choose **English** or **中文** in the upper-right corner. A fresh database shows **System setup**, where you create the first administrator username and password. If that form does not appear, retry the status request above before attempting to sign in.

View File

@@ -42,6 +42,8 @@ Go to **Backup Tasks → New**. Three steps:
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

View File

@@ -6,7 +6,7 @@ import type * as Preset from '@docusaurus/preset-classic';
// https://awuqing.github.io/BackupX/
const config: Config = {
title: 'BackupX',
tagline: 'Self-hosted server backup management — one binary, one command',
tagline: 'Self-hosted backup orchestration for servers, databases, storage targets and remote agents',
favicon: 'img/favicon.ico',
future: {
@@ -76,6 +76,16 @@ const config: Config = {
label: 'Downloads',
position: 'left',
},
{
to: '/community',
label: 'Community',
position: 'left',
},
{
to: '/sponsors',
label: 'Sponsors',
position: 'left',
},
{
type: 'localeDropdown',
position: 'right',
@@ -115,6 +125,22 @@ const config: Config = {
{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`,
},

View File

@@ -1,22 +1,22 @@
{
"home.badge": {
"message": "开源 · v1.6.0",
"message": "开源备份控制平面 · v2.2.1",
"description": "Version badge on the hero"
},
"home.title.part1": {
"message": "为每一台服务器提供",
"message": "面向自托管服务器",
"description": "Hero title, first line"
},
"home.title.part2": {
"message": "自托管备份管理。",
"message": "备份编排平台。",
"description": "Hero title accent second line"
},
"home.tagline": {
"message": "一个二进制,一条命令。文件 / 数据库 / SAP HANA 备份直送 70+ 存储后端。",
"message": "一个清爽控制台中管理文件、数据库、SAP HANA 和远程节点备份。控制平面自己掌握,存储后端灵活选择。",
"description": "Tagline on the home page"
},
"home.pageTitle": {
"message": "自托管备份管理",
"message": "面向自托管服务器的备份编排",
"description": "Page <title> element on the home page"
},
"home.getStarted": {
@@ -28,13 +28,26 @@
"description": "Hero metric label: storage backends"
},
"home.metric.backupTypes": {
"message": "备份类型",
"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": "核心能力",
@@ -78,5 +91,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": "开始阅读文档"}
"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

@@ -10,20 +10,34 @@ description: 从预编译包或源码部署 BackupXsystemd + Nginx
```bash
# 下载对应平台的压缩包
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-v1.6.0-linux-amd64.tar.gz
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-linux-amd64.tar.gz
# 解压并安装
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
tar xzf backupx-linux-amd64.tar.gz && cd backupx-*-linux-amd64
sudo ./install.sh
```
安装脚本自动完成以下步骤:
1. 创建系统用户 `backupx`
2. 复制二进制到 `/opt/backupx/`
3. 生成默认 `config.yaml`(含安全的 JWT/加密密钥)
2. 复制二进制到 `/opt/backupx/bin/backupx`,并把 Web 控制台复制到 `/opt/backupx/web`
3. 把默认配置安装到 `/etc/backupx/config.yaml`
4. 安装并启用 `backupx.service` systemd 单元
5. (可选)生成 Nginx 站点配置 — 参见 [Nginx 反向代理](./nginx)
6. 验证首次初始化接口就绪后才报告安装成功
如果要部署多节点集群,安装后请编辑 `/etc/backupx/config.yaml`,设置远程 Agent 可访问到的 Master URL
```yaml
server:
external_url: "https://backup.example.com"
```
修改后重启 BackupX
```bash
sudo systemctl restart backupx
```
## 从源码构建
@@ -50,11 +64,13 @@ After=network.target
[Service]
Type=simple
User=backupx
Group=backupx
WorkingDirectory=/opt/backupx
ExecStart=/opt/backupx/backupx --config /opt/backupx/config.yaml
ExecStart=/opt/backupx/bin/backupx -config /etc/backupx/config.yaml
Restart=on-failure
RestartSec=5s
LimitNOFILE=65536
RestartSec=5
NoNewPrivileges=true
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
@@ -66,17 +82,20 @@ WantedBy=multi-user.target
sudo systemctl status backupx
sudo journalctl -u backupx -f # 实时日志
sudo systemctl restart backupx
curl -fsS http://127.0.0.1:8340/api/auth/setup/status
```
访问 `http://your-server:8340`,可按需切换到 English然后在“系统初始化 / System setup”页面创建首个管理员。若监听端口不是默认值请为安装脚本传入对应的 `HEALTH_URL`。
## 密码重置
忘记管理员密码时:
```bash
/opt/backupx/backupx reset-password \
/opt/backupx/bin/backupx reset-password \
--username admin \
--password 'newpass123' \
--config /opt/backupx/config.yaml
--config /etc/backupx/config.yaml
```
Docker 等效命令:

View File

@@ -15,13 +15,14 @@ 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_expires_in: "24h"
jwt_expire: "24h" # BACKUPX_SECURITY_JWT_EXPIRE
encryption_key: "" # 用于加密存储配置的 AES-256-GCM 密钥
backup:
@@ -46,7 +47,20 @@ log:
| 配置项 | 环境变量 |
|--------|----------|
| `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

@@ -25,6 +25,8 @@ services:
- /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
@@ -42,6 +44,17 @@ 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_` 前缀环境变量覆盖:

View File

@@ -8,14 +8,26 @@ description: 文件、MySQL、PostgreSQL、SQLite 和 SAP HANA — 各自的能
BackupX 支持五种内置备份类型,类型决定了用哪个 runner 执行。
当任务路由到远程 Agent 时,源路径和外部工具都会在该 Agent 主机上解析。多存储目标上传仍会逐目标记录结果;只要至少一个目标上传成功,备份记录即为成功,详情中的目标结果表会展示部分失败。
## 文件 / 目录
打包(可选 gzip一个或多个文件系统路径。
文件任务提供三种备份模式:
- **全量归档** — 每次生成一份可独立使用的 tar 产物
- **差异归档** — 只保存相对当前全量基线的变化,并按周期刷新全量基线
- **CDC 去重仓库** — 按稳定的 512 KiB / 1 MiB / 4 MiB 内容边界切块,将新块合并到不可变的 32 MiB pack每次运行只新增一份小型快照清单
CDC 仓库会在不同文件、不同快照之间复用相同内容。完整恢复、选择性恢复、完整性校验、下载为 tar、保留策略和垃圾回收都通过仓库索引定位分块。压缩与加密按块执行启用加密时使用带密钥的块 ID不暴露明文哈希。
当前仓库索引采用单写者模型,因此 CDC 模式仅在 Master 本机执行。如需保存多份完整仓库,请在任务中直接多选主存储目标。对象级副本复制会被禁用,因为只有快照清单、没有共享 pack 与索引并不是完整备份。
文件任务的通用选项:
- **源路径** 支持多个UI 中每行一个)
- **排除模式** 支持 gitignore 风格的通配符
- 可选跟随符号链接、保留权限
- 输出单个 `.tar``.tar.gz`
- 全量与差异模式输出 `.tar``.tar.gz``.tar.zst`
## MySQL

View File

@@ -26,23 +26,53 @@ BackupX 支持 Master-Agent 模式:备份任务可以指定在哪个节点执
- **执行** — Agent 复用 BackupRunnerfile / mysql / postgresql / sqlite / saphana并直接上传到存储
- **安全** — 每个节点独立 TokenAgent 不持有 Master 的 JWT 密钥或 AES-256 加密密钥
## 把 B/C/D 服务器集中备份到 M
Master 作为控制面,每台源服务器安装一个 Agent。任务里的 **源服务器** 决定源路径和数据库工具在哪台机器解析,**存储目标** 决定备份产物最终保留在哪里。
BackupX 会根据目标类型选择数据路径:
| 目标 | 数据路径 |
| --- | --- |
| S3、WebDAV、FTP、云盘或其他网络后端 | Agent 直接流式上传到目标 |
| 启用 **远程备份经 Master 中转**`local_disk`(例如通过 NFS 挂载的存储服务器 M | Agent 通过认证后的 Master API 流式中转,由 Master 写入配置目录 |
中转过程不会在 Master 上额外落一份完整临时文件。把 Master 本地磁盘中的备份恢复到源 Agent 时会走反向流式通道。Agent 与 Master 之间跨越不可信网络时必须配置 HTTPS。
典型的 `A → {B,C,D} → M` 拓扑按以下步骤配置:
1. 在 A 运行 BackupX Master如果 M 以 NFS 等文件系统提供存储,先把 M 挂载到 A。
2. 为该挂载点创建 `local_disk` 目标并保持 **远程备份经 Master 中转** 开启;如果 M 提供 S3/WebDAV也可直接创建对应网络目标。升级前已有的本地磁盘目标会继续沿用 Agent 本机落盘,手动开启该选项后才切换到中央目录。
3.**节点管理** 分别在 B、C、D 安装 Agent。
4. 为每台源服务器创建任务,在 **源服务器** 选择 B/C/D浏览该服务器的路径再把 M 选为存储目标。相同任务也可用源服务器池标签动态调度。
5. 在备份记录中检查逐目标结果。Master 本地磁盘目标会记录 `master_relay` 中转模式,网络后端仍为 `direct` 直传。
## 一键部署步骤
### 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` 镜像,国内服务器建议后者)
- **第三步 · 安装命令**:一`curl ... | sudo sh` 命令 + 实时倒计时。点击复制,粘贴到目标机以 root 权限执行
- **第三步 · 安装命令**:一条一键安装命令 + 实时倒计时。点击复制,粘贴到目标机以 root 权限执行。默认命令会嵌入已渲染的安装脚本,目标机无需再通过反向代理访问 `/api/install/:token`;公开安装 URL 仍作为备用路径保留。
### 2. 目标机一条命令完成
示例systemd 模式):
```bash
curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh
```
请直接使用 Web 控制台生成的命令。该命令会把安装脚本写入临时文件,校验 `BACKUPX_AGENT_INSTALL_V1` 魔数,再以 root 权限执行。
脚本会自动:
@@ -53,6 +83,10 @@ curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh
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
@@ -65,14 +99,20 @@ curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh
### 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`
## 已知限制
- **Agent 不支持加密备份**Agent 不持有 Master 的 AES-256 密钥。`encrypt: true` 的任务路由到 Agent 时会直接上报失败
- **加密备份仅支持 Master 本机执行**Agent 不持有 Master 的 AES-256 密钥。创建或更新任务时,如果 `encrypt: true` 且选择了远程节点或节点池,会在入口直接拒绝
- **目录浏览超时**:远程目录浏览通过命令队列做同步 RPC默认 15s 超时
- **派发命令超时**Agent 领取但未完成的命令超过 10 分钟会被置 `timeout`

View File

@@ -19,7 +19,9 @@ BackupX 的目标是接入任何你想放置备份文件的地方。
| **Google Drive** | Client ID/Secret + OAuth 授权 |
| **WebDAV** | 地址 + 用户名/密码 |
| **FTP / FTPS** | 主机 + 端口 + 用户名/密码 |
| **本地磁盘** | 目标目录(绝对路径) |
| **本地磁盘** | 目标目录(绝对路径)+ 可选的远程 Agent 经 Master 中转 |
新建本地磁盘目标默认开启 **远程备份经 Master 中转**。开启时,配置目录属于 Master挂载到 Master 的存储服务器可集中接收多台源 Agent 的备份;如果该路径本就属于各 Agent请关闭此选项。升级前已有目标保持原来的 Agent 本机落盘行为,只有显式开启后才会切换。
## Rclone 后端

View File

@@ -55,10 +55,11 @@ sudo ./install.sh # 创建系统用户、安装到 /opt/backupx、配置
安装脚本会自动:
1. 创建 `backupx` 系统用户
2. 安装二进制到 `/opt/backupx/backupx`
3. 生成 `/opt/backupx/config.yaml`(含安全默认值)
2. 安装二进制到 `/opt/backupx/bin/backupx`,并把 Web 控制台安装到 `/opt/backupx/web`
3. 生成 `/etc/backupx/config.yaml`(含安全默认值)
4. 注册并启用 `backupx.service` systemd 单元
5. (可选)配置 Nginx 反向代理
6. 等待 `/api/auth/setup/status` 就绪;启动失败时输出 systemd 诊断并返回非零状态
## 从源码构建
@@ -67,16 +68,17 @@ sudo ./install.sh # 创建系统用户、安装到 /opt/backupx、配置
```bash
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
make build
# 或使用国内镜像加速构建 Docker
make docker-cn
sudo ./deploy/install.sh
```
`make build` 完成后,二进制位于 `server/bin/backupx`,构建好的 Web UI 位于 `web/dist/`。
安装脚本会直接使用这两个路径,不需要 Docker 运行时。如果已有配置修改了默认端口,可覆盖就绪检查地址,例如:`sudo HEALTH_URL=http://127.0.0.1:9000/api/auth/setup/status ./deploy/install.sh`。
## 验证安装
```bash
backupx --version # 输出如 v1.6.0
/opt/backupx/bin/backupx --version
curl -fsS http://127.0.0.1:8340/api/auth/setup/status
```
打开浏览器访问 `http://your-server:8340`会进入初始化管理员账户页面
打开浏览器访问 `http://your-server:8340`可在右上角选择 **中文****English**。全新数据库会显示“系统初始化 / System setup”在这里创建首个管理员用户名和密码。如果没有出现初始化表单请先重试上面的状态接口不要直接尝试登录

View File

@@ -42,6 +42,8 @@ description: 部署 BackupX、添加存储目标、创建第一个备份任务
2. **源配置** — 文件备份选择源路径(支持多个),数据库备份填写连接信息
3. **存储与策略** — 选择存储目标(支持多个)、压缩策略、保留天数、是否加密
对于路由到 Agent 的任务,加密必须关闭,因为 Agent 不会拿到 Master 的加密密钥。BackupX 会在创建/更新阶段拒绝开启加密的远程节点或节点池任务。
保存后可点击 **立即执行** 测试,**备份记录** 页面实时查看执行日志。
:::note

View File

@@ -2,6 +2,8 @@
"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": "安装"},
@@ -11,5 +13,11 @@
"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.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

@@ -7,6 +7,14 @@
"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"

View File

@@ -1,12 +1,12 @@
{
"name": "docs-site-tmp",
"version": "0.0.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "docs-site-tmp",
"version": "0.0.0",
"version": "1.0.0",
"dependencies": {
"@docusaurus/core": "3.10.0",
"@docusaurus/faster": "3.10.0",
@@ -164,7 +164,6 @@
"resolved": "https://registry.npmmirror.com/@algolia/client-search/-/client-search-5.50.2.tgz",
"integrity": "sha512-ypSboUJ3XJoQz5DeDo82hCnrRuwq3q9ZdFhVKAik9TnZh1DvLqoQsrbBjXg7C7zQOtV/Qbge/HmyoV6V5L7MhQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "5.50.2",
"@algolia/requester-browser-xhr": "5.50.2",
@@ -263,12 +262,12 @@
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -290,7 +289,6 @@
"resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -326,13 +324,13 @@
}
},
"node_modules/@babel/generator": {
"version": "7.29.1",
"resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -451,9 +449,9 @@
}
},
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -473,27 +471,27 @@
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.28.6",
"resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.28.6",
"resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.28.6",
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/traverse": "^7.28.6"
"@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -515,9 +513,9 @@
}
},
"node_modules/@babel/helper-plugin-utils": {
"version": "7.28.6",
"resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
"integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
"integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -571,18 +569,18 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -625,12 +623,12 @@
}
},
"node_modules/@babel/parser": {
"version": "7.29.2",
"resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -1215,15 +1213,15 @@
}
},
"node_modules/@babel/plugin-transform-modules-systemjs": {
"version": "7.29.0",
"resolved": "https://registry.npmmirror.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz",
"integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz",
"integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==",
"license": "MIT",
"dependencies": {
"@babel/helper-module-transforms": "^7.28.6",
"@babel/helper-plugin-utils": "^7.28.6",
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/traverse": "^7.29.0"
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helper-plugin-utils": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -1920,31 +1918,31 @@
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.29.0",
"resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7",
"debug": "^4.3.1"
},
"engines": {
@@ -1952,13 +1950,13 @@
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -2081,7 +2079,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -2104,7 +2101,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2214,7 +2210,6 @@
"resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -2636,7 +2631,6 @@
"resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -3549,7 +3543,6 @@
"resolved": "https://registry.npmmirror.com/@docusaurus/faster/-/faster-3.10.0.tgz",
"integrity": "sha512-GNPtVH14ISjHfSwnHu3KiFGf86ICmJSQDeSv/QaanpBgiZGOtgZaslnC5q8WiguxM1EVkwcGxPuD8BXF4eggKw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@docusaurus/types": "3.10.0",
"@rspack/core": "^1.7.10",
@@ -3680,7 +3673,6 @@
"resolved": "https://registry.npmmirror.com/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.10.0.tgz",
"integrity": "sha512-9BjHhf15ct8Z7TThTC0xRndKDVvMKmVsAGAN7W9FpNRzfMdScOGcXtLmcCWtJGvAezjOJIm6CxOYCy3Io5+RnQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@docusaurus/core": "3.10.0",
"@docusaurus/logger": "3.10.0",
@@ -4712,7 +4704,6 @@
"resolved": "https://registry.npmmirror.com/@mdx-js/react/-/react-3.1.1.tgz",
"integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/mdx": "^2.0.0"
},
@@ -5417,7 +5408,6 @@
"resolved": "https://registry.npmmirror.com/@svgr/core/-/core-8.1.0.tgz",
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
@@ -5522,7 +5512,6 @@
"integrity": "sha512-tglZGyx8N5PC+x1Nd/JrZxqpqlcZoSuG9gTDKO6AuFToFiVB3uS8HvbKFuO7g3lJzvFf9riAb94xs9HU2UhAHQ==",
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.26"
@@ -6247,7 +6236,6 @@
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -6589,7 +6577,6 @@
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6657,7 +6644,6 @@
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -6703,7 +6689,6 @@
"resolved": "https://registry.npmmirror.com/algoliasearch/-/algoliasearch-5.50.2.tgz",
"integrity": "sha512-Tfp26yoNWurUjfgK4GOrVJQhSNXu9tJtHfFFNosgT2YClG+vPyUjX/gbC8rG39qLncnZg8Fj34iarQWpMkqefw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/abtesting": "1.16.2",
"@algolia/client-abtesting": "5.50.2",
@@ -7057,9 +7042,9 @@
}
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
@@ -7070,7 +7055,7 @@
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
@@ -7082,7 +7067,7 @@
},
"node_modules/body-parser/node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
@@ -7091,7 +7076,7 @@
},
"node_modules/body-parser/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
@@ -7100,7 +7085,7 @@
},
"node_modules/body-parser/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
@@ -7183,7 +7168,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -7842,7 +7826,7 @@
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
@@ -8150,7 +8134,6 @@
"resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -9280,14 +9263,14 @@
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmmirror.com/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
@@ -9306,7 +9289,7 @@
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
@@ -9414,9 +9397,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"funding": [
{
"type": "github",
@@ -9524,7 +9507,6 @@
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -10571,7 +10553,7 @@
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
@@ -12069,7 +12051,7 @@
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
@@ -14204,7 +14186,6 @@
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -14260,7 +14241,7 @@
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
@@ -14719,9 +14700,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"funding": [
{
"type": "opencollective",
@@ -14737,7 +14718,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -15641,7 +15621,6 @@
"resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -16335,9 +16314,9 @@
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -16401,7 +16380,7 @@
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.3.tgz",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
@@ -16416,7 +16395,7 @@
},
"node_modules/raw-body/node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
@@ -16458,7 +16437,6 @@
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -16468,7 +16446,6 @@
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.5.tgz",
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -16524,7 +16501,6 @@
"resolved": "https://registry.npmmirror.com/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz",
"integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/react": "*"
},
@@ -16553,7 +16529,6 @@
"resolved": "https://registry.npmmirror.com/react-router/-/react-router-5.3.4.tgz",
"integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
@@ -17229,7 +17204,7 @@
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
@@ -17600,7 +17575,7 @@
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
@@ -17619,7 +17594,7 @@
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
@@ -17635,7 +17610,7 @@
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
@@ -17653,7 +17628,7 @@
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
@@ -18328,8 +18303,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/tsyringe": {
"version": "4.10.0",
@@ -18363,7 +18337,7 @@
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
@@ -18376,7 +18350,7 @@
},
"node_modules/type-is/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
@@ -18385,7 +18359,7 @@
},
"node_modules/type-is/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
@@ -18410,7 +18384,6 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -18752,7 +18725,6 @@
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -18951,7 +18923,6 @@
"resolved": "https://registry.npmmirror.com/webpack/-/webpack-5.106.2.tgz",
"integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@@ -19093,9 +19064,9 @@
}
},
"node_modules/webpack-dev-server": {
"version": "5.2.3",
"resolved": "https://registry.npmmirror.com/webpack-dev-server/-/webpack-dev-server-5.2.3.tgz",
"integrity": "sha512-9Gyu2F7+bg4Vv+pjbovuYDhHX+mqdqITykfzdM9UyKqKHlsE5aAjRhR+oOEfXW5vBeu8tarzlJFIZva4ZjAdrQ==",
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.4.tgz",
"integrity": "sha512-GqDPGZN9bRqKBTkp4aWkobDDHMsrXKoGSdOH56smIri8qR0JG8gfL8/v/f/OZR3/OKXjG8uwJbFVhKm/FNU/UA==",
"license": "MIT",
"dependencies": {
"@types/bonjour": "^3.5.13",

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

@@ -129,7 +129,7 @@ function Feature({title, description, icon, link}: FeatureItem) {
{link && (
<span className={styles.featureLink}>
<Translate id="feat.learnMore">Learn more</Translate>
<span className={styles.featureArrow} aria-hidden="true"></span>
<span className={styles.featureArrow} aria-hidden="true">-&gt;</span>
</span>
)}
</>

View File

@@ -1,5 +1,6 @@
.section {
padding: 6rem 0 4rem;
padding: 5.5rem 0 4.25rem;
background: var(--ifm-background-color);
}
.sectionHead {
@@ -9,14 +10,17 @@
}
.sectionTag {
display: inline-block;
display: inline-flex;
align-items: center;
min-height: 28px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.15em;
font-weight: 750;
letter-spacing: 0;
color: var(--ifm-color-primary);
padding: 4px 12px;
background: rgba(22, 93, 255, 0.08);
border-radius: 4px;
border: 1px solid rgba(22, 93, 255, 0.16);
border-radius: 8px;
margin-bottom: 1rem;
}
@@ -26,10 +30,10 @@
}
.sectionTitle {
font-size: clamp(1.8rem, 3vw, 2.5rem);
font-size: 2.35rem;
line-height: 1.2;
letter-spacing: -0.02em;
font-weight: 700;
letter-spacing: 0;
font-weight: 750;
margin: 0 0 1rem;
color: var(--ifm-heading-color);
}
@@ -51,6 +55,9 @@
.section {
padding: 3.5rem 0 2rem;
}
.sectionTitle {
font-size: 2rem;
}
.grid {
grid-template-columns: 1fr;
}
@@ -70,7 +77,7 @@
padding: 1.75rem;
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 12px;
border-radius: 8px;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
text-decoration: none !important;
color: inherit;
@@ -78,7 +85,7 @@
}
.featureCardLink:hover {
transform: translateY(-3px);
transform: translateY(-2px);
border-color: var(--ifm-color-primary);
box-shadow: 0 12px 30px -8px rgba(22, 93, 255, 0.18);
color: inherit;
@@ -99,26 +106,26 @@
.iconWrap {
width: 48px;
height: 48px;
border-radius: 10px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(22, 93, 255, 0.1) 0%, rgba(143, 75, 255, 0.08) 100%);
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(143, 75, 255, 0.12) 100%);
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: 600;
font-weight: 700;
margin: 0 0 0.6rem;
color: var(--ifm-heading-color);
letter-spacing: -0.01em;
letter-spacing: 0;
}
.featureDesc {
@@ -146,3 +153,17 @@
.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

@@ -110,7 +110,7 @@ export default function HomepageShowcase(): ReactNode {
<p className={styles.captionDesc}>{current.description}</p>
<Link to="/docs/getting-started/quick-start" className={styles.captionLink}>
<Translate id="showcase.cta">Explore the docs</Translate>
<span aria-hidden="true"> </span>
<span aria-hidden="true"> -&gt;</span>
</Link>
</div>
</div>

View File

@@ -1,10 +1,14 @@
.section {
padding: 4rem 0 6rem;
background: linear-gradient(180deg, transparent 0%, rgba(22, 93, 255, 0.03) 100%);
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, transparent 0%, rgba(64, 128, 255, 0.04) 100%);
background:
linear-gradient(180deg, rgba(15, 17, 21, 0) 0%, rgba(255, 255, 255, 0.03) 100%),
var(--ifm-background-color);
}
.sectionHead {
@@ -14,26 +18,30 @@
}
.sectionTag {
display: inline-block;
display: inline-flex;
align-items: center;
min-height: 28px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.15em;
color: #8f4bff;
font-weight: 750;
letter-spacing: 0;
color: #0e7490;
padding: 4px 12px;
background: rgba(143, 75, 255, 0.08);
border-radius: 4px;
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(143, 75, 255, 0.18);
background: rgba(20, 201, 201, 0.16);
color: #67e8f9;
}
.sectionTitle {
font-size: clamp(1.8rem, 3vw, 2.5rem);
font-size: 2.35rem;
line-height: 1.2;
letter-spacing: -0.02em;
font-weight: 700;
letter-spacing: 0;
font-weight: 750;
margin: 0 0 1rem;
color: var(--ifm-heading-color);
}
@@ -49,34 +57,39 @@
.tabs {
display: flex;
justify-content: center;
gap: 8px;
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 var(--ifm-color-emphasis-300);
border-radius: 999px;
border: 1px solid transparent;
border-radius: 8px;
color: var(--ifm-color-content-secondary);
font-size: 14px;
font-weight: 500;
font-weight: 650;
cursor: pointer;
transition: all 0.2s ease;
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);
border-color: var(--ifm-color-primary);
background: var(--ifm-background-color);
}
.tabBtnActive,
.tabBtnActive:hover {
background: linear-gradient(90deg, #165dff 0%, #4080ff 100%);
color: #fff !important;
border-color: transparent;
box-shadow: 0 4px 14px rgba(22, 93, 255, 0.3);
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 */
@@ -96,10 +109,10 @@
.browser {
background: var(--ifm-background-color);
border-radius: 12px;
border-radius: 8px;
overflow: hidden;
box-shadow:
0 30px 60px -20px rgba(22, 93, 255, 0.25),
0 24px 58px -22px rgba(22, 93, 255, 0.28),
0 0 0 1px var(--ifm-color-emphasis-200);
}
@@ -137,7 +150,7 @@
margin: 0 auto;
padding: 3px 14px;
background: var(--ifm-background-color);
border-radius: 999px;
border-radius: 8px;
font-size: 12px;
color: var(--ifm-color-content-secondary);
font-family: 'SFMono-Regular', Menlo, monospace;
@@ -169,8 +182,8 @@
.captionTitle {
font-size: 1.7rem;
line-height: 1.2;
letter-spacing: -0.02em;
font-weight: 700;
letter-spacing: 0;
font-weight: 750;
margin: 0 0 1rem;
color: var(--ifm-heading-color);
}
@@ -186,11 +199,49 @@
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 500;
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

@@ -16,14 +16,15 @@
/* Surfaces */
--ifm-background-color: #ffffff;
--ifm-background-surface-color: #ffffff;
--ifm-color-emphasis-100: #f7f9fc;
--ifm-color-emphasis-200: #eef1f6;
--ifm-color-emphasis-300: #dde3ec;
--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: 600;
--ifm-heading-font-weight: 700;
--ifm-code-font-size: 92%;
--ifm-h1-font-size: 2.25rem;
--ifm-h2-font-size: 1.75rem;
@@ -33,10 +34,11 @@
--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.82);
--ifm-navbar-background-color: rgba(255, 255, 255, 0.9);
--ifm-navbar-link-color: #4e5969;
--ifm-navbar-link-hover-color: var(--ifm-color-primary);
@@ -64,15 +66,16 @@
--ifm-background-color: #0f1115;
--ifm-background-surface-color: #16181d;
--ifm-color-emphasis-100: #1a1d23;
--ifm-color-emphasis-200: #23272f;
--ifm-color-emphasis-300: #2e343d;
--ifm-color-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.82);
--ifm-navbar-background-color: rgba(15, 17, 21, 0.9);
--ifm-navbar-link-color: #c9d1db;
--ifm-menu-color: #c9d1db;
@@ -97,7 +100,7 @@
.navbar__title {
font-weight: 700;
letter-spacing: -0.01em;
letter-spacing: 0;
}
.navbar__link {
@@ -105,10 +108,26 @@
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: 6px;
border-radius: 8px;
padding: 6px 10px;
line-height: 1.4;
}
@@ -226,9 +245,20 @@ code {
}
::-webkit-scrollbar-thumb:hover {
background: var(--ifm-color-emphasis-400, #adb5bd);
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

@@ -1,48 +1,42 @@
/* ── Hero ───────────────────────────────────────────── */
/* Hero */
.hero {
position: relative;
padding: 7rem 0 6rem;
overflow: hidden;
background: var(--bx-hero-bg);
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);
}
.heroBg {
.hero::before {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 15% 20%, rgba(104, 127, 255, 0.18) 0%, transparent 45%),
radial-gradient(circle at 85% 70%, rgba(22, 93, 255, 0.15) 0%, transparent 50%),
linear-gradient(180deg, #f7f9ff 0%, #ffffff 100%);
z-index: 0;
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'] .heroBg {
[data-theme='dark'] .hero {
background:
radial-gradient(circle at 15% 20%, rgba(96, 126, 255, 0.22) 0%, transparent 45%),
radial-gradient(circle at 85% 70%, rgba(118, 70, 255, 0.18) 0%, transparent 50%),
linear-gradient(180deg, #0f1115 0%, #0b0d10 100%);
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: 1.1fr 1fr;
grid-template-columns: minmax(0, 1fr) minmax(420px, 0.9fr);
gap: 4rem;
align-items: center;
}
@media (max-width: 996px) {
.hero {
padding: 4rem 0 3rem;
}
.heroInner {
grid-template-columns: 1fr;
gap: 2.5rem;
text-align: left;
}
}
.heroContent {
display: flex;
flex-direction: column;
@@ -54,137 +48,144 @@
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 14px;
background: rgba(22, 93, 255, 0.08);
border: 1px solid rgba(22, 93, 255, 0.15);
border-radius: 999px;
font-size: 13px;
min-height: 32px;
padding: 5px 12px;
color: var(--ifm-color-primary);
font-weight: 500;
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(96, 126, 255, 0.15);
border-color: rgba(96, 126, 255, 0.3);
background: rgba(64, 128, 255, 0.16);
border-color: rgba(64, 128, 255, 0.3);
color: var(--ifm-color-primary-lighter);
}
.badgeDot {
width: 6px;
height: 6px;
background: var(--ifm-color-primary);
width: 7px;
height: 7px;
background: #00b42a;
border-radius: 50%;
box-shadow: 0 0 0 4px rgba(22, 93, 255, 0.18);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
box-shadow: 0 0 0 4px rgba(0, 180, 42, 0.12);
}
.heroTitle {
font-size: clamp(2.25rem, 4vw, 3.4rem);
line-height: 1.15;
letter-spacing: -0.025em;
font-weight: 700;
margin: 0;
color: var(--ifm-heading-color);
font-size: 3.45rem;
font-weight: 750;
letter-spacing: 0;
line-height: 1.08;
}
.heroTitleAccent {
display: block;
background: linear-gradient(90deg, #4080ff 0%, #8f4bff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-top: 6px;
margin-top: 8px;
color: var(--ifm-color-primary);
}
.heroSubtitle {
font-size: 1.15rem;
line-height: 1.65;
color: var(--ifm-color-content-secondary);
max-width: 540px;
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;
flex-wrap: wrap;
}
.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 {
background: linear-gradient(90deg, #165dff 0%, #4080ff 100%);
border: none;
color: #fff;
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
box-shadow: 0 6px 20px rgba(22, 93, 255, 0.3);
transition: transform 0.2s ease, box-shadow 0.2s ease;
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 {
transform: translateY(-1px);
box-shadow: 0 10px 25px rgba(22, 93, 255, 0.4);
.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 {
transform: translateX(4px);
.primaryBtn:hover .btnArrow,
.primaryBtn:focus-visible .btnArrow {
transform: translateX(3px);
}
.secondaryBtn {
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-300);
color: var(--ifm-font-color-base);
display: inline-flex;
align-items: center;
font-weight: 500;
transition: all 0.2s ease;
color: var(--ifm-font-color-base);
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-300);
font-weight: 600;
}
.secondaryBtn:hover {
border-color: var(--ifm-color-primary);
.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.75rem;
padding-top: 1.5rem;
gap: 1.5rem;
margin-top: 0.5rem;
padding-top: 1.25rem;
}
.metric {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
gap: 4px;
}
.metricValue {
font-size: 1.6rem;
font-weight: 700;
color: var(--ifm-heading-color);
font-size: 1.35rem;
font-weight: 750;
letter-spacing: 0;
line-height: 1.1;
letter-spacing: -0.02em;
white-space: nowrap;
}
.metricLabel {
font-size: 12px;
color: var(--ifm-color-content-secondary);
font-size: 12px;
font-weight: 600;
letter-spacing: 0;
line-height: 1.35;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.metricDivider {
@@ -193,81 +194,277 @@
background: var(--ifm-color-emphasis-300);
}
/* ── Code window (macOS-style) ─────────────────────── */
.heroCode {
position: relative;
/* Product visual */
.heroVisual {
display: grid;
gap: 1rem;
}
.codeWindow {
background: #0f1622;
border-radius: 12px;
box-shadow:
0 20px 50px -10px rgba(15, 22, 34, 0.35),
0 0 0 1px rgba(255, 255, 255, 0.05);
.consolePanel {
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.06);
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='light'] .codeWindow {
box-shadow: 0 20px 50px -10px rgba(22, 93, 255, 0.2), 0 0 0 1px rgba(22, 93, 255, 0.06);
[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);
}
.codeHeader {
.consoleHeader {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 14px;
background: #161f2e;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
.codeDot {
width: 11px;
height: 11px;
border-radius: 50%;
[data-theme='dark'] .consoleHeader {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.codeDotRed { background: #ff5f56; }
.codeDotYellow { background: #ffbd2e; }
.codeDotGreen { background: #27c93f; }
.consoleHeader strong {
display: block;
margin-top: 4px;
color: var(--ifm-heading-color);
font-size: 1.2rem;
}
.codeTitle {
margin-left: auto;
font-size: 11px;
color: #7b8696;
letter-spacing: 0.05em;
.consoleEyebrow {
color: var(--ifm-color-content-secondary);
font-size: 12px;
font-weight: 650;
letter-spacing: 0;
text-transform: uppercase;
}
.codeBody {
margin: 0;
padding: 18px 20px;
font-family: 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-size: 13px;
line-height: 1.65;
color: #e1e7ef;
background: transparent;
.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;
}
.codeBody code {
color: #e5e7eb;
background: transparent;
padding: 0;
border: 0;
color: inherit;
padding: 0;
font-size: 13px;
white-space: nowrap;
}
.codePrompt {
color: #4080ff;
margin-right: 6px;
user-select: none;
@media (max-width: 996px) {
.hero {
padding: 4.5rem 0 3.5rem;
}
.heroInner {
grid-template-columns: 1fr;
gap: 2.25rem;
}
.heroTitle {
font-size: 2.45rem;
}
}
.codeComment {
color: #6e7889;
font-style: italic;
@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);
}
}
.codeString {
color: #82d1ff;
@media (prefers-reduced-motion: reduce) {
.primaryBtn,
.secondaryBtn,
.btnArrow {
transition: none;
}
}

View File

@@ -7,34 +7,34 @@ 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={styles.heroBg} aria-hidden="true" />
<div className={clsx('container', styles.heroInner)}>
<div className={styles.heroContent}>
<div className={styles.badge}>
<span className={styles.badgeDot} />
<Translate id="home.badge">Open-source · v1.6.0</Translate>
<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">Self-hosted backup management</Translate>
<Translate id="home.title.part1">Backup orchestration</Translate>
<span className={styles.heroTitleAccent}>
<Translate id="home.title.part2">for every server.</Translate>
<Translate id="home.title.part2">for self-hosted servers.</Translate>
</span>
</Heading>
<p className={styles.heroSubtitle}>
<Translate id="home.tagline">
One binary, one command. File / database / SAP HANA backups routed to 70+ storage backends.
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"></span>
<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}}>
@@ -52,9 +52,9 @@ function HomepageHeader() {
</div>
<div className={styles.metricDivider} />
<div className={styles.metric}>
<div className={styles.metricValue}>5</div>
<div className={styles.metricValue}>Agent</div>
<div className={styles.metricLabel}>
<Translate id="home.metric.backupTypes">Backup types</Translate>
<Translate id="home.metric.backupTypes">Remote execution</Translate>
</div>
</div>
<div className={styles.metricDivider} />
@@ -66,29 +66,85 @@ function HomepageHeader() {
</div>
</div>
</div>
<div className={styles.heroCode}>
<div className={styles.codeWindow}>
<div className={styles.codeHeader}>
<span className={clsx(styles.codeDot, styles.codeDotRed)} />
<span className={clsx(styles.codeDot, styles.codeDotYellow)} />
<span className={clsx(styles.codeDot, styles.codeDotGreen)} />
<span className={styles.codeTitle}>bash</span>
<div 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>
<pre className={styles.codeBody}>
<code>
<span className={styles.codeComment}># Docker one-liner</span>{'\n'}
<span className={styles.codePrompt}>$</span> docker run -d --name backupx \{'\n'}
{' '}-p 8340:8340 \{'\n'}
{' '}-v backupx-data:/app/data \{'\n'}
{' '}awuqing/backupx:latest{'\n'}
{'\n'}
<span className={styles.codeComment}># Open http://localhost:8340</span>{'\n'}
<span className={styles.codeComment}># Deploy an Agent on a remote host</span>{'\n'}
<span className={styles.codePrompt}>$</span> backupx agent \{'\n'}
{' '}--master <span className={styles.codeString}>http://master:8340</span> \{'\n'}
{' '}--token <span className={styles.codeString}>&lt;token&gt;</span>
</code>
</pre>
<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>
@@ -100,12 +156,13 @@ export default function Home(): ReactNode {
const {siteConfig} = useDocusaurusContext();
return (
<Layout
title={translate({id: 'home.pageTitle', message: 'Self-hosted backup management'})}
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

@@ -3,6 +3,9 @@ server:
host: "0.0.0.0"
port: 8340
mode: "release" # debug | release
external_url: "" # 可选Master 对 Agent 可达的 URL例如 https://backup.example.com
web_root: "" # 前端静态目录;留空自动探测(./web、/opt/backupx/web 等)。
# 命中后后端直接托管 Web 控制台,无需额外 nginx 反向代理。
database:
path: "./data/backupx.db" # SQLite 数据库路径

View File

@@ -6,12 +6,15 @@ require (
github.com/gin-gonic/gin v1.10.1
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/klauspost/compress v1.18.1
github.com/natefinch/lumberjack v2.0.0+incompatible
github.com/rclone/rclone v1.73.3
github.com/pquerna/otp v1.5.0
github.com/prometheus/client_golang v1.23.2
github.com/rclone/rclone v1.73.5
github.com/robfig/cron/v3 v3.0.1
github.com/spf13/viper v1.20.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.48.0
golang.org/x/crypto v0.50.0
golang.org/x/oauth2 v0.34.0
google.golang.org/api v0.255.0
gopkg.in/yaml.v3 v3.0.1
@@ -27,10 +30,10 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3 // indirect
github.com/Azure/go-ntlmssp v0.0.2-0.20251110135918-10b7b7e7cd26 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/FilenCloudDienste/filen-sdk-go v0.0.37 // indirect
github.com/FilenCloudDienste/filen-sdk-go v0.0.38 // indirect
github.com/Files-com/files-sdk-go/v3 v3.2.264 // indirect
github.com/IBM/go-sdk-core/v5 v5.18.5 // indirect
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd // indirect
@@ -47,25 +50,26 @@ require (
github.com/anchore/go-lzo v0.1.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc // indirect
github.com/aws/aws-sdk-go-v2 v1.41.3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 // indirect
github.com/aws/aws-sdk-go-v2/config v1.31.17 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.8 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/aws/smithy-go v1.25.1 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
@@ -107,7 +111,7 @@ require (
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-git/go-billy/v5 v5.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
@@ -146,7 +150,6 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/jtolio/noiseconn v0.0.0-20231127013910-f6d9ecbf1de7 // indirect
github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988 // indirect
github.com/koofr/go-koofrclient v0.0.0-20221207135200-cbd7fc9ad6a6 // indirect
@@ -180,8 +183,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
@@ -224,22 +225,22 @@ require (
go.mongodb.org/mongo-driver v1.17.6 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/arch v0.14.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/image v0.32.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/image v0.41.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.41.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.10 // indirect

View File

@@ -51,8 +51,8 @@ github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 h1:ZJJNFaQ86GVKQ9ehw
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3/go.mod h1:URuDvhmATVKqHBH9/0nOiNKk0+YcwfQ3WkK5PqHKxc8=
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3 h1:sxgSqOB9CDToiaVFpxuvb5wGgGqWa3lCShcm5o0n3bE=
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3/go.mod h1:XdED8i399lEVblYHTZM8eXaP07gv4Z58IL6ueMlVlrg=
github.com/Azure/go-ntlmssp v0.0.2-0.20251110135918-10b7b7e7cd26 h1:gy/jrlpp8EfSyA73a51fofoSfhp5rPNQAUvDr4Dm91c=
github.com/Azure/go-ntlmssp v0.0.2-0.20251110135918-10b7b7e7cd26/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
@@ -61,8 +61,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/FilenCloudDienste/filen-sdk-go v0.0.37 h1:W8S9TrAyZ4//3PXsU6+Bi+fe/6uIL986GyS7PVzIDL4=
github.com/FilenCloudDienste/filen-sdk-go v0.0.37/go.mod h1:0cBhKXQg49XbKZZfk5TCDa3sVLP+xMxZTWL+7KY0XR0=
github.com/FilenCloudDienste/filen-sdk-go v0.0.38 h1:YCyHs3wUXEe2BEWn40vcoyaQ2ruHNmNwkasfo3Th16A=
github.com/FilenCloudDienste/filen-sdk-go v0.0.38/go.mod h1:0cBhKXQg49XbKZZfk5TCDa3sVLP+xMxZTWL+7KY0XR0=
github.com/Files-com/files-sdk-go/v3 v3.2.264 h1:lMHTplAYI9FtmCo/QOcpRxmPA5REVAct1r2riQmDQKw=
github.com/Files-com/files-sdk-go/v3 v3.2.264/go.mod h1:wGqkOzRu/ClJibvDgcfuJNAqI2nLhe8g91tPlDKRCdE=
github.com/IBM/go-sdk-core/v5 v5.18.5 h1:g0JRl3sYXJczB/yuDlrN6x22LJ6jIxhp0Sa4ARNW60c=
@@ -102,44 +102,46 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc h1:LoL75er+LKDHDUfU5tRvFwxH0LjPpZN8OoG8Ll+liGU=
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc/go.mod h1:w648aMHEgFYS6xb0KVMMtZ2uMeemhiKCuD2vj6gY52A=
github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 h1:N4lRUXZpZ1KVEUn6hxtco/1d2lgYhNn1fHkkl8WhlyQ=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/config v1.31.17 h1:QFl8lL6RgakNK86vusim14P2k8BFSxjvUkcWLDjgz9Y=
github.com/aws/aws-sdk-go-v2/config v1.31.17/go.mod h1:V8P7ILjp/Uef/aX8TjGk6OHZN6IKPM5YW6S78QnRD5c=
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
github.com/aws/aws-sdk-go-v2/config v1.32.8 h1:iu+64gwDKEoKnyTQskSku72dAwggKI5sV6rNvgSMpMs=
github.com/aws/aws-sdk-go-v2/config v1.32.8/go.mod h1:MI2XvA+qDi3i9AJxX1E2fu730syEBzp/jnXrjxuHwgI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.4 h1:2fjfz3/G9BRvIKuNZ655GwzpklC2kEH0cowZQGO7uBg=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.4/go.mod h1:Ymws824lvMypLFPwyyUXM52SXuGgxpu0+DISLfKvB+c=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.1 h1:IbWiN670htmBioc+Zj32vSpJgQ2+OYSlvTvfQ1nCORQ=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.1/go.mod h1:tw/B596EUhBWDFGdDGuLC21fVU4A3s4/5Efy8S39W18=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20 h1:qi3e/dmpdONhj1RyIZdi6DKKpDXS5Lb8ftr3p7cyHJc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20/go.mod h1:V1K+TeJVD5JOk3D9e5tsX2KUdL7BlB+FV6cBhdobN8c=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 h1:BYf7XNsJMzl4mObARUBUib+j2tf0U//JAAtTnYqvqCw=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11/go.mod h1:aEUS4WrNk/+FxkBZZa7tVgp4pGH+kFGW40Y8rCPqt5g=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 h1:JnQeStZvPHFHeyky/7LbMlyQjUa+jIBj36OlWm0pzIk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19/go.mod h1:HGyasyHvYdFQeJhvDHfH7HXkHh57htcJGKDZ+7z+I24=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4 h1:4ExZyubQ6LQQVuF2Qp9OsfEvsTdAWh5Gfwf6PgIdLdk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4/go.mod h1:NF3JcMGOiARAss1ld3WGORCw71+4ExDD2cbbdKS5PpA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -256,8 +258,8 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 h1:JnrjqG5iR07/8k7NqrLNilRsl3s1EPRQEGvbPyOce68=
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348/go.mod h1:Czxo/d1g948LtrALAZdL04TL/HnkopquAjxYUuI02bo=
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@@ -530,8 +532,8 @@ github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11 h1:4MI2
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11/go.mod h1:3HLX7dwZgvB7nt+Yl/xdzVPcargQ1yBmJEUg3n+jMKM=
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 h1:Lc+d3ISfQaMJKWZOE7z4ZSY4RVmdzbn1B0IM8xN18qM=
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18/go.mod h1:LB2kCEaZMzNn3ocdz+qYfxXmuLxxN0ka62KJd2x53Bc=
github.com/rclone/rclone v1.73.3 h1:XKlobcnXxxzxnB6UBSVtRB+UeZmYDV9B4QExVSSGoAY=
github.com/rclone/rclone v1.73.3/go.mod h1:QJDWatpAY9sKGXfpKZUXbThvtHoeo78DcFP2+/cbkvc=
github.com/rclone/rclone v1.73.5 h1:r8a9JHYIWUqk7hNRJuMJ3cROkKfB2zmfkADg8ZLYh6I=
github.com/rclone/rclone v1.73.5/go.mod h1:WVv8gvA/lEl/Y37e8I8yosm7ZY+Szq7ujXbJS8Ol63o=
github.com/relvacode/iso8601 v1.7.0 h1:BXy+V60stMP6cpswc+a93Mq3e65PfXCgDFfhvNNGrdo=
github.com/relvacode/iso8601 v1.7.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
@@ -651,16 +653,16 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
@@ -688,8 +690,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -700,12 +702,12 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -730,8 +732,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -771,8 +773,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -796,8 +798,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -847,8 +849,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -860,8 +862,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -877,8 +879,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -931,8 +933,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -5,6 +5,7 @@ import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -125,10 +126,11 @@ type TaskSpec struct {
// StorageTargetConfig 与 service.AgentStorageTargetConfig 对齐
type StorageTargetConfig struct {
ID uint `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Config json.RawMessage `json:"config"`
ID uint `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Config json.RawMessage `json:"config"`
TransferMode string `json:"transferMode"`
}
// GetTaskSpec 拉取任务规格
@@ -143,13 +145,26 @@ func (c *MasterClient) GetTaskSpec(ctx context.Context, taskID uint) (*TaskSpec,
// 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"`
ErrorMessage string `json:"errorMessage,omitempty"`
LogAppend string `json:"logAppend,omitempty"`
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"`
StorageTransferMode string `json:"storageTransferMode,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"`
TransferMode string `json:"transferMode,omitempty"`
Error string `json:"error,omitempty"`
}
// UpdateRecord 上报备份记录的状态/日志
@@ -158,6 +173,39 @@ func (c *MasterClient) UpdateRecord(ctx context.Context, recordID uint, update R
return c.do(ctx, http.MethodPost, path, update, nil)
}
// UploadArtifact streams an artifact through the Master for storage targets
// that are not directly reachable from the Agent.
func (c *MasterClient) UploadArtifact(ctx context.Context, recordID, targetID uint, objectKey string, size int64, checksum string, reader io.Reader) error {
path := fmt.Sprintf("/api/agent/records/%d/artifacts/%d", recordID, targetID)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.baseURL+path, reader)
if err != nil {
return err
}
// The executor owns and closes the artifact file. Prevent net/http from
// closing that underlying reader when it finishes the request body.
req.Body = io.NopCloser(reader)
req.ContentLength = size
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("X-Agent-Token", c.token)
req.Header.Set("X-BackupX-Object-Key", objectKey)
req.Header.Set("X-BackupX-SHA256", checksum)
client := *c.httpClient
client.Timeout = 0
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("relay artifact to Master: %w", err)
}
data, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
closeErr := resp.Body.Close()
if readErr != nil || closeErr != nil {
return errors.Join(readErr, closeErr)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("relay artifact to Master: http %d: %s", resp.StatusCode, string(data))
}
return nil
}
// RestoreSpec 与 service.AgentRestoreSpec 对齐
type RestoreSpec struct {
RestoreRecordID uint `json:"restoreRecordId"`
@@ -179,6 +227,7 @@ type RestoreSpec struct {
Storage StorageTargetConfig `json:"storage"`
StoragePath string `json:"storagePath"`
FileName string `json:"fileName"`
Checksum string `json:"checksum,omitempty"`
}
// RestoreUpdate 与 service.AgentRestoreUpdate 对齐
@@ -198,6 +247,27 @@ func (c *MasterClient) GetRestoreSpec(ctx context.Context, restoreRecordID uint)
return &spec, nil
}
func (c *MasterClient) DownloadRestoreArtifact(ctx context.Context, restoreRecordID uint) (io.ReadCloser, error) {
path := fmt.Sprintf("/api/agent/restores/%d/artifact", restoreRecordID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Agent-Token", c.token)
client := *c.httpClient
client.Timeout = 0
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("download relayed artifact from Master: %w", err)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return resp.Body, nil
}
data, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
closeErr := resp.Body.Close()
return nil, errors.Join(fmt.Errorf("download relayed artifact from Master: http %d: %s", resp.StatusCode, string(data)), readErr, closeErr)
}
// UpdateRestore 上报恢复记录的状态/日志
func (c *MasterClient) UpdateRestore(ctx context.Context, restoreRecordID uint, update RestoreUpdate) error {
path := fmt.Sprintf("/api/agent/restores/%d", restoreRecordID)

View File

@@ -26,7 +26,7 @@ type Config struct {
HeartbeatInterval string `yaml:"heartbeatInterval"`
// PollInterval 命令轮询间隔,默认 5s
PollInterval string `yaml:"pollInterval"`
// TempDir 备份临时目录,默认 /tmp/backupx-agent
// TempDir 备份临时目录,默认 /var/lib/backupx-agent/tmp
TempDir string `yaml:"tempDir"`
// InsecureSkipTLSVerify 测试环境允许跳过 TLS 证书校验
InsecureSkipTLSVerify bool `yaml:"insecureSkipTlsVerify"`
@@ -98,7 +98,7 @@ func applyConfigDefaults(cfg *Config) (*Config, error) {
cfg.PollInterval = "5s"
}
if cfg.TempDir == "" {
cfg.TempDir = "/tmp/backupx-agent"
cfg.TempDir = "/var/lib/backupx-agent/tmp"
}
cfg.Master = strings.TrimRight(strings.TrimSpace(cfg.Master), "/")
return cfg, nil

View File

@@ -50,7 +50,7 @@ func TestLoadConfigDefaults(t *testing.T) {
if cfg.HeartbeatInterval != "15s" || cfg.PollInterval != "5s" {
t.Errorf("default intervals not applied: %+v", cfg)
}
if cfg.TempDir != "/tmp/backupx-agent" {
if cfg.TempDir != "/var/lib/backupx-agent/tmp" {
t.Errorf("default tempdir: %q", cfg.TempDir)
}
}

View File

@@ -4,6 +4,8 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
@@ -19,10 +21,10 @@ import (
// Executor 负责在 Agent 本地执行命令。
type Executor struct {
client *MasterClient
tempDir string
backupRegistry *backup.Registry
storageRegistry *storage.Registry
client *MasterClient
tempDir string
backupRegistry *backup.Registry
storageRegistry *storage.Registry
}
// NewExecutor 构造执行器。预先初始化 backup runner 与 storage registry。
@@ -33,6 +35,7 @@ func NewExecutor(client *MasterClient, tempDir string) *Executor {
backup.NewMySQLRunner(nil),
backup.NewPostgreSQLRunner(nil),
backup.NewSAPHANARunner(nil),
backup.NewMongoDBRunner(nil),
)
storageRegistry := storage.NewRegistry(
storageRclone.NewLocalDiskFactory(),
@@ -59,6 +62,11 @@ func NewExecutor(client *MasterClient, tempDir string) *Executor {
// 注意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 {
@@ -74,10 +82,6 @@ func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) er
// 2) 构造 backup.TaskSpec 并找对应 runner
startedAt := time.Now().UTC()
if err := os.MkdirAll(e.tempDir, 0o755); err != nil {
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("创建临时目录失败: %v", err))
return err
}
backupSpec := buildBackupTaskSpec(spec, startedAt, e.tempDir)
runner, err := e.backupRegistry.Runner(backupSpec.Type)
if err != nil {
@@ -104,6 +108,14 @@ func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) er
return compressErr
}
finalPath = compressedPath
} else if strings.EqualFold(spec.Compression, "zstd") && !strings.HasSuffix(strings.ToLower(finalPath), ".zst") {
e.appendLog(ctx, recordID, "[agent] 开始压缩备份文件zstd\n")
compressedPath, compressErr := compress.ZstdFile(finalPath)
if compressErr != nil {
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("压缩失败: %v", compressErr))
return compressErr
}
finalPath = compressedPath
}
info, err := os.Stat(finalPath)
if err != nil {
@@ -112,7 +124,7 @@ func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) er
}
fileName := filepath.Base(finalPath)
fileSize := info.Size()
storagePath := backup.BuildStorageKey(spec.Type, startedAt, fileName)
storagePath := backup.BuildRecordStorageKey(spec.Type, startedAt, recordID, fileName)
// 5) 计算 checksum一次读一次并上传到所有目标
checksum, err := computeFileSHA256(finalPath)
@@ -124,48 +136,88 @@ func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) er
e.reportRecordFailure(ctx, recordID, "没有关联的存储目标")
return fmt.Errorf("no storage targets")
}
uploadResults := make([]StorageResultItem, 0, len(spec.StorageTargets))
selectedStorageTargetID := uint(0)
selectedStorageTransferMode := ""
var uploadErrors []string
for _, target := range spec.StorageTargets {
if err := e.uploadToTarget(ctx, recordID, target, finalPath, storagePath, fileSize, spec.TaskID); err != nil {
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("上传到 %s 失败: %v", target.Name, err))
return err
if err := e.uploadToTarget(ctx, recordID, target, finalPath, storagePath, fileSize, checksum, spec.TaskID); err != nil {
uploadResults = append(uploadResults, StorageResultItem{
StorageTargetID: target.ID,
StorageTargetName: target.Name,
Status: "failed",
TransferMode: target.TransferMode,
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
selectedStorageTransferMode = target.TransferMode
}
uploadResults = append(uploadResults, StorageResultItem{
StorageTargetID: target.ID,
StorageTargetName: target.Name,
Status: "success",
StoragePath: storagePath,
FileSize: fileSize,
TransferMode: target.TransferMode,
})
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,
LogAppend: fmt.Sprintf("[agent] 任务完成,总计 %d 字节\n", fileSize),
Status: "success",
FileName: fileName,
FileSize: fileSize,
Checksum: checksum,
StoragePath: storagePath,
StorageTargetID: selectedStorageTargetID,
StorageTransferMode: selectedStorageTransferMode,
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)
}
func (e *Executor) uploadToTarget(ctx context.Context, recordID uint, target StorageTargetConfig, filePath, objectKey string, fileSize int64, checksum string, taskID uint) error {
f, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("open artifact: %w", err)
}
defer f.Close()
if target.TransferMode == storage.TransferModeMasterRelay {
uploadErr := e.client.UploadArtifact(ctx, recordID, target.ID, objectKey, fileSize, checksum, f)
return errors.Join(uploadErr, f.Close())
}
var rawConfig map[string]any
if len(target.Config) > 0 {
// DecodeRawConfig 通过 json 解析
if err := jsonUnmarshalMap(target.Config, &rawConfig); err != nil {
return errors.Join(fmt.Errorf("parse storage config: %w", err), f.Close())
}
}
provider, err := e.storageRegistry.Create(ctx, target.Type, rawConfig)
if err != nil {
closeErr := f.Close()
return errors.Join(fmt.Errorf("create provider: %w", err), closeErr)
}
meta := map[string]string{
"taskId": fmt.Sprintf("%d", taskID),
"recordId": fmt.Sprintf("%d", recordID),
}
return provider.Upload(ctx, objectKey, f, fileSize, meta)
uploadErr := provider.Upload(ctx, objectKey, f, fileSize, meta)
return errors.Join(uploadErr, f.Close())
}
// appendLog 追加日志到 Master 记录(尽力而为,失败不中断主流程)
@@ -175,31 +227,22 @@ func (e *Executor) appendLog(ctx context.Context, recordID uint, line string) {
// 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,
LogAppend: fmt.Sprintf("[agent] 错误: %s\n", msg),
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 {
var sourcePaths []string
if strings.TrimSpace(spec.SourcePaths) != "" {
for _, p := range strings.Split(spec.SourcePaths, "\n") {
if p = strings.TrimSpace(p); p != "" {
sourcePaths = append(sourcePaths, p)
}
}
}
var excludes []string
if strings.TrimSpace(spec.ExcludePatterns) != "" {
for _, p := range strings.Split(spec.ExcludePatterns, "\n") {
if p = strings.TrimSpace(p); p != "" {
excludes = append(excludes, p)
}
}
}
sourcePaths := parseStringListField(spec.SourcePaths)
excludes := parseStringListField(spec.ExcludePatterns)
return backup.TaskSpec{
ID: spec.TaskID,
Name: spec.Name,
@@ -222,6 +265,37 @@ func buildBackupTaskSpec(spec *TaskSpec, startedAt time.Time, tempDir string) ba
}
}
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 {
@@ -240,8 +314,8 @@ func (l *recordLogger) WriteLine(message string) {
// restoreLogger 把 runner 日志回传到 Master 恢复记录。
type restoreLogger struct {
ctx context.Context
client *MasterClient
ctx context.Context
client *MasterClient
restoreID uint
}
@@ -265,11 +339,16 @@ func (e *Executor) DeleteStorageObject(ctx context.Context, targetType string, t
// ExecuteRestore 处理 restore_record 命令:拉规格 → 下载 → 解压 → 执行 runner.Restore → 上报结果。
//
// 与 ExecuteRunTask 对称,但方向相反:
// - 下载:通过 spec.Storage 创建 provider → Download(spec.StoragePath)
// - 下载:直连共享存储,或通过 Master 中转其本地磁盘对象
// - 解密:当前 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))
@@ -282,10 +361,6 @@ func (e *Executor) ExecuteRestore(ctx context.Context, restoreRecordID uint) err
}
e.appendRestoreLog(ctx, restoreRecordID, fmt.Sprintf("[agent] 开始恢复 %s (type=%s)\n", spec.TaskName, spec.Type))
if err := os.MkdirAll(e.tempDir, 0o755); err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("创建临时目录失败: %v", err))
return err
}
tmpDir, err := os.MkdirTemp(e.tempDir, "restore-*")
if err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("创建恢复临时目录失败: %v", err))
@@ -293,28 +368,31 @@ func (e *Executor) ExecuteRestore(ctx context.Context, restoreRecordID uint) 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) 下载
// 1) 下载
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)
var reader io.ReadCloser
if spec.Storage.TransferMode == storage.TransferModeMasterRelay {
reader, err = e.client.DownloadRestoreArtifact(ctx, restoreRecordID)
} else {
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, providerErr := e.storageRegistry.Create(ctx, spec.Storage.Type, rawConfig)
if providerErr != nil {
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("创建存储客户端失败: %v", providerErr))
return providerErr
}
reader, err = provider.Download(ctx, spec.StoragePath)
}
if err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("下载备份失败: %v", err))
return err
@@ -324,6 +402,24 @@ func (e *Executor) ExecuteRestore(ctx context.Context, restoreRecordID uint) err
return err
}
// 2.5) 完整性校验:还原前比对下载对象的 SHA-256与 Master 本地恢复路径一致)。
// 拒绝还原损坏或被篡改的备份;早期无 checksum 的备份跳过(向后兼容)。
if strings.TrimSpace(spec.Checksum) != "" {
e.appendRestoreLog(ctx, restoreRecordID, "[agent] 校验备份完整性SHA-256\n")
actual, sumErr := computeFileSHA256(artifactPath)
if sumErr != nil {
msg := fmt.Sprintf("计算校验和失败: %v", sumErr)
e.reportRestoreFailure(ctx, restoreRecordID, msg)
return fmt.Errorf("%s", msg)
}
if !strings.EqualFold(actual, spec.Checksum) {
msg := "备份文件完整性校验失败SHA-256 不匹配,文件可能已损坏或被篡改"
e.reportRestoreFailure(ctx, restoreRecordID, msg)
return fmt.Errorf("%s期望 %s实际 %s", msg, spec.Checksum, actual)
}
e.appendRestoreLog(ctx, restoreRecordID, "[agent] 完整性校验通过\n")
}
// 3) 解压Agent 不支持加密,遇到 .enc 会直接失败)
preparedPath := artifactPath
if strings.HasSuffix(strings.ToLower(preparedPath), ".enc") {
@@ -340,6 +436,15 @@ func (e *Executor) ExecuteRestore(ctx context.Context, restoreRecordID uint) err
}
preparedPath = decompressed
}
if strings.HasSuffix(strings.ToLower(preparedPath), ".zst") {
e.appendRestoreLog(ctx, restoreRecordID, "[agent] 解压 zstd 压缩\n")
decompressed, err := compress.UnzstdFile(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)
@@ -398,8 +503,10 @@ func buildRestoreBackupTaskSpec(spec *RestoreSpec, startedAt time.Time, tempDir
}
// writeReaderToLocal 把 reader 写到本地文件Agent 侧工具函数)。
func writeReaderToLocal(targetPath string, reader io.ReadCloser) error {
defer reader.Close()
func writeReaderToLocal(targetPath string, reader io.ReadCloser) (err error) {
defer func() {
err = errors.Join(err, reader.Close())
}()
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
@@ -407,9 +514,8 @@ func writeReaderToLocal(targetPath string, reader io.ReadCloser) error {
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(file, reader)
return err
_, copyErr := io.Copy(file, reader)
return errors.Join(copyErr, file.Close())
}
// 辅助函数

View File

@@ -0,0 +1,380 @@
package agent
import (
"archive/tar"
"bytes"
"context"
"crypto/sha256"
"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 TestExecuteRunTaskRelaysMasterLocalDiskTarget(t *testing.T) {
sourceDir := t.TempDir()
if err := os.WriteFile(filepath.Join(sourceDir, "index.html"), []byte("centralize me"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
var relayed []byte
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: "remote-source",
Type: "file",
SourcePath: sourceDir,
Compression: "gzip",
StorageTargets: []StorageTargetConfig{{
ID: 11, Name: "master-disk", Type: storage.TypeLocalDisk, TransferMode: storage.TransferModeMasterRelay,
}},
})
case r.Method == http.MethodPut && r.URL.Path == "/api/agent/records/99/artifacts/11":
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("ReadAll relayed body: %v", err)
}
digest := sha256.Sum256(body)
if got := r.Header.Get("X-BackupX-SHA256"); got != fmt.Sprintf("%x", digest[:]) {
t.Fatalf("relay checksum header = %q", got)
}
objectKey := r.Header.Get("X-BackupX-Object-Key")
if !strings.Contains(objectKey, "/records/99/") || r.ContentLength != int64(len(body)) {
t.Fatalf("invalid relay metadata: key=%q length=%d body=%d", objectKey, r.ContentLength, len(body))
}
relayed = append([]byte(nil), body...)
writeAgentEnvelope(t, w, map[string]string{"status": "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)
}
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"))
if err := executor.ExecuteRunTask(context.Background(), 1, 99); err != nil {
t.Fatalf("ExecuteRunTask returned error: %v", err)
}
if len(relayed) == 0 {
t.Fatal("expected artifact bytes to be streamed through Master")
}
if finalUpdate.Status != "success" || finalUpdate.StorageTransferMode != storage.TransferModeMasterRelay {
t.Fatalf("unexpected final relay update: %#v", finalUpdate)
}
if len(finalUpdate.StorageUploadResults) != 1 || finalUpdate.StorageUploadResults[0].TransferMode != storage.TransferModeMasterRelay {
t.Fatalf("unexpected relay target result: %#v", finalUpdate.StorageUploadResults)
}
}
func TestExecuteRestoreDownloadsMasterRelayedArtifact(t *testing.T) {
var archive bytes.Buffer
tarWriter := tar.NewWriter(&archive)
content := []byte("restored through Master")
header := &tar.Header{Name: "site/index.html", Mode: 0o644, Size: int64(len(content)), Typeflag: tar.TypeReg}
if err := tarWriter.WriteHeader(header); err != nil {
t.Fatalf("WriteHeader returned error: %v", err)
}
if _, err := tarWriter.Write(content); err != nil {
t.Fatalf("Write returned error: %v", err)
}
if err := tarWriter.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
artifact := append([]byte(nil), archive.Bytes()...)
digest := sha256.Sum256(artifact)
restoreRoot := t.TempDir()
restoreSource := filepath.Join(restoreRoot, "site")
artifactRequests := 0
var finalUpdate RestoreUpdate
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/agent/restores/77/spec":
writeAgentEnvelope(t, w, RestoreSpec{
RestoreRecordID: 77,
BackupRecordID: 99,
TaskID: 1,
TaskName: "remote-source",
Type: "file",
SourcePath: restoreSource,
Storage: StorageTargetConfig{
ID: 11, Name: "master-disk", Type: storage.TypeLocalDisk, TransferMode: storage.TransferModeMasterRelay,
},
StoragePath: "BackupX/file/site.tar",
FileName: "site.tar",
Checksum: fmt.Sprintf("%x", digest[:]),
})
case r.Method == http.MethodGet && r.URL.Path == "/api/agent/restores/77/artifact":
artifactRequests++
if r.Header.Get("X-Agent-Token") != "token" {
t.Fatalf("missing Agent token on relay download")
}
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(artifact)))
_, _ = w.Write(artifact)
case r.Method == http.MethodPost && r.URL.Path == "/api/agent/restores/77":
var update RestoreUpdate
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"))
if err := executor.ExecuteRestore(context.Background(), 77); err != nil {
t.Fatalf("ExecuteRestore returned error: %v", err)
}
if artifactRequests != 1 {
t.Fatalf("expected one relay artifact request, got %d", artifactRequests)
}
restored, err := os.ReadFile(filepath.Join(restoreRoot, "site", "index.html"))
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
if string(restored) != string(content) {
t.Fatalf("restored content = %q, want %q", restored, content)
}
if finalUpdate.Status != "success" {
t.Fatalf("unexpected final restore update: %#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

@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"sort"
"strings"
)
// DirEntry Agent 返回给 Master 的目录项。
@@ -17,8 +18,8 @@ type DirEntry struct {
// listLocalDir 列出 Agent 所在机器的指定路径。
func listLocalDir(path string) ([]DirEntry, error) {
cleaned := filepath.Clean(path)
if cleaned == "" {
cleaned := filepath.Clean(strings.TrimSpace(path))
if strings.TrimSpace(path) == "" || cleaned == "." {
cleaned = "/"
}
entries, err := os.ReadDir(cleaned)

View File

@@ -36,6 +36,21 @@ func TestListLocalDir(t *testing.T) {
}
}
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

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(),
@@ -81,11 +82,12 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
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))
backupRunnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil), backup.NewSAPHANARunner(nil), backup.NewMongoDBRunner(nil))
logHub := backup.NewLogHub()
retentionService := backupretention.NewService(backupRecordRepo)
retentionService := backupretention.NewService(backupRecordRepo, configCipher.Key())
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,
@@ -102,6 +104,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
restoreRecordRepo := repository.NewRestoreRecordRepository(db)
restoreLogHub := backup.NewLogHub()
dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo)
reportService := service.NewReportService(backupTaskRepo, backupRecordRepo)
settingsService := service.NewSettingsService(systemConfigRepo)
// Audit
@@ -109,6 +112,10 @@ 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)
// 审计日志保留期清理:每 6h 读取 audit_retention_days 设置并清理超期日志0/缺省=永久保留)
auditService.StartRetentionMonitor(ctx, systemConfigRepo, 6*time.Hour)
// Database discovery集群依赖在 agentService 创建后注入)
databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor())
@@ -127,7 +134,8 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
// Agent 协议服务:命令队列 + 任务下发 + 记录上报
agentCmdRepo := repository.NewAgentCommandRepository(db)
agentService := service.NewAgentService(nodeRepo, backupTaskRepo, backupRecordRepo, storageTargetRepo, agentCmdRepo, configCipher)
nodeService.SetAgentCommandRepository(agentCmdRepo)
agentService := service.NewAgentService(nodeRepo, backupTaskRepo, backupRecordRepo, storageTargetRepo, agentCmdRepo, configCipher, storageRegistry)
agentService.SetRestoreRepository(restoreRecordRepo)
agentService.StartCommandTimeoutMonitor(ctx, 30*time.Second, 10*time.Minute)
@@ -226,39 +234,56 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
// 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{
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,
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,
ReportService: reportService,
SettingsService: settingsService,
NodeService: nodeService,
AgentService: agentService,
DatabaseDiscoveryService: databaseDiscoveryService,
AuditService: auditService,
AuditService: auditService,
JWTManager: jwtManager,
UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo,
InstallTokenService: installTokenService,
MasterExternalURL: "", // 如需覆盖 URL可扩展 cfg.Server 增字段;目前留空依赖 X-Forwarded-* / Request.Host
MasterExternalURL: cfg.Server.ExternalURL,
DB: db,
Metrics: appMetrics,
})
httpServer := &stdhttp.Server{

View File

@@ -3,6 +3,7 @@ package backup
import (
"archive/tar"
"context"
"encoding/json"
"fmt"
"io"
"os"
@@ -52,6 +53,20 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R
defer tw.Close()
excludes := normalizeExcludePatterns(task.ExcludePatterns)
// 差异备份:基于上次全量清单仅打包新增/变更条目并记录删除;
// 全量备份记录完整清单manifest供后续差异比对。
differential := task.Differential && len(task.BaseManifest.Entries) > 0
baseIndex := map[string]ManifestEntry{}
seen := map[string]struct{}{}
var manifest *Manifest
if differential {
baseIndex = task.BaseManifest.index()
writer.WriteLine(fmt.Sprintf("差异备份模式:基线含 %d 个条目", len(baseIndex)))
} else {
manifest = &Manifest{Entries: make([]ManifestEntry, 0)}
}
totalFileCount := 0
totalDirCount := 0
@@ -88,6 +103,16 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R
return nil
}
entry := entryFromInfo(archiveName, currentInfo)
if differential {
seen[entry.Path] = struct{}{}
if !changedSince(baseIndex, entry) {
return nil // 自全量以来未变更,跳过
}
} else {
manifest.Entries = append(manifest.Entries, entry)
}
if currentInfo.IsDir() {
dirCount++
writer.WriteLine(fmt.Sprintf("📁 进入目录 %s", archiveName))
@@ -103,13 +128,19 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R
}
if currentInfo.Mode().IsRegular() {
file, err := os.Open(currentPath)
if err != nil {
return err
}
defer file.Close()
if _, err := io.CopyN(tw, file, currentInfo.Size()); err != nil && err != io.EOF {
return err
// 每个文件在独立作用域内打开并关闭,避免在大目录树中累积打开的文件句柄。
if copyErr := func() error {
file, err := os.Open(currentPath)
if err != nil {
return err
}
defer file.Close()
if _, err := io.CopyN(tw, file, currentInfo.Size()); err != nil && err != io.EOF {
return err
}
return nil
}(); copyErr != nil {
return copyErr
}
fileCount++
if fileCount%100 == 0 {
@@ -130,10 +161,16 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R
totalDirCount += dirCount
}
if len(sourcePaths) > 1 {
if differential {
deletions := deletedPaths(baseIndex, seen)
if err := writeDeletionsEntry(tw, deletions); err != nil {
return nil, err
}
writer.WriteLine(fmt.Sprintf("差异备份完成(%d 个目录、%d 个文件变更,删除 %d 项)", totalDirCount, totalFileCount, len(deletions)))
} else if len(sourcePaths) > 1 {
writer.WriteLine(fmt.Sprintf("全部源路径打包完成(共 %d 个目录,%d 个文件)", totalDirCount, totalFileCount))
}
return &RunResult{ArtifactPath: artifactPath, FileName: filepath.Base(artifactPath), TempDir: tempDir}, nil
return &RunResult{ArtifactPath: artifactPath, FileName: filepath.Base(artifactPath), TempDir: tempDir, Manifest: manifest}, nil
}
func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath string, writer LogWriter) error {
@@ -148,9 +185,15 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri
restoreSource = task.SourcePaths[0]
}
targetParent := filepath.Dir(filepath.Clean(strings.TrimSpace(restoreSource)))
// 恢复到指定位置:非空时归档解压到用户指定目录,而非原始源父目录。
if override := strings.TrimSpace(task.RestoreTargetPath); override != "" {
targetParent = filepath.Clean(override)
writer.WriteLine(fmt.Sprintf("恢复到指定目录:%s", targetParent))
}
if err := os.MkdirAll(targetParent, 0o755); err != nil {
return fmt.Errorf("create restore parent: %w", err)
}
var pendingDeletions []string
tr := tar.NewReader(artifactFile)
for {
header, err := tr.Next()
@@ -160,13 +203,27 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri
if err != nil {
return fmt.Errorf("read tar entry: %w", err)
}
// 差异归档的删除清单不落地,留待提取完成后统一应用(避免被同批新增条目误删)。
if header.Name == deletionsEntryName {
data, readErr := io.ReadAll(tr)
if readErr != nil {
return fmt.Errorf("read deletions entry: %w", readErr)
}
if jsonErr := json.Unmarshal(data, &pendingDeletions); jsonErr != nil {
return fmt.Errorf("parse deletions entry: %w", jsonErr)
}
continue
}
cleanName := path.Clean(strings.TrimSpace(header.Name))
if cleanName == "." || cleanName == "" {
continue
}
targetPath := filepath.Clean(filepath.Join(targetParent, filepath.FromSlash(cleanName)))
parentWithSep := filepath.Clean(targetParent) + string(filepath.Separator)
if targetPath != filepath.Clean(targetParent) && !strings.HasPrefix(targetPath, parentWithSep) {
// 选择性恢复:仅提取被选中的文件/目录(及其子项)。
if len(task.SelectedPaths) > 0 && !pathSelected(cleanName, task.SelectedPaths) {
continue
}
targetPath, ok := resolveWithinParent(targetParent, cleanName)
if !ok {
return fmt.Errorf("tar entry escapes restore path")
}
switch header.Typeflag {
@@ -191,10 +248,94 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri
}
}
}
// 选择性恢复时仅对选中范围应用删除,避免误删未选中的文件。
if len(task.SelectedPaths) > 0 {
pendingDeletions = filterSelectedPaths(pendingDeletions, task.SelectedPaths)
}
if err := applyDeletions(targetParent, pendingDeletions, writer); err != nil {
return err
}
writer.WriteLine("文件恢复完成")
return nil
}
// pathSelected 判断归档条目名是否落在选中集合内(精确匹配或位于选中目录之下)。
func pathSelected(name string, selected []string) bool {
for _, sel := range selected {
clean := path.Clean(strings.TrimSpace(sel))
if clean == "" || clean == "." {
continue
}
if name == clean || strings.HasPrefix(name, clean+"/") {
return true
}
}
return false
}
// filterSelectedPaths 仅保留落在选中集合内的路径。
func filterSelectedPaths(paths []string, selected []string) []string {
filtered := make([]string, 0, len(paths))
for _, p := range paths {
if pathSelected(path.Clean(strings.TrimSpace(p)), selected) {
filtered = append(filtered, p)
}
}
return filtered
}
// resolveWithinParent 将归档相对名安全解析为 targetParent 下的绝对路径;
// 越界(路径穿越)时返回 ok=false。提取与删除共用此校验杜绝逃逸。
func resolveWithinParent(targetParent, name string) (string, bool) {
targetPath := filepath.Clean(filepath.Join(targetParent, filepath.FromSlash(name)))
cleanParent := filepath.Clean(targetParent)
if targetPath == cleanParent {
return targetPath, true
}
if !strings.HasPrefix(targetPath, cleanParent+string(filepath.Separator)) {
return "", false
}
return targetPath, true
}
// writeDeletionsEntry 将差异备份的删除路径列表写入归档特殊条目。
func writeDeletionsEntry(tw *tar.Writer, deletions []string) error {
payload, err := json.Marshal(deletions)
if err != nil {
return fmt.Errorf("marshal deletions: %w", err)
}
header := &tar.Header{Name: deletionsEntryName, Mode: 0o600, Size: int64(len(payload)), Typeflag: tar.TypeReg}
if err := tw.WriteHeader(header); err != nil {
return fmt.Errorf("write deletions header: %w", err)
}
if _, err := tw.Write(payload); err != nil {
return fmt.Errorf("write deletions body: %w", err)
}
return nil
}
// applyDeletions 在基线恢复之上删除差异归档记录的路径(仅差异备份恢复时存在)。
// 每个路径经 resolveWithinParent 校验,越界即报错;目标不存在视为已删除。
func applyDeletions(targetParent string, deletions []string, writer LogWriter) error {
for _, name := range deletions {
clean := path.Clean(strings.TrimSpace(name))
if clean == "." || clean == "" {
continue
}
targetPath, ok := resolveWithinParent(targetParent, clean)
if !ok {
return fmt.Errorf("deletion entry escapes restore path")
}
if err := os.RemoveAll(targetPath); err != nil {
return fmt.Errorf("apply deletion %s: %w", clean, err)
}
}
if len(deletions) > 0 {
writer.WriteLine(fmt.Sprintf("已应用差异删除 %d 项", len(deletions)))
}
return nil
}
func normalizeExcludePatterns(items []string) []string {
result := make([]string, 0, len(items))
for _, item := range items {

View File

@@ -0,0 +1,182 @@
package backup
import (
"archive/tar"
"context"
"io"
"os"
"path/filepath"
"testing"
)
func diffWrite(t *testing.T, p, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(p), err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", p, err)
}
}
func diffAssertContent(t *testing.T, p, want string) {
t.Helper()
got, err := os.ReadFile(p)
if err != nil {
t.Fatalf("read %s: %v", p, err)
}
if string(got) != want {
t.Fatalf("%s content = %q, want %q", p, string(got), want)
}
}
func diffAssertAbsent(t *testing.T, p string) {
t.Helper()
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Fatalf("expected %s to be absent, stat err=%v", p, err)
}
}
func diffArchiveNames(t *testing.T, artifactPath string) map[string]bool {
t.Helper()
f, err := os.Open(artifactPath)
if err != nil {
t.Fatalf("open artifact: %v", err)
}
defer f.Close()
names := map[string]bool{}
tr := tar.NewReader(f)
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("read tar: %v", err)
}
names[h.Name] = true
}
return names
}
// TestFileRunnerDifferentialRoundTrip 验证差异备份的端到端正确性:
// 全量 → 修改源(变更/删除/新增)→ 差异 → 链式恢复(全量+差异)→ 结果与修改后源一致。
func TestFileRunnerDifferentialRoundTrip(t *testing.T) {
work := t.TempDir()
src := filepath.Join(work, "src")
diffWrite(t, filepath.Join(src, "a.txt"), "alpha")
diffWrite(t, filepath.Join(src, "b.txt"), "bravo")
diffWrite(t, filepath.Join(src, "sub", "c.txt"), "charlie")
runner := NewFileRunner()
full, err := runner.Run(context.Background(), TaskSpec{Name: "diff", Type: "file", SourcePath: src, TempDir: t.TempDir()}, NopLogWriter{})
if err != nil {
t.Fatalf("full Run: %v", err)
}
if full.Manifest == nil || len(full.Manifest.Entries) == 0 {
t.Fatalf("full backup must produce a manifest, got %#v", full.Manifest)
}
// 变更 a.txt内容变长 → size 差异必被检出)、删除 b.txt、新增 d.txtsub/c.txt 不变
diffWrite(t, filepath.Join(src, "a.txt"), "ALPHA-modified-and-longer")
if err := os.Remove(filepath.Join(src, "b.txt")); err != nil {
t.Fatalf("remove b.txt: %v", err)
}
diffWrite(t, filepath.Join(src, "d.txt"), "delta")
diff, err := runner.Run(context.Background(), TaskSpec{Name: "diff", Type: "file", SourcePath: src, TempDir: t.TempDir(), Differential: true, BaseManifest: *full.Manifest}, NopLogWriter{})
if err != nil {
t.Fatalf("differential Run: %v", err)
}
if diff.Manifest != nil {
t.Fatalf("differential backup must not produce a manifest")
}
// 差异归档应包含变更/新增条目与删除清单,但不含未变更的 sub/c.txt
names := diffArchiveNames(t, diff.ArtifactPath)
if !names["src/a.txt"] || !names["src/d.txt"] {
t.Fatalf("differential archive missing changed/new entries: %v", names)
}
if names["src/sub/c.txt"] {
t.Fatalf("differential archive should not contain unchanged file sub/c.txt")
}
if !names[deletionsEntryName] {
t.Fatalf("differential archive missing deletions entry: %v", names)
}
// 链式恢复到全新目标
restoreRoot := t.TempDir()
restoreSrc := filepath.Join(restoreRoot, "src")
restoreTask := TaskSpec{Name: "diff", Type: "file", SourcePath: restoreSrc}
if err := runner.Restore(context.Background(), restoreTask, full.ArtifactPath, NopLogWriter{}); err != nil {
t.Fatalf("restore full: %v", err)
}
if err := runner.Restore(context.Background(), restoreTask, diff.ArtifactPath, NopLogWriter{}); err != nil {
t.Fatalf("restore differential: %v", err)
}
diffAssertContent(t, filepath.Join(restoreSrc, "a.txt"), "ALPHA-modified-and-longer")
diffAssertContent(t, filepath.Join(restoreSrc, "sub", "c.txt"), "charlie")
diffAssertContent(t, filepath.Join(restoreSrc, "d.txt"), "delta")
diffAssertAbsent(t, filepath.Join(restoreSrc, "b.txt"))
}
func TestPathSelected(t *testing.T) {
sel := []string{"src/a.txt", "src/sub"}
cases := map[string]bool{
"src/a.txt": true,
"src/sub": true,
"src/sub/c.txt": true, // 选中目录下的子项
"src/b.txt": false, // 未选中文件
"src/subother": false, // 前缀相近但非子项,不应误判
}
for name, want := range cases {
if got := pathSelected(name, sel); got != want {
t.Errorf("pathSelected(%q) = %v, want %v", name, got, want)
}
}
}
// TestFileRunnerSelectiveRestore 验证按需恢复:仅选中的文件与目录被还原,未选中的文件不出现。
func TestFileRunnerSelectiveRestore(t *testing.T) {
work := t.TempDir()
src := filepath.Join(work, "src")
diffWrite(t, filepath.Join(src, "a.txt"), "alpha")
diffWrite(t, filepath.Join(src, "b.txt"), "bravo")
diffWrite(t, filepath.Join(src, "sub", "c.txt"), "charlie")
runner := NewFileRunner()
full, err := runner.Run(context.Background(), TaskSpec{Name: "sel", Type: "file", SourcePath: src, TempDir: t.TempDir()}, NopLogWriter{})
if err != nil {
t.Fatalf("full Run: %v", err)
}
restoreRoot := t.TempDir()
restoreSrc := filepath.Join(restoreRoot, "src")
task := TaskSpec{Name: "sel", Type: "file", SourcePath: restoreSrc, SelectedPaths: []string{"src/a.txt", "src/sub"}}
if err := runner.Restore(context.Background(), task, full.ArtifactPath, NopLogWriter{}); err != nil {
t.Fatalf("selective Restore: %v", err)
}
diffAssertContent(t, filepath.Join(restoreSrc, "a.txt"), "alpha")
diffAssertContent(t, filepath.Join(restoreSrc, "sub", "c.txt"), "charlie") // 选中目录 → 子项一并恢复
diffAssertAbsent(t, filepath.Join(restoreSrc, "b.txt")) // 未选中 → 不恢复
}
// TestFileRunnerDifferentialWithoutBaseIsFull 验证无基线时差异请求回退为全量(产出清单、含全部文件)。
func TestFileRunnerDifferentialWithoutBaseIsFull(t *testing.T) {
src := filepath.Join(t.TempDir(), "src")
diffWrite(t, filepath.Join(src, "a.txt"), "alpha")
runner := NewFileRunner()
res, err := runner.Run(context.Background(), TaskSpec{Name: "diff", Type: "file", SourcePath: src, TempDir: t.TempDir(), Differential: true}, NopLogWriter{})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Manifest == nil {
t.Fatalf("differential without base must fall back to full and produce a manifest")
}
if names := diffArchiveNames(t, res.ArtifactPath); !names["src/a.txt"] || names[deletionsEntryName] {
t.Fatalf("fallback-full archive unexpected: %v", names)
}
}

View File

@@ -0,0 +1,92 @@
package backup
import (
"encoding/json"
"os"
"path/filepath"
"sort"
)
// deletionsEntryName 是差异备份归档中记录「自全量以来被删除路径」的特殊条目名。
// 恢复时该条目不落地为文件,而是用于在基线之上删除对应路径。
const deletionsEntryName = ".backupx/deletions.json"
// ManifestEntry 记录一次全量备份中单个归档条目(文件或目录)的指纹,
// 供差异备份比对「自全量以来的变化」。Path 为归档内相对名slash 分隔,
// 与 tar header.Name 一致)。字段使用短键以压缩清单体积。
type ManifestEntry struct {
Path string `json:"p"`
Size int64 `json:"s"`
ModTimeNs int64 `json:"m"`
Mode uint32 `json:"o"`
IsDir bool `json:"d,omitempty"`
}
// Manifest 是一次全量备份的完整条目清单(文件与目录)。
type Manifest struct {
Entries []ManifestEntry `json:"entries"`
}
// EncodeManifest 将清单序列化为紧凑 JSON。
func EncodeManifest(m Manifest) ([]byte, error) {
return json.Marshal(m)
}
// DecodeManifest 反序列化清单;空输入返回空清单(视为「无基线」)。
func DecodeManifest(data []byte) (Manifest, error) {
m := Manifest{}
if len(data) == 0 {
return m, nil
}
if err := json.Unmarshal(data, &m); err != nil {
return Manifest{}, err
}
return m, nil
}
// index 构建 path -> entry 映射,便于差异比对 O(1) 查找。
func (m Manifest) index() map[string]ManifestEntry {
idx := make(map[string]ManifestEntry, len(m.Entries))
for _, e := range m.Entries {
idx[e.Path] = e
}
return idx
}
// entryFromInfo 由归档名与文件信息构造指纹条目。
func entryFromInfo(archiveName string, info os.FileInfo) ManifestEntry {
return ManifestEntry{
Path: filepath.ToSlash(archiveName),
Size: info.Size(),
ModTimeNs: info.ModTime().UnixNano(),
Mode: uint32(info.Mode().Perm()),
IsDir: info.IsDir(),
}
}
// changedSince 判断当前条目相对基线是否为「新增或变更」(即应纳入差异归档)。
// - 不在基线中 → 新增,纳入;
// - 已存在的目录 → 不携带数据,跳过(其下变更文件会各自判定);
// - 文件大小或 mtime 变化 → 变更纳入rsync 风格启发式)。
func changedSince(base map[string]ManifestEntry, cur ManifestEntry) bool {
prev, ok := base[cur.Path]
if !ok {
return true
}
if cur.IsDir {
return false
}
return prev.Size != cur.Size || prev.ModTimeNs != cur.ModTimeNs
}
// deletedPaths 返回基线中存在、但本次遍历未出现的路径(被删除的条目),按路径升序。
func deletedPaths(base map[string]ManifestEntry, seen map[string]struct{}) []string {
deleted := make([]string, 0)
for p := range base {
if _, ok := seen[p]; !ok {
deleted = append(deleted, p)
}
}
sort.Strings(deleted)
return deleted
}

View File

@@ -0,0 +1,79 @@
package backup
import (
"reflect"
"testing"
)
func TestEncodeDecodeManifestRoundTrip(t *testing.T) {
m := Manifest{Entries: []ManifestEntry{
{Path: "src/a.txt", Size: 10, ModTimeNs: 100, Mode: 0o644},
{Path: "src", Size: 0, ModTimeNs: 50, Mode: 0o755, IsDir: true},
}}
data, err := EncodeManifest(m)
if err != nil {
t.Fatalf("EncodeManifest: %v", err)
}
got, err := DecodeManifest(data)
if err != nil {
t.Fatalf("DecodeManifest: %v", err)
}
if !reflect.DeepEqual(got, m) {
t.Fatalf("roundtrip mismatch:\n got %#v\nwant %#v", got, m)
}
}
func TestDecodeManifestEmpty(t *testing.T) {
got, err := DecodeManifest(nil)
if err != nil {
t.Fatalf("DecodeManifest(nil): %v", err)
}
if len(got.Entries) != 0 {
t.Fatalf("expected empty manifest, got %#v", got)
}
}
func TestChangedSince(t *testing.T) {
base := Manifest{Entries: []ManifestEntry{
{Path: "a.txt", Size: 10, ModTimeNs: 100},
{Path: "dir", IsDir: true, ModTimeNs: 100},
}}.index()
cases := []struct {
name string
cur ManifestEntry
want bool
}{
{"unchanged file", ManifestEntry{Path: "a.txt", Size: 10, ModTimeNs: 100}, false},
{"size changed", ManifestEntry{Path: "a.txt", Size: 11, ModTimeNs: 100}, true},
{"mtime changed", ManifestEntry{Path: "a.txt", Size: 10, ModTimeNs: 200}, true},
{"new file", ManifestEntry{Path: "b.txt", Size: 1, ModTimeNs: 1}, true},
{"existing dir skipped", ManifestEntry{Path: "dir", IsDir: true, ModTimeNs: 999}, false},
{"new dir included", ManifestEntry{Path: "newdir", IsDir: true, ModTimeNs: 1}, true},
}
for _, tc := range cases {
if got := changedSince(base, tc.cur); got != tc.want {
t.Errorf("%s: changedSince=%v want %v", tc.name, got, tc.want)
}
}
}
func TestDeletedPaths(t *testing.T) {
base := Manifest{Entries: []ManifestEntry{
{Path: "a"}, {Path: "b"}, {Path: "c"},
}}.index()
seen := map[string]struct{}{"a": {}, "c": {}}
got := deletedPaths(base, seen)
want := []string{"b"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("deletedPaths=%v want %v", got, want)
}
}
func TestDeletedPathsNoneWhenAllSeen(t *testing.T) {
base := Manifest{Entries: []ManifestEntry{{Path: "a"}, {Path: "b"}}}.index()
seen := map[string]struct{}{"a": {}, "b": {}}
if got := deletedPaths(base, seen); len(got) != 0 {
t.Fatalf("expected no deletions, got %v", got)
}
}

View File

@@ -0,0 +1,119 @@
package backup
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// MongoDBRunner 通过 mongodump/mongorestore 备份与恢复 MongoDB 数据库。
// 采用 --archive 流式模式dump 写 stdout、restore 读 stdin与 MySQLRunner
// 的 mysqldump/mysql 管线保持一致;产物为未压缩的 mongo archive由备份管线统一压缩/加密。
type MongoDBRunner struct {
executor CommandExecutor
}
func NewMongoDBRunner(executor CommandExecutor) *MongoDBRunner {
if executor == nil {
executor = NewOSCommandExecutor()
}
return &MongoDBRunner{executor: executor}
}
func (r *MongoDBRunner) Type() string {
return "mongodb"
}
func (r *MongoDBRunner) Run(ctx context.Context, task TaskSpec, writer LogWriter) (*RunResult, error) {
if _, err := r.executor.LookPath("mongodump"); err != nil {
return nil, fmt.Errorf("未找到 mongodump 命令 (请确保服务器已安装 mongodb-database-tools)")
}
startedAt := task.StartedAt
if startedAt.IsZero() {
startedAt = time.Now().UTC()
}
tempDir, err := CreateTaskTempDir(task.Name, startedAt)
if err != nil {
return nil, err
}
fileName := BuildArtifactName(task.Name, startedAt, "archive")
artifactPath := filepath.Join(tempDir, fileName)
file, err := os.Create(artifactPath)
if err != nil {
return nil, fmt.Errorf("create mongodump archive file: %w", err)
}
defer file.Close()
args := mongoConnArgs(task.Database)
dbNames := normalizeDatabaseNames(task.Database.Names)
if len(dbNames) == 1 {
args = append(args, "--db", dbNames[0])
writer.WriteLine(fmt.Sprintf("备份数据库: %s", dbNames[0]))
} else {
writer.WriteLine("备份全部数据库")
}
args = append(args, "--archive") // 归档流式写入 stdout
writer.WriteLine(fmt.Sprintf("连接到 MongoDB: %s:%d", task.Database.Host, task.Database.Port))
stderrWriter := newLogLineWriter(writer, "mongodump")
writer.WriteLine("开始执行 mongodump")
if err := r.executor.Run(ctx, "mongodump", args, CommandOptions{Stdout: file, Stderr: stderrWriter}); err != nil {
return nil, fmt.Errorf("run mongodump: %w: %s", err, stderrWriter.collected())
}
info, err := file.Stat()
if err != nil {
return nil, fmt.Errorf("stat mongodump archive: %w", err)
}
if info.Size() == 0 {
return nil, fmt.Errorf("mongodump 产物为空,请检查数据库连接与权限")
}
writer.WriteLine(fmt.Sprintf("MongoDB 导出完成(文件大小: %s", formatFileSize(info.Size())))
return &RunResult{ArtifactPath: artifactPath, FileName: fileName, TempDir: tempDir, Size: info.Size(), StorageKey: BuildStorageKey("mongodb", startedAt, fileName)}, nil
}
func (r *MongoDBRunner) Restore(ctx context.Context, task TaskSpec, artifactPath string, writer LogWriter) error {
if _, err := r.executor.LookPath("mongorestore"); err != nil {
return fmt.Errorf("未找到 mongorestore 命令 (请确保服务器已安装 mongodb-database-tools)")
}
input, err := os.Open(filepath.Clean(artifactPath))
if err != nil {
return fmt.Errorf("open mongodb restore archive: %w", err)
}
defer input.Close()
args := mongoConnArgs(task.Database)
// --drop恢复前删除同名集合保证恢复后与归档一致与 mysql 恢复的整库覆盖语义对齐)。
args = append(args, "--drop", "--archive")
stderr := &bytes.Buffer{}
writer.WriteLine("开始执行 mongorestore")
if err := r.executor.Run(ctx, "mongorestore", args, CommandOptions{Stdin: input, Stderr: stderr}); err != nil {
return fmt.Errorf("run mongorestore: %w: %s", err, strings.TrimSpace(stderr.String()))
}
writer.WriteLine("MongoDB 恢复完成")
return nil
}
// mongoConnArgs 构造 mongodump/mongorestore 的连接与认证参数。
// 注意mongodb-database-tools 无类似 MYSQL_PWD 的密码环境变量,密码只能经 --password 传入;
// 认证库默认 admin绝大多数部署的管理账号所在库
func mongoConnArgs(db DatabaseSpec) []string {
args := make([]string, 0, 8)
if strings.TrimSpace(db.Host) != "" {
args = append(args, "--host", db.Host)
}
if db.Port > 0 {
args = append(args, "--port", strconv.Itoa(db.Port))
}
if strings.TrimSpace(db.User) != "" {
args = append(args, "--username", db.User, "--authenticationDatabase", "admin")
if strings.TrimSpace(db.Password) != "" {
args = append(args, "--password", db.Password)
}
}
return args
}

View File

@@ -0,0 +1,102 @@
package backup
import (
"context"
"errors"
"io"
"os"
"strings"
"testing"
)
func argIndex(args []string, target string) int {
for i, a := range args {
if a == target {
return i
}
}
return -1
}
func TestMongoDBRunnerRunUsesMongodump(t *testing.T) {
executor := &fakeCommandExecutor{runFunc: func(name string, args []string, options CommandOptions) error {
if options.Stdout != nil {
_, _ = io.WriteString(options.Stdout, "mongo archive bytes")
}
return nil
}}
runner := NewMongoDBRunner(executor)
result, err := runner.Run(context.Background(), TaskSpec{Name: "mongo", Type: "mongodb", Database: DatabaseSpec{Host: "127.0.0.1", Port: 27017, User: "admin", Password: "secret", Names: []string{"app"}}}, NopLogWriter{})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if executor.lastName != "mongodump" {
t.Fatalf("expected mongodump, got %s", executor.lastName)
}
args := executor.lastArgs
if argIndex(args, "--archive") < 0 {
t.Fatalf("expected --archive flag, got %#v", args)
}
if i := argIndex(args, "--db"); i < 0 || i+1 >= len(args) || args[i+1] != "app" {
t.Fatalf("expected --db app, got %#v", args)
}
if i := argIndex(args, "--username"); i < 0 || args[i+1] != "admin" {
t.Fatalf("expected --username admin, got %#v", args)
}
if argIndex(args, "--authenticationDatabase") < 0 || argIndex(args, "--password") < 0 {
t.Fatalf("expected auth args, got %#v", args)
}
if _, err := os.Stat(result.ArtifactPath); err != nil {
t.Fatalf("artifact file missing: %v", err)
}
if result.StorageKey == "" || !strings.HasSuffix(result.FileName, ".archive") {
t.Fatalf("unexpected result metadata: %#v", result)
}
}
func TestMongoDBRunnerRunBackupsAllWhenNoDatabase(t *testing.T) {
executor := &fakeCommandExecutor{runFunc: func(name string, args []string, options CommandOptions) error {
_, _ = io.WriteString(options.Stdout, "all dbs")
return nil
}}
runner := NewMongoDBRunner(executor)
_, err := runner.Run(context.Background(), TaskSpec{Name: "mongo", Type: "mongodb", Database: DatabaseSpec{Host: "127.0.0.1", Port: 27017}}, NopLogWriter{})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if argIndex(executor.lastArgs, "--db") >= 0 {
t.Fatalf("expected no --db when backing up all databases, got %#v", executor.lastArgs)
}
}
func TestMongoDBRunnerRunRejectsEmptyOutput(t *testing.T) {
executor := &fakeCommandExecutor{} // runFunc nil → writes nothing
runner := NewMongoDBRunner(executor)
_, err := runner.Run(context.Background(), TaskSpec{Name: "mongo", Type: "mongodb", Database: DatabaseSpec{Host: "127.0.0.1", Port: 27017, Names: []string{"app"}}}, NopLogWriter{})
if err == nil {
t.Fatal("expected error for empty mongodump output")
}
}
func TestMongoDBRunnerRestoreUsesMongorestore(t *testing.T) {
executor := &fakeCommandExecutor{}
runner := NewMongoDBRunner(executor)
artifact := filepathJoinTempFile(t, "dump.archive", "mongo archive bytes")
if err := runner.Restore(context.Background(), TaskSpec{Name: "mongo", Type: "mongodb", Database: DatabaseSpec{Host: "127.0.0.1", Port: 27017, User: "admin", Password: "secret"}}, artifact, NopLogWriter{}); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
if executor.lastName != "mongorestore" {
t.Fatalf("expected mongorestore, got %s", executor.lastName)
}
if argIndex(executor.lastArgs, "--drop") < 0 || argIndex(executor.lastArgs, "--archive") < 0 {
t.Fatalf("expected --drop --archive, got %#v", executor.lastArgs)
}
}
func TestMongoDBRunnerRunReturnsLookupError(t *testing.T) {
runner := NewMongoDBRunner(&fakeCommandExecutor{lookupErr: errors.New("missing")})
_, err := runner.Run(context.Background(), TaskSpec{Name: "mongo", Type: "mongodb", Database: DatabaseSpec{Host: "127.0.0.1", Port: 27017, Names: []string{"app"}}}, NopLogWriter{})
if err == nil {
t.Fatal("expected error when mongodump is missing")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,131 @@
package backup
import (
"context"
"fmt"
"io"
)
const (
repositoryChunkMin = 512 << 10
repositoryChunkAvg = 1 << 20
repositoryChunkMax = 4 << 20
)
// contentDefinedChunker implements the normalized FastCDC cut-point strategy.
// The rolling Gear hash only retains the latest 64 bytes through uint64
// overflow, so chunk boundaries re-synchronize after insertions or deletions.
type contentDefinedChunker struct {
minSize int
avgSize int
maxSize int
smallMask uint64
largeMask uint64
gear [256]uint64
}
func newContentDefinedChunker() *contentDefinedChunker {
chunker := &contentDefinedChunker{
minSize: repositoryChunkMin,
avgSize: repositoryChunkAvg,
maxSize: repositoryChunkMax,
smallMask: (1 << 21) - 1,
largeMask: (1 << 19) - 1,
}
// SplitMix64 produces a stable, well-distributed Gear table. The seed and
// generation algorithm are part of repository format v1 and must not change.
seed := uint64(0x6a09e667f3bcc909)
for i := range chunker.gear {
seed += 0x9e3779b97f4a7c15
value := seed
value = (value ^ (value >> 30)) * 0xbf58476d1ce4e5b9
value = (value ^ (value >> 27)) * 0x94d049bb133111eb
chunker.gear[i] = value ^ (value >> 31)
}
return chunker
}
func (c *contentDefinedChunker) Split(ctx context.Context, reader io.Reader, emit func([]byte) error) error {
if reader == nil || emit == nil {
return fmt.Errorf("chunk reader and emitter are required")
}
pending := make([]byte, 0, c.maxSize+(256<<10))
readBuffer := make([]byte, 256<<10)
eof := false
for {
if !eof {
readCount, readErr := reader.Read(readBuffer)
if readCount > 0 {
pending = append(pending, readBuffer[:readCount]...)
}
switch readErr {
case nil:
case io.EOF:
eof = true
default:
return fmt.Errorf("read source for chunking: %w", readErr)
}
if readCount == 0 && readErr == nil {
continue
}
}
for len(pending) > 0 {
if err := ctx.Err(); err != nil {
return err
}
cut := c.findCutPoint(pending, eof)
if cut == 0 {
break
}
chunk := make([]byte, cut)
copy(chunk, pending[:cut])
if err := emit(chunk); err != nil {
return err
}
copy(pending, pending[cut:])
pending = pending[:len(pending)-cut]
}
if eof {
if len(pending) != 0 {
return fmt.Errorf("chunker stopped with %d buffered bytes", len(pending))
}
return nil
}
}
}
func (c *contentDefinedChunker) findCutPoint(data []byte, eof bool) int {
if len(data) < c.minSize {
if eof {
return len(data)
}
return 0
}
limit := len(data)
if limit > c.maxSize {
limit = c.maxSize
}
var hash uint64
for index := c.minSize; index < limit; index++ {
hash = (hash << 1) + c.gear[data[index]]
mask := c.largeMask
if index < c.avgSize {
mask = c.smallMask
}
if hash&mask == 0 {
return index + 1
}
}
if len(data) >= c.maxSize {
return c.maxSize
}
if eof {
return len(data)
}
return 0
}

View File

@@ -0,0 +1,414 @@
package backup
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"math/rand"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"testing"
"time"
"backupx/server/internal/storage"
)
func TestContentDefinedChunkerResynchronizesAfterInsertion(t *testing.T) {
source := make([]byte, 8<<20)
if _, err := rand.New(rand.NewSource(42)).Read(source); err != nil {
t.Fatalf("generate source: %v", err)
}
modified := make([]byte, 0, len(source)+4096)
modified = append(modified, source[:2<<20]...)
modified = append(modified, bytes.Repeat([]byte("inserted"), 512)...)
modified = append(modified, source[2<<20:]...)
chunker := newContentDefinedChunker()
collect := func(data []byte) map[string]struct{} {
t.Helper()
ids := make(map[string]struct{})
err := chunker.Split(context.Background(), bytes.NewReader(data), func(chunk []byte) error {
digest := sha256.Sum256(chunk)
ids[fmt.Sprintf("%x", digest[:])] = struct{}{}
if len(chunk) > repositoryChunkMax {
return fmt.Errorf("chunk exceeds maximum: %d", len(chunk))
}
return nil
})
if err != nil {
t.Fatalf("split chunks: %v", err)
}
return ids
}
originalChunks := collect(source)
modifiedChunks := collect(modified)
shared := 0
for chunkID := range originalChunks {
if _, ok := modifiedChunks[chunkID]; ok {
shared++
}
}
if shared < len(originalChunks)/2 {
t.Fatalf("content-defined boundaries did not resynchronize: shared=%d original=%d", shared, len(originalChunks))
}
}
func TestRepositoryRoundTripDedupAndPrune(t *testing.T) {
ctx := context.Background()
tempDir := t.TempDir()
sourceDir := filepath.Join(tempDir, "dataset")
if err := os.MkdirAll(filepath.Join(sourceDir, "empty"), 0o755); err != nil {
t.Fatalf("create source: %v", err)
}
original := make([]byte, 6<<20)
if _, err := rand.New(rand.NewSource(7)).Read(original); err != nil {
t.Fatalf("generate fixture: %v", err)
}
primaryPath := filepath.Join(sourceDir, "primary.bin")
duplicatePath := filepath.Join(sourceDir, "duplicate.bin")
if err := os.WriteFile(primaryPath, original, 0o640); err != nil {
t.Fatalf("write primary: %v", err)
}
if err := os.WriteFile(duplicatePath, original, 0o640); err != nil {
t.Fatalf("write duplicate: %v", err)
}
key := sha256.Sum256([]byte("repository-test-key"))
store := NewRepositoryStore(key[:])
provider := newMemoryRepositoryProvider()
task := TaskSpec{
ID: 12,
Name: "repository-test",
Type: "file",
SourcePaths: []string{sourceDir},
Compression: "zstd",
Encrypt: true,
StartedAt: time.Date(2026, 8, 6, 1, 2, 3, 0, time.UTC),
TempDir: tempDir,
}
firstPlan, err := store.BuildPlan(ctx, task, NopLogWriter{})
if err != nil {
t.Fatalf("build first plan: %v", err)
}
firstKey := store.SnapshotKey(task.ID, 1, task.StartedAt)
firstResult, err := store.Upload(ctx, provider, firstPlan, firstKey)
if closeErr := firstPlan.Close(); closeErr != nil {
t.Fatalf("close first plan: %v", closeErr)
}
if err != nil {
t.Fatalf("upload first snapshot: %v", err)
}
if firstResult.NewChunks == 0 || firstResult.UniqueChunks == 0 {
t.Fatalf("first upload did not create chunks: %+v", firstResult)
}
if firstPlan.UniqueSize >= firstPlan.LogicalSize {
t.Fatalf("duplicate file was not deduplicated within plan: unique=%d logical=%d", firstPlan.UniqueSize, firstPlan.LogicalSize)
}
modified := make([]byte, 0, len(original)+4096)
modified = append(modified, original[:2<<20]...)
modified = append(modified, bytes.Repeat([]byte("changed!"), 512)...)
modified = append(modified, original[2<<20:]...)
if err := os.WriteFile(primaryPath, modified, 0o640); err != nil {
t.Fatalf("modify primary: %v", err)
}
task.StartedAt = task.StartedAt.Add(time.Hour)
secondPlan, err := store.BuildPlan(ctx, task, NopLogWriter{})
if err != nil {
t.Fatalf("build second plan: %v", err)
}
secondKey := store.SnapshotKey(task.ID, 2, task.StartedAt)
secondResult, err := store.Upload(ctx, provider, secondPlan, secondKey)
if closeErr := secondPlan.Close(); closeErr != nil {
t.Fatalf("close second plan: %v", closeErr)
}
if err != nil {
t.Fatalf("upload second snapshot: %v", err)
}
if secondResult.ReusedBytes <= secondResult.LogicalSize/3 {
t.Fatalf("second snapshot reused too little data: %+v", secondResult)
}
if secondResult.UploadedBytes >= secondResult.LogicalSize {
t.Fatalf("incremental upload was not smaller than logical data: %+v", secondResult)
}
verify, err := store.Verify(ctx, provider, secondKey, secondResult.Checksum)
if err != nil {
t.Fatalf("verify repository: %v", err)
}
if verify.Chunks == 0 || verify.Bytes == 0 {
t.Fatalf("empty verification result: %+v", verify)
}
restoreRoot := filepath.Join(tempDir, "restore")
restoreTask := task
restoreTask.RestoreTargetPath = restoreRoot
if err := store.Restore(ctx, provider, secondKey, strings.Repeat("0", sha256.Size*2), restoreTask, NopLogWriter{}); err == nil {
t.Fatal("restore accepted a mismatched snapshot checksum")
}
if err := store.Restore(ctx, provider, secondKey, "", restoreTask, NopLogWriter{}); err == nil {
t.Fatal("restore accepted a missing snapshot checksum")
}
if err := store.Restore(ctx, provider, secondKey, secondResult.Checksum, restoreTask, NopLogWriter{}); err != nil {
t.Fatalf("restore snapshot: %v", err)
}
restored, err := os.ReadFile(filepath.Join(restoreRoot, filepath.Base(sourceDir), "primary.bin"))
if err != nil {
t.Fatalf("read restored primary: %v", err)
}
if !bytes.Equal(restored, modified) {
t.Fatalf("restored primary differs from source")
}
if info, err := os.Stat(filepath.Join(restoreRoot, filepath.Base(sourceDir), "empty")); err != nil || !info.IsDir() {
t.Fatalf("empty directory was not restored: info=%v err=%v", info, err)
}
if err := provider.Delete(ctx, firstKey); err != nil {
t.Fatalf("delete first snapshot: %v", err)
}
if _, err := store.Prune(ctx, provider); err != nil {
t.Fatalf("prune with live snapshot: %v", err)
}
if err := provider.Delete(ctx, secondKey); err != nil {
t.Fatalf("delete second snapshot: %v", err)
}
pruned, err := store.Prune(ctx, provider)
if err != nil {
t.Fatalf("prune empty repository: %v", err)
}
if pruned.DeletedPacks == 0 || pruned.DeletedIndexes == 0 {
t.Fatalf("prune did not reclaim repository data: %+v", pruned)
}
if objects, err := provider.List(ctx, repositoryPackPrefix); err != nil || len(objects) != 0 {
t.Fatalf("packs remain after prune: objects=%v err=%v", objects, err)
}
}
func TestRepositoryRestoreRejectsUnsafeSnapshotMetadata(t *testing.T) {
cases := []struct {
name string
entries []repositoryEntry
}{
{
name: "path traversal",
entries: []repositoryEntry{{Path: "../escape", Kind: "directory", Mode: 0o755}},
},
{
name: "entry below symlink",
entries: []repositoryEntry{
{Path: "link", Kind: "symlink", Mode: 0o777, LinkTarget: "inside"},
{Path: "link/payload", Kind: "file", Mode: 0o600, Size: 1, Chunks: []string{"p-" + strings.Repeat("0", sha256.Size*2)}},
},
},
{
name: "escaping symlink target",
entries: []repositoryEntry{{Path: "escape", Kind: "symlink", Mode: 0o777, LinkTarget: "../outside"}},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
store := NewRepositoryStore(nil)
provider := newMemoryRepositoryProvider()
snapshot := repositorySnapshot{
Version: repositoryFormatVersion,
TaskID: 1,
CreatedAt: time.Now().UTC(),
Compression: "none",
Entries: tc.entries,
}
data, err := store.encodeSnapshot(snapshot)
if err != nil {
t.Fatalf("encodeSnapshot returned error: %v", err)
}
key := store.SnapshotKey(1, 1, snapshot.CreatedAt)
if err := provider.Upload(ctx, key, bytes.NewReader(data), int64(len(data)), nil); err != nil {
t.Fatalf("Upload snapshot returned error: %v", err)
}
digest := sha256.Sum256(data)
task := TaskSpec{SourcePath: filepath.Join(t.TempDir(), "source"), RestoreTargetPath: filepath.Join(t.TempDir(), "restore")}
if err := store.Restore(ctx, provider, key, fmt.Sprintf("%x", digest[:]), task, NopLogWriter{}); err == nil {
t.Fatal("restore accepted unsafe snapshot metadata")
}
})
}
}
func TestRepositoryRestorePreservesDirectoryWhenSnapshotContainsSymlink(t *testing.T) {
ctx := context.Background()
store := NewRepositoryStore(nil)
provider := newMemoryRepositoryProvider()
snapshot := repositorySnapshot{
Version: repositoryFormatVersion,
TaskID: 1,
CreatedAt: time.Now().UTC(),
Compression: "none",
Entries: []repositoryEntry{{Path: "link", Kind: "symlink", Mode: 0o777, LinkTarget: "inside"}},
}
data, err := store.encodeSnapshot(snapshot)
if err != nil {
t.Fatalf("encodeSnapshot returned error: %v", err)
}
key := store.SnapshotKey(1, 1, snapshot.CreatedAt)
if err := provider.Upload(ctx, key, bytes.NewReader(data), int64(len(data)), nil); err != nil {
t.Fatalf("Upload snapshot returned error: %v", err)
}
digest := sha256.Sum256(data)
restoreRoot := filepath.Join(t.TempDir(), "restore")
markerPath := filepath.Join(restoreRoot, "link", "keep.txt")
if err := os.MkdirAll(filepath.Dir(markerPath), 0o755); err != nil {
t.Fatalf("MkdirAll marker parent: %v", err)
}
if err := os.WriteFile(markerPath, []byte("keep"), 0o600); err != nil {
t.Fatalf("WriteFile marker: %v", err)
}
task := TaskSpec{SourcePath: filepath.Join(t.TempDir(), "source"), RestoreTargetPath: restoreRoot}
if err := store.Restore(ctx, provider, key, fmt.Sprintf("%x", digest[:]), task, NopLogWriter{}); err == nil {
t.Fatal("restore replaced an existing directory with a symlink")
}
if data, err := os.ReadFile(markerPath); err != nil || string(data) != "keep" {
t.Fatalf("existing directory content changed: data=%q err=%v", data, err)
}
}
func TestRepositoryRejectsOversizedChunkLocation(t *testing.T) {
ctx := context.Background()
store := NewRepositoryStore(nil)
provider := newMemoryRepositoryProvider()
chunkID := "p-" + strings.Repeat("0", sha256.Size*2)
packID := strings.Repeat("a", sha256.Size*2)
segment := repositoryIndexSegment{
Version: repositoryFormatVersion,
Pack: fmt.Sprintf("%s/%s/%s.pack", repositoryPackPrefix, packID[:2], packID),
Chunks: map[string]repositoryChunkLocation{
chunkID: {Pack: fmt.Sprintf("%s/%s/%s.pack", repositoryPackPrefix, packID[:2], packID), Offset: 0, Length: repositoryMaxEncoded + 1, PlainSize: 1, Compression: "none"},
},
}
data, err := json.Marshal(segment)
if err != nil {
t.Fatalf("Marshal index returned error: %v", err)
}
indexKey := fmt.Sprintf("%s/%s.json", repositoryIndexPrefix, packID)
if err := provider.Upload(ctx, indexKey, bytes.NewReader(data), int64(len(data)), nil); err != nil {
t.Fatalf("Upload index returned error: %v", err)
}
if _, err := store.loadIndex(ctx, provider); err == nil {
t.Fatal("loadIndex accepted an oversized encoded chunk")
}
}
func TestRepositoryRejectsIndexPackMismatch(t *testing.T) {
ctx := context.Background()
store := NewRepositoryStore(nil)
provider := newMemoryRepositoryProvider()
chunkID := "p-" + strings.Repeat("0", sha256.Size*2)
indexID := strings.Repeat("a", sha256.Size*2)
otherPackID := strings.Repeat("b", sha256.Size*2)
expectedPack := fmt.Sprintf("%s/%s/%s.pack", repositoryPackPrefix, indexID[:2], indexID)
segment := repositoryIndexSegment{
Version: repositoryFormatVersion,
Pack: expectedPack,
Chunks: map[string]repositoryChunkLocation{
chunkID: {
Pack: fmt.Sprintf("%s/%s/%s.pack", repositoryPackPrefix, otherPackID[:2], otherPackID),
Offset: 0,
Length: 1,
PlainSize: 1,
Compression: "none",
},
},
}
data, err := json.Marshal(segment)
if err != nil {
t.Fatalf("Marshal index returned error: %v", err)
}
indexKey := fmt.Sprintf("%s/%s.json", repositoryIndexPrefix, indexID)
if err := provider.Upload(ctx, indexKey, bytes.NewReader(data), int64(len(data)), nil); err != nil {
t.Fatalf("Upload index returned error: %v", err)
}
if _, err := store.loadIndex(ctx, provider); err == nil {
t.Fatal("loadIndex accepted a chunk location pointing to a different pack")
}
}
type memoryRepositoryProvider struct {
mu sync.RWMutex
objects map[string][]byte
times map[string]time.Time
}
func newMemoryRepositoryProvider() *memoryRepositoryProvider {
return &memoryRepositoryProvider{objects: make(map[string][]byte), times: make(map[string]time.Time)}
}
func (p *memoryRepositoryProvider) Type() storage.ProviderType { return "memory" }
func (p *memoryRepositoryProvider) TestConnection(context.Context) error { return nil }
func (p *memoryRepositoryProvider) Upload(_ context.Context, key string, reader io.Reader, size int64, _ map[string]string) error {
data, err := io.ReadAll(reader)
if err != nil {
return err
}
if int64(len(data)) != size {
return fmt.Errorf("size mismatch for %s: %d != %d", key, len(data), size)
}
p.mu.Lock()
defer p.mu.Unlock()
p.objects[key] = append([]byte(nil), data...)
p.times[key] = time.Now().UTC()
return nil
}
func (p *memoryRepositoryProvider) Download(_ context.Context, key string) (io.ReadCloser, error) {
p.mu.RLock()
defer p.mu.RUnlock()
data, ok := p.objects[key]
if !ok {
return nil, fmt.Errorf("object %s not found", key)
}
return io.NopCloser(bytes.NewReader(append([]byte(nil), data...))), nil
}
func (p *memoryRepositoryProvider) DownloadRange(_ context.Context, key string, offset, length int64) (io.ReadCloser, error) {
p.mu.RLock()
defer p.mu.RUnlock()
data, ok := p.objects[key]
if !ok {
return nil, fmt.Errorf("object %s not found", key)
}
if offset < 0 || length <= 0 || offset+length > int64(len(data)) {
return nil, fmt.Errorf("invalid range %d:%d for %s", offset, length, key)
}
return io.NopCloser(bytes.NewReader(append([]byte(nil), data[offset:offset+length]...))), nil
}
func (p *memoryRepositoryProvider) Delete(_ context.Context, key string) error {
p.mu.Lock()
defer p.mu.Unlock()
if _, ok := p.objects[key]; !ok {
return fmt.Errorf("object %s not found", key)
}
delete(p.objects, key)
delete(p.times, key)
return nil
}
func (p *memoryRepositoryProvider) List(_ context.Context, prefix string) ([]storage.ObjectInfo, error) {
p.mu.RLock()
defer p.mu.RUnlock()
result := make([]storage.ObjectInfo, 0)
for key, data := range p.objects {
if strings.HasPrefix(key, prefix) {
result = append(result, storage.ObjectInfo{Key: key, Size: int64(len(data)), UpdatedAt: p.times[key]})
}
}
sort.Slice(result, func(i, j int) bool { return result[i].Key < result[j].Key })
return result, nil
}

View File

@@ -0,0 +1,55 @@
package retention
import (
"testing"
"backupx/server/internal/model"
)
func retentionRecIDs(records []model.BackupRecord) []uint {
ids := make([]uint, 0, len(records))
for _, r := range records {
ids = append(ids, r.ID)
}
return ids
}
// 基线全量仍被「不在删除集合中的差异」依赖 → 必须保留,否则差异无法恢复。
func TestProtectDifferentialBasesKeepsBaseWithSurvivingDiff(t *testing.T) {
all := []model.BackupRecord{
{ID: 1, BackupKind: model.BackupKindFull},
{ID: 2, BackupKind: model.BackupKindDifferential, BaseRecordID: 1},
}
candidates := []model.BackupRecord{{ID: 1, BackupKind: model.BackupKindFull}}
if got := protectDifferentialBases(all, candidates); len(got) != 0 {
t.Fatalf("base with surviving diff must be protected, got %v", retentionRecIDs(got))
}
}
// 基线全量与其全部差异都在删除集合中 → 可一并删除(无残留差异失去基线)。
func TestProtectDifferentialBasesDeletesBaseWhenDiffAlsoDeleted(t *testing.T) {
all := []model.BackupRecord{
{ID: 1, BackupKind: model.BackupKindFull},
{ID: 2, BackupKind: model.BackupKindDifferential, BaseRecordID: 1},
}
candidates := []model.BackupRecord{
{ID: 1, BackupKind: model.BackupKindFull},
{ID: 2, BackupKind: model.BackupKindDifferential, BaseRecordID: 1},
}
if got := protectDifferentialBases(all, candidates); len(got) != 2 {
t.Fatalf("base+diff both expired should both be deleted, got %v", retentionRecIDs(got))
}
}
// 无差异备份时原样透传(不影响既有全量保留逻辑)。
func TestProtectDifferentialBasesNoDiffsPassThrough(t *testing.T) {
all := []model.BackupRecord{
{ID: 1, BackupKind: model.BackupKindFull},
{ID: 2, BackupKind: model.BackupKindFull},
}
candidates := []model.BackupRecord{{ID: 1, BackupKind: model.BackupKindFull}}
got := protectDifferentialBases(all, candidates)
if len(got) != 1 || got[0].ID != 1 {
t.Fatalf("no diffs should pass through unchanged, got %v", retentionRecIDs(got))
}
}

View File

@@ -2,10 +2,13 @@ package retention
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"backupx/server/internal/backup"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"backupx/server/internal/storage"
@@ -39,16 +42,48 @@ type CleanupResult struct {
Warnings []string
}
type Service struct {
records repository.BackupRecordRepository
now func() time.Time
type cleanupObject struct {
targetID uint
path string
}
func NewService(records repository.BackupRecordRepository) *Service {
return &Service{records: records, now: func() time.Time { return time.Now().UTC() }}
type storedUploadResult struct {
StorageTargetID uint `json:"storageTargetId"`
Status string `json:"status"`
StoragePath string `json:"storagePath"`
}
type Service struct {
records repository.BackupRecordRepository
repositoryKey []byte
now func() time.Time
}
func NewService(records repository.BackupRecordRepository, repositoryKey ...[]byte) *Service {
var key []byte
if len(repositoryKey) > 0 {
key = append([]byte(nil), repositoryKey[0]...)
}
return &Service{records: records, repositoryKey: key, now: func() time.Time { return time.Now().UTC() }}
}
func (s *Service) Cleanup(ctx context.Context, task *model.BackupTask, provider storage.StorageProvider) (*CleanupResult, error) {
return s.cleanup(ctx, task, func(uint) (storage.StorageProvider, bool) {
return provider, provider != nil
})
}
// CleanupProviders applies one retention decision to every successful copy of
// a record before deleting its database row. This prevents multi-target tasks
// from leaving stale objects after the first target removes the shared record.
func (s *Service) CleanupProviders(ctx context.Context, task *model.BackupTask, providers map[uint]storage.StorageProvider) (*CleanupResult, error) {
return s.cleanup(ctx, task, func(targetID uint) (storage.StorageProvider, bool) {
provider, ok := providers[targetID]
return provider, ok && provider != nil
})
}
func (s *Service) cleanup(ctx context.Context, task *model.BackupTask, resolveProvider func(uint) (storage.StorageProvider, bool)) (*CleanupResult, error) {
if task == nil {
return nil, fmt.Errorf("backup task is required")
}
@@ -56,19 +91,45 @@ func (s *Service) Cleanup(ctx context.Context, task *model.BackupTask, provider
if err != nil {
return nil, fmt.Errorf("list successful records: %w", err)
}
candidates := selectRecordsToDelete(records, task.RetentionDays, task.MaxBackups, s.now())
var candidates []model.BackupRecord
if gfsEnabled(task) {
// GFS 策略:按天/周/月/年分层保留代表性备份,取代简单的天数/数量策略。
candidates = selectGFSToDelete(records, task.KeepDaily, task.KeepWeekly, task.KeepMonthly, task.KeepYearly)
} else {
candidates = selectRecordsToDelete(records, task.RetentionDays, task.MaxBackups, s.now())
}
// 差异链保护:保留仍被存活差异依赖的全量,避免删除基线后差异无法恢复。
candidates = protectDifferentialBases(records, candidates)
result := &CleanupResult{}
repositoryProviders := make(map[uint]storage.StorageProvider)
touchedProviders := make(map[uint]storage.StorageProvider)
for _, record := range candidates {
if strings.TrimSpace(record.StoragePath) != "" {
if provider == nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("record %d missing storage provider for cleanup", record.ID))
objects, objectErr := cleanupObjectsForRecord(record)
if objectErr != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("decode storage copies for record %d failed: %v", record.ID, objectErr))
continue
}
allObjectsDeleted := true
for _, object := range objects {
provider, ok := resolveProvider(object.targetID)
if !ok {
result.Warnings = append(result.Warnings, fmt.Sprintf("record %d missing storage provider %d for cleanup", record.ID, object.targetID))
allObjectsDeleted = false
continue
}
if err := provider.Delete(ctx, record.StoragePath); err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("delete storage object %s failed: %v", record.StoragePath, err))
if err := provider.Delete(ctx, object.path); err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("delete storage object %s from target %d failed: %v", object.path, object.targetID, err))
allObjectsDeleted = false
continue
}
result.DeletedObjects++
touchedProviders[object.targetID] = provider
if record.BackupKind == model.BackupKindRepository {
repositoryProviders[object.targetID] = provider
}
}
if !allObjectsDeleted {
continue
}
if err := s.records.Delete(ctx, record.ID); err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("delete backup record %d failed: %v", record.ID, err))
@@ -76,13 +137,25 @@ func (s *Service) Cleanup(ctx context.Context, task *model.BackupTask, provider
}
result.DeletedRecords++
}
for targetID, provider := range repositoryProviders {
pruned, pruneErr := backup.NewRepositoryStore(s.repositoryKey).Prune(ctx, provider)
if pruneErr != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("prune CDC repository on target %d failed: %v", targetID, pruneErr))
} else {
result.DeletedObjects += pruned.DeletedPacks + pruned.DeletedIndexes
}
}
// 清理空目录:收集被删除文件的父目录,尝试移除空目录
if dirCleaner, ok := provider.(storage.StorageDirCleaner); ok && result.DeletedObjects > 0 {
for targetID, provider := range touchedProviders {
dirCleaner, ok := provider.(storage.StorageDirCleaner)
if !ok {
continue
}
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))
result.Warnings = append(result.Warnings, fmt.Sprintf("cleanup empty dirs for %s on target %d: %v", prefix, targetID, err))
}
}
}
@@ -90,7 +163,87 @@ func (s *Service) Cleanup(ctx context.Context, task *model.BackupTask, provider
return result, nil
}
func cleanupObjectsForRecord(record model.BackupRecord) ([]cleanupObject, error) {
defaultPath := strings.TrimSpace(record.StoragePath)
if strings.TrimSpace(record.StorageUploadResults) == "" {
if defaultPath == "" {
return nil, nil
}
return []cleanupObject{{targetID: record.StorageTargetID, path: defaultPath}}, nil
}
var results []storedUploadResult
if err := json.Unmarshal([]byte(record.StorageUploadResults), &results); err != nil {
return nil, err
}
objects := make([]cleanupObject, 0, len(results))
seen := make(map[uint]struct{}, len(results))
for _, result := range results {
if !strings.EqualFold(strings.TrimSpace(result.Status), model.BackupRecordStatusSuccess) {
continue
}
objectPath := strings.TrimSpace(result.StoragePath)
if objectPath == "" {
objectPath = defaultPath
}
if objectPath == "" {
continue
}
if _, exists := seen[result.StorageTargetID]; exists {
continue
}
seen[result.StorageTargetID] = struct{}{}
objects = append(objects, cleanupObject{targetID: result.StorageTargetID, path: objectPath})
}
if len(objects) == 0 && defaultPath != "" {
return nil, fmt.Errorf("successful record has no successful storage copy")
}
return objects, nil
}
// protectDifferentialBases 从删除候选中剔除「仍被存活差异依赖的全量」,
// 避免删除基线后其差异备份失去依据、无法恢复。全量仅当其全部差异都已过期/删除时才会被清理。
func protectDifferentialBases(all []model.BackupRecord, candidates []model.BackupRecord) []model.BackupRecord {
deleting := make(map[uint]struct{}, len(candidates))
for _, r := range candidates {
deleting[r.ID] = struct{}{}
}
protected := make(map[uint]struct{})
for _, r := range all {
if r.BackupKind != model.BackupKindDifferential || r.BaseRecordID == 0 {
continue
}
if _, beingDeleted := deleting[r.ID]; beingDeleted {
continue // 该差异本身也将被删除,无需保护其基线
}
protected[r.BaseRecordID] = struct{}{}
}
if len(protected) == 0 {
return candidates
}
filtered := make([]model.BackupRecord, 0, len(candidates))
for _, r := range candidates {
if r.BackupKind == model.BackupKindFull {
if _, keep := protected[r.ID]; keep {
continue
}
}
filtered = append(filtered, r)
}
return filtered
}
func selectRecordsToDelete(records []model.BackupRecord, retentionDays int, maxBackups int, now time.Time) []model.BackupRecord {
// 保留锁定(法律保留)的记录永不参与清理:先从候选集中剔除,
// 锁定备份既不被删除,也不占用 maxBackups 轮转名额。
if hasLocked(records) {
unlocked := make([]model.BackupRecord, 0, len(records))
for _, r := range records {
if !r.Locked {
unlocked = append(unlocked, r)
}
}
records = unlocked
}
selected := make(map[uint]model.BackupRecord)
if maxBackups > 0 && len(records) > maxBackups {
for _, record := range records[maxBackups:] {
@@ -113,3 +266,81 @@ func selectRecordsToDelete(records []model.BackupRecord, retentionDays int, maxB
}
return result
}
func hasLocked(records []model.BackupRecord) bool {
for i := range records {
if records[i].Locked {
return true
}
}
return false
}
// gfsEnabled 判定任务是否启用 GFS 分层保留(任一层级 > 0
func gfsEnabled(task *model.BackupTask) bool {
return task.KeepDaily > 0 || task.KeepWeekly > 0 || task.KeepMonthly > 0 || task.KeepYearly > 0
}
func recordTime(r *model.BackupRecord) time.Time {
if r.CompletedAt != nil {
return *r.CompletedAt
}
return r.StartedAt
}
func isoWeekKey(t time.Time) string {
y, w := t.ISOWeek()
return fmt.Sprintf("%d-W%02d", y, w)
}
// selectGFSToDelete 按 GFS祖父-父-子)策略选出应删除的记录。
//
// 规则:对每个层级(天/周/月/年),在按时间降序排列后,保留最近 keep 个不同周期中
// 每个周期最新的一份备份;各层级保留集合取并集即「保留集」,其余删除。
// 锁定(法律保留)的记录始终排除在删除候选之外。
func selectGFSToDelete(records []model.BackupRecord, daily, weekly, monthly, yearly int) []model.BackupRecord {
active := make([]model.BackupRecord, 0, len(records))
for i := range records {
if !records[i].Locked {
active = append(active, records[i])
}
}
sort.SliceStable(active, func(i, j int) bool {
return recordTime(&active[i]).After(recordTime(&active[j]))
})
keep := make(map[uint]bool, len(active))
keepTier := func(count int, key func(time.Time) string) {
if count <= 0 {
return
}
periods := 0
lastPeriod := ""
havePrev := false
for i := range active {
p := key(recordTime(&active[i]))
if havePrev && p == lastPeriod {
continue // 同周期已保留代表(最新一份)
}
if periods >= count {
break // 该层级已保留足够多的周期
}
keep[active[i].ID] = true
lastPeriod = p
havePrev = true
periods++
}
}
keepTier(daily, func(t time.Time) string { return t.Format("2006-01-02") })
keepTier(weekly, isoWeekKey)
keepTier(monthly, func(t time.Time) string { return t.Format("2006-01") })
keepTier(yearly, func(t time.Time) string { return t.Format("2006") })
del := make([]model.BackupRecord, 0)
for i := range active {
if !keep[active[i].ID] {
del = append(del, active[i])
}
}
return del
}

View File

@@ -24,6 +24,9 @@ func (r *fakeRecordRepository) List(context.Context, repository.BackupRecordList
func (r *fakeRecordRepository) FindByID(context.Context, uint) (*model.BackupRecord, error) {
return nil, nil
}
func (r *fakeRecordRepository) FindRunningByTaskAndNode(context.Context, uint, uint) (*model.BackupRecord, error) {
return nil, nil
}
func (r *fakeRecordRepository) Create(context.Context, *model.BackupRecord) error { return nil }
func (r *fakeRecordRepository) Update(context.Context, *model.BackupRecord) error { return nil }
func (r *fakeRecordRepository) Delete(_ context.Context, id uint) error {
@@ -42,6 +45,9 @@ func (r *fakeRecordRepository) ListByTask(_ context.Context, _ uint) ([]model.Ba
func (r *fakeRecordRepository) ListSuccessfulByTask(_ context.Context, _ uint) ([]model.BackupRecord, error) {
return r.records, nil
}
func (r *fakeRecordRepository) CountDependentDifferentials(context.Context, uint) (int64, error) {
return 0, nil
}
func (r *fakeRecordRepository) Count(context.Context) (int64, error) { return 0, nil }
func (r *fakeRecordRepository) CountSince(context.Context, time.Time) (int64, error) { return 0, nil }
func (r *fakeRecordRepository) CountSuccessSince(context.Context, time.Time) (int64, error) {
@@ -90,6 +96,105 @@ func TestSelectRecordsToDelete(t *testing.T) {
}
}
func gfsRecord(id uint, ts time.Time, locked bool) model.BackupRecord {
completed := ts
return model.BackupRecord{ID: id, StartedAt: ts, CompletedAt: &completed, Locked: locked}
}
func gfsDay(y, m, d, h int) time.Time {
return time.Date(y, time.Month(m), d, h, 0, 0, 0, time.UTC)
}
func deletedIDSet(records []model.BackupRecord) map[uint]bool {
out := make(map[uint]bool, len(records))
for i := range records {
out[records[i].ID] = true
}
return out
}
func assertDeleted(t *testing.T, del []model.BackupRecord, want ...uint) {
t.Helper()
got := deletedIDSet(del)
if len(got) != len(want) {
t.Fatalf("deleted set size = %d %v, want %d %v", len(got), got, len(want), want)
}
for _, id := range want {
if !got[id] {
t.Fatalf("expected id %d to be deleted; got %v", id, got)
}
}
}
// TestSelectGFSToDelete_DailyTier 验证按天分层:每天仅保留最新一份,且只保留最近 N 天。
func TestSelectGFSToDelete_DailyTier(t *testing.T) {
records := []model.BackupRecord{
gfsRecord(5, gfsDay(2026, 3, 7, 12), false), // 今天,最新 → 保留
gfsRecord(4, gfsDay(2026, 3, 7, 6), false), // 今天,较早 → 删除(非当天代表)
gfsRecord(3, gfsDay(2026, 3, 6, 12), false), // 昨天 → 保留
gfsRecord(2, gfsDay(2026, 3, 5, 12), false), // 前天 → 超出 daily=2 → 删除
gfsRecord(1, gfsDay(2026, 3, 4, 12), false), // 更早 → 删除
}
del := selectGFSToDelete(records, 2, 0, 0, 0)
assertDeleted(t, del, 4, 2, 1)
}
// TestSelectGFSToDelete_TierUnion 验证多层级取并集:月度层级保留日度层级会删除的旧备份。
func TestSelectGFSToDelete_TierUnion(t *testing.T) {
records := []model.BackupRecord{
gfsRecord(3, gfsDay(2026, 3, 7, 12), false), // 3 月(最新)
gfsRecord(2, gfsDay(2026, 2, 15, 12), false), // 2 月
gfsRecord(1, gfsDay(2026, 1, 15, 12), false), // 1 月
}
// daily=1 只留 ID3monthly=2 留最近两个月3 月=ID3、2 月=ID2。并集={3,2},删除 ID1。
del := selectGFSToDelete(records, 1, 0, 2, 0)
assertDeleted(t, del, 1)
}
// TestSelectGFSToDelete_SkipsLocked 验证锁定记录即使超出所有层级也永不删除。
func TestSelectGFSToDelete_SkipsLocked(t *testing.T) {
records := []model.BackupRecord{
gfsRecord(3, gfsDay(2026, 3, 7, 12), false),
gfsRecord(2, gfsDay(2026, 3, 6, 12), false),
gfsRecord(1, gfsDay(2020, 1, 1, 12), true), // 远超 daily=1 但已锁定 → 不删
}
del := selectGFSToDelete(records, 1, 0, 0, 0)
assertDeleted(t, del, 2) // 仅 ID2 被删ID1 锁定豁免ID3 为当日代表
}
func TestGFSEnabled(t *testing.T) {
if gfsEnabled(&model.BackupTask{}) {
t.Fatal("empty GFS config should be disabled")
}
if !gfsEnabled(&model.BackupTask{KeepWeekly: 4}) {
t.Fatal("KeepWeekly>0 should enable GFS")
}
}
// TestSelectRecordsToDelete_SkipsLocked 验证保留锁定(法律保留)的记录永不被选中删除,
// 即使它既超过保留期、又超过 maxBackups 名额。
func TestSelectRecordsToDelete_SkipsLocked(t *testing.T) {
now := time.Date(2026, 3, 7, 16, 0, 0, 0, time.UTC)
completedNew := now.Add(-24 * time.Hour)
completedOld := now.Add(-15 * 24 * time.Hour)
records := []model.BackupRecord{
{ID: 3, CompletedAt: &completedNew},
{ID: 2, CompletedAt: &completedNew},
{ID: 1, CompletedAt: &completedOld, Locked: true}, // 超期但锁定 → 不应删除
}
selected := selectRecordsToDelete(records, 7, 2, now)
for _, r := range selected {
if r.ID == 1 {
t.Fatalf("locked record #1 must never be selected for deletion: %#v", selected)
}
}
// 锁定记录不占 maxBackups 名额:未锁定仅 2 条maxBackups=2 → 无超额删除,
// 且无未锁定记录超期 → 选中集为空。
if len(selected) != 0 {
t.Fatalf("expected no deletions (locked excluded), got %#v", selected)
}
}
func TestCleanupDeletesExpiredRecords(t *testing.T) {
now := time.Date(2026, 3, 7, 16, 0, 0, 0, time.UTC)
completedNew := now.Add(-24 * time.Hour)
@@ -116,3 +221,66 @@ func TestCleanupDeletesExpiredRecords(t *testing.T) {
t.Fatalf("unexpected deleted objects: %#v", provider.deleted)
}
}
func TestCleanupProvidersDeletesEverySuccessfulCopyBeforeRecord(t *testing.T) {
now := time.Date(2026, 3, 7, 16, 0, 0, 0, time.UTC)
completedNew := now.Add(-time.Hour)
completedOld := now.Add(-24 * time.Hour)
repo := &fakeRecordRepository{records: []model.BackupRecord{
{ID: 2, TaskID: 1, StoragePath: "records/2", Status: model.BackupRecordStatusSuccess, CompletedAt: &completedNew},
{
ID: 1, TaskID: 1, StoragePath: "records/1", Status: model.BackupRecordStatusSuccess, CompletedAt: &completedOld,
StorageUploadResults: `[{"storageTargetId":11,"status":"success","storagePath":"first/1"},{"storageTargetId":12,"status":"success","storagePath":"second/1"},{"storageTargetId":13,"status":"failed"}]`,
},
}}
first := &fakeProvider{}
second := &fakeProvider{}
service := NewService(repo)
service.now = func() time.Time { return now }
result, err := service.CleanupProviders(context.Background(), &model.BackupTask{ID: 1, MaxBackups: 1}, map[uint]storage.StorageProvider{
11: first,
12: second,
})
if err != nil {
t.Fatalf("CleanupProviders returned error: %v", err)
}
if result.DeletedRecords != 1 || result.DeletedObjects != 2 || len(result.Warnings) != 0 {
t.Fatalf("unexpected cleanup result: %#v", result)
}
if len(repo.deleted) != 1 || repo.deleted[0] != 1 {
t.Fatalf("unexpected deleted records: %#v", repo.deleted)
}
if len(first.deleted) != 1 || first.deleted[0] != "first/1" {
t.Fatalf("unexpected first-target deletes: %#v", first.deleted)
}
if len(second.deleted) != 1 || second.deleted[0] != "second/1" {
t.Fatalf("unexpected second-target deletes: %#v", second.deleted)
}
}
func TestCleanupProvidersKeepsRecordWhenCopyProviderIsUnavailable(t *testing.T) {
now := time.Date(2026, 3, 7, 16, 0, 0, 0, time.UTC)
completedNew := now.Add(-time.Hour)
completedOld := now.Add(-24 * time.Hour)
repo := &fakeRecordRepository{records: []model.BackupRecord{
{ID: 2, TaskID: 1, StoragePath: "records/2", Status: model.BackupRecordStatusSuccess, CompletedAt: &completedNew},
{
ID: 1, TaskID: 1, StoragePath: "records/1", Status: model.BackupRecordStatusSuccess, CompletedAt: &completedOld,
StorageUploadResults: `[{"storageTargetId":11,"status":"success","storagePath":"first/1"},{"storageTargetId":12,"status":"success","storagePath":"second/1"}]`,
},
}}
first := &fakeProvider{}
service := NewService(repo)
result, err := service.CleanupProviders(context.Background(), &model.BackupTask{ID: 1, MaxBackups: 1}, map[uint]storage.StorageProvider{11: first})
if err != nil {
t.Fatalf("CleanupProviders returned error: %v", err)
}
if result.DeletedRecords != 0 || result.DeletedObjects != 1 || len(result.Warnings) != 1 {
t.Fatalf("unexpected safe partial-cleanup result: %#v", result)
}
if len(repo.deleted) != 0 {
t.Fatalf("record must remain until all copies are deleted: %#v", repo.deleted)
}
}

View File

@@ -55,6 +55,17 @@ func BuildStorageKey(backupType string, startedAt time.Time, fileName string) st
return filepath.ToSlash(filepath.Join("BackupX", typeName, startedAt.UTC().Format("060102"), fileName))
}
// BuildRecordStorageKey gives remote-Agent artifacts a record-owned namespace.
// The Master validates this namespace before accepting a relayed upload, so one
// Agent cannot overwrite another record's object on centrally mounted storage.
func BuildRecordStorageKey(backupType string, startedAt time.Time, recordID uint, fileName string) string {
typeName := strings.TrimSpace(strings.ToLower(backupType))
if typeName == "" {
typeName = "file"
}
return filepath.ToSlash(filepath.Join("BackupX", typeName, startedAt.UTC().Format("060102"), "records", fmt.Sprintf("%d", recordID), fileName))
}
func sanitizeTaskName(value string) string {
trimmed := strings.TrimSpace(strings.ToLower(value))
trimmed = strings.ReplaceAll(trimmed, " ", "-")

View File

@@ -36,6 +36,15 @@ type TaskSpec struct {
MaxBackups int
StartedAt time.Time
TempDir string
// Differential 为 true 时执行差异备份:仅打包自 BaseManifest 以来新增/变更的条目,
// 并记录被删除的路径。仅文件类型任务支持BaseManifest 为空时回退为全量。
Differential bool
BaseManifest Manifest
// SelectedPaths 非空时仅恢复这些归档相对路径(及其子项),用于按需(选择性)恢复;仅文件类型生效。
SelectedPaths []string
// RestoreTargetPath 仅用于恢复:非空时,文件类型恢复将归档解压到该目录,
// 而非默认的原始源路径父目录。用于「恢复到指定位置」(迁移/测试/并排恢复)。
RestoreTargetPath string
}
type RunResult struct {
@@ -44,6 +53,8 @@ type RunResult struct {
TempDir string
Size int64
StorageKey string
// Manifest 为全量备份产出的条目清单,供后续差异备份比对;差异备份运行时为 nil。
Manifest *Manifest
}
type LogEvent struct {
@@ -62,7 +73,7 @@ type ProgressInfo struct {
BytesSent int64 `json:"bytesSent"`
TotalBytes int64 `json:"totalBytes"`
Percent float64 `json:"percent"`
SpeedBps float64 `json:"speedBps"` // bytes/sec
SpeedBps float64 `json:"speedBps"` // bytes/sec
TargetName string `json:"targetName"`
}

View File

@@ -17,9 +17,14 @@ type Config struct {
}
type ServerConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"`
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"`
ExternalURL string `mapstructure:"external_url"`
// WebRoot 指向前端构建产物目录。留空时后端会按部署惯例自动探测
// ./web、./web/dist、/opt/backupx/web 等)。探测命中后后端直接托管
// 前端 SPA无需额外的 nginx 反向代理即可访问 Web 控制台。
WebRoot string `mapstructure:"web_root"`
}
type DatabaseConfig struct {
@@ -136,6 +141,8 @@ func applyDefaults(v *viper.Viper) {
v.SetDefault("server.host", "0.0.0.0")
v.SetDefault("server.port", 8340)
v.SetDefault("server.mode", "release")
v.SetDefault("server.external_url", "")
v.SetDefault("server.web_root", "")
v.SetDefault("database.path", "./data/backupx.db")
v.SetDefault("security.jwt_expire", "24h")
v.SetDefault("backup.temp_dir", "/tmp/backupx")

View File

@@ -1,6 +1,10 @@
package config
import "testing"
import (
"os"
"path/filepath"
"testing"
)
func TestLoadUsesDefaultsWithoutConfigFile(t *testing.T) {
cfg, err := Load("")
@@ -18,3 +22,33 @@ func TestLoadUsesDefaultsWithoutConfigFile(t *testing.T) {
t.Fatalf("expected default database path, got %s", cfg.Database.Path)
}
}
func TestLoadReadsServerExternalURLFromFile(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yaml")
content := []byte("server:\n external_url: \"https://backup.example.com\"\n")
if err := os.WriteFile(configPath, content, 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.Server.ExternalURL != "https://backup.example.com" {
t.Fatalf("expected external URL from config, got %q", cfg.Server.ExternalURL)
}
}
func TestLoadReadsServerExternalURLFromEnv(t *testing.T) {
t.Setenv("BACKUPX_SERVER_EXTERNAL_URL", "https://env-backup.example.com")
cfg, err := Load("")
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.Server.ExternalURL != "https://env-backup.example.com" {
t.Fatalf("expected external URL from env, got %q", cfg.Server.ExternalURL)
}
}

View File

@@ -156,6 +156,44 @@ func (h *AgentHandler) UpdateRecord(c *gin.Context) {
response.Success(c, gin.H{"status": "ok"})
}
// UploadArtifact streams a remote source artifact into storage mounted only on
// the Master. The request body is never buffered as a whole in memory or disk.
func (h *AgentHandler) UploadArtifact(c *gin.Context) {
node, err := h.agentService.AuthenticatedNode(c.Request.Context(), extractToken(c))
if err != nil {
response.Error(c, err)
return
}
recordID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.Error(c, err)
return
}
targetID, err := strconv.ParseUint(c.Param("targetId"), 10, 32)
if err != nil {
response.Error(c, err)
return
}
if c.Request.ContentLength < 0 {
c.JSON(stdhttp.StatusLengthRequired, gin.H{"code": "CONTENT_LENGTH_REQUIRED", "message": "artifact content length is required"})
return
}
if err := h.agentService.UploadArtifact(
c.Request.Context(),
node,
uint(recordID),
uint(targetID),
c.GetHeader("X-BackupX-Object-Key"),
c.Request.ContentLength,
c.GetHeader("X-BackupX-SHA256"),
c.Request.Body,
); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"status": "ok"})
}
// GetRestoreSpec Agent 拉取恢复规格。
func (h *AgentHandler) GetRestoreSpec(c *gin.Context) {
if h.restoreService == nil {
@@ -208,6 +246,34 @@ func (h *AgentHandler) UpdateRestore(c *gin.Context) {
response.Success(c, gin.H{"status": "ok"})
}
// DownloadRestoreArtifact streams a Master-local backup back to its source
// Agent for restore without exposing the local storage configuration.
func (h *AgentHandler) DownloadRestoreArtifact(c *gin.Context) {
if h.restoreService == nil {
c.JSON(stdhttp.StatusServiceUnavailable, gin.H{"code": "RESTORE_SERVICE_DISABLED", "message": "restore service is not enabled"})
return
}
node, err := h.agentService.AuthenticatedNode(c.Request.Context(), extractToken(c))
if err != nil {
response.Error(c, err)
return
}
restoreID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.Error(c, err)
return
}
artifact, err := h.restoreService.DownloadAgentArtifact(c.Request.Context(), node, uint(restoreID))
if err != nil {
response.Error(c, err)
return
}
c.DataFromReader(stdhttp.StatusOK, artifact.Size, "application/octet-stream", artifact.Reader, nil)
if err := artifact.Reader.Close(); err != nil {
_ = c.Error(err)
}
}
// Self 返回当前 Agent token 所属节点的状态,供安装脚本末尾探活。
func (h *AgentHandler) Self(c *gin.Context) {
node, err := h.agentService.AuthenticatedNode(c.Request.Context(), extractToken(c))

View File

@@ -1,12 +1,23 @@
package http
import (
"net"
stdhttp "net/http"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
const (
trustedDeviceCookieName = "backupx_trusted_device"
trustedDeviceCookiePath = "/api/auth"
trustedDeviceCookieMaxAge = int((30 * 24 * time.Hour) / time.Second)
)
type AuthHandler struct {
authService *service.AuthService
}
@@ -44,11 +55,18 @@ func (h *AuthHandler) Login(c *gin.Context) {
response.Error(c, apperror.BadRequest("AUTH_LOGIN_INVALID", "登录参数不合法", err))
return
}
if strings.TrimSpace(input.TrustedDeviceToken) == "" {
input.TrustedDeviceToken = trustedDeviceCookieValue(c)
}
payload, err := h.authService.Login(c.Request.Context(), input, ClientKey(c))
if err != nil {
response.Error(c, err)
return
}
if payload.TrustedDeviceToken != "" {
setTrustedDeviceCookie(c, payload.TrustedDeviceToken)
payload.TrustedDeviceToken = ""
}
response.Success(c, payload)
}
@@ -83,9 +101,315 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
response.Error(c, err)
return
}
clearTrustedDeviceCookie(c)
response.Success(c, gin.H{"changed": true})
}
func (h *AuthHandler) PrepareTwoFactor(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.TwoFactorSetupInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
return
}
payload, err := h.authService.PrepareTwoFactor(c.Request.Context(), subject, input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
func (h *AuthHandler) EnableTwoFactor(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.EnableTwoFactorInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
return
}
user, err := h.authService.EnableTwoFactor(c.Request.Context(), subject, input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, user)
}
func (h *AuthHandler) DisableTwoFactor(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.DisableTwoFactorInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
return
}
user, err := h.authService.DisableTwoFactor(c.Request.Context(), subject, input)
if err != nil {
response.Error(c, err)
return
}
if !user.MFAEnabled {
clearTrustedDeviceCookie(c)
}
response.Success(c, user)
}
func (h *AuthHandler) RegenerateRecoveryCodes(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.RegenerateRecoveryCodesInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
return
}
payload, err := h.authService.RegenerateRecoveryCodes(c.Request.Context(), subject, input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
func (h *AuthHandler) ConfigureOTP(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.OTPConfigInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_OTP_INVALID", "参数不合法", err))
return
}
user, err := h.authService.ConfigureOutOfBandOTP(c.Request.Context(), subject, input)
if err != nil {
response.Error(c, err)
return
}
if !user.MFAEnabled {
clearTrustedDeviceCookie(c)
}
response.Success(c, user)
}
func (h *AuthHandler) SendLoginOTP(c *gin.Context) {
var input service.LoginOTPInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_OTP_INVALID", "参数不合法", err))
return
}
if err := h.authService.SendLoginOTP(c.Request.Context(), input, ClientKey(c)); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"sent": true})
}
func (h *AuthHandler) BeginWebAuthnRegistration(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.WebAuthnRegistrationOptionsInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
return
}
options, err := h.authService.BeginWebAuthnRegistration(c.Request.Context(), subject, input, webAuthnRequestContext(c))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, options)
}
func (h *AuthHandler) FinishWebAuthnRegistration(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.WebAuthnRegistrationFinishInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
return
}
user, err := h.authService.FinishWebAuthnRegistration(c.Request.Context(), subject, input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, user)
}
func (h *AuthHandler) BeginWebAuthnLogin(c *gin.Context) {
var input service.WebAuthnLoginOptionsInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
return
}
options, err := h.authService.BeginWebAuthnLogin(c.Request.Context(), input, webAuthnRequestContext(c), ClientKey(c))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, options)
}
func (h *AuthHandler) ListWebAuthnCredentials(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
items, err := h.authService.ListWebAuthnCredentials(c.Request.Context(), subject)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *AuthHandler) DeleteWebAuthnCredential(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.WebAuthnCredentialDeleteInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
return
}
user, err := h.authService.DeleteWebAuthnCredential(c.Request.Context(), subject, c.Param("id"), input)
if err != nil {
response.Error(c, err)
return
}
if !user.MFAEnabled {
clearTrustedDeviceCookie(c)
}
response.Success(c, user)
}
func (h *AuthHandler) ListTrustedDevices(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
items, err := h.authService.ListTrustedDevices(c.Request.Context(), subject)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *AuthHandler) RevokeTrustedDevice(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.TrustedDeviceRevokeInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_TRUSTED_DEVICE_INVALID", "参数不合法", err))
return
}
if err := h.authService.RevokeTrustedDevice(c.Request.Context(), subject, c.Param("id"), input); err != nil {
response.Error(c, err)
return
}
clearTrustedDeviceCookie(c)
response.Success(c, gin.H{"deleted": true})
}
func (h *AuthHandler) Logout(c *gin.Context) {
response.Success(c, gin.H{"loggedOut": true})
}
func webAuthnRequestContext(c *gin.Context) service.WebAuthnRequestContext {
host := firstForwardedValue(c.Request.Host)
if forwardedHost := firstForwardedValue(c.GetHeader("X-Forwarded-Host")); forwardedHost != "" {
host = forwardedHost
}
rpID := host
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
rpID = parsedHost
}
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
}
if forwardedProto := firstForwardedValue(c.GetHeader("X-Forwarded-Proto")); forwardedProto != "" {
scheme = forwardedProto
}
origin := strings.TrimSpace(c.GetHeader("Origin"))
if origin == "" {
origin = scheme + "://" + host
}
return service.WebAuthnRequestContext{RPID: rpID, Origin: origin}
}
func firstForwardedValue(value string) string {
parts := strings.Split(value, ",")
if len(parts) == 0 {
return ""
}
return strings.TrimSpace(parts[0])
}
func trustedDeviceCookieValue(c *gin.Context) string {
token, err := c.Cookie(trustedDeviceCookieName)
if err != nil {
return ""
}
return strings.TrimSpace(token)
}
func setTrustedDeviceCookie(c *gin.Context, token string) {
writeTrustedDeviceCookie(c, strings.TrimSpace(token), trustedDeviceCookieMaxAge)
}
func clearTrustedDeviceCookie(c *gin.Context) {
writeTrustedDeviceCookie(c, "", -1)
}
func writeTrustedDeviceCookie(c *gin.Context, value string, maxAge int) {
c.SetSameSite(stdhttp.SameSiteLaxMode)
c.SetCookie(trustedDeviceCookieName, value, maxAge, trustedDeviceCookiePath, "", requestIsSecure(c), true)
}
func requestIsSecure(c *gin.Context) bool {
if c.Request.TLS != nil {
return true
}
return strings.EqualFold(firstForwardedValue(c.GetHeader("X-Forwarded-Proto")), "https")
}

View File

@@ -52,6 +52,20 @@ func (h *BackupRecordHandler) Get(c *gin.Context) {
response.Success(c, item)
}
// Contents 返回备份记录的文件清单(内容浏览,只读)。
func (h *BackupRecordHandler) Contents(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
contents, err := h.service.ListContents(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, contents)
}
func (h *BackupRecordHandler) StreamLogs(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
@@ -137,7 +151,14 @@ func (h *BackupRecordHandler) Restore(c *gin.Context) {
if subject, exists := c.Get(contextUserSubjectKey); exists {
triggeredBy = strings.TrimSpace(fmt.Sprintf("%v", subject))
}
detail, err := h.restoreService.Start(c.Request.Context(), id, triggeredBy)
// 可选请求体selectedPaths 按需选择性恢复targetPath 恢复到指定目录(仅文件类型本机恢复)。
// 无 body 时为整体恢复到原始路径。
var body struct {
SelectedPaths []string `json:"selectedPaths"`
TargetPath string `json:"targetPath"`
}
_ = c.ShouldBindJSON(&body)
detail, err := h.restoreService.StartSelective(c.Request.Context(), id, body.SelectedPaths, strings.TrimSpace(body.TargetPath), triggeredBy)
if err != nil {
response.Error(c, err)
return
@@ -161,6 +182,32 @@ func (h *BackupRecordHandler) Delete(c *gin.Context) {
response.Success(c, gin.H{"deleted": true})
}
// SetLock 设置/解除备份记录的保留锁定(法律保留)。
func (h *BackupRecordHandler) SetLock(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
var input struct {
Locked bool `json:"locked"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("BACKUP_RECORD_LOCK_INVALID", "锁定参数不合法", err))
return
}
detail, err := h.service.SetLock(c.Request.Context(), id, input.Locked)
if err != nil {
response.Error(c, err)
return
}
action, desc := "unlock", fmt.Sprintf("解除备份记录保留锁定 (ID: %d)", id)
if input.Locked {
action, desc = "lock", fmt.Sprintf("设置备份记录保留锁定 (ID: %d)", id)
}
recordAudit(c, h.auditService, "backup_record", action, "backup_record", fmt.Sprintf("%d", id), "", desc)
response.Success(c, detail)
}
func (h *BackupRecordHandler) BatchDelete(c *gin.Context) {
var input struct {
IDs []uint `json:"ids" binding:"required,min=1"`
@@ -216,7 +263,7 @@ func writeSSEEvent(writer io.Writer, event backup.LogEvent) error {
}
func parseUintString(value string) (uint, bool) {
parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 0)
if err != nil {
return 0, false
}

View File

@@ -3,10 +3,12 @@ package http
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
@@ -17,15 +19,20 @@ import (
"backupx/server/internal/repository"
"backupx/server/internal/security"
"backupx/server/internal/service"
"backupx/server/internal/storage/codec"
)
// setupInstallFlowRouter 构造一个 Node + Agent + InstallToken 全量依赖的 router
// 并返回已登录管理员 JWT。
func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
return setupInstallFlowRouterWithExternalURL(t, "")
}
func setupInstallFlowRouterWithExternalURL(t *testing.T, externalURL string) (http.Handler, string) {
t.Helper()
tempDir := t.TempDir()
cfg := config.Config{
Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test"},
Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test", ExternalURL: externalURL},
Database: config.DatabaseConfig{Path: filepath.Join(tempDir, "backupx.db")},
Security: config.SecurityConfig{JWTExpire: "24h"},
Log: config.LogConfig{Level: "error"},
@@ -38,6 +45,13 @@ func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
if err != nil {
t.Fatalf("db: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("sql db: %v", err)
}
t.Cleanup(func() {
_ = sqlDB.Close()
})
userRepo := repository.NewUserRepository(db)
systemConfigRepo := repository.NewSystemConfigRepository(db)
@@ -46,7 +60,7 @@ func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
t.Fatalf("security: %v", err)
}
jwtMgr := security.NewJWTManager(resolved.JWTSecret, time.Hour)
authSvc := service.NewAuthService(userRepo, systemConfigRepo, jwtMgr, security.NewLoginRateLimiter(5, time.Minute))
authSvc := service.NewAuthService(userRepo, systemConfigRepo, jwtMgr, security.NewLoginRateLimiter(5, time.Minute), codec.NewConfigCipher(resolved.EncryptionKey))
systemSvc := service.NewSystemService(cfg, "test", time.Now().UTC())
nodeRepo := repository.NewNodeRepository(db)
@@ -58,9 +72,6 @@ func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
installTokenRepo := repository.NewAgentInstallTokenRepository(db)
installTokenSvc := service.NewInstallTokenService(installTokenRepo, nodeRepo)
auditLogRepo := repository.NewAuditLogRepository(db)
auditSvc := service.NewAuditService(auditLogRepo)
// 用 cancelable ctx测试结束时停掉 handler 启动的后台 GC 协程,
// 避免 goroutine 持有 map 导致 tempdir 清理失败。
ctx, cancel := context.WithCancel(context.Background())
@@ -75,7 +86,7 @@ func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
SystemService: systemSvc,
NodeService: nodeSvc,
InstallTokenService: installTokenSvc,
AuditService: auditSvc,
MasterExternalURL: cfg.Server.ExternalURL,
JWTManager: jwtMgr,
UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo,
@@ -104,6 +115,73 @@ func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
return router, setupResp.Data.Token
}
func TestInstallTokenUsesConfiguredExternalURL(t *testing.T) {
const externalURL = "https://public.example.com/base"
router, jwt := setupInstallFlowRouterWithExternalURL(t, externalURL)
batchBody, _ := json.Marshal(map[string][]string{"names": {"external-url-node"}})
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody))
batchReq.Header.Set("Content-Type", "application/json")
batchReq.Header.Set("Authorization", "Bearer "+jwt)
batchRec := httptest.NewRecorder()
router.ServeHTTP(batchRec, batchReq)
if batchRec.Code != 200 {
t.Fatalf("batch create failed: %d %s", batchRec.Code, batchRec.Body.String())
}
var batchResp struct {
Data []struct {
ID uint `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(batchRec.Body.Bytes(), &batchResp); err != nil {
t.Fatalf("unmarshal batch: %v", err)
}
if len(batchResp.Data) != 1 {
t.Fatalf("expected 1 node, got %d", len(batchResp.Data))
}
genBody, _ := json.Marshal(map[string]any{
"mode": "systemd",
"arch": "auto",
"agentVersion": "v1.7.0",
"downloadSrc": "github",
"ttlSeconds": 900,
})
genReq := httptest.NewRequest(http.MethodPost,
"/api/nodes/"+formatUint(batchResp.Data[0].ID)+"/install-tokens", bytes.NewBuffer(genBody))
genReq.Header.Set("Content-Type", "application/json")
genReq.Header.Set("Authorization", "Bearer "+jwt)
genRec := httptest.NewRecorder()
router.ServeHTTP(genRec, genReq)
if genRec.Code != 200 {
t.Fatalf("install-tokens failed: %d %s", genRec.Code, genRec.Body.String())
}
var genResp struct {
Data struct {
InstallToken string `json:"installToken"`
URL string `json:"url"`
FallbackURL string `json:"fallbackUrl"`
ScriptBase64 string `json:"scriptBase64"`
} `json:"data"`
}
if err := json.Unmarshal(genRec.Body.Bytes(), &genResp); err != nil {
t.Fatalf("unmarshal gen: %v", err)
}
if genResp.Data.URL != externalURL+"/api/install/"+genResp.Data.InstallToken {
t.Fatalf("url should use external URL, got %q", genResp.Data.URL)
}
if genResp.Data.FallbackURL != externalURL+"/install/"+genResp.Data.InstallToken {
t.Fatalf("fallbackUrl should use external URL, got %q", genResp.Data.FallbackURL)
}
decodedScript, err := base64.StdEncoding.DecodeString(genResp.Data.ScriptBase64)
if err != nil {
t.Fatalf("scriptBase64 should be valid base64: %v", err)
}
if !strings.Contains(string(decodedScript), `MASTER_URL="`+externalURL+`"`) {
t.Fatalf("script should use external MASTER_URL:\n%s", string(decodedScript))
}
}
func TestOneClickInstallFlow(t *testing.T) {
router, jwt := setupInstallFlowRouter(t)
@@ -152,6 +230,8 @@ func TestOneClickInstallFlow(t *testing.T) {
Data struct {
InstallToken string `json:"installToken"`
URL string `json:"url"`
FallbackURL string `json:"fallbackUrl"`
ScriptBase64 string `json:"scriptBase64"`
} `json:"data"`
}
if err := json.Unmarshal(genRec.Body.Bytes(), &genResp); err != nil {
@@ -160,6 +240,16 @@ func TestOneClickInstallFlow(t *testing.T) {
if genResp.Data.InstallToken == "" {
t.Fatalf("missing installToken")
}
if !strings.Contains(genResp.Data.FallbackURL, "/install/") {
t.Fatalf("missing fallback install URL, got %q", genResp.Data.FallbackURL)
}
decodedScript, err := base64.StdEncoding.DecodeString(genResp.Data.ScriptBase64)
if err != nil {
t.Fatalf("scriptBase64 should be valid base64: %v", err)
}
if !strings.Contains(string(decodedScript), "BACKUPX_AGENT_INSTALL_V1") {
t.Fatalf("scriptBase64 should contain rendered install script")
}
// 3. 公开端点消费
scriptReq := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
@@ -171,6 +261,22 @@ func TestOneClickInstallFlow(t *testing.T) {
if !strings.Contains(scriptRec.Body.String(), "systemctl enable --now backupx-agent") {
t.Fatalf("script missing systemctl enable:\n%s", scriptRec.Body.String())
}
// Issue #46 防嗅探 headerstext/plain + nosniff + no-store + Content-Disposition
if ct := scriptRec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
t.Errorf("script Content-Type should be text/plain*, got %q", ct)
}
if nosniff := scriptRec.Header().Get("X-Content-Type-Options"); nosniff != "nosniff" {
t.Errorf("missing X-Content-Type-Options: nosniff (got %q)", nosniff)
}
if cc := scriptRec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("missing Cache-Control: no-store (got %q)", cc)
}
if cd := scriptRec.Header().Get("Content-Disposition"); !strings.Contains(cd, "backupx-agent-install.sh") {
t.Errorf("Content-Disposition should name the script file (got %q)", cd)
}
if !strings.Contains(scriptRec.Body.String(), "BACKUPX_AGENT_INSTALL_V1") {
t.Errorf("script missing magic marker BACKUPX_AGENT_INSTALL_V1")
}
// 4. 再次消费应 410
scriptReq2 := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
@@ -181,6 +287,81 @@ func TestOneClickInstallFlow(t *testing.T) {
}
}
// TestInstallScriptAliasUnderAPI 验证 /api/install/:token 别名路径可用,
// 这是 Issue #46 的根本修复:让 install 端点自动命中反向代理的 /api/ 转发规则,
// 避免 nginx SPA fallback 把请求当前端路由返回 index.html。
func TestInstallScriptAliasUnderAPI(t *testing.T) {
router, token := setupInstallFlowRouter(t)
// 1. 创建一个节点,生成 install token
batchBody, _ := json.Marshal(map[string][]string{"names": {"alias-node"}})
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewReader(batchBody))
batchReq.Header.Set("Content-Type", "application/json")
batchReq.Header.Set("Authorization", "Bearer "+token)
batchRec := httptest.NewRecorder()
router.ServeHTTP(batchRec, batchReq)
if batchRec.Code != 200 {
t.Fatalf("batch create failed: %d %s", batchRec.Code, batchRec.Body.String())
}
var batchResp struct {
Data []struct {
ID uint `json:"id"`
} `json:"data"`
}
_ = json.Unmarshal(batchRec.Body.Bytes(), &batchResp)
if len(batchResp.Data) == 0 {
t.Fatalf("batch create returned no nodes: %s", batchRec.Body.String())
}
nodeID := batchResp.Data[0].ID
genBody, _ := json.Marshal(map[string]any{
"mode": "systemd", "arch": "auto", "agentVersion": "v1.7.0", "downloadSrc": "github", "ttlSeconds": 600,
})
genReq := httptest.NewRequest(http.MethodPost,
"/api/nodes/"+strconv.FormatUint(uint64(nodeID), 10)+"/install-tokens", bytes.NewReader(genBody))
genReq.Header.Set("Content-Type", "application/json")
genReq.Header.Set("Authorization", "Bearer "+token)
genRec := httptest.NewRecorder()
router.ServeHTTP(genRec, genReq)
if genRec.Code != 200 {
t.Fatalf("gen install token failed: %d %s", genRec.Code, genRec.Body.String())
}
var genResp struct {
Data struct {
InstallToken string `json:"installToken"`
URL string `json:"url"`
FallbackURL string `json:"fallbackUrl"`
ScriptBase64 string `json:"scriptBase64"`
} `json:"data"`
}
_ = json.Unmarshal(genRec.Body.Bytes(), &genResp)
// 2. 新生成的 url 应指向 /api/install/... —— 让反向代理的 /api/ 转发规则自动接管
if !strings.Contains(genResp.Data.URL, "/api/install/") {
t.Errorf("new install URL should use /api/install/ prefix, got %s", genResp.Data.URL)
}
if !strings.Contains(genResp.Data.FallbackURL, "/install/") {
t.Errorf("fallback install URL should use /install/ prefix, got %s", genResp.Data.FallbackURL)
}
if genResp.Data.ScriptBase64 == "" {
t.Errorf("new install response should include scriptBase64 for proxy-independent commands")
}
// 3. /api/install/:token 必须可消费(与 /install/:token 等价)
aliasReq := httptest.NewRequest(http.MethodGet, "/api/install/"+genResp.Data.InstallToken, nil)
aliasRec := httptest.NewRecorder()
router.ServeHTTP(aliasRec, aliasReq)
if aliasRec.Code != 200 {
t.Fatalf("/api/install alias failed: %d %s", aliasRec.Code, aliasRec.Body.String())
}
if !strings.Contains(aliasRec.Body.String(), "systemctl enable --now backupx-agent") {
t.Errorf("alias should return rendered script, got:\n%s", aliasRec.Body.String())
}
if ct := aliasRec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
t.Errorf("alias Content-Type should be text/plain*, got %q", ct)
}
}
func TestInstallTokenRateLimit(t *testing.T) {
router, jwt := setupInstallFlowRouter(t)
@@ -315,6 +496,76 @@ func TestInstallFlowComposeModeMismatch(t *testing.T) {
}
}
func TestInstallFlowComposeSuccessConsumesToken(t *testing.T) {
router, jwt := setupInstallFlowRouter(t)
batchBody, _ := json.Marshal(map[string][]string{"names": {"compose-ok"}})
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody))
batchReq.Header.Set("Content-Type", "application/json")
batchReq.Header.Set("Authorization", "Bearer "+jwt)
batchRec := httptest.NewRecorder()
router.ServeHTTP(batchRec, batchReq)
if batchRec.Code != 200 {
t.Fatalf("batch create failed: %d %s", batchRec.Code, batchRec.Body.String())
}
var batchResp struct {
Data []struct {
ID uint `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(batchRec.Body.Bytes(), &batchResp); err != nil {
t.Fatalf("unmarshal batch: %v", err)
}
if len(batchResp.Data) != 1 {
t.Fatalf("expected 1 node, got %d", len(batchResp.Data))
}
genBody, _ := json.Marshal(map[string]any{
"mode": "docker",
"arch": "auto",
"agentVersion": "v1.7.0",
"downloadSrc": "github",
"ttlSeconds": 900,
})
genReq := httptest.NewRequest(http.MethodPost,
"/api/nodes/"+formatUint(batchResp.Data[0].ID)+"/install-tokens", bytes.NewBuffer(genBody))
genReq.Header.Set("Content-Type", "application/json")
genReq.Header.Set("Authorization", "Bearer "+jwt)
genRec := httptest.NewRecorder()
router.ServeHTTP(genRec, genReq)
if genRec.Code != 200 {
t.Fatalf("install-tokens failed: %d %s", genRec.Code, genRec.Body.String())
}
var genResp struct {
Data struct {
InstallToken string `json:"installToken"`
} `json:"data"`
}
if err := json.Unmarshal(genRec.Body.Bytes(), &genResp); err != nil {
t.Fatalf("unmarshal gen: %v", err)
}
if genResp.Data.InstallToken == "" {
t.Fatalf("missing installToken")
}
composeReq := httptest.NewRequest(http.MethodGet, "/api/install/"+genResp.Data.InstallToken+"/compose.yml", nil)
composeRec := httptest.NewRecorder()
router.ServeHTTP(composeRec, composeReq)
if composeRec.Code != 200 {
t.Fatalf("compose fetch failed: %d %s", composeRec.Code, composeRec.Body.String())
}
if !strings.Contains(composeRec.Body.String(), "BACKUPX_AGENT_TOKEN") {
t.Fatalf("compose missing token env:\n%s", composeRec.Body.String())
}
scriptReq := httptest.NewRequest(http.MethodGet, "/api/install/"+genResp.Data.InstallToken, nil)
scriptRec := httptest.NewRecorder()
router.ServeHTTP(scriptRec, scriptReq)
if scriptRec.Code != http.StatusGone {
t.Fatalf("script after compose should be 410, got %d: %s", scriptRec.Code, scriptRec.Body.String())
}
}
// formatUint 小工具uint → 十进制字符串(无需引入 strconv
func formatUint(u uint) string {
if u == 0 {

View File

@@ -36,6 +36,13 @@ func NewInstallHandler(gcCtx context.Context, tokenService *service.InstallToken
}
// Script 消费 install token 并返回 shell 脚本Mode 由 token 存储决定systemd/docker/foreground 均返回 shell
//
// 响应头策略issue #46 教训):
// - Content-Type 用 text/plain 而非 text/x-shellscript避免 Cloudflare/反向代理把
// 脚本内容按特殊类型识别并触发 minify/HTML rewrite导致 `curl | sh` 收到非脚本内容
// - X-Content-Type-Options: nosniff禁止浏览器/中间层按内容嗅探改写 MIME
// - Cache-Control: no-storetoken 一次性消费,禁止任何缓存层留存旧脚本
// - Content-Disposition: inline; filename=...:部分代理会跳过带文件名的响应
func (h *InstallHandler) Script(c *gin.Context) {
if !h.limiter.allow(c.ClientIP()) {
c.String(stdhttp.StatusTooManyRequests, "请求过于频繁,请稍后再试\n")
@@ -52,21 +59,15 @@ func (h *InstallHandler) Script(c *gin.Context) {
return
}
h.recordConsumeAudit(c, consumed, "script")
script, err := installscript.RenderScript(installscript.Context{
MasterURL: resolveMasterURL(c, h.externalURL),
AgentToken: consumed.Node.Token,
AgentVersion: consumed.Record.AgentVer,
Mode: consumed.Record.Mode,
Arch: consumed.Record.Arch,
DownloadBase: installscript.DownloadBaseFor(consumed.Record.DownloadSrc),
InstallPrefix: "/opt/backupx-agent",
NodeID: consumed.Node.ID,
})
script, err := renderInstallScript(resolveMasterURL(c, h.externalURL), consumed.Node, consumed.Record)
if err != nil {
c.String(stdhttp.StatusInternalServerError, "render error\n")
return
}
c.Data(stdhttp.StatusOK, "text/x-shellscript; charset=utf-8", []byte(script))
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Cache-Control", "no-store")
c.Header("Content-Disposition", `inline; filename="backupx-agent-install.sh"`)
c.Data(stdhttp.StatusOK, "text/plain; charset=utf-8", []byte(script))
}
// Compose 消费 install token 并返回 docker-compose YAML仅 Mode=docker 有效。
@@ -131,6 +132,19 @@ func (h *InstallHandler) recordConsumeAudit(c *gin.Context, consumed *service.Co
})
}
func renderInstallScript(masterURL string, node *model.Node, record *model.AgentInstallToken) (string, error) {
return installscript.RenderScript(installscript.Context{
MasterURL: masterURL,
AgentToken: node.Token,
AgentVersion: record.AgentVer,
Mode: record.Mode,
Arch: record.Arch,
DownloadBase: installscript.DownloadBaseFor(record.DownloadSrc),
InstallPrefix: "/opt/backupx-agent",
NodeID: node.ID,
})
}
// resolveMasterURL 按优先级推导 Master URL外部配置 > X-Forwarded-* > Request.Host。
// 此为包级 helper供 install_handler 和 node_handler 共用。
func resolveMasterURL(c *gin.Context, externalURL string) string {

View File

@@ -244,14 +244,17 @@ func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
input.TTLSeconds = 900
}
out, err := h.installTokenSvc.Create(c.Request.Context(), service.InstallTokenInput{
NodeID: uint(id),
Mode: input.Mode,
Arch: input.Arch,
AgentVersion: input.AgentVersion,
DownloadSrc: input.DownloadSrc,
TTLSeconds: input.TTLSeconds,
CreatedByID: h.resolveCurrentUserID(c),
out, err := h.installTokenSvc.CreateCommand(c.Request.Context(), service.InstallCommandInput{
InstallTokenInput: service.InstallTokenInput{
NodeID: uint(id),
Mode: input.Mode,
Arch: input.Arch,
AgentVersion: input.AgentVersion,
DownloadSrc: input.DownloadSrc,
TTLSeconds: input.TTLSeconds,
CreatedByID: h.resolveCurrentUserID(c),
},
MasterURL: resolveMasterURL(c, h.externalURL),
})
if err != nil {
response.Error(c, err)
@@ -261,15 +264,19 @@ func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
fmt.Sprintf("%d", id), out.Node.Name,
fmt.Sprintf("生成 %s/%s install token TTL=%ds", input.Mode, input.Arch, input.TTLSeconds))
masterURL := resolveMasterURL(c, h.externalURL)
// 使用 /api/install/... 而非 /install/... —— 让反向代理的 /api/ 转发规则
// 自动接管,避免 SPA fallback 把请求当成前端路由返回 index.htmlissue #46
// 同时返回 /install/... 备用地址,兼容会剥离 /api 前缀的外层反向代理。
// scriptBase64 让前端可以生成不依赖公开下载路径的嵌入式命令,解决 Lucky 等代理
// 把 /api/install/* 也 fallback 到 index.html 的场景。
body := gin.H{
"installToken": out.Token,
"expiresAt": out.ExpiresAt,
"url": masterURL + "/install/" + out.Token,
"composeUrl": "",
}
if input.Mode == "docker" {
body["composeUrl"] = masterURL + "/install/" + out.Token + "/compose.yml"
"installToken": out.Token,
"expiresAt": out.ExpiresAt,
"url": out.URL,
"fallbackUrl": out.FallbackURL,
"scriptBase64": out.ScriptBase64,
"composeUrl": out.ComposeURL,
"fallbackComposeUrl": out.FallbackComposeURL,
}
response.Success(c, body)
}

View File

@@ -0,0 +1,95 @@
package http
import (
"encoding/csv"
"fmt"
"strconv"
"strings"
"time"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type ReportHandler struct {
service *service.ReportService
}
func NewReportHandler(reportService *service.ReportService) *ReportHandler {
return &ReportHandler{service: reportService}
}
func reportDays(c *gin.Context) int {
days := 30
if v := strings.TrimSpace(c.Query("days")); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
days = parsed
}
}
return days
}
// Compliance 返回 JSON 合规报表(按任务的备份合规证据 + 汇总)。
func (h *ReportHandler) Compliance(c *gin.Context) {
payload, err := h.service.ComplianceReport(c.Request.Context(), reportDays(c))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
// ComplianceCSV 把合规报表导出为 CSV供审计归档。带 UTF-8 BOM 以便 Excel 正确识别中文。
func (h *ReportHandler) ComplianceCSV(c *gin.Context) {
report, err := h.service.ComplianceReport(c.Request.Context(), reportDays(c))
if err != nil {
response.Error(c, err)
return
}
filename := fmt.Sprintf("backupx-compliance-%s.csv", report.GeneratedAt.Format("20060102-150405"))
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", "attachment; filename="+filename)
_, _ = c.Writer.WriteString("\ufeff") // UTF-8 BOM
w := csv.NewWriter(c.Writer)
_ = w.Write([]string{
"任务ID", "任务名", "类型", "启用", "节点", "加密", "保留天数", "SLA(RPO小时)",
"周期内运行", "成功", "失败", "成功率", "最近状态", "最近运行(UTC)", "最近成功(UTC)", "保护字节数", "合规判定",
})
for _, row := range report.Tasks {
_ = w.Write([]string{
strconv.FormatUint(uint64(row.TaskID), 10),
row.TaskName,
row.Type,
boolCN(row.Enabled),
row.NodeName,
boolCN(row.Encrypted),
strconv.Itoa(row.RetentionDays),
strconv.Itoa(row.SLAHoursRPO),
strconv.Itoa(row.TotalRuns),
strconv.Itoa(row.Successes),
strconv.Itoa(row.Failures),
fmt.Sprintf("%.2f%%", row.SuccessRate*100),
row.LastStatus,
fmtTimePtr(row.LastRunAt),
fmtTimePtr(row.LastSuccessAt),
strconv.FormatInt(row.ProtectedBytes, 10),
row.Risk,
})
}
w.Flush()
}
func boolCN(b bool) string {
if b {
return "是"
}
return "否"
}
func fmtTimePtr(t *time.Time) string {
if t == nil {
return ""
}
return t.UTC().Format("2006-01-02 15:04:05")
}

View File

@@ -7,6 +7,7 @@ import (
"backupx/server/internal/apperror"
"backupx/server/internal/config"
"backupx/server/internal/metrics"
"backupx/server/internal/repository"
"backupx/server/internal/security"
"backupx/server/internal/service"
@@ -40,6 +41,7 @@ type RouterDependencies struct {
ApiKeyService *service.ApiKeyService
NotificationService *service.NotificationService
DashboardService *service.DashboardService
ReportService *service.ReportService
SettingsService *service.SettingsService
NodeService *service.NodeService
AgentService *service.AgentService
@@ -52,6 +54,8 @@ type RouterDependencies struct {
MasterExternalURL string
// DB 注入给健康检查端点做 liveness/readiness 探测。
DB *gorm.DB
// Metrics 注入给 /metrics 端点;为 nil 时端点返回 503。
Metrics *metrics.Metrics
}
func NewRouter(deps RouterDependencies) *gin.Engine {
@@ -91,9 +95,22 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
auth.GET("/setup/status", authHandler.SetupStatus)
auth.POST("/setup", authHandler.Setup)
auth.POST("/login", authHandler.Login)
auth.POST("/otp/send", authHandler.SendLoginOTP)
auth.POST("/webauthn/login/options", authHandler.BeginWebAuthnLogin)
auth.POST("/logout", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.Logout)
auth.GET("/profile", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.Profile)
auth.PUT("/password", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ChangePassword)
auth.POST("/2fa/setup", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.PrepareTwoFactor)
auth.POST("/2fa/enable", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.EnableTwoFactor)
auth.POST("/2fa/recovery-codes", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.RegenerateRecoveryCodes)
auth.DELETE("/2fa", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.DisableTwoFactor)
auth.PUT("/otp/config", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ConfigureOTP)
auth.POST("/webauthn/register/options", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.BeginWebAuthnRegistration)
auth.POST("/webauthn/register/finish", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.FinishWebAuthnRegistration)
auth.GET("/webauthn/credentials", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ListWebAuthnCredentials)
auth.DELETE("/webauthn/credentials/:id", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.DeleteWebAuthnCredential)
auth.GET("/trusted-devices", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ListTrustedDevices)
auth.DELETE("/trusted-devices/:id", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.RevokeTrustedDevice)
}
system := api.Group("/system")
@@ -150,9 +167,11 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
backupRecords.GET("/:id", backupRecordHandler.Get)
backupRecords.GET("/:id/logs/stream", backupRecordHandler.StreamLogs)
backupRecords.GET("/:id/download", backupRecordHandler.Download)
backupRecords.GET("/:id/contents", backupRecordHandler.Contents)
backupRecords.POST("/:id/restore", RequireNotViewer(), backupRecordHandler.Restore)
backupRecords.POST("/batch-delete", RequireNotViewer(), backupRecordHandler.BatchDelete)
backupRecords.DELETE("/:id", RequireNotViewer(), backupRecordHandler.Delete)
backupRecords.PUT("/:id/lock", RequireNotViewer(), backupRecordHandler.SetLock)
// 恢复记录独立命名空间:列表/详情/SSE 日志流。
// 创建恢复仍然走 POST /backup/records/:id/restore以源备份记录为触发点
@@ -195,6 +214,15 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
// 基于备份记录的验证入口:与 restore 对称
backupRecords.POST("/:id/verify", RequireNotViewer(), verificationHandler.TriggerByRecord)
}
// 企业合规报表:按任务的可导出备份合规证据(区别于 Dashboard 的实时聚合视图)。
if deps.ReportService != nil {
reportHandler := NewReportHandler(deps.ReportService)
reports := api.Group("/reports")
reports.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
reports.GET("/compliance", reportHandler.Compliance)
reports.GET("/compliance/export", reportHandler.ComplianceCSV)
}
dashboard := api.Group("/dashboard")
dashboard.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
dashboard.GET("/stats", dashboardHandler.Stats)
@@ -226,6 +254,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
users.GET("", userHandler.List)
users.POST("", userHandler.Create)
users.PUT("/:id", userHandler.Update)
users.POST("/:id/2fa/reset", userHandler.ResetTwoFactor)
users.DELETE("/:id", userHandler.Delete)
}
@@ -275,11 +304,14 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
nodes.POST("", RequireRole("admin"), nodeHandler.Create)
nodes.PUT("/:id", RequireRole("admin"), nodeHandler.Update)
nodes.DELETE("/:id", RequireRole("admin"), nodeHandler.Delete)
nodes.GET("/:id/fs/list", nodeHandler.ListDirectory)
nodes.POST("/batch", RequireRole("admin"), nodeHandler.BatchCreate)
nodes.POST("/:id/install-tokens", RequireRole("admin"), nodeHandler.CreateInstallToken)
nodes.POST("/:id/rotate-token", RequireRole("admin"), nodeHandler.RotateToken)
nodes.GET("/:id/install-script-preview", RequireRole("admin"), nodeHandler.PreviewScript)
// 文件浏览会枚举节点文件系统目录(含 /etc、/root 等),属敏感读操作:
// 限制为非 vieweradmin/operator与"创建备份任务需选源路径"的权限对齐,
// 避免只读 viewer 借此探查服务器目录结构。
nodes.GET("/:id/fs/list", RequireNotViewer(), nodeHandler.ListDirectory)
nodes.POST("/batch", RequireRole("admin"), nodeHandler.BatchCreate)
nodes.POST("/:id/install-tokens", RequireRole("admin"), nodeHandler.CreateInstallToken)
nodes.POST("/:id/rotate-token", RequireRole("admin"), nodeHandler.RotateToken)
nodes.GET("/:id/install-script-preview", RequireRole("admin"), nodeHandler.PreviewScript)
// Agent APItoken 认证,无需 JWT
if deps.AgentService != nil {
@@ -290,7 +322,9 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
agent.POST("/commands/:id/result", agentHandler.SubmitCommandResult)
agent.GET("/tasks/:id", agentHandler.GetTaskSpec)
agent.POST("/records/:id", agentHandler.UpdateRecord)
agent.PUT("/records/:id/artifacts/:targetId", agentHandler.UploadArtifact)
agent.GET("/restores/:id/spec", agentHandler.GetRestoreSpec)
agent.GET("/restores/:id/artifact", agentHandler.DownloadRestoreArtifact)
agent.POST("/restores/:id", agentHandler.UpdateRestore)
// Agent v1安装脚本探活用仅 Self 端点
@@ -311,7 +345,19 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
engine.GET("/api/health", healthHandler.Live)
engine.GET("/api/ready", healthHandler.Ready)
// 公开安装路由(不走 JWT 中间件)
// Prometheus /metrics 端点(公开、无认证;内网/反向代理授权即可)。
// 业内通行做法:/metrics 通常由 Prometheus pull 抓取,不走 API Key。
if deps.Metrics != nil {
engine.GET("/metrics", gin.WrapH(deps.Metrics.Handler()))
}
// 公开安装路由(不走 JWT 中间件)。
// 同时注册到 / 和 /api 前缀下:
// - /install/:token 保留历史 URL兼容旧 nginx 部署
// - /api/install/:token 新 URL自动走反向代理的 /api/ 转发规则
//
// Issue #46用户的 nginx 只转发 /api//install/* 被 SPA fallback 到 index.html
// 返回 HTML 被 sh 解释成 "Syntax error"。使用 /api/install/ 可避开此问题。
if deps.InstallTokenService != nil {
gcCtx := deps.Context
if gcCtx == nil {
@@ -320,11 +366,29 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
installHandler := NewInstallHandler(gcCtx, deps.InstallTokenService, deps.AuditService, deps.MasterExternalURL)
engine.GET("/install/:token", installHandler.Script)
engine.GET("/install/:token/compose.yml", installHandler.Compose)
engine.GET("/api/install/:token", installHandler.Script)
engine.GET("/api/install/:token/compose.yml", installHandler.Compose)
}
engine.NoRoute(func(c *gin.Context) {
// 未匹配路由处理:
// - 找到前端产物目录时,托管 SPA静态文件 + index.html 回退),
// 使后端在无 nginx 反向代理时也能直接提供 Web 控制台issue #62
// - 未找到前端目录时退化为纯 API 服务,统一返回结构化 JSON 404。
apiNotFound := func(c *gin.Context) {
response.Error(c, apperror.New(stdhttp.StatusNotFound, "NOT_FOUND", "接口不存在", errors.New("route not found")))
})
}
if webRoot := resolveWebRoot(deps.Config.Server.WebRoot); webRoot != "" {
if deps.Logger != nil {
deps.Logger.Info("serving web frontend", zap.String("web_root", webRoot))
}
engine.NoRoute(spaFileServer(webRoot, apiNotFound))
} else {
if deps.Logger != nil {
deps.Logger.Warn("web frontend directory not found; serving API only",
zap.String("hint", "set server.web_root in config, or place the built frontend at ./web"))
}
engine.NoRoute(apiNotFound)
}
return engine
}

View File

@@ -16,50 +16,17 @@ import (
"backupx/server/internal/repository"
"backupx/server/internal/security"
"backupx/server/internal/service"
"backupx/server/internal/storage/codec"
"github.com/pquerna/otp/totp"
)
func TestSetupLoginAndProfileFlow(t *testing.T) {
tempDir := t.TempDir()
cfg := config.Config{
Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test"},
Database: config.DatabaseConfig{Path: filepath.Join(tempDir, "backupx.db")},
Security: config.SecurityConfig{JWTExpire: "24h"},
Log: config.LogConfig{Level: "error"},
}
log, err := logger.New(cfg.Log)
if err != nil {
t.Fatalf("logger.New error: %v", err)
}
db, err := database.Open(cfg.Database, log)
if err != nil {
t.Fatalf("database.Open error: %v", err)
}
userRepo := repository.NewUserRepository(db)
systemConfigRepo := repository.NewSystemConfigRepository(db)
resolved, err := service.ResolveSecurity(context.Background(), cfg.Security, systemConfigRepo)
if err != nil {
t.Fatalf("ResolveSecurity error: %v", err)
}
jwtManager := security.NewJWTManager(resolved.JWTSecret, time.Hour)
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, security.NewLoginRateLimiter(5, time.Minute))
systemService := service.NewSystemService(cfg, "test", time.Now().UTC())
router := NewRouter(RouterDependencies{
Config: cfg,
Version: "test",
Logger: log,
AuthService: authService,
SystemService: systemService,
JWTManager: jwtManager,
UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo,
})
router, _ := newTestHTTPRouter(t)
setupBody, _ := json.Marshal(map[string]string{
"username": "admin",
"password": "password-123",
"username": "admin",
"password": "password-123",
"displayName": "Admin",
})
setupRequest := httptest.NewRequest(http.MethodPost, "/api/auth/setup", bytes.NewBuffer(setupBody))
@@ -92,3 +59,143 @@ func TestSetupLoginAndProfileFlow(t *testing.T) {
t.Fatalf("expected profile 200, got %d", profileRecorder.Code)
}
}
func TestTrustedDeviceCookieSkipsMFA(t *testing.T) {
router, authService := newTestHTTPRouter(t)
if _, err := authService.Setup(context.Background(), service.SetupInput{
Username: "admin", Password: "password-123", DisplayName: "Admin",
}); err != nil {
t.Fatalf("Setup error: %v", err)
}
totpSetup, err := authService.PrepareTwoFactor(context.Background(), "1", service.TwoFactorSetupInput{
CurrentPassword: "password-123",
})
if err != nil {
t.Fatalf("PrepareTwoFactor error: %v", err)
}
enableCode, err := totp.GenerateCode(totpSetup.Secret, time.Now().UTC())
if err != nil {
t.Fatalf("GenerateCode error: %v", err)
}
if _, err := authService.EnableTwoFactor(context.Background(), "1", service.EnableTwoFactorInput{Code: enableCode}); err != nil {
t.Fatalf("EnableTwoFactor error: %v", err)
}
loginCode, err := totp.GenerateCode(totpSetup.Secret, time.Now().UTC())
if err != nil {
t.Fatalf("GenerateCode login error: %v", err)
}
loginBody, _ := json.Marshal(map[string]any{
"username": "admin",
"password": "password-123",
"twoFactorCode": loginCode,
"rememberDevice": true,
"trustedDeviceName": "test browser",
})
loginRequest := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBuffer(loginBody))
loginRequest.Header.Set("Content-Type", "application/json")
loginRecorder := httptest.NewRecorder()
router.ServeHTTP(loginRecorder, loginRequest)
if loginRecorder.Code != http.StatusOK {
t.Fatalf("expected login 200, got %d: %s", loginRecorder.Code, loginRecorder.Body.String())
}
trustedCookie := findCookie(loginRecorder.Result().Cookies(), trustedDeviceCookieName)
if trustedCookie == nil {
t.Fatalf("expected trusted device cookie")
}
if !trustedCookie.HttpOnly {
t.Fatalf("expected trusted device cookie to be HttpOnly")
}
if trustedCookie.Path != trustedDeviceCookiePath {
t.Fatalf("expected trusted device cookie path %q, got %q", trustedDeviceCookiePath, trustedCookie.Path)
}
var loginResponse struct {
Data struct {
Token string `json:"token"`
TrustedDeviceToken string `json:"trustedDeviceToken"`
TrustedDevice *service.TrustedDeviceOutput `json:"trustedDevice"`
} `json:"data"`
}
if err := json.Unmarshal(loginRecorder.Body.Bytes(), &loginResponse); err != nil {
t.Fatalf("unmarshal login response: %v", err)
}
if loginResponse.Data.Token == "" || loginResponse.Data.TrustedDevice == nil {
t.Fatalf("expected login token and trusted device metadata")
}
if loginResponse.Data.TrustedDeviceToken != "" {
t.Fatalf("trusted device token should not be exposed in response body")
}
secondBody, _ := json.Marshal(map[string]string{
"username": "admin",
"password": "password-123",
})
secondRequest := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBuffer(secondBody))
secondRequest.Header.Set("Content-Type", "application/json")
secondRequest.AddCookie(trustedCookie)
secondRecorder := httptest.NewRecorder()
router.ServeHTTP(secondRecorder, secondRequest)
if secondRecorder.Code != http.StatusOK {
t.Fatalf("expected trusted device login 200, got %d: %s", secondRecorder.Code, secondRecorder.Body.String())
}
}
func newTestHTTPRouter(t *testing.T) (http.Handler, *service.AuthService) {
t.Helper()
tempDir := t.TempDir()
cfg := config.Config{
Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test"},
Database: config.DatabaseConfig{Path: filepath.Join(tempDir, "backupx.db")},
Security: config.SecurityConfig{JWTExpire: "24h"},
Log: config.LogConfig{Level: "error"},
}
log, err := logger.New(cfg.Log)
if err != nil {
t.Fatalf("logger.New error: %v", err)
}
db, err := database.Open(cfg.Database, log)
if err != nil {
t.Fatalf("database.Open error: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("db.DB error: %v", err)
}
t.Cleanup(func() {
_ = sqlDB.Close()
})
userRepo := repository.NewUserRepository(db)
systemConfigRepo := repository.NewSystemConfigRepository(db)
resolved, err := service.ResolveSecurity(context.Background(), cfg.Security, systemConfigRepo)
if err != nil {
t.Fatalf("ResolveSecurity error: %v", err)
}
jwtManager := security.NewJWTManager(resolved.JWTSecret, time.Hour)
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, security.NewLoginRateLimiter(5, time.Minute), codec.NewConfigCipher(resolved.EncryptionKey))
systemService := service.NewSystemService(cfg, "test", time.Now().UTC())
router := NewRouter(RouterDependencies{
Config: cfg,
Version: "test",
Logger: log,
AuthService: authService,
SystemService: systemService,
JWTManager: jwtManager,
UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo,
})
return router, authService
}
func findCookie(cookies []*http.Cookie, name string) *http.Cookie {
for _, cookie := range cookies {
if cookie.Name == name {
return cookie
}
}
return nil
}

View File

@@ -0,0 +1,86 @@
package http
import (
stdhttp "net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
// resolveWebRoot 返回前端静态资源目录。优先使用显式配置的路径,
// 否则按部署惯例依次探测常见位置,返回首个包含 index.html 的目录。
// 返回空字符串表示未找到前端产物,此时后端退化为纯 API 服务。
func resolveWebRoot(configured string) string {
candidates := []string{
configured,
"./web/dist", // 源码树根目录构建产物(优先于 ./web避免命中前端源码模板
"./web", // systemdWorkingDirectory=/opt/backupx → /opt/backupx/web容器 WORKDIR=/app → /app/web
"../web/dist", // 从 server/ 目录运行make dev-server
"/opt/backupx/web",
"/app/web",
}
for _, dir := range candidates {
if strings.TrimSpace(dir) == "" {
continue
}
if hasIndexHTML(dir) {
if abs, err := filepath.Abs(dir); err == nil {
return abs
}
return dir
}
}
return ""
}
func hasIndexHTML(dir string) bool {
info, err := os.Stat(filepath.Join(dir, "index.html"))
return err == nil && !info.IsDir()
}
// isReservedBackendPath 判断请求是否命中后端保留前缀API、探针、安装脚本
// 这些路径即使未匹配到具体路由,也应返回结构化 JSON 404而不是回退到
// 前端 index.html —— 否则反向代理/安装脚本会把 HTML 当成接口响应(参考 issue #46
func isReservedBackendPath(p string) bool {
switch p {
case "/health", "/ready", "/metrics", "/api", "/install":
return true
}
return strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/install/")
}
// spaFileServer 构造 SPA 静态资源处理器,用作 gin 的 NoRoute 回退:
// - 后端保留前缀返回 apiNotFoundJSON 404
// - 其余 GET/HEAD 请求若在 webRoot 内命中真实文件则直接返回该文件;
// - 未命中文件的路径回退到 index.html交由前端路由处理history 模式刷新)。
func spaFileServer(webRoot string, apiNotFound gin.HandlerFunc) gin.HandlerFunc {
indexPath := filepath.Join(webRoot, "index.html")
return func(c *gin.Context) {
reqPath := c.Request.URL.Path
if isReservedBackendPath(reqPath) {
apiNotFound(c)
return
}
if c.Request.Method != stdhttp.MethodGet && c.Request.Method != stdhttp.MethodHead {
apiNotFound(c)
return
}
// 防目录穿越:以 webRoot 为根清理路径,确保最终目标仍位于 webRoot 内。
clean := filepath.Clean("/" + strings.TrimPrefix(reqPath, "/"))
target := filepath.Join(webRoot, clean)
if rel, err := filepath.Rel(webRoot, target); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
apiNotFound(c)
return
}
if info, err := os.Stat(target); err == nil && !info.IsDir() {
c.File(target)
return
}
// 前端 SPA 路由(/dashboard、/tasks 等)回退到 index.html。
c.File(indexPath)
}
}

View File

@@ -0,0 +1,175 @@
package http
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
)
func TestResolveWebRoot(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("explicit configured dir with index.html", func(t *testing.T) {
dir := t.TempDir()
writeIndex(t, dir)
got := resolveWebRoot(dir)
abs, _ := filepath.Abs(dir)
if got != abs {
t.Fatalf("resolveWebRoot(%q) = %q, want %q", dir, got, abs)
}
})
t.Run("configured dir without index.html falls through to none", func(t *testing.T) {
dir := t.TempDir() // no index.html, and no conventional ./web in CWD during test
if got := resolveWebRoot(dir); got != "" {
// 允许 CWD 恰好存在约定目录的环境,但临时目录本身不应被选中。
abs, _ := filepath.Abs(dir)
if got == abs {
t.Fatalf("expected dir without index.html to be skipped, got %q", got)
}
}
})
t.Run("empty configured uses auto-detect order", func(t *testing.T) {
// 切到一个仅含 ./web/dist/index.html 的临时工作目录,验证自动探测。
root := t.TempDir()
distDir := filepath.Join(root, "web", "dist")
if err := os.MkdirAll(distDir, 0o755); err != nil {
t.Fatal(err)
}
writeIndex(t, distDir)
restore := chdir(t, root)
defer restore()
// 以 chdir 之后的实际工作目录为基准计算期望值,避免 macOS 上
// /var → /private/var 符号链接导致字符串不一致。
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
want := filepath.Join(wd, "web", "dist")
got := resolveWebRoot("")
if got != want {
t.Fatalf("auto-detect = %q, want %q", got, want)
}
})
}
func TestIsReservedBackendPath(t *testing.T) {
reserved := []string{"/health", "/ready", "/metrics", "/api", "/install", "/api/", "/api/system/info", "/install/abc", "/install/abc/compose.yml"}
for _, p := range reserved {
if !isReservedBackendPath(p) {
t.Errorf("isReservedBackendPath(%q) = false, want true", p)
}
}
notReserved := []string{"/", "/dashboard", "/assets/app.js", "/installer", "/apidocs", "/favicon.ico"}
for _, p := range notReserved {
if isReservedBackendPath(p) {
t.Errorf("isReservedBackendPath(%q) = true, want false", p)
}
}
}
func TestSpaFileServer(t *testing.T) {
gin.SetMode(gin.TestMode)
webRoot := t.TempDir()
writeIndexContent(t, webRoot, "<!doctype html><title>BackupX</title>")
assetsDir := filepath.Join(webRoot, "assets")
if err := os.MkdirAll(assetsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(assetsDir, "app.js"), []byte("console.log(1)"), 0o644); err != nil {
t.Fatal(err)
}
apiNotFoundHit := false
apiNotFound := func(c *gin.Context) {
apiNotFoundHit = true
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND"})
}
engine := gin.New()
engine.NoRoute(spaFileServer(webRoot, apiNotFound))
cases := []struct {
name string
method string
path string
wantStatus int
wantBody string // 子串;为空表示不校验
wantAPI404 bool
}{
{name: "root serves index", method: http.MethodGet, path: "/", wantStatus: 200, wantBody: "BackupX"},
{name: "spa route falls back to index", method: http.MethodGet, path: "/dashboard", wantStatus: 200, wantBody: "BackupX"},
{name: "real asset served", method: http.MethodGet, path: "/assets/app.js", wantStatus: 200, wantBody: "console.log"},
{name: "api path returns json 404", method: http.MethodGet, path: "/api/garbage", wantStatus: 404, wantAPI404: true},
{name: "health returns json 404 via reserved", method: http.MethodGet, path: "/health", wantStatus: 404, wantAPI404: true},
{name: "non-GET on spa path is api 404", method: http.MethodPost, path: "/dashboard", wantStatus: 404, wantAPI404: true},
{name: "directory falls back to index", method: http.MethodGet, path: "/assets/", wantStatus: 200, wantBody: "BackupX"},
// 含 ".." 的请求路径被 net/http 在文件服务层直接拒绝400 invalid URL path
// 绝不会泄露 webRoot 之外的文件;这是在 filepath.Rel 校验之上的纵深防御。
{name: "traversal rejected, never serves passwd", method: http.MethodGet, path: "/../../etc/passwd", wantStatus: 400, wantBody: "invalid URL path"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
apiNotFoundHit = false
req := httptest.NewRequest(tc.method, tc.path, nil)
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, tc.wantStatus, rec.Body.String())
}
if tc.wantBody != "" && !contains(rec.Body.String(), tc.wantBody) {
t.Fatalf("body %q does not contain %q", rec.Body.String(), tc.wantBody)
}
if tc.wantAPI404 != apiNotFoundHit {
t.Fatalf("apiNotFoundHit = %v, want %v", apiNotFoundHit, tc.wantAPI404)
}
})
}
}
func writeIndex(t *testing.T, dir string) {
t.Helper()
writeIndexContent(t, dir, "<!doctype html><title>BackupX</title>")
}
func writeIndexContent(t *testing.T, dir, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func chdir(t *testing.T, dir string) func() {
t.Helper()
orig, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
return func() { _ = os.Chdir(orig) }
}
func contains(s, sub string) bool {
return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}

View File

@@ -199,7 +199,7 @@ func (h *StorageTargetHandler) GoogleDriveProfile(c *gin.Context) {
func parseUintParam(c *gin.Context, key string) (uint, bool) {
value := strings.TrimSpace(c.Param(key))
parsed, err := strconv.ParseUint(value, 10, 64)
parsed, err := strconv.ParseUint(value, 10, 0)
if err != nil {
response.Error(c, apperror.BadRequest("INVALID_ID", fmt.Sprintf("参数 %s 不合法", key), err))
return 0, false

View File

@@ -78,3 +78,18 @@ func (h *UserHandler) Delete(c *gin.Context) {
fmt.Sprintf("删除用户 (ID: %d)", id))
response.Success(c, gin.H{"deleted": true})
}
func (h *UserHandler) ResetTwoFactor(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
item, err := h.service.ResetTwoFactor(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "user", "reset_two_factor", "user", fmt.Sprintf("%d", id), item.Username,
fmt.Sprintf("重置用户 %s 的 MFA", item.Username))
response.Success(c, item)
}

View File

@@ -0,0 +1,61 @@
package installscript
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestDeployInstallScriptSyntax(t *testing.T) {
scriptPath := filepath.Join("..", "..", "..", "deploy", "install.sh")
cmd := exec.Command("sh", "-n", scriptPath)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("install.sh syntax invalid: %v\n%s", err, output)
}
}
func TestDeployInstallScriptSupportsReleasePackageLayout(t *testing.T) {
scriptPath := filepath.Join("..", "..", "..", "deploy", "install.sh")
data, err := os.ReadFile(scriptPath)
if err != nil {
t.Fatal(err)
}
script := string(data)
for _, want := range []string{
`SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)`,
`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}"`,
`发布包安装请确认当前目录包含 ./backupx、./web 和 ./install.sh。`,
`cat > "/etc/systemd/system/$SERVICE_NAME.service" <<UNIT`,
`if [ -d "/etc/nginx/conf.d" ] && [ -f "$NGINX_SOURCE" ]; then`,
} {
if !strings.Contains(script, want) {
t.Fatalf("install.sh missing %q", want)
}
}
}
func TestDeployInstallScriptSupportsSourceBuildAndVerifiesFirstSetup(t *testing.T) {
scriptPath := filepath.Join("..", "..", "..", "deploy", "install.sh")
data, err := os.ReadFile(scriptPath)
if err != nil {
t.Fatal(err)
}
script := string(data)
for _, want := range []string{
`SOURCE_BIN_DEFAULT="$PROJECT_ROOT/server/bin/backupx"`,
`For a source install, run 'make build' in the repository root first.`,
`HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8340/api/auth/setup/status}"`,
`systemctl is-active --quiet "$SERVICE_NAME"`,
`System setup`,
} {
if !strings.Contains(script, want) {
t.Fatalf("install.sh missing %q", want)
}
}
}

View File

@@ -0,0 +1,58 @@
package installscript
import (
"strings"
"testing"
"backupx/server/internal/model"
)
// TestRenderScriptIncludesMagicMarker 渲染脚本必须包含 Issue #46 引入的魔数注释,
// 方便用户通过 `head -3 脚本` 自查是否被中间层改写。
func TestRenderScriptIncludesMagicMarker(t *testing.T) {
for _, mode := range []string{model.InstallModeSystemd, model.InstallModeDocker, model.InstallModeForeground} {
ctx := testCtx
ctx.Mode = mode
got, err := RenderScript(ctx)
if err != nil {
t.Fatalf("render err (%s): %v", mode, err)
}
if !strings.Contains(got, "BACKUPX_AGENT_INSTALL_V1") {
t.Errorf("mode=%s: script missing magic marker:\n%s", mode, got)
}
}
}
// TestRenderScriptBashBootstrap 脚本顶部必须有 bash 自举段,文件执行时跳到 bash。
func TestRenderScriptBashBootstrap(t *testing.T) {
got, err := RenderScript(testCtx)
if err != nil {
t.Fatalf("render err: %v", err)
}
if !strings.Contains(got, `[ -z "${BASH_VERSION:-}" ]`) {
t.Errorf("script missing bash bootstrap guard:\n%s", got)
}
if !strings.Contains(got, `exec bash "$0" "$@"`) {
t.Errorf("script missing exec bash fallback:\n%s", got)
}
}
func TestRenderScriptUsesRootForBareMetalBackups(t *testing.T) {
got, err := RenderScript(testCtx)
if err != nil {
t.Fatalf("render err: %v", err)
}
for _, want := range []string{
"/var/lib/backupx-agent/tmp",
"install -d -m 0700 /var/lib/backupx-agent /var/lib/backupx-agent/tmp",
} {
if !strings.Contains(got, want) {
t.Errorf("script missing %q:\n%s", want, got)
}
}
for _, forbidden := range []string{"User=backupx", "Group=backupx", "NoNewPrivileges=true"} {
if strings.Contains(got, forbidden) {
t.Errorf("script should not contain %q for bare-metal backups:\n%s", forbidden, got)
}
}
}

View File

@@ -1,6 +1,8 @@
package installscript
import (
"os"
"path/filepath"
"strings"
"testing"
@@ -27,8 +29,10 @@ func TestRenderScriptSystemd(t *testing.T) {
mustContain := []string{
"BACKUPX_AGENT_MASTER=${MASTER_URL}",
`Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"`,
"/var/lib/backupx-agent/tmp",
"systemctl daemon-reload",
"systemctl enable --now backupx-agent",
"systemctl status backupx-agent",
"X-Agent-Token: ${AGENT_TOKEN}",
"MASTER_URL=\"https://master.example.com\"",
"AGENT_TOKEN=\"deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef\"",
@@ -56,6 +60,9 @@ func TestRenderScriptForeground(t *testing.T) {
if !strings.Contains(got, `exec "${INSTALL_PREFIX}/backupx" agent`) {
t.Errorf("foreground script missing exec line:\n%s", got)
}
if !strings.Contains(got, "/var/lib/backupx-agent/tmp") {
t.Errorf("foreground script missing dedicated temp dir:\n%s", got)
}
if strings.Contains(got, "systemctl daemon-reload") {
t.Errorf("foreground script should not reference systemctl:\n%s", got)
}
@@ -74,14 +81,44 @@ func TestRenderScriptDocker(t *testing.T) {
if !strings.Contains(got, "docker run") {
t.Errorf("docker script missing `docker run`:\n%s", got)
}
if !strings.Contains(got, "/var/lib/backupx-agent:/var/lib/backupx-agent") {
t.Errorf("docker script missing agent data volume:\n%s", got)
}
if !strings.Contains(got, "awuqing/backupx:${AGENT_VERSION}") {
t.Errorf("docker script missing image tag reference:\n%s", got)
}
if !strings.Contains(got, `"awuqing/backupx:${AGENT_VERSION}" agent`) {
t.Errorf("docker script must start image in agent mode:\n%s", got)
}
if !strings.Contains(got, `-e "BACKUPX_AGENT_TEMP_DIR=/var/lib/backupx-agent/tmp"`) {
t.Errorf("docker script missing temp dir env:\n%s", got)
}
if !strings.Contains(got, `docker logs --tail=100 backupx-agent`) {
t.Errorf("docker script missing diagnostic log command:\n%s", got)
}
if !strings.Contains(got, `grep -q '"status":"online"'`) {
t.Errorf("docker script missing online probe:\n%s", got)
}
if strings.Contains(got, "systemctl daemon-reload") {
t.Errorf("docker script should not reference systemctl:\n%s", got)
}
}
func TestDockerEntrypointForwardsAgentSubcommand(t *testing.T) {
entrypointPath := filepath.Join("..", "..", "..", "deploy", "docker", "entrypoint.sh")
got, err := os.ReadFile(entrypointPath)
if err != nil {
t.Fatalf("read docker entrypoint: %v", err)
}
script := string(got)
if !strings.Contains(script, `"${1:-}" = "agent"`) {
t.Fatalf("entrypoint must detect the agent subcommand before starting server:\n%s", script)
}
if !strings.Contains(script, `exec /app/bin/backupx "$@"`) {
t.Fatalf("entrypoint must exec backupx with forwarded args:\n%s", script)
}
}
func TestRenderComposeYaml(t *testing.T) {
ctx := testCtx
ctx.Mode = model.InstallModeDocker
@@ -92,17 +129,26 @@ func TestRenderComposeYaml(t *testing.T) {
if !strings.Contains(got, "image: awuqing/backupx:v1.7.0") {
t.Errorf("compose missing image:\n%s", got)
}
if !strings.Contains(got, `command: ["agent"]`) {
t.Errorf("compose must start image in agent mode:\n%s", got)
}
if !strings.Contains(got, `BACKUPX_AGENT_TOKEN: "deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef"`) {
t.Errorf("compose missing token env:\n%s", got)
}
if !strings.Contains(got, `BACKUPX_AGENT_TEMP_DIR: "/var/lib/backupx-agent/tmp"`) {
t.Errorf("compose missing temp dir env:\n%s", got)
}
if !strings.Contains(got, "/var/lib/backupx-agent:/var/lib/backupx-agent") {
t.Errorf("compose missing agent data volume:\n%s", got)
}
}
func TestRenderScriptRejectsInjectedMasterURL(t *testing.T) {
bad := []string{
"https://example.com\" other: inject", // 含引号和空格
"javascript:alert(1)", // scheme 非法
"https://example.com\n- privileged", // 含换行YAML 注入经典 payload
"", // 空
"javascript:alert(1)", // scheme 非法
"https://example.com\n- privileged", // 含换行YAML 注入经典 payload
"", // 空
}
for _, u := range bad {
ctx := testCtx
@@ -161,8 +207,8 @@ func TestDownloadBaseMapping(t *testing.T) {
func TestRenderScriptDefaultsApplied(t *testing.T) {
ctx := testCtx
ctx.InstallPrefix = "" // 应被默认为 /opt/backupx-agent
ctx.DownloadBase = "" // 应被默认为 github
ctx.InstallPrefix = "" // 应被默认为 /opt/backupx-agent
ctx.DownloadBase = "" // 应被默认为 github
got, err := RenderScript(ctx)
if err != nil {
t.Fatalf("render err: %v", err)

View File

@@ -9,5 +9,6 @@ services:
environment:
BACKUPX_AGENT_MASTER: "{{.MasterURL}}"
BACKUPX_AGENT_TOKEN: "{{.AgentToken}}"
BACKUPX_AGENT_TEMP_DIR: "/var/lib/backupx-agent/tmp"
volumes:
- /var/lib/backupx-agent:/tmp/backupx-agent
- /var/lib/backupx-agent:/var/lib/backupx-agent

View File

@@ -1,8 +1,16 @@
#!/bin/sh
# BackupX Agent 一键安装脚本(由 Master 动态渲染)
# Magic: BACKUPX_AGENT_INSTALL_V1 —— 若 `head -3 脚本` 看不到此行,说明反向代理/CDN 改写了响应
# 模式: {{.Mode}} | 架构: {{.Arch}} | 版本: {{.AgentVersion}}
set -eu
# 自举到 bash文件执行模式下生效管道模式 $0 不是文件exec 会静默失败,继续用 sh
# 动机:部分 Debian/Ubuntu 用户通过 `curl | sudo sh` 触发时dash 对本脚本报语法错误;
# 若目标机装有 bash优先切换到 bash 获得更一致的行为。
if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1 && [ -f "$0" ]; then
exec bash "$0" "$@"
fi
MASTER_URL="{{.MasterURL}}"
AGENT_TOKEN="{{.AgentToken}}"
AGENT_VERSION="{{.AgentVersion}}"
@@ -39,10 +47,10 @@ else
fi
tar xzf "$TMPDIR/pkg.tar.gz" -C "$TMPDIR"
# 4. 安装二进制 + 用户
# 4. 安装二进制 + 数据目录
echo "[2/4] 安装到 ${INSTALL_PREFIX}"
id backupx >/dev/null 2>&1 || useradd --system --home-dir "$INSTALL_PREFIX" --shell /usr/sbin/nologin backupx
install -d -o backupx -g backupx "$INSTALL_PREFIX" /var/lib/backupx-agent
install -d -m 0755 "$INSTALL_PREFIX"
install -d -m 0700 /var/lib/backupx-agent /var/lib/backupx-agent/tmp
install -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx"
{{end}}
@@ -57,13 +65,13 @@ Wants=network-online.target
[Service]
Type=simple
User=backupx
Environment="BACKUPX_AGENT_MASTER=${MASTER_URL}"
Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"
ExecStart=${INSTALL_PREFIX}/backupx agent --temp-dir /var/lib/backupx-agent
ExecStart=${INSTALL_PREFIX}/backupx agent --temp-dir /var/lib/backupx-agent/tmp
Restart=on-failure
RestartSec=10s
NoNewPrivileges=true
# Agent 需以 root 运行以读取任意源数据;与单机服务端保持一致的资源/句柄上限。
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
@@ -82,6 +90,7 @@ for i in $(seq 1 15); do
fi
done
echo "⚠ 30s 内未收到上线心跳,请检查防火墙或 journalctl -u backupx-agent"
echo "提示systemd 服务名是 backupx-agent可执行 systemctl status backupx-agent 查看状态。"
exit 2
{{end}}
@@ -90,7 +99,7 @@ exit 2
echo "[3/3] 前台启动 agentCtrl+C 退出)"
export BACKUPX_AGENT_MASTER="${MASTER_URL}"
export BACKUPX_AGENT_TOKEN="${AGENT_TOKEN}"
exec "${INSTALL_PREFIX}/backupx" agent --temp-dir /var/lib/backupx-agent
exec "${INSTALL_PREFIX}/backupx" agent --temp-dir /var/lib/backupx-agent/tmp
{{end}}
{{if eq .Mode "docker"}}
@@ -102,7 +111,20 @@ docker rm -f backupx-agent >/dev/null 2>&1 || true
docker run -d --name backupx-agent --restart=unless-stopped \
-e "BACKUPX_AGENT_MASTER=${MASTER_URL}" \
-e "BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}" \
-v /var/lib/backupx-agent:/tmp/backupx-agent \
-e "BACKUPX_AGENT_TEMP_DIR=/var/lib/backupx-agent/tmp" \
-v /var/lib/backupx-agent:/var/lib/backupx-agent \
"awuqing/backupx:${AGENT_VERSION}" agent
echo "✓ 容器已启动"
echo "✓ 容器已启动,等待节点上线"
for i in $(seq 1 15); do
sleep 2
if curl -fsSL -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null \
| grep -q '"status":"online"'; then
echo "✓ 节点已上线"
exit 0
fi
done
echo "⚠ 30s 内未收到上线心跳,请检查容器状态、网络与 Master URL。"
echo "排查命令docker ps -a --filter name=backupx-agent"
echo "排查命令docker logs --tail=100 backupx-agent"
exit 2
{{end}}

View File

@@ -0,0 +1,167 @@
package metrics
import (
"context"
"time"
"backupx/server/internal/model"
"backupx/server/internal/repository"
)
// SampleSource 抽象 Collector 需要的仓储访问,便于单测替换。
type SampleSource interface {
ListStorageTargets(ctx context.Context) ([]model.StorageTarget, error)
StorageUsage(ctx context.Context) ([]repository.BackupStorageUsageItem, error)
ListNodes(ctx context.Context) ([]model.Node, error)
AgentQueueSummaries(ctx context.Context) (map[uint]repository.AgentCommandQueueSummary, error)
CountSLABreach(ctx context.Context) (int, error)
}
// repoSource 把 repository 适配到 SampleSource。
type repoSource struct {
targets repository.StorageTargetRepository
records repository.BackupRecordRepository
nodes repository.NodeRepository
tasks repository.BackupTaskRepository
commands repository.AgentCommandRepository
now func() time.Time
}
// NewRepoSource 用仓储实例构造 SampleSource。
func NewRepoSource(
targets repository.StorageTargetRepository,
records repository.BackupRecordRepository,
nodes repository.NodeRepository,
tasks repository.BackupTaskRepository,
commands repository.AgentCommandRepository,
) SampleSource {
return &repoSource{
targets: targets,
records: records,
nodes: nodes,
tasks: tasks,
commands: commands,
now: func() time.Time { return time.Now().UTC() },
}
}
func (s *repoSource) ListStorageTargets(ctx context.Context) ([]model.StorageTarget, error) {
return s.targets.List(ctx)
}
func (s *repoSource) StorageUsage(ctx context.Context) ([]repository.BackupStorageUsageItem, error) {
return s.records.StorageUsage(ctx)
}
func (s *repoSource) ListNodes(ctx context.Context) ([]model.Node, error) {
return s.nodes.List(ctx)
}
func (s *repoSource) AgentQueueSummaries(ctx context.Context) (map[uint]repository.AgentCommandQueueSummary, error) {
if s.commands == nil {
return nil, nil
}
return s.commands.NodeQueueSummaries(ctx)
}
// CountSLABreach 统计当前违反 RPO 的任务:
// - 任务启用且配置了 SLAHoursRPO > 0
// - 最近一次成功备份距今超出 SLA 时间窗,或从未成功过
func (s *repoSource) CountSLABreach(ctx context.Context) (int, error) {
tasks, err := s.tasks.List(ctx, repository.BackupTaskListOptions{})
if err != nil {
return 0, err
}
now := s.now()
count := 0
for i := range tasks {
task := &tasks[i]
if task.SLAHoursRPO <= 0 || !task.Enabled {
continue
}
threshold := now.Add(-time.Duration(task.SLAHoursRPO) * time.Hour)
if task.LastRunAt == nil || task.LastRunAt.Before(threshold) {
count++
}
}
return count, nil
}
// Collector 周期性采集 gauge 类指标存储用量、节点在线、SLA 违约)。
// 用后台 goroutine 驱动,避免在 /metrics 请求路径做慢 IO。
type Collector struct {
metrics *Metrics
source SampleSource
interval time.Duration
}
// NewCollector 创建周期采集器。interval=0 走默认 30s。
func NewCollector(m *Metrics, source SampleSource, interval time.Duration) *Collector {
if interval <= 0 {
interval = 30 * time.Second
}
return &Collector{metrics: m, source: source, interval: interval}
}
// Start 在后台运行采集循环;随 ctx 取消而终止。
// 启动时立即采一次,之后按 interval 轮询。
func (c *Collector) Start(ctx context.Context) {
if c == nil || c.metrics == nil || c.source == nil {
return
}
go func() {
c.collect(ctx)
ticker := time.NewTicker(c.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.collect(ctx)
}
}
}()
}
// collect 执行一次采样;单轮失败不影响下次。
func (c *Collector) collect(ctx context.Context) {
// 存储用量:按 StorageTargetID 聚合 file_size对应 target name/type
if targets, err := c.source.ListStorageTargets(ctx); err == nil {
nameByID := make(map[uint]string, len(targets))
typeByID := make(map[uint]string, len(targets))
for i := range targets {
nameByID[targets[i].ID] = targets[i].Name
typeByID[targets[i].ID] = targets[i].Type
}
if usage, uerr := c.source.StorageUsage(ctx); uerr == nil {
c.metrics.ResetStorageUsed()
for _, item := range usage {
name := nameByID[item.StorageTargetID]
if name == "" {
continue
}
c.metrics.SetStorageUsed(name, typeByID[item.StorageTargetID], item.TotalSize)
}
}
}
// 节点在线状态role 约定为 master / agent
if nodes, err := c.source.ListNodes(ctx); err == nil {
queueByNode, _ := c.source.AgentQueueSummaries(ctx)
c.metrics.ResetNodeOnline()
c.metrics.ResetAgentQueue()
for i := range nodes {
n := &nodes[i]
role := "agent"
if n.IsLocal {
role = "master"
}
c.metrics.SetNodeOnline(n.Name, role, n.Status == model.NodeStatusOnline)
queue := queueByNode[n.ID]
c.metrics.SetAgentQueue(n.Name, role, queue.Depth, queue.Running, queue.Timeouts)
}
}
if breach, err := c.source.CountSLABreach(ctx); err == nil {
c.metrics.SetSLABreach(breach)
}
}

View File

@@ -0,0 +1,264 @@
// Package metrics 暴露 BackupX 的 Prometheus 采集器。
//
// 设计要点:
// - 使用独立 Registry避免与 default registry 中的 Go runtime metrics 混淆
// - Counter/Gauge/Histogram 全部以 backupx_ 为前缀,遵循 Prometheus 命名规范
// - 所有指标都支持零值:未注入时调用方法是 no-op不会 panic
// - 组件只依赖本包,不反向引用 service/repository避免循环
package metrics
import (
"net/http"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// Metrics 聚合所有采集器,由 app 层组装一次并按需注入到 service。
type Metrics struct {
registry *prometheus.Registry
// 任务执行计数labels: status, task_type
TaskRunTotal *prometheus.CounterVec
// 任务耗时分布labels: task_type
TaskRunDuration *prometheus.HistogramVec
// 任务产出字节数labels: task_type
TaskBytesTotal *prometheus.CounterVec
// 正在运行的任务数
TaskRunningGauge prometheus.Gauge
// 存储目标用量labels: target_name, target_type
StorageUsedBytes *prometheus.GaugeVec
// 节点在线状态labels: node_name, rolevalue: 0/1
NodeOnline *prometheus.GaugeVec
// Agent 命令队列深度labels: node_name, role
AgentCommandQueueDepth *prometheus.GaugeVec
// Agent 正在执行的长命令数labels: node_name, role
AgentCommandRunning *prometheus.GaugeVec
// Agent 命令超时累计数快照labels: node_name, role
AgentCommandTimeoutTotal *prometheus.GaugeVec
// 验证演练结果labels: status
VerifyRunTotal *prometheus.CounterVec
// 恢复操作结果labels: status
RestoreRunTotal *prometheus.CounterVec
// 副本复制结果labels: status
ReplicationRunTotal *prometheus.CounterVec
// SLA 违约数gauge
SLABreachGauge prometheus.Gauge
// 应用信息label: version
AppInfo *prometheus.GaugeVec
}
// New 构造并注册所有采集器。
// 失败时 panic采集器注册失败属于启动期编程错误没有合理 fallback。
func New(version string) *Metrics {
reg := prometheus.NewRegistry()
// 注入标准 Go runtime + process 指标
reg.MustRegister(collectors.NewGoCollector())
reg.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
m := &Metrics{
registry: reg,
TaskRunTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "backupx_task_run_total",
Help: "备份任务执行总数,按状态和任务类型细分",
}, []string{"status", "task_type"}),
TaskRunDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "backupx_task_run_duration_seconds",
Help: "备份任务耗时分布",
Buckets: []float64{1, 5, 15, 30, 60, 120, 300, 600, 1800, 3600, 7200},
}, []string{"task_type"}),
TaskBytesTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "backupx_task_bytes_total",
Help: "备份任务累计产出字节数",
}, []string{"task_type"}),
TaskRunningGauge: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "backupx_task_running",
Help: "当前正在执行的备份任务数",
}),
StorageUsedBytes: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "backupx_storage_used_bytes",
Help: "存储目标已用字节数",
}, []string{"target_name", "target_type"}),
NodeOnline: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "backupx_node_online",
Help: "集群节点在线状态1 在线 / 0 离线)",
}, []string{"node_name", "role"}),
AgentCommandQueueDepth: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "backupx_agent_command_queue_depth",
Help: "Agent 当前 pending/dispatched 命令总数",
}, []string{"node_name", "role"}),
AgentCommandRunning: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "backupx_agent_command_running",
Help: "Agent 当前正在执行的长命令数",
}, []string{"node_name", "role"}),
AgentCommandTimeoutTotal: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "backupx_agent_command_timeout_total",
Help: "Agent 已超时命令数快照",
}, []string{"node_name", "role"}),
VerifyRunTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "backupx_verify_run_total",
Help: "备份验证演练执行总数",
}, []string{"status"}),
RestoreRunTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "backupx_restore_run_total",
Help: "恢复操作执行总数",
}, []string{"status"}),
ReplicationRunTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "backupx_replication_run_total",
Help: "备份副本复制执行总数",
}, []string{"status"}),
SLABreachGauge: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "backupx_sla_breach_tasks",
Help: "当前违反 SLA/RPO 的任务数",
}),
AppInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "backupx_app_info",
Help: "BackupX 应用元信息(恒为 1通过 label 暴露版本号)",
}, []string{"version"}),
}
reg.MustRegister(
m.TaskRunTotal,
m.TaskRunDuration,
m.TaskBytesTotal,
m.TaskRunningGauge,
m.StorageUsedBytes,
m.NodeOnline,
m.AgentCommandQueueDepth,
m.AgentCommandRunning,
m.AgentCommandTimeoutTotal,
m.VerifyRunTotal,
m.RestoreRunTotal,
m.ReplicationRunTotal,
m.SLABreachGauge,
m.AppInfo,
)
m.AppInfo.WithLabelValues(version).Set(1)
return m
}
// Handler 返回 /metrics 的 HTTP handler。
// 使用本包专属 registry避免混入其他组件的默认 metrics。
func (m *Metrics) Handler() http.Handler {
if m == nil {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "metrics disabled", http.StatusServiceUnavailable)
})
}
return promhttp.HandlerFor(m.registry, promhttp.HandlerOpts{
EnableOpenMetrics: false,
})
}
// ObserveTaskRun 记录一次任务执行结果。
// status 常用值success / failed / cancelled。nil 接收器安全。
func (m *Metrics) ObserveTaskRun(taskType, status string, durationSec float64, bytes int64) {
if m == nil {
return
}
m.TaskRunTotal.WithLabelValues(status, taskType).Inc()
m.TaskRunDuration.WithLabelValues(taskType).Observe(durationSec)
if bytes > 0 {
m.TaskBytesTotal.WithLabelValues(taskType).Add(float64(bytes))
}
}
// IncTaskRunning / DecTaskRunning 配套使用,反映并发中任务数。
func (m *Metrics) IncTaskRunning() {
if m == nil {
return
}
m.TaskRunningGauge.Inc()
}
func (m *Metrics) DecTaskRunning() {
if m == nil {
return
}
m.TaskRunningGauge.Dec()
}
// ObserveRestore / ObserveVerify / ObserveReplication 记录子动作结果。
// 所有方法对 nil 接收器安全:未注入 Metrics 时静默降级,不 panic。
func (m *Metrics) ObserveRestore(status string) {
if m == nil {
return
}
m.RestoreRunTotal.WithLabelValues(status).Inc()
}
func (m *Metrics) ObserveVerify(status string) {
if m == nil {
return
}
m.VerifyRunTotal.WithLabelValues(status).Inc()
}
func (m *Metrics) ObserveReplication(status string) {
if m == nil {
return
}
m.ReplicationRunTotal.WithLabelValues(status).Inc()
}
// SetStorageUsed 刷新某存储目标的用量。调用方负责周期采集。
func (m *Metrics) SetStorageUsed(name, targetType string, bytes int64) {
if m == nil {
return
}
m.StorageUsedBytes.WithLabelValues(name, targetType).Set(float64(bytes))
}
// SetNodeOnline 刷新节点在线状态。
func (m *Metrics) SetNodeOnline(name, role string, online bool) {
if m == nil {
return
}
val := 0.0
if online {
val = 1
}
m.NodeOnline.WithLabelValues(name, role).Set(val)
}
// ResetNodeOnline 清空节点 gauge当节点被删除时避免残留指标
func (m *Metrics) ResetNodeOnline() {
if m == nil {
return
}
m.NodeOnline.Reset()
}
func (m *Metrics) SetAgentQueue(name, role string, depth, running, timeoutCount int) {
if m == nil {
return
}
m.AgentCommandQueueDepth.WithLabelValues(name, role).Set(float64(depth))
m.AgentCommandRunning.WithLabelValues(name, role).Set(float64(running))
m.AgentCommandTimeoutTotal.WithLabelValues(name, role).Set(float64(timeoutCount))
}
func (m *Metrics) ResetAgentQueue() {
if m == nil {
return
}
m.AgentCommandQueueDepth.Reset()
m.AgentCommandRunning.Reset()
m.AgentCommandTimeoutTotal.Reset()
}
// ResetStorageUsed 清空存储目标 gauge。
func (m *Metrics) ResetStorageUsed() {
if m == nil {
return
}
m.StorageUsedBytes.Reset()
}
// SetSLABreach 刷新 SLA 违约任务数。
func (m *Metrics) SetSLABreach(count int) {
if m == nil {
return
}
m.SLABreachGauge.Set(float64(count))
}

View File

@@ -0,0 +1,82 @@
package metrics
import (
"io"
"net/http/httptest"
"strings"
"testing"
"github.com/prometheus/client_golang/prometheus/testutil"
)
func TestNew_AppInfoVersionLabel(t *testing.T) {
m := New("2.1.0")
if got := testutil.ToFloat64(m.AppInfo.WithLabelValues("2.1.0")); got != 1 {
t.Fatalf("app_info(version=2.1.0) expected 1, got %v", got)
}
}
func TestObserveTaskRun_IncrementsCounterAndHistogram(t *testing.T) {
m := New("test")
m.ObserveTaskRun("mysql", "success", 12.5, 1024)
m.ObserveTaskRun("mysql", "failed", 3.0, 0)
if got := testutil.ToFloat64(m.TaskRunTotal.WithLabelValues("success", "mysql")); got != 1 {
t.Fatalf("task_run_total{status=success,task_type=mysql}: expected 1, got %v", got)
}
if got := testutil.ToFloat64(m.TaskRunTotal.WithLabelValues("failed", "mysql")); got != 1 {
t.Fatalf("task_run_total{status=failed,task_type=mysql}: expected 1, got %v", got)
}
if got := testutil.ToFloat64(m.TaskBytesTotal.WithLabelValues("mysql")); got != 1024 {
t.Fatalf("task_bytes_total{task_type=mysql}: expected 1024, got %v", got)
}
}
func TestObserveTaskRun_NilReceiverIsSafe(t *testing.T) {
var m *Metrics // nil
m.ObserveTaskRun("file", "success", 1, 1)
m.ObserveRestore("success")
m.ObserveVerify("failed")
m.ObserveReplication("success")
m.IncTaskRunning()
m.DecTaskRunning()
m.SetStorageUsed("a", "s3", 1)
m.SetNodeOnline("n1", "master", true)
m.SetAgentQueue("n1", "agent", 2, 1, 3)
m.SetSLABreach(3)
m.ResetNodeOnline()
m.ResetStorageUsed()
m.ResetAgentQueue()
// no panic -> pass
}
func TestHandler_ExposesBackupxMetrics(t *testing.T) {
m := New("0.0.0-test")
m.ObserveTaskRun("file", "success", 1.0, 2048)
m.SetNodeOnline("n1", "master", true)
m.SetAgentQueue("edge-a", "agent", 3, 1, 2)
m.SetSLABreach(1)
recorder := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/metrics", nil)
m.Handler().ServeHTTP(recorder, req)
body, err := io.ReadAll(recorder.Result().Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
content := string(body)
for _, keyword := range []string{
"backupx_task_run_total",
"backupx_task_run_duration_seconds",
"backupx_node_online",
"backupx_agent_command_queue_depth",
"backupx_agent_command_running",
"backupx_agent_command_timeout_total",
"backupx_sla_breach_tasks",
"backupx_app_info",
} {
if !strings.Contains(content, keyword) {
t.Errorf("expected /metrics to contain %q", keyword)
}
}
}

View File

@@ -8,28 +8,47 @@ const (
BackupRecordStatusFailed = "failed"
)
const (
// BackupKindFull 全量归档BackupKindDifferential 差异归档;
// BackupKindRepository 为可独立恢复的 CDC 内容寻址快照。
BackupKindFull = "full"
BackupKindDifferential = "differential"
BackupKindRepository = "repository"
)
type BackupRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
Task BackupTask `json:"task,omitempty"`
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"`
StorageTarget StorageTarget `json:"storageTarget,omitempty"`
// NodeID 执行该次备份的节点0 = 本机 Master用于集群中识别 local_disk 类型
// 存储的归属节点,避免 Master 端试图跨节点访问远程 Agent 的本地存储
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
Status string `gorm:"size:20;index;not null" json:"status"`
FileName string `gorm:"column:file_name;size:255" json:"fileName"`
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
Checksum string `gorm:"column:checksum;size:64" json:"checksum"`
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
StorageUploadResults string `gorm:"column:storage_upload_results;type:text" json:"-"`
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID uint `gorm:"primaryKey" json:"id"`
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
Task BackupTask `json:"task,omitempty"`
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"`
StorageTarget StorageTarget `json:"storageTarget,omitempty"`
// NodeID 执行该次备份的节点0 = 本机 MasterStorageTransferMode 进一步
// 区分远程 Agent 直写与 Master 中转,避免在错误节点访问 local_disk
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
Status string `gorm:"size:20;index;not null" json:"status"`
FileName string `gorm:"column:file_name;size:255" json:"fileName"`
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
Checksum string `gorm:"column:checksum;size:64" json:"checksum"`
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
// 空值表示旧版 Agent 直写direct / master_relay 记录新协议的实际数据路径。
StorageTransferMode string `gorm:"column:storage_transfer_mode;size:20" json:"storageTransferMode,omitempty"`
StorageUploadResults string `gorm:"column:storage_upload_results;type:text" json:"-"`
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
// Locked 保留锁定(法律保留):为 true 时该备份不参与保留期/数量自动清理,
// 且禁止手动删除,直到显式解锁。用于保护合规快照、迁移前基线等关键备份。
Locked bool `gorm:"column:locked;not null;default:false;index" json:"locked"`
// BackupKind 备份类型full全量/ differential差异/ repositoryCDC 快照)。
BackupKind string `gorm:"column:backup_kind;size:16;not null;default:'full';index" json:"backupKind"`
// BaseRecordID 差异备份所基于的全量备份记录 ID全量记录为 0
BaseRecordID uint `gorm:"column:base_record_id;index;not null;default:0" json:"baseRecordId"`
// Manifest 全量备份的条目清单JSON供后续差异备份比对差异记录为空。
Manifest string `gorm:"column:manifest;type:text" json:"-"`
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (BackupRecord) TableName() string {

View File

@@ -8,6 +8,15 @@ const (
BackupTaskTypeSQLite = "sqlite"
BackupTaskTypePostgreSQL = "postgresql"
BackupTaskTypeSAPHANA = "saphana"
BackupTaskTypeMongoDB = "mongodb"
)
const (
// BackupModeFull 全量模式默认BackupModeDifferential 差异归档;
// BackupModeRepository 为 CDC 内容寻址仓库模式(仅文件类型本机任务)。
BackupModeFull = "full"
BackupModeDifferential = "differential"
BackupModeRepository = "repository"
)
const (
@@ -18,38 +27,54 @@ const (
)
type BackupTask struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:100;uniqueIndex;not null" json:"name"`
Type string `gorm:"size:20;index;not null" json:"type"`
Enabled bool `gorm:"not null;default:true" json:"enabled"`
CronExpr string `gorm:"column:cron_expr;size:64" json:"cronExpr"`
SourcePath string `gorm:"column:source_path;size:500" json:"sourcePath"`
SourcePaths string `gorm:"column:source_paths;type:text" json:"sourcePaths"`
ExcludePatterns string `gorm:"column:exclude_patterns;type:text" json:"excludePatterns"`
DBHost string `gorm:"column:db_host;size:255" json:"dbHost"`
DBPort int `gorm:"column:db_port" json:"dbPort"`
DBUser string `gorm:"column:db_user;size:100" json:"dbUser"`
DBPasswordCiphertext string `gorm:"column:db_password_ciphertext;type:text" json:"-"`
DBName string `gorm:"column:db_name;size:255" json:"dbName"`
DBPath string `gorm:"column:db_path;size:500" json:"dbPath"`
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:100;uniqueIndex;not null" json:"name"`
Type string `gorm:"size:20;index;not null" json:"type"`
Enabled bool `gorm:"not null;default:true" json:"enabled"`
CronExpr string `gorm:"column:cron_expr;size:64" json:"cronExpr"`
SourcePath string `gorm:"column:source_path;size:500" json:"sourcePath"`
SourcePaths string `gorm:"column:source_paths;type:text" json:"sourcePaths"`
ExcludePatterns string `gorm:"column:exclude_patterns;type:text" json:"excludePatterns"`
DBHost string `gorm:"column:db_host;size:255" json:"dbHost"`
DBPort int `gorm:"column:db_port" json:"dbPort"`
DBUser string `gorm:"column:db_user;size:100" json:"dbUser"`
DBPasswordCiphertext string `gorm:"column:db_password_ciphertext;type:text" json:"-"`
DBName string `gorm:"column:db_name;size:255" json:"dbName"`
DBPath string `gorm:"column:db_path;size:500" json:"dbPath"`
// ExtraConfig 类型特有的扩展配置JSON如 SAP HANA 的 backupLevel / backupChannels 等
ExtraConfig string `gorm:"column:extra_config;type:text" json:"extraConfig"`
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"` // deprecated: 保留兼容
StorageTarget StorageTarget `json:"storageTarget,omitempty"` // deprecated: 保留兼容
StorageTargets []StorageTarget `gorm:"many2many:backup_task_storage_targets" json:"storageTargets,omitempty"`
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
Node Node `json:"node,omitempty"`
Tags string `gorm:"column:tags;size:500" json:"tags"`
RetentionDays int `gorm:"column:retention_days;not null;default:30" json:"retentionDays"`
Compression string `gorm:"size:10;not null;default:'gzip'" json:"compression"`
Encrypt bool `gorm:"not null;default:false" json:"encrypt"`
MaxBackups int `gorm:"column:max_backups;not null;default:10" json:"maxBackups"`
LastRunAt *time.Time `gorm:"column:last_run_at" json:"lastRunAt,omitempty"`
LastStatus string `gorm:"column:last_status;size:20;not null;default:'idle'" json:"lastStatus"`
ExtraConfig string `gorm:"column:extra_config;type:text" json:"extraConfig"`
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"` // deprecated: 保留兼容
StorageTarget StorageTarget `json:"storageTarget,omitempty"` // deprecated: 保留兼容
StorageTargets []StorageTarget `gorm:"many2many:backup_task_storage_targets" json:"storageTargets,omitempty"`
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
Node Node `json:"node,omitempty"`
// NodePoolTag 节点池标签(可选)。非空且 NodeID=0 时,调度器会从 Node.Labels 包含该 tag
// 的在线节点中动态挑选一台执行(按运行中任务数最少原则),失败会 best-effort 切换到下一个候选。
// 典型场景NodePoolTag="db" 让 MySQL 备份任务在任意标有 "db" 的数据库节点执行。
NodePoolTag string `gorm:"column:node_pool_tag;size:64;index" json:"nodePoolTag"`
Tags string `gorm:"column:tags;size:500" json:"tags"`
RetentionDays int `gorm:"column:retention_days;not null;default:30" json:"retentionDays"`
Compression string `gorm:"size:10;not null;default:'gzip'" json:"compression"`
Encrypt bool `gorm:"not null;default:false" json:"encrypt"`
MaxBackups int `gorm:"column:max_backups;not null;default:10" json:"maxBackups"`
// BackupMode 备份模式full全量默认/ differential差异归档/
// repositoryFastCDC 分块、全局去重快照)。后两者仅支持本机文件任务。
BackupMode string `gorm:"column:backup_mode;size:16;not null;default:'full'" json:"backupMode"`
// DiffFullIntervalDays 差异模式下强制全量的间隔(天):最近全量超过该天数则本次自动改为全量,
// 限制差异链跨度与单个差异体积。默认 7。
DiffFullIntervalDays int `gorm:"column:diff_full_interval_days;not null;default:7" json:"diffFullIntervalDays"`
// GFS祖父-父-子)保留:分别保留最近 N 天 / M 周 / K 月 / Y 年的代表性备份(每周期保留最新一份)。
// 任一 > 0 即启用 GFS取代 RetentionDays/MaxBackups 简单策略;全为 0 时维持简单策略(向后兼容)。
KeepDaily int `gorm:"column:keep_daily;not null;default:0" json:"keepDaily"`
KeepWeekly int `gorm:"column:keep_weekly;not null;default:0" json:"keepWeekly"`
KeepMonthly int `gorm:"column:keep_monthly;not null;default:0" json:"keepMonthly"`
KeepYearly int `gorm:"column:keep_yearly;not null;default:0" json:"keepYearly"`
LastRunAt *time.Time `gorm:"column:last_run_at" json:"lastRunAt,omitempty"`
LastStatus string `gorm:"column:last_status;size:20;not null;default:'idle'" json:"lastStatus"`
// 验证(恢复演练)配置 — 定期自动校验备份可恢复性
VerifyEnabled bool `gorm:"column:verify_enabled;not null;default:false" json:"verifyEnabled"`
VerifyCronExpr string `gorm:"column:verify_cron_expr;size:64" json:"verifyCronExpr"`
VerifyMode string `gorm:"column:verify_mode;size:20;not null;default:'quick'" json:"verifyMode"`
VerifyEnabled bool `gorm:"column:verify_enabled;not null;default:false" json:"verifyEnabled"`
VerifyCronExpr string `gorm:"column:verify_cron_expr;size:64" json:"verifyCronExpr"`
VerifyMode string `gorm:"column:verify_mode;size:20;not null;default:'quick'" json:"verifyMode"`
// SLA 配置 — RPO期望最长未备份间隔与告警阈值
SLAHoursRPO int `gorm:"column:sla_hours_rpo;not null;default:0" json:"slaHoursRpo"`
AlertOnConsecutiveFails int `gorm:"column:alert_on_consecutive_fails;not null;default:1" json:"alertOnConsecutiveFails"`
@@ -64,9 +89,9 @@ type BackupTask struct {
// 语义:上游任务成功后自动触发本任务,形成工作流(如 DB 备份完成 → 归档压缩)。
// 调度器继续按本任务自己的 cron 触发,仅"自动触发"路径响应依赖完成事件。
// 循环依赖检查在 service 层完成,避免配置阶段即出错。
DependsOnTaskIDs string `gorm:"column:depends_on_task_ids;size:500" json:"dependsOnTaskIds"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DependsOnTaskIDs string `gorm:"column:depends_on_task_ids;size:500" json:"dependsOnTaskIds"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (BackupTask) TableName() string {

View File

@@ -1,36 +1,97 @@
package model
import "time"
import (
"strings"
"time"
)
const (
NodeStatusOnline = "online"
NodeStatusOffline = "offline"
)
// OfflineGracePeriod 节点心跳超时判定阈值:超过该时长未心跳的远程节点视为离线。
// Agent 默认 15s 心跳一次,预留 3 次重试空间。
const OfflineGracePeriod = 45 * time.Second
// EffectiveStatus 返回节点的「实时」在线状态。
//
// 存储字段 Status 由心跳置 online、由后台离线监控置 offline二者之间存在最长一个
// 监控周期的滞后窗口;期间 List/Get/调度器可能读到过期的 "online",进而把任务下发
// 给一台刚刚失联的节点。本方法直接以 LastSeen 推导:远程节点若超过 OfflineGracePeriod
// 未心跳即视为 offline消除该滞后导致的误判。本机节点恒以存储状态为准它就是 Master
// 自身,不依赖心跳)。
func (n *Node) EffectiveStatus(now time.Time) string {
if n == nil {
return NodeStatusOffline
}
if n.IsLocal {
return n.Status
}
if now.Sub(n.LastSeen) > OfflineGracePeriod {
return NodeStatusOffline
}
return n.Status
}
// Node represents a managed server node in the cluster.
// The default "local" node is auto-created for single-machine backward compatibility.
type Node struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"`
Hostname string `gorm:"size:255" json:"hostname"`
IPAddress string `gorm:"column:ip_address;size:64" json:"ipAddress"`
Token string `gorm:"size:128;uniqueIndex;not null" json:"-"`
Status string `gorm:"size:20;not null;default:'offline'" json:"status"`
IsLocal bool `gorm:"not null;default:false" json:"isLocal"`
OS string `gorm:"size:64" json:"os"`
Arch string `gorm:"size:32" json:"arch"`
AgentVer string `gorm:"column:agent_version;size:32" json:"agentVersion"`
LastSeen time.Time `gorm:"column:last_seen" json:"lastSeen"`
PrevToken string `gorm:"size:128;index" json:"-"`
PrevTokenExpires *time.Time `gorm:"column:prev_token_expires" json:"-"`
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"`
Hostname string `gorm:"size:255" json:"hostname"`
IPAddress string `gorm:"column:ip_address;size:64" json:"ipAddress"`
Token string `gorm:"size:128;uniqueIndex;not null" json:"-"`
Status string `gorm:"size:20;not null;default:'offline'" json:"status"`
IsLocal bool `gorm:"not null;default:false" json:"isLocal"`
OS string `gorm:"size:64" json:"os"`
Arch string `gorm:"size:32" json:"arch"`
AgentVer string `gorm:"column:agent_version;size:32" json:"agentVersion"`
LastSeen time.Time `gorm:"column:last_seen" json:"lastSeen"`
PrevToken string `gorm:"size:128;index" json:"-"`
PrevTokenExpires *time.Time `gorm:"column:prev_token_expires" json:"-"`
// MaxConcurrent 该节点允许的最大并发任务数0=不限制,沿用全局 cfg.Backup.MaxConcurrent
// 用于大集群中限制单节点资源占用:例如小内存 Agent 节点可配 1避免多个大备份同时跑挤爆。
MaxConcurrent int `gorm:"column:max_concurrent;not null;default:0" json:"maxConcurrent"`
// BandwidthLimit 该节点上传带宽上限rclone 可识别格式10M / 1G / 0=不限)。
// 对集群感知的上传场景有效Master 本地与 Agent 运行时均会应用)。
BandwidthLimit string `gorm:"column:bandwidth_limit;size:32" json:"bandwidthLimit"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// Labels 节点标签CSV如 "prod,db-host,high-mem")。
// 用于任务调度的节点池选择:任务配置 NodePoolTag 时,调度器会从 Labels 包含该 tag 的
// 在线节点中自动挑选一台执行(按当前运行中任务数升序)。单节点可属多个池。
Labels string `gorm:"column:labels;size:500" json:"labels"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// LabelSet 把 CSV Labels 解析为 set便于做成员判定。
// 空白与空 token 自动忽略。
func (n *Node) LabelSet() map[string]struct{} {
if n == nil {
return nil
}
out := make(map[string]struct{})
for _, raw := range strings.Split(n.Labels, ",") {
label := strings.TrimSpace(raw)
if label != "" {
out[label] = struct{}{}
}
}
return out
}
// HasLabel 判断节点是否属于指定池。nil/空 tag 返回 false。
func (n *Node) HasLabel(tag string) bool {
tag = strings.TrimSpace(tag)
if n == nil || tag == "" {
return false
}
for _, raw := range strings.Split(n.Labels, ",") {
if strings.TrimSpace(raw) == tag {
return true
}
}
return false
}
func (Node) TableName() string {

View File

@@ -0,0 +1,47 @@
package model
import "testing"
func TestNodeHasLabel(t *testing.T) {
cases := []struct {
labels string
tag string
want bool
}{
{"prod,db,high-mem", "prod", true},
{"prod,db,high-mem", "db", true},
{"prod,db,high-mem", "backup", false},
{" prod , db ", "db", true}, // trim 空白
{"", "prod", false},
{"prod", "", false}, // 空 tag 不匹配
}
for _, c := range cases {
n := &Node{Labels: c.labels}
if got := n.HasLabel(c.tag); got != c.want {
t.Errorf("labels=%q tag=%q want %v got %v", c.labels, c.tag, c.want, got)
}
}
}
func TestNodeLabelSet(t *testing.T) {
n := &Node{Labels: "prod, db ,,high-mem,prod"}
set := n.LabelSet()
for _, want := range []string{"prod", "db", "high-mem"} {
if _, ok := set[want]; !ok {
t.Errorf("expected label %q in set", want)
}
}
if len(set) != 3 {
t.Errorf("duplicates not deduped, got %v", set)
}
}
func TestNilNodeHasLabelSafe(t *testing.T) {
var n *Node
if n.HasLabel("anything") {
t.Error("nil node should never match any label")
}
if s := n.LabelSet(); s != nil {
t.Errorf("nil node LabelSet should be nil, got %v", s)
}
}

View File

@@ -0,0 +1,58 @@
package model
import (
"testing"
"time"
)
func TestNodeEffectiveStatus(t *testing.T) {
now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
cases := []struct {
name string
node *Node
want string
}{
{
name: "remote fresh heartbeat → stored online",
node: &Node{IsLocal: false, Status: NodeStatusOnline, LastSeen: now.Add(-10 * time.Second)},
want: NodeStatusOnline,
},
{
name: "remote stale heartbeat but stored online → derived offline",
node: &Node{IsLocal: false, Status: NodeStatusOnline, LastSeen: now.Add(-90 * time.Second)},
want: NodeStatusOffline,
},
{
name: "remote just past grace period → offline",
node: &Node{IsLocal: false, Status: NodeStatusOnline, LastSeen: now.Add(-(OfflineGracePeriod + time.Second))},
want: NodeStatusOffline,
},
{
name: "remote within grace period → online",
node: &Node{IsLocal: false, Status: NodeStatusOnline, LastSeen: now.Add(-(OfflineGracePeriod - time.Second))},
want: NodeStatusOnline,
},
{
name: "local node ignores LastSeen → stored online",
node: &Node{IsLocal: true, Status: NodeStatusOnline, LastSeen: now.Add(-24 * time.Hour)},
want: NodeStatusOnline,
},
{
name: "remote stored offline stays offline",
node: &Node{IsLocal: false, Status: NodeStatusOffline, LastSeen: now.Add(-5 * time.Second)},
want: NodeStatusOffline,
},
{
name: "nil node → offline",
node: nil,
want: NodeStatusOffline,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.node.EffectiveStatus(now); got != tc.want {
t.Fatalf("EffectiveStatus = %q, want %q", got, tc.want)
}
})
}
}

View File

@@ -11,21 +11,23 @@ const (
)
type RestoreRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
BackupRecordID uint `gorm:"column:backup_record_id;index;not null" json:"backupRecordId"`
BackupRecord BackupRecord `json:"backupRecord,omitempty"`
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
Task BackupTask `json:"task,omitempty"`
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
Status string `gorm:"size:20;index;not null" json:"status"`
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
TriggeredBy string `gorm:"column:triggered_by;size:100" json:"triggeredBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID uint `gorm:"primaryKey" json:"id"`
BackupRecordID uint `gorm:"column:backup_record_id;index;not null" json:"backupRecordId"`
BackupRecord BackupRecord `json:"backupRecord,omitempty"`
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
Task BackupTask `json:"task,omitempty"`
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
// TargetPath 恢复到指定目录(仅文件类型本机恢复);空 = 恢复到原始源路径。
TargetPath string `gorm:"column:target_path;size:500" json:"targetPath"`
Status string `gorm:"size:20;index;not null" json:"status"`
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
TriggeredBy string `gorm:"column:triggered_by;size:100" json:"triggeredBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (RestoreRecord) TableName() string {

View File

@@ -22,12 +22,25 @@ func IsValidRole(role string) bool {
}
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
PasswordHash string `gorm:"column:password_hash;not null" json:"-"`
DisplayName string `gorm:"size:128;not null" json:"displayName"`
Email string `gorm:"size:255" json:"email"`
Role string `gorm:"size:32;not null;default:admin" json:"role"`
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
PasswordHash string `gorm:"column:password_hash;not null" json:"-"`
DisplayName string `gorm:"size:128;not null" json:"displayName"`
Email string `gorm:"size:255" json:"email"`
Phone string `gorm:"size:64" json:"phone"`
Role string `gorm:"size:32;not null;default:admin" json:"role"`
// TwoFactorSecretCiphertext 保存 TOTP 密钥密文;未启用时可作为待确认密钥。
TwoFactorEnabled bool `gorm:"column:two_factor_enabled;not null;default:false" json:"twoFactorEnabled"`
TwoFactorSecretCiphertext string `gorm:"column:two_factor_secret_ciphertext;type:text" json:"-"`
// TwoFactorRecoveryCodeHashes 保存一次性恢复码哈希的 JSON 数组。
TwoFactorRecoveryCodeHashes string `gorm:"column:two_factor_recovery_code_hashes;type:text" json:"-"`
// WebAuthnCredentials 保存通行密钥公钥元数据 JSON不包含私钥或明文密钥。
WebAuthnCredentials string `gorm:"column:webauthn_credentials;type:text" json:"-"`
WebAuthnChallengeCiphertext string `gorm:"column:webauthn_challenge_ciphertext;type:text" json:"-"`
TrustedDevices string `gorm:"column:trusted_devices;type:text" json:"-"`
EmailOTPEnabled bool `gorm:"column:email_otp_enabled;not null;default:false" json:"emailOtpEnabled"`
SMSOTPEnabled bool `gorm:"column:sms_otp_enabled;not null;default:false" json:"smsOtpEnabled"`
OutOfBandOTPCiphertext string `gorm:"column:out_of_band_otp_ciphertext;type:text" json:"-"`
// Disabled 禁用账号(不删除保留审计)。禁用后无法登录。
Disabled bool `gorm:"not null;default:false" json:"disabled"`
CreatedAt time.Time `json:"createdAt"`

View File

@@ -37,13 +37,12 @@ func (n *EmailNotifier) Send(_ context.Context, config map[string]any, message M
from := strings.TrimSpace(asString(config["from"]))
toList := splitCommaValues(asString(config["to"]))
address := host + ":" + strconv.Itoa(port)
headers := []string{"From: " + from, "To: " + strings.Join(toList, ", "), "Subject: " + message.Title, "MIME-Version: 1.0", "Content-Type: text/plain; charset=UTF-8", "", message.Body}
var auth smtp.Auth
if username != "" {
auth = smtp.PlainAuth("", username, password, host)
}
rawMessage := []byte(strings.Join(headers, "\r\n"))
rawMessage := buildRawMessage(from, toList, message)
if port == 465 {
tlsConfig := &tls.Config{ServerName: host}
@@ -86,3 +85,31 @@ func (n *EmailNotifier) Send(_ context.Context, config map[string]any, message M
return smtp.SendMail(address, auth, from, toList, rawMessage)
}
// buildRawMessage 构造 RFC 5322 邮件原文。所有头部值都会剔除 CR/LF
// 防止 SMTP 头注入:备份任务名等用户可控内容会进入 Subject若包含
// 换行符可被用来注入额外头部(如 Bcc或伪造正文。正文本身不做处理
// 允许包含换行。
func buildRawMessage(from string, toList []string, message Message) []byte {
sanitizedTo := make([]string, 0, len(toList))
for _, addr := range toList {
if s := sanitizeHeaderValue(addr); s != "" {
sanitizedTo = append(sanitizedTo, s)
}
}
headers := []string{
"From: " + sanitizeHeaderValue(from),
"To: " + strings.Join(sanitizedTo, ", "),
"Subject: " + sanitizeHeaderValue(message.Title),
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"",
message.Body,
}
return []byte(strings.Join(headers, "\r\n"))
}
// sanitizeHeaderValue 移除头部值中的 CR 与 LF消除头注入向量。
func sanitizeHeaderValue(value string) string {
return strings.NewReplacer("\r", "", "\n", "").Replace(strings.TrimSpace(value))
}

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