Compare commits

...

76 Commits

Author SHA1 Message Date
Awuqing
5767c259fe docs(cluster): document bastion and recovery operations 2026-08-09 02:32:19 +08:00
Awuqing
8c5953500d build(deps): refresh supported web and docs tooling 2026-08-09 02:32:01 +08:00
Awuqing
b25099b05e perf(cluster): reduce agent command queue contention 2026-08-09 02:31:44 +08:00
Awuqing
ae9623c078 fix(deploy): harden container and bare-metal installs 2026-08-09 02:31:22 +08:00
Awuqing
ab181b3197 feat(deploy): harden restricted-network agent installs 2026-08-09 02:31:06 +08:00
Awuqing
ea46a30f11 feat(cluster): support restricted-network agent connectivity 2026-08-09 02:30:42 +08:00
Wu Qing
00151e466c feat: 解决 CDC 去重、集中备份与首次初始化问题 (#105)
实现 CDC 内容寻址仓库、远程 Agent 中央中转备份与首次初始化体验,并补充安全校验、测试及双语文档。

Closes #94
Closes #101
Closes #104
2026-08-07 23:01:29 +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
Wu Qing
539e9e64c4 功能: v2.0.0 企业级备份管理平台 — 11 项核心能力 (#45)
* 功能: v2.0.0 企业级备份管理平台 — 11 项核心能力

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

## 集群能力

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

## 企业功能

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

## 规模化运维

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

## 体验 & 可达性

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

## 合规 & 可部署

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

## 破坏性变更

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

* 修复: CodeQL 安全扫描告警

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

前端(系统设置页重构):
- 新增"检查更新"按钮,点击后展示更新结果
- 有新版本时显示版本号、更新说明、下载按钮、Docker 更新命令
- 新增磁盘状态卡片(总空间/已用/可用/使用率)
- 运行模式用彩色 Tag 区分(生产/开发)
2026-04-01 23:13:32 +08:00
Wu Qing
a772b94ca5 修复: rclone 后端列表不显示 + 调度审计 + 批量删除 (#30)
修复: rclone 后端列表不显示 + 调度审计 + 批量删除
2026-04-01 23:02:40 +08:00
Awuqing
3bd15bf3fd 修复: rclone 后端列表不显示 + 调度审计 + 批量删除
1. 修复前端 rclone 后端 API 路径双重 /api 前缀导致 404,
   存储类型下拉框现在正确显示全部 70+ rclone 后端
2. 调度器自动触发的备份任务计入审计日志(用户名: system)
3. 新增备份记录批量删除 API (POST /api/backup/records/batch-delete)
2026-04-01 22:57:55 +08:00
Wu Qing
5ae7fb2f5d 修复: 上传操作级重试 (#29)
修复: 上传操作级重试,解决远端临时故障导致自动备份失败
2026-04-01 18:40:13 +08:00
Awuqing
37ad6b1db1 修复: 上传操作级重试,解决 Google Drive 等远端临时故障导致自动备份连续失败
问题:rclone 底层重试只覆盖单个 HTTP 请求,但 Google API 的 502/timeout
等临时故障会导致整个上传操作失败,自动触发的备份任务连续失败。

修复:在 provider.Upload 外层增加操作级重试(最多 3 次,指数退避 10s/40s/90s),
每次重试重新打开文件并重建 reader 链。重试过程通过日志流实时反馈。
2026-04-01 18:35:26 +08:00
Wu Qing
d9e0609089 功能: 全部 rclone 后端注册为一级存储类型 (#28)
功能: 全部 rclone 后端注册为一级存储类型
2026-04-01 12:59:29 +08:00
Awuqing
ab9919f15f 功能: 全部 rclone 后端注册为一级存储类型
将全部 70+ rclone 后端(SFTP、Azure Blob、Dropbox、OneDrive、B2、SMB 等)
自动注册为独立 Factory,与 S3、FTP 等内置类型完全平级。

- 新增 GenericBackendFactory + RegisterAllBackends 自动注册全部后端
- 移除 oneof 硬编码白名单,type 字段接受任意已注册存储类型
- 前端类型选择器合并内置类型和全部 rclone 后端为统一可搜索下拉框
- 选择 SFTP 直接存储 type="sftp",非内置类型自动从 API 获取配置字段
2026-04-01 12:52:06 +08:00
Wu Qing
d70b4094af 优化: 重新设计 Cron 编辑器交互体验 (#27)
优化: 重新设计 Cron 编辑器交互体验
2026-04-01 07:50:16 +08:00
Awuqing
eeec7678a1 优化: 重新设计 Cron 编辑器交互体验
核心问题:预设选中后下方 Tab 编辑器仍展开显示混乱的技术细节。

重新设计为三层交互:
1. 预设按钮(一键选择常见场景,选中高亮,无多余 UI)
2. 自定义选择器(每天/每周/每月/间隔四种模式,直观的时间选择器
   和星期按钮,无需理解 cron 语法)
3. 手动输入(高级用户直接编辑 cron 表达式)

同时优化中文描述为自然语言("每天 02:00 执行" 替代 "02 时 00 分 执行")
2026-04-01 07:44:19 +08:00
Wu Qing
cefbdf3a53 优化: Cron 表达式编辑器增加预设和中文描述 (#26)
优化: Cron 表达式编辑器增加预设和中文描述
2026-04-01 00:17:38 +08:00
Wu Qing
4a56ad05fc 修复: 审计日志补充操作详情 + 版本号注入修复 (#25)
修复: 审计日志补充操作详情 + 版本号注入修复
2026-04-01 00:17:34 +08:00
Wu Qing
9ea02566cb 修复: 存储目标创建/连接测试/类型选择三个关键问题 (#24)
修复: 存储目标创建/连接测试/类型选择三个关键问题
2026-04-01 00:17:29 +08:00
Awuqing
a45b1f7bfb 优化: Cron 表达式编辑器增加预设和中文描述
1. 新增 8 个常用预设按钮(每天 02:00、每 6 小时、每周日、每月 1 日等),
   一键设置无需逐个 Tab 操作
2. 新增中文可读描述(如 "02 时 00 分 执行"),实时显示在表达式下方
3. 选中的预设按钮高亮显示
2026-04-01 00:12:32 +08:00
Awuqing
bfc8728785 修复: 审计日志补充操作详情 + 版本号注入修复
1. 审计日志:所有 handler 的 recordAudit 调用补充有意义的 detail,
   包括创建/更新时记录类型、删除时记录 ID、设置变更时记录修改的 key
2. 版本号:Makefile 的 run/build 都通过 ldflags 注入 git 版本号,
   开发模式不再显示 "dev"
2026-04-01 00:10:51 +08:00
Awuqing
3023a089fb 修复: 存储目标创建/连接测试/类型选择三个关键问题
1. 修复 oneof 白名单仅含 4 种类型,阿里云/腾讯/七牛/FTP/Rclone
   类型的存储目标无法创建(binding 验证直接拒绝)
2. 修复本地磁盘 TestConnection 报 "directory not found",
   在 List 前先 Mkdir 确保目录存在
3. 前端存储类型选项明确标注 Rclone 支持 SFTP/Azure/Dropbox 等
2026-04-01 00:06:08 +08:00
Wu Qing
c437a72aad 功能: 集成 rclone 高级传输特性 + 全 70+ 后端支持 (#23)
功能: 集成 rclone 高级传输特性 + 全 70+ 后端支持
2026-03-31 23:46:02 +08:00
Awuqing
93bf8435b0 功能: 集成 rclone 高级传输特性 + 全 70+ 后端支持
1. 失败自动重试:rclone Pacer 指数退避,默认 10 次底层 HTTP 重试
2. 带宽限制:配置 bandwidth_limit + Settings 运行时可调
3. 上传实时进度:progressReader + LogHub SSE 推送字节级进度/速率
4. 存储空间查询:StorageAbout 可选接口,GetUsage 返回远端真实空间
5. 全 rclone 后端:backend/all 引入 70+ 后端,新增 rclone 存储类型,
   API 驱动的可搜索后端选择器 + 动态配置表单
2026-03-31 23:37:59 +08:00
Wu Qing
b2055c08f1 重构: 存储传输层集成 rclone 替代自研实现 (#22)
重构: 存储传输层集成 rclone 替代自研实现
2026-03-31 22:55:41 +08:00
372 changed files with 67475 additions and 2805 deletions

View File

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

63
.github/workflows/docs.yml vendored Normal file
View File

@@ -0,0 +1,63 @@
name: Deploy Docs
# 触发条件:
# - 推送 main 时,如果 docs-site/ 或站点相关 README 有变化
# - 手动触发(在 Actions 页面)
on:
push:
branches:
- main
paths:
- 'docs-site/**'
- '.github/workflows/docs.yml'
workflow_dispatch:
# 允许写入 Pages用于发布到 github.com/Awuqing/BackupX 的 Pages 站点
permissions:
contents: read
pages: write
id-token: write
# 同时只保留一个部署任务
concurrency:
group: pages-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: docs-site
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: docs-site/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build site
run: npm run build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs-site/build
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -110,13 +110,26 @@ 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
cp deploy/backupx.service "${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"
sha256sum "${ARCHIVE_NAME}.tar.gz" > "${ARCHIVE_NAME}.tar.gz.sha256"
sha256sum "backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz" > "backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz.sha256"
- 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-${{ env.VERSION }}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz.sha256
backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz
backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz.sha256
generate_release_notes: true
# ─── Job 3: Docker 多架构 → Docker Hub ───

5
.gitignore vendored
View File

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

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

@@ -51,11 +51,11 @@ RUN if [ "$USE_CHINA_MIRROR" = "true" ]; then \
sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories; \
fi
# Database client binaries are required by MySQL and PostgreSQL backup tasks.
RUN apk add --no-cache \
nginx \
tzdata \
ca-certificates \
# Required by mysql/postgresql backup tasks
su-exec \
mysql-client \
postgresql16-client \
&& rm -rf /var/cache/apk/*
@@ -69,24 +69,30 @@ COPY --from=server-builder /build/server/backupx /app/bin/backupx
# Copy frontend static files
COPY --from=web-builder /build/web/dist /app/web
# Copy nginx config
COPY deploy/docker/nginx.conf /etc/nginx/http.d/default.conf
# Copy entrypoint
COPY deploy/docker/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
# Create data directories
RUN mkdir -p /app/data /tmp/backupx && \
chown -R backupx:backupx /app /tmp/backupx
# Nginx needs to write to these dirs
RUN mkdir -p /var/lib/nginx/tmp /var/log/nginx && \
chown -R backupx:backupx /var/lib/nginx /var/log/nginx /run/nginx
touch /app/data/.backupx-owner-v2 && \
chown -R backupx:backupx /app/data /tmp/backupx && \
chmod 0750 /app/data /tmp/backupx
WORKDIR /app
EXPOSE 8340
VOLUME ["/app/data"]
ENV BACKUPX_SERVER_HOST=0.0.0.0 \
BACKUPX_SERVER_PORT=8340 \
BACKUPX_SERVER_WEB_ROOT=/app/web
USER root
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD su-exec backupx:backupx wget -q -T 3 -O /dev/null http://127.0.0.1:8340/ready || exit 1
STOPSIGNAL SIGTERM
ENTRYPOINT ["/app/entrypoint.sh"]

335
README.md
View File

@@ -1,11 +1,11 @@
<p align="right">
<a href="README_EN.md">English</a> | <strong>中文</strong>
<strong>English</strong> | <a href="README.zh-CN.md">中文</a>
</p>
<p align="center">
<h1 align="center">BackupX</h1>
<p align="center">
<strong>自托管服务器备份管理平台</strong><br>
一个二进制,一条命令,管好你所有服务器的备份。
<strong>Self-hosted server backup management</strong><br>
One binary, one command — manage every backup of every server.
</p>
<p align="center">
<a href="https://github.com/Awuqing/BackupX/stargazers"><img src="https://img.shields.io/github/stars/Awuqing/BackupX?style=flat-square&color=f5c542" alt="Stars"></a>
@@ -15,307 +15,98 @@
<img src="https://img.shields.io/badge/SQLite-embedded-003B57?style=flat-square&logo=sqlite" alt="SQLite">
<a href="LICENSE"><img src="https://img.shields.io/github/license/Awuqing/BackupX?style=flat-square" alt="License"></a>
</p>
<p align="center">
<a href="https://awuqing.github.io/BackupX/"><strong>Docs</strong></a> ·
<a href="https://github.com/Awuqing/BackupX/releases"><strong>Downloads</strong></a> ·
<a href="https://hub.docker.com/r/awuqing/backupx"><strong>Docker Hub</strong></a>
</p>
</p>
---
<table>
<tr>
<td width="50%"><img src="screenshots/dashboard.png" alt="仪表盘"></td>
<td width="50%"><img src="screenshots/backup-tasks.png" alt="备份任务"></td>
<td width="50%"><img src="screenshots/dashboard.png" alt="Dashboard"></td>
<td width="50%"><img src="screenshots/backup-tasks.png" alt="Backup Tasks"></td>
</tr>
<tr>
<td><img src="screenshots/storage-targets.png" alt="存储目标"></td>
<td><img src="screenshots/backup-records.png" alt="备份记录"></td>
<td><img src="screenshots/storage-targets.png" alt="Storage Targets"></td>
<td><img src="screenshots/backup-records.png" alt="Backup Records"></td>
</tr>
</table>
## 功能亮点
## Highlights
| 能力 | 说明 |
|------|------|
| **备份类型** | 文件/目录(多源路径)、MySQLPostgreSQLSQLiteSAP HANA |
| **存储后端** | 阿里云 OSS、腾讯云 COS、七牛云、S3 兼容(AWS/MinIO/R2)、Google Drive、WebDAV、FTP/FTPS、本地磁盘 |
| **自动调度** | Cron 定时 + 可视化编辑器 + 自动保留策略(按天数/份数清理) |
| **多节点** | Master-Agent 集群,统一管理多台服务器的备份 |
| **安全** | JWT + bcrypt + AES-256-GCM 加密配置 + 可选备份文件加密 + 审计日志 |
| **通知** | 邮件 / Webhook / Telegram备份成功或失败时自动推送 |
| **部署** | 单二进制 + 内嵌 SQLiteDocker 一键启动,零外部依赖 |
| Capability | Details |
|-----------|---------|
| **Backup Types** | Files/directories (multi-source), MySQL, PostgreSQL, SQLite, SAP HANA (full / incremental / differential / log + parallel channels + retry) |
| **SAP HANA Backint Agent** | Built-in Backint protocol — HANA's native interface routes data directly to any BackupX storage backend |
| **70+ Storage Backends** | Alibaba OSS, Tencent COS, Qiniu, S3, Google Drive, WebDAV, FTP + SFTP, Azure Blob, Dropbox, OneDrive and dozens more via rclone |
| **Scheduling** | Cron + visual editor + auto-retention (by days/count + empty-directory cleanup) |
| **Multi-Node Cluster** | Outbound-only Master-Agent polling with proxy, private-CA, and SSH-bastion support; Agents run tasks locally with no reverse connectivity required |
| **Security** | JWT + bcrypt + AES-256-GCM encrypted config + optional backup encryption + full audit log |
| **Notifications** | Email / Webhook / Telegram on success or failure |
| **Observability** | Prometheus `/metrics` endpoint + `/health` + `/ready` probes + SLA breach gauge |
| **Audit Webhook** | HMAC-SHA256 signed forwarding to SIEM / WORM storage for compliance (SOC2 / GDPR) |
| **Flow Control** | Per-node bandwidth cap + per-node concurrency limit — tune big/small nodes independently |
| **Deployment** | Single binary + embedded SQLite, Docker one-click, zero external dependencies |
---
## 快速开始
### 1. 安装
**Docker推荐无需克隆仓库**
## Quick Start
```bash
# 创建 docker-compose.yml 后一键启动
docker compose up -d
# 或直接运行
# Docker (recommended)
docker run -d --name backupx -p 8340:8340 -v backupx-data:/app/data awuqing/backupx:latest
# Or prebuilt archive
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-linux-amd64.tar.gz
tar xzf backupx-*.tar.gz && cd backupx-* && sudo ./install.sh
# 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
```
> Docker Hub 镜像:[`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx),支持 linux/amd64 和 linux/arm64。
For ARM64 hosts, use `backupx-linux-arm64.tar.gz`. The archive contains `backupx`, `web/`, `config.example.yaml`, and `install.sh`; run `install.sh` from the extracted directory.
<details>
<summary>docker-compose.yml 参考</summary>
Open `http://your-server:8340`, 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).
```yaml
services:
backupx:
image: awuqing/backupx:latest
container_name: backupx
restart: unless-stopped
ports:
- "8340:8340"
volumes:
- backupx-data:/app/data
# 挂载需要备份的宿主机目录(按需添加):
# - /var/www:/mnt/www:ro
# - /etc/nginx:/mnt/nginx-conf:ro
environment:
- TZ=Asia/Shanghai
## Documentation
volumes:
backupx-data:
```
The full docs live at **https://awuqing.github.io/BackupX/** — Getting Started, Deployment, SAP HANA, Multi-Node Cluster, API reference, and more. Switch to Chinese via the language dropdown in the top-right nav.
</details>
Quick links:
**预编译包(裸机部署):**
- [Quick Start](https://awuqing.github.io/BackupX/docs/getting-started/quick-start) — first backup in five minutes
- [Installation](https://awuqing.github.io/BackupX/docs/getting-started/installation) — Docker / bare metal / source
- [Multi-Node Cluster](https://awuqing.github.io/BackupX/docs/features/multi-node) — deploy the Agent on remote servers
- [SAP HANA Support](https://awuqing.github.io/BackupX/docs/features/sap-hana) — hdbsql Runner and native Backint
- [API Reference](https://awuqing.github.io/BackupX/docs/reference/api) — REST endpoints
从 [Releases](https://github.com/Awuqing/BackupX/releases) 下载对应平台的压缩包:
```bash
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
sudo ./install.sh # 自动配置 systemd + Nginx
```
**从源码构建:**
## Development
```bash
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
make build # 构建前后端
make docker-cn # 或用国内镜像构建 Dockergoproxy.cn / npmmirror / 阿里云 apk
make dev-server # Terminal 1 — backend (:8340)
make dev-web # Terminal 2 — frontend (Vite HMR)
make test # run all tests
make build # produce server/bin/backupx + web/dist
```
### 2. 打开控制台
浏览器访问 `http://your-server:8340`,首次打开会引导创建管理员账户。
### 3. 添加存储目标
进入 **存储目标** 页面,点击 **添加**,选择存储类型并填写凭证:
| 存储类型 | 需要填写 |
|---------|---------|
| 阿里云 OSS | Region + AccessKey ID/Secret + Bucket |
| 腾讯云 COS | Region + SecretId/SecretKey + Bucket格式 `name-appid` |
| 七牛云 Kodo | Region + AccessKey/SecretKey + Bucket |
| S3 兼容 | Endpoint + AccessKey + Bucket |
| Google Drive | Client ID/Secret → 点击授权完成 OAuth |
| WebDAV | 服务器地址 + 用户名/密码 |
| FTP | 主机 + 端口 + 用户名/密码 |
| 本地磁盘 | 目标目录路径 |
> 国内云厂商只需填 Region 和 AccessKey系统自动组装 Endpoint。
添加后点击 **测试连接** 确认配置正确。
### 4. 创建备份任务
进入 **备份任务** 页面,点击 **新建**,三步完成:
1. **基础信息** — 任务名称、备份类型、Cron 表达式(留空则仅手动执行)
2. **源配置** — 文件备份选择源路径(支持多个)、数据库备份填写连接信息
3. **存储与策略** — 选择存储目标、压缩策略、保留天数、是否加密
保存后可以点击 **立即执行** 测试,在 **备份记录** 页面实时查看执行日志。
### 5. 配置通知(可选)
进入 **通知配置** 页面支持邮件、Webhook、Telegram 三种方式,可分别配置成功/失败时是否推送。
---
## 部署指南
### Docker 部署
```bash
docker compose up -d # 使用上方的 docker-compose.yml
```
备份宿主机目录时需要挂载路径(在 docker-compose.yml 的 `volumes` 中添加):
```yaml
volumes:
- backupx-data:/app/data
- /var/www:/mnt/www:ro # 挂载需要备份的目录
- /etc/nginx:/mnt/nginx-conf:ro # 可以挂载多个
```
通过环境变量调整配置:
```yaml
environment:
- TZ=Asia/Shanghai
- BACKUPX_LOG_LEVEL=debug
- BACKUPX_BACKUP_MAX_CONCURRENT=4
```
### 裸机部署
```bash
# 使用预编译包
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
sudo ./install.sh
# 或从源码
make build
sudo ./deploy/install.sh
```
安装脚本自动完成:创建系统用户 → 安装二进制到 `/opt/backupx/` → 配置 systemd → 配置 Nginx 反向代理。
### Nginx 反向代理(裸机部署时)
```nginx
server {
listen 80;
server_name backup.example.com;
location / {
root /opt/backupx/web;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8340;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
### 配置文件
配置文件路径 `./config.yaml`,也可通过 `BACKUPX_` 前缀环境变量覆盖:
```yaml
server:
port: 8340
database:
path: "./data/backupx.db"
security:
jwt_secret: "" # 留空自动生成并持久化到数据库
encryption_key: "" # 留空自动生成
backup:
temp_dir: "/tmp/backupx"
max_concurrent: 2
log:
level: "info" # debug | info | warn | error
file: "./data/backupx.log"
```
### 密码重置
忘记管理员密码时通过 CLI 重置:
```bash
# 裸机
./backupx reset-password --username admin --password newpass123
# Docker
docker exec -it backupx /app/bin/backupx reset-password --username admin --password newpass123
```
---
## 多节点集群
BackupX 支持 Master-Agent 模式管理多台服务器:
1. Web 控制台 → **节点管理****添加节点**,系统生成 Token
2. 在远程服务器部署 Agent 并使用 Token 连接 Master
3. 创建备份任务时选择对应节点Master 自动下发任务
创建文件备份任务时,可通过可视化目录浏览器远程选择 Agent 节点上的目录,无需手动输入路径。
---
## 开发指南
**环境要求:** Go >= 1.25 · Node.js >= 20 · npm
```bash
# 开发模式
make dev-server # 终端 1后端默认 :8340
make dev-web # 终端 2前端Vite HMR
# 测试
make test # 运行全部测试
# 构建
make build # 前后端一起构建
make docker # Docker 构建
make docker-cn # 国内 Docker 构建(镜像加速)
```
### 发版
```bash
git tag v1.2.3 && git push --tags
# GitHub Actions 自动:编译双架构二进制 → 发布 GitHub Release → 推送 Docker Hub 镜像
```
也可在 GitHub Actions 页面手动触发 Release workflow。
---
## API 参考
所有接口以 `/api` 为前缀,使用 JWT Bearer Token 认证。
| 模块 | 端点 | 说明 |
|------|------|------|
| **认证** | `POST /auth/setup` | 初始化管理员 |
| | `POST /auth/login` | 登录 |
| | `PUT /auth/password` | 修改密码 |
| **备份任务** | `GET\|POST /backup/tasks` | 列表 / 创建 |
| | `GET\|PUT\|DELETE /backup/tasks/:id` | 详情 / 更新 / 删除 |
| | `PUT /backup/tasks/:id/toggle` | 启用/禁用 |
| | `POST /backup/tasks/:id/run` | 手动执行 |
| **备份记录** | `GET /backup/records` | 列表(支持筛选) |
| | `GET /backup/records/:id/logs/stream` | 实时日志 (SSE) |
| | `GET /backup/records/:id/download` | 下载 |
| | `POST /backup/records/:id/restore` | 恢复 |
| **存储目标** | `GET\|POST /storage-targets` | 列表 / 添加 |
| | `POST /storage-targets/test` | 测试连接 |
| **节点** | `GET\|POST /nodes` | 列表 / 添加 |
| | `GET /nodes/:id/fs/list` | 目录浏览 |
| **通知** | `GET\|POST /notifications` | 列表 / 添加 |
| **仪表盘** | `GET /dashboard/stats` | 概览统计 |
| **审计日志** | `GET /audit-logs` | 操作审计 |
| **系统** | `GET /system/info` | 系统信息 |
---
## 技术栈
| 组件 | 技术 |
|------|------|
| **后端** | Go · Gin · GORM · SQLite · robfig/cron |
| **前端** | React 18 · TypeScript · ArcoDesign · Vite · Zustand · ECharts |
| **存储** | AWS SDK v2 · Google Drive API v3 · gowebdav · jlaffaye/ftp |
| **安全** | JWT · bcrypt · AES-256-GCM |
See the [development guide](https://awuqing.github.io/BackupX/docs/development/setup) for more.
## Contributing
欢迎提交 Issue 和 Pull Request
Issues and pull requests welcome. Please read the [contributing guide](https://awuqing.github.io/BackupX/docs/development/contributing) before opening a PR — commit messages and PRs on this project are written in Chinese.
## 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

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

@@ -0,0 +1,103 @@
<p align="right">
<a href="README.md">English</a> | <strong>中文</strong>
</p>
<p align="center">
<h1 align="center">BackupX</h1>
<p align="center">
<strong>自托管服务器备份管理平台</strong><br>
一个二进制,一条命令,管好你所有服务器的备份。
</p>
<p align="center">
<a href="https://github.com/Awuqing/BackupX/stargazers"><img src="https://img.shields.io/github/stars/Awuqing/BackupX?style=flat-square&color=f5c542" alt="Stars"></a>
<a href="https://github.com/Awuqing/BackupX/releases"><img src="https://img.shields.io/github/v/release/Awuqing/BackupX?style=flat-square&color=brightgreen" alt="Release"></a>
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go" alt="Go">
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
<img src="https://img.shields.io/badge/SQLite-embedded-003B57?style=flat-square&logo=sqlite" alt="SQLite">
<a href="LICENSE"><img src="https://img.shields.io/github/license/Awuqing/BackupX?style=flat-square" alt="License"></a>
</p>
<p align="center">
<a href="https://awuqing.github.io/BackupX/zh-Hans/"><strong>文档</strong></a> ·
<a href="https://github.com/Awuqing/BackupX/releases"><strong>下载</strong></a> ·
<a href="https://hub.docker.com/r/awuqing/backupx"><strong>Docker Hub</strong></a>
</p>
</p>
---
<table>
<tr>
<td width="50%"><img src="screenshots/dashboard.png" alt="仪表盘"></td>
<td width="50%"><img src="screenshots/backup-tasks.png" alt="备份任务"></td>
</tr>
<tr>
<td><img src="screenshots/storage-targets.png" alt="存储目标"></td>
<td><img src="screenshots/backup-records.png" alt="备份记录"></td>
</tr>
</table>
## 功能亮点
| 能力 | 说明 |
|------|------|
| **备份类型** | 文件/目录多源路径、MySQL、PostgreSQL、SQLite、SAP HANA完整/增量/差异/日志备份 + 并行通道 + 失败重试) |
| **SAP HANA Backint 代理** | 内置 SAP HANA Backint 协议代理HANA 原生备份接口可直接把数据路由到 BackupX 支持的任意存储后端 |
| **70+ 存储后端** | 内置阿里云 OSS / 腾讯云 COS / 七牛云 / S3 / Google Drive / WebDAV / FTP + 通过 rclone 集成 SFTP、Azure Blob、Dropbox、OneDrive 等 70+ 后端 |
| **自动调度** | Cron 定时 + 可视化编辑器 + 自动保留策略(按天数/份数清理,自动回收空目录) |
| **多节点集群** | Master-Agent 模式,基于 Agent 主动出站的 HTTP 轮询跨服务器管理备份,支持代理、私有 CA 与 SSH 堡垒机,无需反向连通性 |
| **安全** | JWT + bcrypt + AES-256-GCM 加密配置 + 可选备份文件加密 + 完整审计日志 |
| **通知** | 邮件 / Webhook / Telegram备份成功或失败时自动推送 |
| **可观测性** | Prometheus `/metrics` 端点 + `/health` + `/ready` 探针 + SLA 违约监控 |
| **审计外输** | HMAC-SHA256 签名 Webhook对接 SIEM / WORM 存储满足 SOC2 / GDPR 合规 |
| **流控** | 节点级带宽限速 + 节点级并发控制,大小节点分别配置,避免小内存 Agent 被挤爆 |
| **部署** | 单二进制 + 内嵌 SQLiteDocker 一键启动,零外部依赖 |
## 快速开始
```bash
# Docker推荐
docker run -d --name backupx -p 8340:8340 -v backupx-data:/app/data awuqing/backupx:latest
# 或使用预编译包
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-linux-amd64.tar.gz
tar xzf backupx-*.tar.gz && cd backupx-* && sudo ./install.sh
# 或从源码构建并裸机安装(无需 Docker
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
make build && sudo ./deploy/install.sh
```
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) 完成首次备份。
## 文档
完整文档见 **https://awuqing.github.io/BackupX/zh-Hans/** — 快速开始、部署、SAP HANA、多节点集群、API 参考等。
快捷链接:
- [快速开始](https://awuqing.github.io/BackupX/zh-Hans/docs/getting-started/quick-start) — 五分钟跑通第一个备份
- [安装](https://awuqing.github.io/BackupX/zh-Hans/docs/getting-started/installation) — Docker / 裸机 / 源码
- [多节点集群](https://awuqing.github.io/BackupX/zh-Hans/docs/features/multi-node) — 远程服务器部署 Agent
- [SAP HANA 支持](https://awuqing.github.io/BackupX/zh-Hans/docs/features/sap-hana) — hdbsql Runner 与原生 Backint
- [API 参考](https://awuqing.github.io/BackupX/zh-Hans/docs/reference/api) — REST 端点
## 开发
```bash
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
make dev-server # 终端 1后端:8340
make dev-web # 终端 2前端Vite HMR
make test # 运行全部测试
make build # 产出 server/bin/backupx + web/dist
```
更多细节见 [开发指南](https://awuqing.github.io/BackupX/zh-Hans/docs/development/setup)。
## 贡献
欢迎提交 Issue 与 Pull Request。提交 PR 前请先阅读 [贡献指南](https://awuqing.github.io/BackupX/zh-Hans/docs/development/contributing) — 本项目的 commit message 和 PR 正文均使用中文。
## License
[Apache License 2.0](LICENSE)

View File

@@ -1,318 +0,0 @@
<p align="right">
<strong>English</strong> | <a href="README.md">中文</a>
</p>
<p align="center">
<h1 align="center">BackupX</h1>
<p align="center">
<strong>Self-hosted Server Backup Management Platform</strong><br>
One binary, one command — manage all your server backups.
</p>
<p align="center">
<a href="https://github.com/Awuqing/BackupX/stargazers"><img src="https://img.shields.io/github/stars/Awuqing/BackupX?style=flat-square&color=f5c542" alt="Stars"></a>
<a href="https://github.com/Awuqing/BackupX/releases"><img src="https://img.shields.io/github/v/release/Awuqing/BackupX?style=flat-square&color=brightgreen" alt="Release"></a>
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go" alt="Go">
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
<img src="https://img.shields.io/badge/SQLite-embedded-003B57?style=flat-square&logo=sqlite" alt="SQLite">
<a href="LICENSE"><img src="https://img.shields.io/github/license/Awuqing/BackupX?style=flat-square" alt="License"></a>
</p>
</p>
---
<table>
<tr>
<td width="50%"><img src="screenshots/dashboard.png" alt="Dashboard"></td>
<td width="50%"><img src="screenshots/backup-tasks.png" alt="Backup Tasks"></td>
</tr>
<tr>
<td><img src="screenshots/storage-targets.png" alt="Storage Targets"></td>
<td><img src="screenshots/backup-records.png" alt="Backup Records"></td>
</tr>
</table>
## Highlights
| Capability | Details |
|-----------|---------|
| **Backup Types** | Files/Directories (multi-source), MySQL, PostgreSQL, SQLite, SAP HANA |
| **Storage Backends** | Alibaba Cloud OSS, Tencent COS, Qiniu Kodo, S3-compatible (AWS/MinIO/R2), Google Drive, WebDAV, FTP/FTPS, Local Disk |
| **Scheduling** | Cron-based scheduling + visual editor + auto-retention policy (by days/count) |
| **Multi-Node** | Master-Agent cluster for managing backups across multiple servers |
| **Security** | JWT + bcrypt + AES-256-GCM encrypted config + optional backup encryption + audit logs |
| **Notifications** | Email / Webhook / Telegram — push on success or failure |
| **Deployment** | Single binary + embedded SQLite, Docker one-click, zero external dependencies |
---
## Quick Start
### 1. Install
**Docker (recommended, no clone needed):**
```bash
# Create a docker-compose.yml then start
docker compose up -d
# Or run directly
docker run -d --name backupx -p 8340:8340 -v backupx-data:/app/data awuqing/backupx:latest
```
> Docker Hub: [`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx) — supports linux/amd64 and linux/arm64.
<details>
<summary>docker-compose.yml reference</summary>
```yaml
services:
backupx:
image: awuqing/backupx:latest
container_name: backupx
restart: unless-stopped
ports:
- "8340:8340"
volumes:
- backupx-data:/app/data
# Mount host directories to back up (add as needed):
# - /var/www:/mnt/www:ro
# - /etc/nginx:/mnt/nginx-conf:ro
environment:
- TZ=Asia/Shanghai
volumes:
backupx-data:
```
</details>
**Pre-built binaries (bare metal):**
Download from [Releases](https://github.com/Awuqing/BackupX/releases):
```bash
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
sudo ./install.sh # Auto-configures systemd + Nginx
```
**Build from source:**
```bash
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
make build # Build frontend + backend
make docker-cn # Or Docker build with China mirrors (goproxy.cn / npmmirror / Aliyun apk)
```
### 2. Open the Console
Visit `http://your-server:8340` in your browser. First-time access guides you through admin account creation.
### 3. Add a Storage Target
Go to **Storage Targets****Add**, choose a storage type and enter credentials:
| Storage Type | Required Fields |
|-------------|----------------|
| Alibaba Cloud OSS | Region + AccessKey ID/Secret + Bucket |
| Tencent Cloud COS | Region + SecretId/SecretKey + Bucket (`name-appid`) |
| Qiniu Cloud Kodo | Region + AccessKey/SecretKey + Bucket |
| S3 Compatible | Endpoint + AccessKey + Bucket |
| Google Drive | Client ID/Secret → click Authorize for OAuth |
| WebDAV | Server URL + Username/Password |
| FTP | Host + Port + Username/Password |
| Local Disk | Target directory path |
Click **Test Connection** to verify.
### 4. Create a Backup Task
Go to **Backup Tasks****Create**, complete 3 steps:
1. **Basic Info** — Task name, backup type, Cron expression (leave empty for manual-only)
2. **Source Config** — File backup: select source paths (supports multiple); Database: enter connection info
3. **Storage & Policy** — Select storage target(s), compression, retention days, encryption toggle
Save, then click **Run Now** to test. View real-time logs in **Backup Records**.
### 5. Set Up Notifications (Optional)
Go to **Notifications** to configure Email, Webhook, or Telegram alerts for backup success/failure.
---
## Deployment Guide
### Docker
```bash
docker compose up -d # Using the docker-compose.yml above
```
Mount host directories for file backup (add to `volumes` in docker-compose.yml):
```yaml
volumes:
- backupx-data:/app/data
- /var/www:/mnt/www:ro
- /etc/nginx:/mnt/nginx-conf:ro
```
Override config via environment variables:
```yaml
environment:
- TZ=Asia/Shanghai
- BACKUPX_LOG_LEVEL=debug
- BACKUPX_BACKUP_MAX_CONCURRENT=4
```
### Bare Metal
```bash
# From pre-built package
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
sudo ./install.sh
# Or from source
make build
sudo ./deploy/install.sh
```
The install script creates a system user, installs to `/opt/backupx/`, configures systemd, and sets up Nginx reverse proxy.
### Nginx Reverse Proxy (bare metal)
```nginx
server {
listen 80;
server_name backup.example.com;
location / {
root /opt/backupx/web;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8340;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
### Configuration
Config file: `./config.yaml` (or override with `BACKUPX_` prefixed env vars):
```yaml
server:
port: 8340
database:
path: "./data/backupx.db"
security:
jwt_secret: "" # Auto-generated and persisted to DB
encryption_key: "" # Auto-generated
backup:
temp_dir: "/tmp/backupx"
max_concurrent: 2
log:
level: "info" # debug | info | warn | error
file: "./data/backupx.log"
```
### Password Reset
```bash
# Bare metal
./backupx reset-password --username admin --password newpass123
# Docker
docker exec -it backupx /app/bin/backupx reset-password --username admin --password newpass123
```
---
## Multi-Node Cluster
BackupX supports Master-Agent mode for managing multiple servers:
1. Web Console → **Node Management****Add Node** — system generates a Token
2. Deploy Agent on remote server, connect using the Token
3. Create backup tasks and assign to specific nodes — Master dispatches automatically
The visual directory browser lets you pick directories on remote Agent nodes — no manual path typing.
---
## Development
**Requirements:** Go >= 1.25 · Node.js >= 20 · npm
```bash
# Dev mode
make dev-server # Terminal 1: backend (:8340)
make dev-web # Terminal 2: frontend (Vite HMR)
# Test
make test # Run all tests
# Build
make build # Build frontend + backend
make docker # Docker build
make docker-cn # Docker build with China mirrors
```
### Release
```bash
git tag v1.2.3 && git push --tags
# GitHub Actions: compile dual-arch binaries → publish GitHub Release → push Docker Hub image
```
Or manually trigger the Release workflow from GitHub Actions page.
---
## API Reference
All endpoints prefixed with `/api`, authenticated via JWT Bearer Token.
| Module | Endpoint | Description |
|--------|----------|-------------|
| **Auth** | `POST /auth/setup` | Initialize admin |
| | `POST /auth/login` | Login |
| | `PUT /auth/password` | Change password |
| **Backup Tasks** | `GET\|POST /backup/tasks` | List / Create |
| | `GET\|PUT\|DELETE /backup/tasks/:id` | Detail / Update / Delete |
| | `PUT /backup/tasks/:id/toggle` | Enable / Disable |
| | `POST /backup/tasks/:id/run` | Manual run |
| **Backup Records** | `GET /backup/records` | List (with filter) |
| | `GET /backup/records/:id/logs/stream` | Real-time logs (SSE) |
| | `GET /backup/records/:id/download` | Download |
| | `POST /backup/records/:id/restore` | Restore |
| **Storage Targets** | `GET\|POST /storage-targets` | List / Add |
| | `POST /storage-targets/test` | Test connection |
| **Nodes** | `GET\|POST /nodes` | List / Add |
| | `GET /nodes/:id/fs/list` | Directory browser |
| **Notifications** | `GET\|POST /notifications` | List / Add |
| **Dashboard** | `GET /dashboard/stats` | Overview stats |
| **Audit Logs** | `GET /audit-logs` | Operation audit |
| **System** | `GET /system/info` | System info |
---
## Tech Stack
| Component | Technology |
|-----------|-----------|
| **Backend** | Go · Gin · GORM · SQLite · robfig/cron |
| **Frontend** | React 18 · TypeScript · ArcoDesign · Vite · Zustand · ECharts |
| **Storage** | AWS SDK v2 · Google Drive API v3 · gowebdav · jlaffaye/ftp |
| **Security** | JWT · bcrypt · AES-256-GCM |
## Contributing
Issues and Pull Requests are welcome!
## License
[Apache License 2.0](LICENSE)

View File

@@ -12,6 +12,7 @@ ExecStart=/opt/backupx/bin/backupx -config /etc/backupx/config.yaml
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
UMask=0027
LimitNOFILE=65535
[Install]

View File

@@ -1,23 +1,18 @@
#!/bin/sh
set -e
set -eu
# Backend listens on internal port 8341, Nginx exposes 8340
export BACKUPX_SERVER_PORT="${BACKUPX_SERVER_PORT_INTERNAL:-8341}"
# 旧镜像曾以 root 写入数据卷。Master 启动时做一次所有权迁移,随后
# 降权运行Agent 模式由部署命令显式决定用户,以访问宿主机备份路径。
if [ "$(id -u)" -eq 0 ] && [ "${1:-}" != "agent" ]; then
chown backupx:backupx /app/data /tmp/backupx
if [ ! -f /app/data/.backupx-owner-v2 ]; then
chown -R backupx:backupx /app/data
su-exec backupx:backupx touch /app/data/.backupx-owner-v2
fi
export HOME=/app
exec su-exec backupx:backupx /app/bin/backupx "$@"
fi
# Start Nginx in background
nginx -g "daemon off;" &
NGINX_PID=$!
# Start BackupX backend
/app/bin/backupx &
APP_PID=$!
# Trap signals for graceful shutdown
trap 'kill $APP_PID $NGINX_PID 2>/dev/null; wait $APP_PID $NGINX_PID 2>/dev/null' SIGTERM SIGINT
echo "BackupX started — Nginx :8340 -> Backend :8341"
# Wait for either process to exit
wait -n $APP_PID $NGINX_PID 2>/dev/null || true
kill $APP_PID $NGINX_PID 2>/dev/null || true
wait $APP_PID $NGINX_PID 2>/dev/null || true
# Web 静态文件由 BackupX 后端直接托管。容器只运行一个前台进程,
# 让 Docker 准确传递信号、收集退出码并执行健康检查。
exec /app/bin/backupx "$@"

View File

@@ -1,32 +0,0 @@
server {
listen 8340;
server_name _;
root /app/web;
index index.html;
# API reverse proxy to backend
location /api/ {
proxy_pass http://127.0.0.1:8341/api/;
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_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Static assets cache
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}

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,35 +1,94 @@
#!/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}"
SERVICE_SOURCE="${SERVICE_SOURCE:-$PROJECT_ROOT/deploy/backupx.service}"
NGINX_SOURCE="${NGINX_SOURCE:-$PROJECT_ROOT/deploy/nginx.conf}"
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}"
SERVICE_SOURCE_DEFAULT="$SCRIPT_DIR/backupx.service"
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}"
SERVICE_SOURCE_DEFAULT="$PROJECT_ROOT/deploy/backupx.service"
fi
SERVICE_SOURCE_EXPLICIT=0
if [ -n "${SERVICE_SOURCE:-}" ]; then
SERVICE_SOURCE_EXPLICIT=1
fi
SERVICE_SOURCE="${SERVICE_SOURCE:-$SERVICE_SOURCE_DEFAULT}"
INSTALL_NGINX="${INSTALL_NGINX:-0}"
if [ "$(id -u)" -ne 0 ]; then
echo "请使用 root 或 sudo 执行安装脚本。" >&2
exit 1
fi
validate_install_path() {
path_name="$1"
path_value="$2"
case "$path_value" in
/*) ;;
*) echo "$path_name 必须是绝对路径: $path_value" >&2; exit 1 ;;
esac
case "$path_value" in
/|*"//"*|*"/./"*|*"/."|*"/../"*|*"/.."|*[!A-Za-z0-9_./+-]*)
echo "$path_name 必须是规范、安全且非根目录的绝对路径: $path_value" >&2
exit 1
;;
esac
}
validate_install_path PREFIX "$PREFIX"
validate_install_path ETC_DIR "$ETC_DIR"
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
if [ "$SERVICE_SOURCE_EXPLICIT" = "1" ] && [ ! -f "$SERVICE_SOURCE" ]; then
echo "指定的 systemd unit 不存在:$SERVICE_SOURCE" >&2
exit 1
fi
for managed_path in "$PREFIX" "$PREFIX/bin" "$PREFIX/web" "$PREFIX/data" "$ETC_DIR"; do
if [ -L "$managed_path" ]; then
echo "拒绝通过符号链接写入受管目录:$managed_path" >&2
exit 1
fi
done
if ! getent group "$APP_GROUP" >/dev/null 2>&1; then
groupadd --system "$APP_GROUP"
fi
@@ -38,25 +97,103 @@ if ! id "$APP_USER" >/dev/null 2>&1; then
useradd --system --gid "$APP_GROUP" --home-dir "$PREFIX" --shell /usr/sbin/nologin "$APP_USER"
fi
install -d -o "$APP_USER" -g "$APP_GROUP" "$PREFIX" "$PREFIX/bin" "$PREFIX/web" "$PREFIX/data" "$ETC_DIR"
install -m 0755 "$BIN_SOURCE" "$PREFIX/bin/backupx"
install -d -o root -g root -m 0755 "$PREFIX" "$PREFIX/bin" "$PREFIX/web"
install -d -o "$APP_USER" -g "$APP_GROUP" -m 0750 "$PREFIX/data"
install -d -o root -g "$APP_GROUP" -m 0750 "$ETC_DIR"
install -o root -g root -m 0755 "$BIN_SOURCE" "$PREFIX/bin/backupx.new"
mv -f "$PREFIX/bin/backupx.new" "$PREFIX/bin/backupx"
cp -R "$WEB_SOURCE/." "$PREFIX/web/"
chown -R "$APP_USER:$APP_GROUP" "$PREFIX"
chown -R root:root "$PREFIX/bin" "$PREFIX/web"
find "$PREFIX/web" -type d -exec chmod 0755 {} \;
find "$PREFIX/web" -type f -exec chmod 0644 {} \;
chown -R "$APP_USER:$APP_GROUP" "$PREFIX/data"
if [ ! -f "$ETC_DIR/config.yaml" ]; then
install -m 0640 "$CONFIG_TEMPLATE" "$ETC_DIR/config.yaml"
install -o root -g "$APP_GROUP" -m 0640 "$CONFIG_TEMPLATE" "$ETC_DIR/config.yaml"
fi
# 服务账户只需读取配置,不应拥有修改 /etc 配置或可执行文件的权限。
chown root:"$APP_GROUP" "$ETC_DIR/config.yaml"
chmod 0640 "$ETC_DIR/config.yaml"
# 仓库 unit 使用标准路径;自定义 PREFIX/ETC_DIR 时动态生成以保持路径一致。
# 显式传入 SERVICE_SOURCE 表示调用方已经审核其中的路径,始终优先使用。
if [ -f "$SERVICE_SOURCE" ] && { [ "$SERVICE_SOURCE_EXPLICIT" = "1" ] || { [ "$PREFIX" = "/opt/backupx" ] && [ "$ETC_DIR" = "/etc/backupx" ]; }; }; 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
UMask=0027
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
UNIT
fi
systemctl daemon-reload
if ! systemctl enable "$SERVICE_NAME" || ! systemctl restart "$SERVICE_NAME"; then
echo "BackupX systemd 服务启动失败。" >&2
systemctl status "$SERVICE_NAME" --no-pager >&2 || true
journalctl -u "$SERVICE_NAME" -n 50 --no-pager >&2 || true
exit 1
fi
install -m 0644 "$SERVICE_SOURCE" "/etc/systemd/system/$SERVICE_NAME.service"
systemctl daemon-reload
systemctl enable --now "$SERVICE_NAME"
if [ -d "/etc/nginx/conf.d" ]; then
install -m 0644 "$NGINX_SOURCE" "/etc/nginx/conf.d/$SERVICE_NAME.conf"
if command -v nginx >/dev/null 2>&1; then
nginx -t
systemctl reload nginx || true
# 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 [ "$INSTALL_NGINX" = "1" ]; then
if [ ! -d "/etc/nginx/conf.d" ] || [ ! -f "$NGINX_SOURCE" ]; then
echo "已请求安装 Nginx 配置,但未找到 /etc/nginx/conf.d 或配置模板。" >&2
exit 1
fi
install -o root -g root -m 0644 "$NGINX_SOURCE" "/etc/nginx/conf.d/$SERVICE_NAME.conf"
command -v nginx >/dev/null 2>&1 || { echo "未找到 nginx 命令。" >&2; exit 1; }
nginx -t
systemctl reload nginx
fi
cat <<MESSAGE
@@ -67,6 +204,20 @@ 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 模板,请审核域名与 TLS 配置后重新执行:
sudo INSTALL_NGINX=1 ./install.sh
排查:若服务未监听端口,请查看日志:
journalctl -u "$SERVICE_NAME" -n 50 --no-pager
如需修改监听地址、数据库路径或日志级别,请编辑 "$ETC_DIR/config.yaml" 后执行:
systemctl restart "$SERVICE_NAME"
MESSAGE

View File

@@ -8,16 +8,38 @@ server {
location /api/ {
proxy_pass http://127.0.0.1:8340/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $http_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_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Connection "";
client_max_body_size 0;
proxy_request_buffering off;
proxy_buffering off;
proxy_cache off;
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 $http_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_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
}
# 健康检查端点同样不走 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

@@ -3,27 +3,46 @@
# 快速启动docker compose up -d
# 访问地址http://localhost:8340
#
# 如需从源码构建镜像(而非拉取线上镜像),取消注释 build 行并注释 image 行
# 生产环境建议在 .env 中固定 BACKUPX_IMAGE 版本,并通过 HTTPS 反向代理暴露服务
services:
backupx:
image: awuqing/backupx:latest
# build: . # 从源码构建时取消此行注释
image: ${BACKUPX_IMAGE:-awuqing/backupx:latest}
# build: .
container_name: backupx
restart: unless-stopped
init: true
stop_grace_period: 30s
ports:
- "8340:8340"
- "${BACKUPX_BIND_ADDRESS:-0.0.0.0}:${BACKUPX_PORT:-8340}:8340"
volumes:
- backupx-data:/app/data
# 挂载需要备份的宿主机目录(按需添加,:ro 表示只读):
# - /var/www:/mnt/www:ro
# - /etc/nginx:/mnt/nginx-conf:ro
# - /home/user/data:/mnt/data:ro
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
# 仅用于旧数据卷迁移与降权;应用进程随后以 backupx 运行。
- CHOWN
- DAC_OVERRIDE
- SETGID
- SETUID
environment:
- TZ=Asia/Shanghai
# 通过 BACKUPX_ 前缀环境变量覆盖配置
# - BACKUPX_LOG_LEVEL=debug
# - BACKUPX_BACKUP_MAX_CONCURRENT=4
TZ: ${TZ:-Asia/Shanghai}
# 远程 Agent 连接 Master 时,配置为所有节点可达的稳定 HTTPS URL
# BACKUPX_SERVER_EXTERNAL_URL: https://backup.example.com
# BACKUPX_LOG_LEVEL: debug
# BACKUPX_BACKUP_MAX_CONCURRENT: "4"
healthcheck:
test: ["CMD", "su-exec", "backupx:backupx", "wget", "-q", "-T", "3", "-O", "/dev/null", "http://127.0.0.1:8340/ready"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
backupx-data:

20
docs-site/.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
# Dependencies
/node_modules
# Production
/build
# Generated files
.docusaurus
.cache-loader
# Misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

41
docs-site/README.md Normal file
View File

@@ -0,0 +1,41 @@
# Website
This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator.
## Installation
```bash
yarn
```
## Local Development
```bash
yarn start
```
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
## Build
```bash
yarn build
```
This command generates static content into the `build` directory and can be served using any static contents hosting service.
## Deployment
Using SSH:
```bash
USE_SSH=true yarn deploy
```
Not using SSH:
```bash
GIT_USER=<Your GitHub username> yarn deploy
```
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.

View File

@@ -0,0 +1,118 @@
---
sidebar_position: 2
title: Bare-metal Deployment
description: Hardened systemd deployment from the prebuilt release tarball or source, with opt-in Nginx.
---
# Bare-metal Deployment
## From prebuilt release
```bash
# Download the matching tarball
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-linux-amd64.tar.gz
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-linux-amd64.tar.gz.sha256
sha256sum -c backupx-linux-amd64.tar.gz.sha256
# Extract and install
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/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. Leaves Nginx unchanged unless `INSTALL_NGINX=1` is explicitly requested
6. Verifies the first-setup API before reporting success
The executable and web assets are owned by root; only `/opt/backupx/data` is writable by the `backupx` service account. `/etc/backupx/config.yaml` is installed as `root:backupx` with mode `0640`.
The bundled Nginx template is a starting point and may conflict with an existing default server. Review its hostname and TLS policy first, then opt in:
```bash
sudo INSTALL_NGINX=1 ./install.sh
```
For multi-node clusters, edit `/etc/backupx/config.yaml` after installation and set the Master URL that remote Agents can reach:
```yaml
server:
external_url: "https://backup.example.com"
```
Restart BackupX after changing it:
```bash
sudo systemctl restart backupx
```
## From source
```bash
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
make build
sudo ./deploy/install.sh
```
`make build` compiles:
- `server/bin/backupx` (Go backend, no CGO)
- `web/dist/` (React frontend, `npm run build`)
## systemd
The installed unit:
```ini title="/etc/systemd/system/backupx.service"
[Unit]
Description=BackupX backup management service
After=network.target
[Service]
Type=simple
User=backupx
Group=backupx
WorkingDirectory=/opt/backupx
ExecStart=/opt/backupx/bin/backupx -config /etc/backupx/config.yaml
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
UMask=0027
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
```
Typical operations:
```bash
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`.
For production, expose BackupX through HTTPS or restrict port `8340` at the firewall. The installer does not make firewall changes.
## Password reset
If the admin password is lost:
```bash
/opt/backupx/bin/backupx reset-password \
--username admin \
--password 'newpass123' \
--config /etc/backupx/config.yaml
```
Docker equivalent:
```bash
docker exec -it backupx /app/bin/backupx reset-password --username admin --password 'newpass123'
```

View File

@@ -0,0 +1,86 @@
---
sidebar_position: 4
title: Configuration Reference
description: All server.yaml configuration keys with defaults and matching environment variables.
---
# Configuration Reference
BackupX loads `./config.yaml` from the working directory by default. You can override the path with `--config`. Every key can also be set via a `BACKUPX_` prefixed environment variable.
## Full config reference
```yaml title="config.yaml"
server:
host: "0.0.0.0" # BACKUPX_SERVER_HOST
port: 8340 # BACKUPX_SERVER_PORT
mode: "release" # release | debug
external_url: "" # BACKUPX_SERVER_EXTERNAL_URL — stable public Master URL
trusted_proxies: # BACKUPX_SERVER_TRUSTED_PROXIES — exact proxy IPs/CIDRs
- "127.0.0.1"
- "::1"
web_root: "" # BACKUPX_SERVER_WEB_ROOT — built frontend directory
database:
path: "./data/backupx.db" # BACKUPX_DATABASE_PATH — embedded SQLite
security:
jwt_secret: "" # BACKUPX_SECURITY_JWT_SECRET — auto-generated if empty
jwt_expire: "24h" # BACKUPX_SECURITY_JWT_EXPIRE
encryption_key: "" # AES-256-GCM key for storage config encryption
backup:
temp_dir: "/tmp/backupx" # BACKUPX_BACKUP_TEMP_DIR
max_concurrent: 2 # BACKUPX_BACKUP_MAX_CONCURRENT
retries: 3 # Per-upload rclone low-level retries
bandwidth_limit: "" # e.g. "10M" to cap transfers at 10 MB/s
log:
level: "info" # debug | info | warn | error
file: "./data/backupx.log"
```
## Secret generation
If `jwt_secret` or `encryption_key` is empty on first start, BackupX generates a random value and persists it to the `system_configs` table. Keep a backup of `data/backupx.db` — losing it invalidates all existing encrypted storage configurations.
## Environment variables
The environment wins when both file and env are set. All dot-paths become underscores and uppercase:
| Config key | Env variable |
|------------|--------------|
| `server.port` | `BACKUPX_SERVER_PORT` |
| `server.external_url` | `BACKUPX_SERVER_EXTERNAL_URL` |
| `server.trusted_proxies` | `BACKUPX_SERVER_TRUSTED_PROXIES` (comma-separated for env) |
| `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.
The install wizard can set an Agent-specific runtime URL for a proxy or SSH-bastion node. The public install link continues to use `server.external_url`, while the generated Agent config uses that override.
## Trusted reverse proxies
BackupX trusts forwarded client-address headers only from `server.trusted_proxies`. The default permits loopback Nginx only. If a reverse proxy runs in another container or host, add its exact IP or subnet:
```yaml
server:
trusted_proxies:
- "127.0.0.1"
- "172.18.0.0/16"
```
Do not configure `0.0.0.0/0`: client addresses feed authentication throttling, install-token throttling, and audit records. Set an empty list when BackupX is exposed directly and should trust no forwarded headers.

View File

@@ -0,0 +1,96 @@
---
sidebar_position: 1
title: Docker Deployment
description: Hardened single-process Docker deployment with health checks and persistent data.
---
# Docker Deployment
The official [`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx) image supports `linux/amd64` and `linux/arm64`.
## Compose file
```yaml title="docker-compose.yml"
services:
backupx:
image: ${BACKUPX_IMAGE:-awuqing/backupx:latest}
container_name: backupx
restart: unless-stopped
init: true
stop_grace_period: 30s
ports:
- "${BACKUPX_BIND_ADDRESS:-0.0.0.0}:${BACKUPX_PORT:-8340}:8340"
volumes:
- backupx-data:/app/data
# - /var/www:/mnt/www:ro
# - /etc/nginx:/mnt/nginx-conf:ro
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- DAC_OVERRIDE
- SETGID
- SETUID
environment:
TZ: Asia/Shanghai
# BACKUPX_SERVER_EXTERNAL_URL: https://backup.example.com
BACKUPX_LOG_LEVEL: info
BACKUPX_BACKUP_MAX_CONCURRENT: "2"
healthcheck:
test: ["CMD", "su-exec", "backupx:backupx", "wget", "-q", "-T", "3", "-O", "/dev/null", "http://127.0.0.1:8340/ready"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
backupx-data:
```
```bash
docker compose up -d
docker compose ps
```
The entrypoint uses root only to migrate ownership of data written by older images, then starts one unprivileged `backupx` process. Compose retains only the ownership and UID/GID transition capabilities needed for that initialization. The backend serves both the API and built web assets; the image neither mounts the Docker socket nor bundles a Docker CLI. Pin `BACKUPX_IMAGE` to a release tag in production.
## Host-directory backups
Mount each source directory and use its container path in the task. The container's `backupx` user must be able to read it; restore destinations need a separate, narrowly scoped writable mount. Prefer a remote Agent for privileged host paths. If a Master-side task truly requires root, make that exception explicit with `user: "0:0"` and review every mount.
## Multi-node cluster
Set the stable URL that Agents can reach:
```yaml
environment:
BACKUPX_SERVER_EXTERNAL_URL: https://backup.example.com
```
Use HTTPS across untrusted networks. Proxy, private-CA, and SSH-bastion deployments are covered in [Multi-Node Cluster](../features/multi-node).
If an external reverse proxy is in another container, add only its bridge subnet to `BACKUPX_SERVER_TRUSTED_PROXIES`, for example `172.18.0.0/16`. Do not trust every address.
## Environment overrides
```yaml
environment:
TZ: Asia/Shanghai
BACKUPX_LOG_LEVEL: debug
BACKUPX_BACKUP_MAX_CONCURRENT: "4"
BACKUPX_BACKUP_TEMP_DIR: /tmp/backupx
```
The image's internal port is fixed at `8340`; change only the published host port with `BACKUPX_PORT`.
## Upgrade and rollback preparation
```bash
docker compose pull
docker compose up -d
docker compose ps
```
Wait for `healthy` before switching traffic or removing an old deployment. Before upgrades, stop the Master for a file-level copy or take an atomic snapshot of the entire `backupx-data` volume. Keep exactly one active Master for a data volume; SQLite does not support multiple Master containers sharing `/app/data`.

View File

@@ -0,0 +1,60 @@
---
sidebar_position: 3
title: Nginx Reverse Proxy
description: Expose BackupX behind Nginx with HTTPS and SSE-friendly buffering disabled.
---
# Nginx Reverse Proxy
A minimal production-ready Nginx site for BackupX:
```nginx title="/etc/nginx/sites-available/backupx"
server {
listen 80;
server_name backup.example.com;
# Static UI (served from /opt/backupx/web)
location / {
root /opt/backupx/web;
try_files $uri $uri/ /index.html;
}
# API reverse proxy
location /api/ {
proxy_pass http://127.0.0.1:8340;
proxy_http_version 1.1;
proxy_set_header Host $http_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_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
# Large uploads (restore flow)
client_max_body_size 0;
proxy_request_buffering off;
# Live log stream uses SSE — buffering must be off
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
```
`proxy_request_buffering off` is required for Master-relay cluster backups. Without it, Nginx writes the complete Agent upload to its temporary storage before BackupX receives it, defeating streaming and potentially filling the proxy disk.
If Nginx runs on another host or in another container, add only that proxy IP or subnet to `server.trusted_proxies`. Do not use `0.0.0.0/0`; BackupX uses the trusted client address for login throttling, install-token throttling, and audit records.
## HTTPS with certbot
```bash
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d backup.example.com
```
Certbot rewrites the config to listen on 443 with auto-renewal.
:::caution Agent needs a stable URL
If Master is behind HTTPS, remote Agent deployments must use the public HTTPS URL for `--master`. Self-signed certs require `--insecure-tls` (testing only).
:::

View File

@@ -0,0 +1,41 @@
---
sidebar_position: 2
title: Contributing
description: How to report issues, propose changes, and submit PRs.
---
# Contributing
BackupX is open-source under Apache License 2.0. Issues and pull requests are welcome.
## Reporting bugs
Open an issue at [github.com/Awuqing/BackupX/issues](https://github.com/Awuqing/BackupX/issues). Please include:
- BackupX version (`backupx --version`)
- Your deployment mode (Docker / bare metal / from source)
- Relevant backup task type and storage backend
- Steps to reproduce
- Stdout / `backupx.log` excerpt for the window around the problem
## Proposing changes
For significant features or refactors, open an issue first to align on scope before investing in a PR.
## Pull requests
1. Fork and create a topic branch (e.g. `fix/windows-path-escape`)
2. Run `make test` and make sure everything passes
3. Keep changes focused — one concern per PR
4. Write commit messages in Chinese following `类型: 简要描述` — examples:
- `功能: 新增审计日志模块`
- `修复: 目录浏览器无法进入子目录`
- `重构: 简化存储目标解密逻辑`
- Types: `功能` / `修复` / `重构` / `文档` / `构建` / `测试`
5. PR title and body in Chinese too. Describe the why and how, not just the what.
## Coding guidelines
- **Go** — handle every error (no `_ = err`); use the existing logger (`zap`); no `fmt.Println` in production paths
- **TypeScript** — strict mode, no implicit any, follow existing ESLint/Prettier configs
- **Commit scope** — one logical change per commit; don't mix drive-by cleanups with feature work

View File

@@ -0,0 +1,83 @@
---
sidebar_position: 1
title: Development Setup
description: Get a BackupX dev environment running — backend, frontend, tests.
---
# Development Setup
**Requirements:** Go ≥ 1.25, Node.js ≥ 20, npm.
## Clone & install
```bash
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
cd web && npm install && cd ..
```
## Dev servers
Run the backend and the Vite dev server in two terminals:
```bash
# Terminal 1: backend on :8340
make dev-server
# Terminal 2: Vite with HMR on :5173
make dev-web
```
The Vite config proxies `/api` to `http://127.0.0.1:8340` so you can open the UI at `http://localhost:5173`.
## Tests
```bash
make test # runs Go + Web test suites
make test-server # Go only
make test-web # Vitest only
```
## Production build
```bash
make build # server/bin/backupx + web/dist
make docker # Docker image
make docker-cn # Docker image with mainland China mirrors
```
## Tech stack
| Component | Stack |
|-----------|-------|
| **Backend** | Go · Gin · GORM · SQLite · robfig/cron · rclone |
| **Frontend** | React 18 · TypeScript · ArcoDesign · Vite · Zustand · ECharts |
| **Storage** | rclone (70+ backends) · AWS SDK v2 · Google Drive API v3 |
| **Security** | JWT · bcrypt · AES-256-GCM |
## Project layout
```
BackupX/
├── server/ # Go backend
│ ├── cmd/backupx/ # Entry point + subcommands (agent, backint, reset-password)
│ ├── internal/
│ │ ├── agent/ # Agent CLI logic
│ │ ├── app/ # Wiring (repositories → services → handlers)
│ │ ├── backup/ # Backup runners (file / mysql / postgres / sqlite / saphana)
│ │ ├── backint/ # SAP HANA Backint protocol
│ │ ├── http/ # HTTP handlers + router
│ │ ├── model/ # GORM models
│ │ ├── repository/ # DB access
│ │ ├── service/ # Business logic
│ │ └── storage/ # Storage providers (rclone + direct SDKs)
│ └── pkg/ # Generic utilities
├── web/ # React frontend (Vite)
│ └── src/
│ ├── components/
│ ├── pages/
│ ├── services/
│ └── types/
├── docs-site/ # This documentation site (Docusaurus)
├── deploy/ # install.sh, systemd unit, nginx config
└── Makefile
```

View File

@@ -0,0 +1,54 @@
---
sidebar_position: 1
title: Backup Types
description: File, MySQL, PostgreSQL, SQLite and SAP HANA — what they back up and what to configure.
---
# Backup Types
BackupX supports five built-in backup types. Type determines which runner executes the job.
When a task is routed to a remote Agent, the source tools and paths are resolved on that Agent host. Multi-target uploads are still tracked per storage target; if at least one target succeeds, the backup record is marked successful and the per-target result table shows partial failures.
## File / Directory
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
- Full and differential modes output `.tar`, `.tar.gz`, or `.tar.zst` artifacts
## MySQL
Uses `mysqldump` under the hood. Requires `mysqldump` to be on `$PATH` of the host running the task (Master or Agent).
- **Host / port / user / password / database** — multi-database allowed (comma-separated)
- Output: `.sql` or `.sql.gz`
- Default flags: `--single-transaction --routines --triggers --events`
## PostgreSQL
Uses `pg_dump`. Same connection fields as MySQL plus database name.
## SQLite
Copies the database file directly (with a consistency snapshot). No external tool required.
## SAP HANA
Two modes are supported — see the dedicated [SAP HANA](./sap-hana) page.
## Deletion behavior
When a task is deleted, BackupX removes backup artifacts from every storage target but preserves backup records for audit. Task deletion also tears down the cron schedule entry.

View File

@@ -0,0 +1,229 @@
---
sidebar_position: 4
title: Multi-Node Cluster
description: Deploy BackupX Agents through direct HTTPS, forward proxies, or SSH bastions.
---
# Multi-Node Cluster
BackupX uses a single active Master as the control plane and an Agent on each source server. Agents initiate every connection, report a heartbeat every 15 seconds, and poll for commands every 5 seconds. No inbound Agent port is required.
## Architecture and boundaries
```text
[Web console] ────────> [Active Master + SQLite]
^
| outbound HTTP(S) polling
+---------+---------+
| | |
[Agent B] [Agent C] [Agent D]
| | |
+----> storage targets
```
- Each node has an independent Agent Token. The Agent never receives the Master's JWT or encryption key.
- A node is marked offline after 45 seconds without a heartbeat.
- The Master persists commands; an Agent claims and executes them locally.
- Network storage is normally written directly by the Agent. A Master-local target can opt into authenticated streaming relay.
:::warning Single-active Master
The embedded SQLite database is not a shared multi-writer database. Run exactly one active Master against a data directory. For control-plane recovery, use an active/passive host, persistent-volume snapshots, and a stable DNS name or virtual IP. Never scale multiple Master replicas over the same `/app/data` or `backupx.db`.
:::
BackupX applies a five-second SQLite busy timeout and command-queue indexes to reduce contention from concurrent Agent polls and task updates. Keep the database on a local or block-backed filesystem. For a file-level control-plane backup, stop the Master before copying the whole data directory; do not copy only `backupx.db` while it is running.
## Choose a network path
| Scenario | Agent Master URL | Agent proxy URL | Notes |
| --- | --- | --- | --- |
| Routed network or public service | `https://backup.example.com` | empty | Recommended; allow only outbound TCP 443 |
| Corporate forward proxy | `https://backup.example.com` | `http://proxy.internal:3128` | HTTP(S) and SOCKS5(H) are supported |
| SSH dynamic tunnel through a bastion | `https://backup.internal` | `socks5h://127.0.0.1:1080` | Preserves TLS hostname and resolves internal DNS through the tunnel |
| SSH fixed local forward | `http://127.0.0.1:18340` | empty | The HTTP hop is protected by SSH; bind the forward to loopback only |
For private PKI, provide the absolute path of a pre-provisioned PEM CA certificate. Do not use `--insecure-tls` in production.
When no explicit proxy is configured, Agent-to-Master HTTP traffic follows `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`. A system service does not normally inherit an interactive shell's environment, so set the proxy in the install wizard or Agent YAML for systemd deployments.
## Prepare the Master
Set a stable URL before generating commands:
```yaml title="/etc/backupx/config.yaml"
server:
external_url: "https://backup.example.com"
trusted_proxies:
- "127.0.0.1"
- "::1"
# Add the exact reverse-proxy IP or subnet when it is not local.
# - "172.18.0.0/16"
```
`external_url` is the default install and Agent runtime address. A restricted node can override both generated target-side URLs with its tunnel or internal address while the browser continues to use the public address.
Use HTTPS across untrusted networks. For Master-relay uploads, configure the reverse proxy with unlimited request body size and request buffering disabled; see [Nginx Reverse Proxy](../deployment/nginx).
Configure the Agent with the final API URL, not an HTTP-to-HTTPS redirect. The Agent deliberately does not follow redirects so its authentication Token cannot be forwarded to an unintended host.
## Deploy an Agent
Open **Node Management → Add Node**:
1. Enter one node name, or up to 50 names in batch mode.
2. Select systemd, Docker, or foreground mode; architecture; Agent release; command TTL; and download source.
3. Select **Direct** or **Proxy or bastion**. For the restricted path, set an Agent-specific Master URL, proxy URL, or private CA path.
4. Copy the generated command to the target host and run it with root privileges.
Systemd is recommended for host-file backup and restore because the Agent needs access to arbitrary local paths. A Docker Agent sees only explicitly mounted paths; recreate it with read-only backup-source mounts and separately scoped writable restore destinations before assigning file tasks.
The URL-based command downloads a one-time installer and verifies its marker before execution. The wizard binds the selected Agent URL, explicit proxy, and private CA to that download command as well as to the installed Agent configuration. If the install endpoint is still unreachable, use the separately displayed embedded command. The embedded command contains the long-lived node Token and must be handled as a secret.
The installer:
1. Detects `linux/amd64` or `linux/arm64`.
2. Downloads the selected Release archive through the explicit proxy when configured, otherwise using the host's normal direct/environment-proxy route, and verifies its SHA-256 sidecar when the release provides one.
3. Writes `/etc/backupx-agent/config.yaml` and `/etc/backupx-agent/agent.token` with mode `0600`.
4. Keeps the Token out of the systemd unit and Docker environment metadata.
5. Starts the Agent and checks `/api/v1/agent/self` for up to 30 seconds.
6. Returns non-zero with systemd or Docker diagnostics when the node does not become online.
Older releases without checksum sidecars remain installable with a warning. New releases should always publish and verify the sidecar.
### Installed systemd configuration
```yaml title="/etc/backupx-agent/config.yaml"
master: "https://backup.example.com"
tokenFile: "/etc/backupx-agent/agent.token"
heartbeatInterval: "15s"
pollInterval: "5s"
tempDir: "/var/lib/backupx-agent/tmp"
proxyUrl: ""
caCertFile: ""
```
```ini title="/etc/systemd/system/backupx-agent.service"
[Unit]
Description=BackupX Agent
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=10
[Service]
Type=simple
ExecStart=/opt/backupx-agent/backupx agent --config /etc/backupx-agent/config.yaml
Restart=on-failure
RestartSec=10s
TimeoutStopSec=30s
UMask=0077
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
```
The Agent runs as root because file backup and restore paths may belong to arbitrary system users. Restrict who can create tasks and who can modify the root-owned Agent configuration.
## SSH bastion example
Prefer a SOCKS tunnel when the internal Master uses HTTPS: its hostname and certificate validation remain unchanged.
Create a dedicated SSH account and pre-provision its private key plus a verified `known_hosts` file. Then create:
```sshconfig title="/etc/backupx-agent/ssh_config"
Host backupx-bastion
HostName bastion.example.com
User backupx-tunnel
IdentityFile /etc/backupx-agent/tunnel_ed25519
IdentitiesOnly yes
BatchMode yes
UserKnownHostsFile /etc/backupx-agent/known_hosts
StrictHostKeyChecking yes
DynamicForward 127.0.0.1:1080
ExitOnForwardFailure yes
ServerAliveInterval 30
ServerAliveCountMax 3
```
```ini title="/etc/systemd/system/backupx-agent-tunnel.service"
[Unit]
Description=BackupX Agent SSH tunnel
After=network-online.target
Wants=network-online.target
Before=backupx-agent.service
[Service]
Type=simple
ExecStart=/usr/bin/ssh -NT -F /etc/backupx-agent/ssh_config backupx-bastion
Restart=always
RestartSec=5s
[Install]
WantedBy=multi-user.target
```
Add a drop-in so the Agent fails closed when the tunnel is unavailable:
```ini title="/etc/systemd/system/backupx-agent.service.d/tunnel.conf"
[Unit]
Requires=backupx-agent-tunnel.service
After=backupx-agent-tunnel.service
```
Reload and start both units:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now backupx-agent-tunnel backupx-agent
```
In the wizard, keep the internal HTTPS Master URL and set the proxy to `socks5h://127.0.0.1:1080`. Verify the bastion host key out-of-band before enabling the service.
## Central storage data paths
| 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 | Agent streams through the authenticated Master API; Master writes to its local mount |
The relay does not create a second complete temporary copy on the Master. Restore uses the reverse streaming path. Nginx request buffering must be disabled for this behavior to remain streaming.
## Operations
```bash
sudo systemctl status backupx-agent
sudo journalctl -u backupx-agent -n 100 --no-pager
sudo /opt/backupx-agent/backupx agent --config /etc/backupx-agent/config.yaml
```
Rotate a node Token from its action menu. Update `/etc/backupx-agent/agent.token` on the node and restart the service during the 24-hour overlap window.
Monitor these Prometheus metrics:
- `backupx_agent_command_queue_depth`
- `backupx_agent_command_running`
- `backupx_agent_command_timeout_total`
- `backupx_node_online`
## CLI reference
```text
backupx agent --help
-master string Master URL
-token string Agent authentication token
-token-file string Read the Agent Token from a file
-config string YAML configuration path
-temp-dir string Local temporary directory
-proxy-url string HTTP(S) or SOCKS5(H) proxy
-ca-cert string PEM CA certificate used to verify the Master
-insecure-tls Skip TLS verification (testing only)
```
Environment variables: `BACKUPX_AGENT_MASTER`, `BACKUPX_AGENT_TOKEN`, `BACKUPX_AGENT_TOKEN_FILE`, `BACKUPX_AGENT_HEARTBEAT`, `BACKUPX_AGENT_POLL`, `BACKUPX_AGENT_TEMP_DIR`, `BACKUPX_AGENT_PROXY_URL`, `BACKUPX_AGENT_CA_CERT_FILE`, and `BACKUPX_AGENT_INSECURE_TLS`.
## Known limitations
- The Master is single-active because it uses embedded SQLite.
- Encrypted backups are Master-only because Agents do not hold the Master encryption key.
- Remote directory browsing is a synchronous queue RPC with a 15-second timeout.
- Claimed commands that stop reporting progress are timed out according to the Master command monitor.

View File

@@ -0,0 +1,49 @@
---
sidebar_position: 5
title: Notifications
description: Email, webhook, and Telegram notifications on backup success or failure.
---
# Notifications
BackupX supports three notification channels. Configure per-channel rules for success-only, failure-only, or both.
## Email (SMTP)
| Field | Notes |
|-------|-------|
| SMTP host / port | e.g. `smtp.gmail.com:587` |
| Username / password | App-specific password recommended |
| From address | Used in `From:` header |
| Recipients | Comma-separated list |
| Use TLS / StartTLS | Match your SMTP provider |
## Webhook
Send a JSON POST to an arbitrary URL. Body shape:
```json
{
"event": "backup_result",
"task": {"id": 1, "name": "web-files", "type": "file"},
"record": {"id": 42, "status": "success", "fileSize": 1048576, "durationSeconds": 12},
"error": ""
}
```
Useful for custom workflows: Slack incoming webhook, PagerDuty, your own API, etc.
## Telegram
| Field | Notes |
|-------|-------|
| Bot token | From [@BotFather](https://t.me/BotFather) |
| Chat ID | Numeric — obtain via `/start` + bot's `getUpdates` |
## Event rules
Each notification configuration can be scoped to:
- **Success only** — quiet during normal runs, pings on first failure
- **Failure only** — recommended for loud channels
- **Both** — useful during initial setup to verify notifications flow

View File

@@ -0,0 +1,79 @@
---
sidebar_position: 3
title: SAP HANA Support
description: Two SAP HANA backup modes — managed hdbsql runner and native Backint protocol agent.
---
# SAP HANA Support
BackupX provides two SAP HANA backup modes. Pick whichever fits your operations workflow.
## Mode 1: hdbsql Runner (console-managed)
Create a SAP HANA backup task in the Web console. The backend invokes `hdbsql` to execute the backup. Use this when BackupX should own the schedule.
**Source configuration supports:**
| Field | Options | Description |
|-------|---------|-------------|
| Backup type | `data` / `log` | Data or log backup |
| Backup level | `full` / `incremental` / `differential` | Auto-disabled for log backups |
| Parallel channels | `1 ~ 32` | Multi-path SQL (`BACKUP DATA USING FILE ('c1', 'c2', ...)`) |
| Retry count | `1 ~ 10` | Exponential backoff (`5s × attempt²`) |
| Instance number | Optional | Inferred from port or specified manually |
## Mode 2: Backint Protocol Agent (HANA native)
BackupX ships a built-in Backint Agent. SAP HANA calls it via the native `BACKUP DATA USING BACKINT` syntax, and data is routed automatically to any BackupX storage target (S3 / OSS / COS / WebDAV / 70+ backends).
### 1. Parameter file
```ini title="/opt/backupx/backint_params.ini"
#STORAGE_TYPE = s3
#STORAGE_CONFIG_JSON = /opt/backupx/storage.json
#PARALLEL_FACTOR = 4
#COMPRESS = true
#KEY_PREFIX = hana-backup
#CATALOG_DB = /opt/backupx/backint_catalog.db
#LOG_FILE = /var/log/backupx/backint.log
```
### 2. Storage config (same schema as storage targets)
```json title="/opt/backupx/storage.json"
{
"endpoint": "https://s3.amazonaws.com",
"region": "us-east-1",
"bucket": "hana-prod",
"accessKeyId": "AKIA...",
"secretAccessKey": "..."
}
```
### 3. Create the hdbbackint symlink
```bash
ln -s /opt/backupx/backupx /usr/sap/<SID>/SYS/global/hdb/opt/hdbbackint
```
### 4. Enable Backint in HANA `global.ini`
```ini
[backup]
data_backup_using_backint = true
catalog_backup_using_backint = true
log_backup_using_backint = true
data_backup_parameter_file = /opt/backupx/backint_params.ini
log_backup_parameter_file = /opt/backupx/backint_params.ini
```
### 5. Manual CLI invocation (troubleshooting)
```bash
backupx backint -f backup -i input.txt -o output.txt -p backint_params.ini
backupx backint -f restore -i input.txt -o output.txt -p backint_params.ini
backupx backint -f inquire -i input.txt -o output.txt -p backint_params.ini
backupx backint -f delete -i input.txt -o output.txt -p backint_params.ini
```
The Backint Agent maintains an `EBID ↔ object-key` catalog in a local SQLite DB. All operations follow the SAP HANA Backint protocol (`#PIPE` / `#SAVED` / `#RESTORED` / `#BACKUP` / `#NOTFOUND` / `#DELETED` / `#ERROR`).

View File

@@ -0,0 +1,40 @@
---
sidebar_position: 2
title: Storage Backends
description: 70+ storage backends — built-in cloud providers plus any rclone backend.
---
# Storage Backends
BackupX aims to accept any place you'd want to drop a backup file.
## Built-in providers
| Type | Required fields |
|------|-----------------|
| **Alibaba OSS** | Region + AccessKey ID/Secret + Bucket (endpoint auto-assembled) |
| **Tencent COS** | Region + SecretId/SecretKey + Bucket (format `name-appid`) |
| **Qiniu Kodo** | Region + AccessKey/SecretKey + Bucket |
| **S3-compatible** | Endpoint + AccessKey + Bucket |
| **Google Drive** | Client ID/Secret + OAuth authorization |
| **WebDAV** | URL + username/password |
| **FTP / FTPS** | Host + port + username/password |
| **Local disk** | Target directory (absolute path) + 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
Every [rclone backend](https://rclone.org/overview/) is exposed as a first-class storage type — SFTP, Azure Blob, Dropbox, OneDrive, Backblaze B2, Wasabi, pCloud, HDFS, and many more.
- The form groups fields into **required** and **advanced** (advanced collapsed by default)
- Validation and connection tests reuse rclone's built-in probe
## Multiple targets per task
A backup task can fan out to multiple targets in parallel. All targets receive the same artifact; a per-target status is recorded:
- Success: storage path + size
- Failed: error message
If any target fails after retries, the record status is `failed` but successful targets are preserved (no rollback).

View File

@@ -0,0 +1,87 @@
---
sidebar_position: 1
title: Installation
description: Install BackupX via Docker, prebuilt archive, or from source.
---
# Installation
BackupX ships as a single static binary. Three ways to install, pick the one that matches your environment.
## Docker (recommended)
No cloning required.
```bash
docker run -d --name backupx \
-p 8340:8340 \
-v backupx-data:/app/data \
awuqing/backupx:latest
```
Or use `docker compose`:
```yaml title="docker-compose.yml"
services:
backupx:
image: awuqing/backupx:latest
container_name: backupx
restart: unless-stopped
ports:
- "8340:8340"
volumes:
- backupx-data:/app/data
# Mount host directories to back up (as needed):
# - /var/www:/mnt/www:ro
# - /etc/nginx:/mnt/nginx-conf:ro
environment:
- TZ=Asia/Shanghai
volumes:
backupx-data:
```
Images: [`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx) — supports `linux/amd64` and `linux/arm64`.
## Prebuilt archive (bare metal)
Download from the [Releases page](https://github.com/Awuqing/BackupX/releases) and run the installer:
```bash
sha256sum -c backupx-v*-linux-amd64.tar.gz.sha256
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
sudo ./install.sh # creates system user, installs to /opt/backupx, sets up systemd
```
The installer:
1. Creates a `backupx` system user
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. Leaves Nginx unchanged unless `INSTALL_NGINX=1` is explicitly requested
6. Waits for `/api/auth/setup/status`; if startup fails, prints systemd diagnostics and exits non-zero
## From source
Requires Go ≥ 1.25 and Node.js ≥ 20.
```bash
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
make build
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`.
The Nginx template is opt-in because automatically installing a catch-all virtual host can intercept existing sites. Review `deploy/nginx.conf`, then use `sudo INSTALL_NGINX=1 ./deploy/install.sh` only when it matches the host.
## Verify the install
```bash
/opt/backupx/bin/backupx --version
curl -fsS http://127.0.0.1:8340/api/auth/setup/status
```
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

@@ -0,0 +1,61 @@
---
sidebar_position: 2
title: Quick Start
description: Set up BackupX, add a storage target, create your first backup task.
---
# Quick Start
After [installation](./installation), get a first backup running in five minutes.
## 1. Open the console
Browse to `http://your-server:8340`. The first time, you'll be guided through creating an admin account.
## 2. Add a storage target
Navigate to **Storage Targets → Add**. Pick a type and fill the required fields:
| Type | Fields |
|------|--------|
| Alibaba OSS | Region + AccessKey ID/Secret + Bucket |
| Tencent COS | Region + SecretId/SecretKey + Bucket (format `name-appid`) |
| Qiniu Kodo | Region + AccessKey/SecretKey + Bucket |
| S3-compatible | Endpoint + AccessKey + Bucket |
| Google Drive | Client ID/Secret → click "Authorize" for OAuth flow |
| WebDAV | URL + username/password |
| FTP | Host + port + username/password |
| Local disk | Target directory |
| SFTP / Azure / Dropbox / OneDrive | Type-specific required fields; advanced options collapsed |
:::tip
For mainland China cloud vendors you only fill Region and AccessKey — BackupX assembles the endpoint automatically. Rclone-style providers separate required fields from advanced ones, with advanced collapsed by default.
:::
Click **Test Connection** to verify.
## 3. Create a backup task
Go to **Backup Tasks → New**. Three steps:
1. **Basic info** — name, type, cron expression (leave empty for manual-only)
2. **Source** — paths for file backup (multi-source supported), or connection info for databases
3. **Storage & policy** — pick target(s), compression, retention days, encryption on/off
For Agent-routed tasks, encryption must stay off because the Agent never receives the Master's encryption key. BackupX rejects remote-node or node-pool tasks with encryption enabled during create/update.
Save, then click **Run Now** to trigger a test. Live logs stream on the **Backup Records** page.
:::note
Deleting a task also removes remote backup files to prevent orphans, but records are kept for audit.
:::
## 4. Configure notifications (optional)
**Notifications** page supports email, webhook, and Telegram. Configure per-channel rules for success/failure events.
## Next up
- Explore [backup types](/docs/features/backup-types) and [storage backends](/docs/features/storage-backends)
- Running SAP HANA? See [SAP HANA Support](/docs/features/sap-hana)
- Managing many servers? See [Multi-Node Cluster](/docs/features/multi-node)

40
docs-site/docs/intro.md Normal file
View File

@@ -0,0 +1,40 @@
---
id: intro
slug: /intro
sidebar_position: 1
title: Introduction
description: Overview of BackupX — a self-hosted server backup management platform.
---
# BackupX
**BackupX** is a self-hosted server backup management platform. One static binary, one command, and every backup job for every server is under control.
- **Single binary + embedded SQLite** — no external database or orchestrator required
- **Files, databases, SAP HANA** — in one place, with a visual scheduler
- **70+ storage backends** — Alibaba OSS, Tencent COS, Qiniu, S3, Google Drive, WebDAV, FTP, plus SFTP / Azure Blob / Dropbox / OneDrive and dozens more via rclone
- **Multi-node cluster** — Master-Agent mode manages backups across servers, agents run tasks locally and upload straight to storage
- **Secure by default** — JWT auth, bcrypt, AES-256-GCM encrypted config, optional backup encryption, full audit log
## Architecture at a Glance
```
[Web Console] ─── JWT ──→ [Master (backupx)]
│ outbound HTTP polling (token auth)
[Agent (backupx agent)]
[70+ Storage Backends]
```
Tasks routed to the local Master run in-process; tasks assigned to remote nodes are dispatched through a command queue and executed by the Agent locally. Agents only ever initiate outbound HTTP — no reverse connectivity required.
## Where to Next
- **New to BackupX?** Read the [Quick Start](/docs/getting-started/quick-start) first.
- **Deploying to production?** See the [Deployment Guide](/docs/deployment/docker).
- **SAP HANA operator?** Both `hdbsql` Runner and native Backint are supported — see [SAP HANA](/docs/features/sap-hana).
- **Managing multiple servers?** See [Multi-Node Cluster](/docs/features/multi-node).
- **Integrating programmatically?** See the [API Reference](/docs/reference/api).

View File

@@ -0,0 +1,135 @@
---
sidebar_position: 1
title: API Reference
description: REST API endpoints — all under /api with JWT Bearer authentication.
---
# API Reference
All endpoints are prefixed with `/api` and authenticated with a JWT Bearer token, obtained via `POST /api/auth/login`. Agent endpoints use `X-Agent-Token` instead.
## Authentication
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/auth/setup/status` | Check whether admin initialization is needed |
| `POST` | `/api/auth/setup` | Initialize the first admin (only when no user exists) |
| `POST` | `/api/auth/login` | Log in and receive a JWT |
| `POST` | `/api/auth/logout` | Log out (invalidate current token) |
| `GET` | `/api/auth/profile` | Current user profile |
| `PUT` | `/api/auth/password` | Change password |
## Backup Tasks
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/backup/tasks` | List tasks |
| `POST` | `/api/backup/tasks` | Create |
| `GET` | `/api/backup/tasks/:id` | Detail |
| `PUT` | `/api/backup/tasks/:id` | Update |
| `DELETE` | `/api/backup/tasks/:id` | Delete |
| `PUT` | `/api/backup/tasks/:id/toggle` | Enable / disable |
| `POST` | `/api/backup/tasks/:id/run` | Trigger a manual run |
## Backup Records
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/backup/records` | List records with filters |
| `GET` | `/api/backup/records/:id` | Record detail |
| `GET` | `/api/backup/records/:id/logs/stream` | Live logs (SSE) |
| `GET` | `/api/backup/records/:id/download` | Download the artifact |
| `POST` | `/api/backup/records/:id/restore` | Restore to the original source |
| `DELETE` | `/api/backup/records/:id` | Delete a record |
| `POST` | `/api/backup/records/batch-delete` | Bulk delete |
## Storage Targets
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/storage-targets` | List |
| `POST` | `/api/storage-targets` | Create |
| `GET` | `/api/storage-targets/:id` | Detail |
| `PUT` | `/api/storage-targets/:id` | Update |
| `DELETE` | `/api/storage-targets/:id` | Delete |
| `POST` | `/api/storage-targets/test` | Test connection with pending config |
| `POST` | `/api/storage-targets/:id/test` | Re-test a saved target |
| `PUT` | `/api/storage-targets/:id/star` | Toggle favourite |
| `GET` | `/api/storage-targets/:id/usage` | Query remote usage (where supported) |
| `GET` | `/api/storage-targets/rclone/backends` | List all available rclone backends |
| `POST` | `/api/storage-targets/google-drive/auth-url` | Start Google Drive OAuth |
| `POST` | `/api/storage-targets/google-drive/complete` | Complete OAuth flow |
## Nodes (Cluster)
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/nodes` | List nodes |
| `POST` | `/api/nodes` | Create a node and return its token |
| `GET` | `/api/nodes/:id` | Node detail |
| `PUT` | `/api/nodes/:id` | Rename |
| `DELETE` | `/api/nodes/:id` | Delete (rejected if tasks are still attached) |
| `GET` | `/api/nodes/:id/fs/list` | Browse a directory (remote nodes use an async RPC via Agent) |
## Agent Protocol (X-Agent-Token)
Dedicated endpoints for the Agent CLI. Authenticated via the `X-Agent-Token` header instead of JWT.
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/agent/heartbeat` | Report liveness; returns the node ID |
| `POST` | `/api/agent/commands/poll` | Claim one pending command |
| `POST` | `/api/agent/commands/:id/result` | Report command result |
| `GET` | `/api/agent/tasks/:id` | Fetch task spec with decrypted storage configs |
| `POST` | `/api/agent/records/:id` | Append logs / update record status |
## Notifications
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/notifications` | List |
| `POST` | `/api/notifications` | Create |
| `GET` | `/api/notifications/:id` | Detail |
| `PUT` | `/api/notifications/:id` | Update |
| `DELETE` | `/api/notifications/:id` | Delete |
| `POST` | `/api/notifications/test` | Test with pending config |
| `POST` | `/api/notifications/:id/test` | Re-test a saved notifier |
## Dashboard
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/dashboard/stats` | Overview statistics |
| `GET` | `/api/dashboard/timeline` | Recent activity timeline |
## Audit / System / Settings
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/audit-logs` | Audit log list |
| `GET` | `/api/system/info` | System information |
| `GET` | `/api/system/update-check` | Check for a newer release |
| `GET` | `/api/settings` | System-level settings |
| `PUT` | `/api/settings` | Update system settings |
## Response Envelope
All successful responses follow the shape:
```json
{
"code": "OK",
"message": "",
"data": { /* actual payload */ }
}
```
Errors return an HTTP 4xx/5xx plus:
```json
{
"code": "BACKUP_TASK_NOT_FOUND",
"message": "备份任务不存在",
"data": null
}
```

View File

@@ -0,0 +1,72 @@
---
sidebar_position: 2
title: CLI Reference
description: backupx subcommands — server, agent, backint, reset-password.
---
# CLI Reference
The `backupx` binary ships several subcommands. Running `backupx` with no subcommand starts the main server process.
## `backupx` (default: server)
```bash
backupx --config /opt/backupx/config.yaml
backupx --version
```
| Flag | Description |
|------|-------------|
| `--config <path>` | Path to config YAML (default: `./config.yaml`) |
| `--version` | Print version and exit |
## `backupx agent`
Run in Agent mode, connecting to a Master. See [Multi-Node Cluster](../features/multi-node).
```bash
backupx agent --master http://master:8340 --token <token>
```
| Flag | Description |
|------|-------------|
| `--master <url>` | Master URL |
| `--token <token>` | Agent auth token |
| `--token-file <path>` | Read the Agent Token from a file; preferred for services and containers |
| `--config <path>` | YAML config (takes precedence over env) |
| `--temp-dir <path>` | Local temp directory (default `/tmp/backupx-agent`) |
| `--proxy-url <url>` | Explicit HTTP(S) or SOCKS5(H) proxy |
| `--ca-cert <path>` | PEM CA certificate used to verify the Master |
| `--insecure-tls` | Skip TLS verification (testing only) |
Environment variables: `BACKUPX_AGENT_MASTER`, `BACKUPX_AGENT_TOKEN`, `BACKUPX_AGENT_TOKEN_FILE`, `BACKUPX_AGENT_HEARTBEAT`, `BACKUPX_AGENT_POLL`, `BACKUPX_AGENT_TEMP_DIR`, `BACKUPX_AGENT_PROXY_URL`, `BACKUPX_AGENT_CA_CERT_FILE`, `BACKUPX_AGENT_INSECURE_TLS`. When no explicit proxy URL is set, the Agent also honors `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`.
## `backupx backint`
SAP HANA Backint protocol agent. See [SAP HANA Support](../features/sap-hana).
```bash
backupx backint -f <function> -i <input> -o <output> -p <params>
```
| Flag | Description |
|------|-------------|
| `-f <fn>` | `backup` / `restore` / `inquire` / `delete` |
| `-i <path>` | Input file |
| `-o <path>` | Output file |
| `-p <path>` | Parameter file |
| `-u / -c / -l / -v` | Accepted and ignored for SAP compatibility |
## `backupx reset-password`
Reset an admin password directly in the SQLite database. No server restart needed.
```bash
backupx reset-password --username admin --password 'newpass123' [--config /path/to/config.yaml]
```
| Flag | Description |
|------|-------------|
| `--username` | Target username (default: `admin`) |
| `--password` | New password (min 8 chars, required) |
| `--config` | Config path (used to locate the database file) |

View File

@@ -0,0 +1,155 @@
import {themes as prismThemes} from 'prism-react-renderer';
import type {Config} from '@docusaurus/types';
import type * as Preset from '@docusaurus/preset-classic';
// BackupX 官方站点 — 托管在 GitHub Pages
// https://awuqing.github.io/BackupX/
const config: Config = {
title: 'BackupX',
tagline: 'Self-hosted backup orchestration for servers, databases, storage targets and remote agents',
favicon: 'img/favicon.ico',
future: {
v4: true,
},
url: 'https://awuqing.github.io',
baseUrl: '/BackupX/',
organizationName: 'Awuqing',
projectName: 'BackupX',
deploymentBranch: 'gh-pages',
trailingSlash: false,
onBrokenLinks: 'warn',
markdown: {
hooks: {
onBrokenMarkdownLinks: 'warn',
},
},
i18n: {
defaultLocale: 'en',
locales: ['en', 'zh-Hans'],
localeConfigs: {
en: {label: 'English', direction: 'ltr', htmlLang: 'en-US'},
'zh-Hans': {label: '简体中文', direction: 'ltr', htmlLang: 'zh-CN'},
},
},
presets: [
[
'classic',
{
docs: {
sidebarPath: './sidebars.ts',
editUrl: 'https://github.com/Awuqing/BackupX/edit/main/docs-site/',
},
blog: false,
theme: {
customCss: './src/css/custom.css',
},
} satisfies Preset.Options,
],
],
themeConfig: {
image: 'img/social-card.png',
colorMode: {
respectPrefersColorScheme: true,
},
navbar: {
title: 'BackupX',
logo: {
alt: 'BackupX Logo',
src: 'img/logo.svg',
},
items: [
{
type: 'docSidebar',
sidebarId: 'docs',
position: 'left',
label: 'Docs',
},
{
href: 'https://github.com/Awuqing/BackupX/releases',
label: 'Downloads',
position: 'left',
},
{
to: '/community',
label: 'Community',
position: 'left',
},
{
to: '/sponsors',
label: 'Sponsors',
position: 'left',
},
{
type: 'localeDropdown',
position: 'right',
},
{
href: 'https://github.com/Awuqing/BackupX',
label: 'GitHub',
position: 'right',
},
],
},
footer: {
style: 'dark',
links: [
{
title: 'Docs',
items: [
{label: 'Introduction', to: '/docs/intro'},
{label: 'Quick Start', to: '/docs/getting-started/quick-start'},
{label: 'Installation', to: '/docs/getting-started/installation'},
],
},
{
title: 'Features',
items: [
{label: 'SAP HANA', to: '/docs/features/sap-hana'},
{label: 'Multi-Node Cluster', to: '/docs/features/multi-node'},
{label: 'API Reference', to: '/docs/reference/api'},
],
},
{
title: 'More',
items: [
{label: 'GitHub', href: 'https://github.com/Awuqing/BackupX'},
{label: 'Releases', href: 'https://github.com/Awuqing/BackupX/releases'},
{label: 'Docker Hub', href: 'https://hub.docker.com/r/awuqing/backupx'},
{label: 'Issues', href: 'https://github.com/Awuqing/BackupX/issues'},
],
},
{
title: 'Community',
items: [
{label: 'Contributors', href: 'https://github.com/Awuqing/BackupX/graphs/contributors'},
{label: 'Pull Requests', href: 'https://github.com/Awuqing/BackupX/pulls'},
{label: 'Sponsor', to: '/sponsors'},
],
},
{
title: 'Sponsors',
items: [
{label: 'Sponsor BackupX', href: 'https://github.com/sponsors/Awuqing'},
{label: 'Partnership', href: 'https://github.com/Awuqing/BackupX/issues/new/choose'},
{label: 'Sponsor tiers', to: '/sponsors'},
],
},
],
copyright: `Copyright © ${new Date().getFullYear()} BackupX · Apache License 2.0`,
},
prism: {
theme: prismThemes.github,
darkTheme: prismThemes.dracula,
additionalLanguages: ['bash', 'yaml', 'ini', 'json', 'go', 'sql', 'nginx'],
},
} satisfies Preset.ThemeConfig,
};
export default config;

View File

@@ -0,0 +1,160 @@
{
"home.badge": {
"message": "开源备份控制平面 · v2.2.1",
"description": "Version badge on the hero"
},
"home.title.part1": {
"message": "面向自托管服务器的",
"description": "Hero title, first line"
},
"home.title.part2": {
"message": "备份编排平台。",
"description": "Hero title accent second line"
},
"home.tagline": {
"message": "在一个清爽控制台中管理文件、数据库、SAP HANA 和远程节点备份。控制平面自己掌握,存储后端灵活选择。",
"description": "Tagline on the home page"
},
"home.pageTitle": {
"message": "面向自托管服务器的备份编排",
"description": "Page <title> element on the home page"
},
"home.getStarted": {
"message": "快速开始",
"description": "Primary CTA on the home page"
},
"home.metric.backends": {
"message": "存储后端",
"description": "Hero metric label: storage backends"
},
"home.metric.backupTypes": {
"message": "远程执行",
"description": "Hero metric label: backup types"
},
"home.metric.license": {
"message": "开源协议",
"description": "Hero metric label: license"
},
"home.visual.eyebrow": {"message": "BackupX 控制台"},
"home.visual.title": {"message": "运维概览"},
"home.visual.status": {"message": "健康"},
"home.visual.success": {"message": "成功率"},
"home.visual.nodes": {"message": "活跃节点"},
"home.visual.targets": {"message": "存储目标"},
"home.visual.row1.title": {"message": "PostgreSQL 夜间备份"},
"home.visual.row1.desc": {"message": "加密归档已上传至 S3"},
"home.visual.row2.title": {"message": "SAP HANA 快照"},
"home.visual.row2.desc": {"message": "正在 agent-shanghai-02 上运行"},
"home.visual.row3.title": {"message": "保留策略清理"},
"home.visual.row3.desc": {"message": "下一次执行在 4 小时后"},
"home.command.title": {"message": "使用 Docker 启动"},
"section.features.tag": {
"message": "核心能力",
"description": "FEATURES section tag"
},
"section.features.title": {
"message": "该有的都有,多余的没有",
"description": "Features section title"
},
"section.features.subtitle": {
"message": "备份 Runner、存储 Provider、调度、集群 — 每一块都经过打磨。",
"description": "Features section subtitle"
},
"feat.types.title": {"message": "多种备份类型"},
"feat.types.desc": {"message": "文件与目录(支持多源路径),以及 MySQL、PostgreSQL、SQLite、SAP HANA 统一管理。"},
"feat.storage.title": {"message": "70+ 存储后端"},
"feat.storage.desc": {"message": "内置阿里云 OSS、腾讯云 COS、七牛、S3、Google Drive、WebDAV、FTP以及 SFTP、Azure Blob、Dropbox 等 rclone 后端。"},
"feat.scheduling.title": {"message": "调度与保留策略"},
"feat.scheduling.desc": {"message": "基于 Cron 的可视化调度编辑器,支持按天数/份数自动保留和空目录清理。"},
"feat.cluster.title": {"message": "多节点集群"},
"feat.cluster.desc": {"message": "Master-Agent 采用 Agent 主动出站轮询。支持代理、私有 CA 与 SSH 堡垒机,无需反向连通性。"},
"feat.security.title": {"message": "默认安全"},
"feat.security.desc": {"message": "JWT 认证、bcrypt、AES-256-GCM 加密配置、可选备份加密、完整审计日志。"},
"feat.deploy.title": {"message": "部署轻量"},
"feat.deploy.desc": {"message": "单个静态二进制 + 内嵌 SQLite。Docker 一键启动或裸机 — 零外部依赖。"},
"feat.learnMore": {"message": "了解更多"},
"showcase.tag": {"message": "产品界面"},
"showcase.title": {"message": "精心打磨的控制台,而非 DIY 脚本"},
"showcase.subtitle": {"message": "每个页面都为运维而生 — 可观测优先,可配置次之。"},
"showcase.tab.dashboard": {"message": "仪表盘"},
"showcase.tab.tasks": {"message": "备份任务"},
"showcase.tab.storage": {"message": "存储目标"},
"showcase.tab.nodes": {"message": "多节点"},
"showcase.dashboard.title": {"message": "一眼掌握全局"},
"showcase.dashboard.desc": {"message": "备份成功率、存储使用量、最近执行记录、即将触发的计划 — 一页实时数据。"},
"showcase.tasks.title": {"message": "可视化任务编辑器"},
"showcase.tasks.desc": {"message": "文件、MySQL、PostgreSQL、SQLite、SAP HANA — 三步完成。Cron 编辑器、多目标分发、保留策略、压缩、加密 — 点击即用。"},
"showcase.storage.title": {"message": "70+ 后端,统一体验"},
"showcase.storage.desc": {"message": "阿里云 OSS、腾讯云 COS、S3、Google Drive、WebDAV — 加上每一种 rclone 后端。测试连接、收藏、查看实时容量。"},
"showcase.nodes.title": {"message": "几分钟搭起 Master-Agent"},
"showcase.nodes.desc": {"message": "创建节点、复制令牌、在任意远程主机启动 Agent。路由到节点的任务在本地执行并直接上传到存储 — 无需反向连通性。"},
"showcase.cta": {"message": "开始阅读文档"},
"community.tag": {"message": "社区"},
"community.pageTitle": {"message": "社区、赞助商与贡献者"},
"community.pageDescription": {"message": "赞助 BackupX了解贡献者并找到务实的参与方式。"},
"community.title": {"message": "开放协作,面向长期运维"},
"community.subtitle": {"message": "备份软件的信任来自透明发布、真实部署反馈,以及足够务实的贡献路径。"},
"community.sponsor.kicker": {"message": "赞助商"},
"community.sponsor.wallTitle": {"message": "赞助商"},
"community.sponsor.title": {"message": "支持你依赖的备份基础设施"},
"community.sponsor.cta": {"message": "赞助 BackupX"},
"community.sponsor.openSlot": {"message": "赞助席位开放"},
"community.sponsor.logo.project": {"message": "项目赞助"},
"community.sponsor.logo.cloud": {"message": "云服务伙伴"},
"community.sponsor.logo.object": {"message": "对象存储"},
"community.sponsor.logo.cdn": {"message": "CDN 伙伴"},
"community.sponsor.logo.database": {"message": "数据库伙伴"},
"community.sponsor.logo.security": {"message": "安全审计"},
"community.sponsor.logo.agent": {"message": "远程节点实验室"},
"community.sponsor.logo.docs": {"message": "文档赞助"},
"community.sponsor.logo.release": {"message": "发布赞助"},
"community.sponsor.logo.s3": {"message": "S3 兼容"},
"community.sponsor.logo.webdav": {"message": "WebDAV 伙伴"},
"community.sponsor.logo.sftp": {"message": "SFTP 伙伴"},
"community.sponsor.logo.docker": {"message": "容器伙伴"},
"community.sponsor.logo.mirror": {"message": "镜像伙伴"},
"community.sponsor.logo.restore": {"message": "恢复演练"},
"community.sponsor.logo.qa": {"message": "测试实验室"},
"community.sponsor.logo.oss": {"message": "开源支持"},
"community.sponsor.logo.open": {"message": "赞助席位开放"},
"community.sponsor.infrastructure.label": {"message": "基础设施"},
"community.sponsor.infrastructure.title": {"message": "云与存储生态伙伴"},
"community.sponsor.infrastructure.desc": {"message": "帮助 BackupX 覆盖对象存储、WebDAV、SFTP 以及区域云平台的真实验证。"},
"community.sponsor.security.label": {"message": "安全"},
"community.sponsor.security.title": {"message": "审计与可靠性支持者"},
"community.sponsor.security.desc": {"message": "支持加密、恢复演练、发布签名和运维检查等强化工作。"},
"community.sponsor.community.label": {"message": "社区"},
"community.sponsor.community.title": {"message": "开源支持者"},
"community.sponsor.community.desc": {"message": "支持文档、示例、平台测试和贡献者引导。"},
"community.sponsor.tier.backer.name": {"message": "Backer"},
"community.sponsor.tier.backer.amount": {"message": "适合个人与小团队"},
"community.sponsor.tier.backer.desc": {"message": "支持文档、Issue 分流、兼容性测试和小型体验改进。"},
"community.sponsor.tier.partner.name": {"message": "Partner"},
"community.sponsor.tier.partner.amount": {"message": "适合存储与基础设施厂商"},
"community.sponsor.tier.partner.desc": {"message": "支持 Provider 验证、部署示例、基准说明和集成指南。"},
"community.sponsor.tier.enterprise.name": {"message": "Enterprise"},
"community.sponsor.tier.enterprise.amount": {"message": "适合生产环境使用方"},
"community.sponsor.tier.enterprise.desc": {"message": "赞助恢复演练、发布加固、审计和长期维护等可靠性工作。"},
"community.contributor.kicker": {"message": "贡献者"},
"community.contributor.all": {"message": "查看全部"},
"community.contributor.source": {"message": "浏览器端通过 GitHub contributors API 获取。"},
"community.contributor.botRole": {"message": "自动化贡献者"},
"community.contributor.githubRole": {"message": "GitHub 贡献者"},
"community.contributor.contributions": {"message": "{count} 次贡献"},
"community.path.kicker": {"message": "贡献路径"},
"community.path.issues.title": {"message": "反馈生产问题"},
"community.path.issues.desc": {"message": "提交日志、部署拓扑和恢复预期。"},
"community.path.docs.title": {"message": "完善文档与示例"},
"community.path.docs.desc": {"message": "贡献存储、Agent 和数据库部署指南。"},
"community.path.code.title": {"message": "提交聚焦的 PR"},
"community.path.code.desc": {"message": "保持改动小而可测,并贴合现有架构。"},
"sponsors.pageTitle": {"message": "赞助商"},
"sponsors.pageDescription": {"message": "赞助 BackupX 的可靠性、文档、存储兼容性和长期维护。"},
"sponsors.tag": {"message": "赞助商"},
"sponsors.title": {"message": "赞助 BackupX 生态"},
"sponsors.subtitle": {"message": "赞助帮助 BackupX 更贴近真实运维:经过验证的存储 Provider、可靠发布、恢复信心和更完善的文档。"}
}

View File

@@ -0,0 +1,8 @@
{
"version.label": {"message": "Next"},
"sidebar.docs.category.Getting Started": {"message": "快速开始"},
"sidebar.docs.category.Deployment": {"message": "部署"},
"sidebar.docs.category.Features": {"message": "功能特性"},
"sidebar.docs.category.Reference": {"message": "参考"},
"sidebar.docs.category.Development": {"message": "开发"}
}

View File

@@ -0,0 +1,118 @@
---
sidebar_position: 2
title: 裸机部署
description: 从预编译包或源码加固部署 BackupXNginx 改为显式启用。
---
# 裸机部署
## 使用预编译包
```bash
# 下载对应平台的压缩包
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-linux-amd64.tar.gz
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-linux-amd64.tar.gz.sha256
sha256sum -c backupx-linux-amd64.tar.gz.sha256
# 解压并安装
tar xzf backupx-linux-amd64.tar.gz && cd backupx-*-linux-amd64
sudo ./install.sh
```
安装脚本自动完成以下步骤:
1. 创建系统用户 `backupx`
2. 复制二进制到 `/opt/backupx/bin/backupx`,并把 Web 控制台复制到 `/opt/backupx/web`
3. 把默认配置安装到 `/etc/backupx/config.yaml`
4. 安装并启用 `backupx.service` systemd 单元
5. 默认不修改 Nginx只有显式设置 `INSTALL_NGINX=1` 时才安装模板
6. 验证首次初始化接口就绪后才报告安装成功
可执行文件与前端资源由 root 所有,只有 `/opt/backupx/data` 允许 `backupx` 服务账户写入。`/etc/backupx/config.yaml``root:backupx``0640` 权限安装。
仓库提供的 Nginx 模板只是起点,可能与现有默认站点冲突。先审核域名与 TLS 策略,再显式启用:
```bash
sudo INSTALL_NGINX=1 ./install.sh
```
如果要部署多节点集群,安装后请编辑 `/etc/backupx/config.yaml`,设置远程 Agent 可访问到的 Master URL
```yaml
server:
external_url: "https://backup.example.com"
```
修改后重启 BackupX
```bash
sudo systemctl restart backupx
```
## 从源码构建
```bash
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
make build
sudo ./deploy/install.sh
```
`make build` 会产出:
- `server/bin/backupx`Go 后端,无 CGO
- `web/dist/`React 前端,执行 `npm run build`
## systemd
安装后的 service 文件:
```ini title="/etc/systemd/system/backupx.service"
[Unit]
Description=BackupX backup management service
After=network.target
[Service]
Type=simple
User=backupx
Group=backupx
WorkingDirectory=/opt/backupx
ExecStart=/opt/backupx/bin/backupx -config /etc/backupx/config.yaml
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
UMask=0027
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
```
常用命令:
```bash
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`。
生产环境应通过 HTTPS 暴露 BackupX或在防火墙限制 `8340` 端口。安装器不会自动修改防火墙。
## 密码重置
忘记管理员密码时:
```bash
/opt/backupx/bin/backupx reset-password \
--username admin \
--password 'newpass123' \
--config /etc/backupx/config.yaml
```
Docker 等效命令:
```bash
docker exec -it backupx /app/bin/backupx reset-password --username admin --password 'newpass123'
```

View File

@@ -0,0 +1,86 @@
---
sidebar_position: 4
title: 配置参考
description: server.yaml 所有配置项及对应的环境变量。
---
# 配置参考
BackupX 默认从工作目录加载 `./config.yaml`,可通过 `--config` 指定其他路径。所有配置项都可通过 `BACKUPX_` 前缀环境变量覆盖。
## 完整配置
```yaml title="config.yaml"
server:
host: "0.0.0.0" # BACKUPX_SERVER_HOST
port: 8340 # BACKUPX_SERVER_PORT
mode: "release" # release | debug
external_url: "" # BACKUPX_SERVER_EXTERNAL_URL — 稳定的 Master 对外 URL
trusted_proxies: # BACKUPX_SERVER_TRUSTED_PROXIES — 准确的代理 IP/CIDR
- "127.0.0.1"
- "::1"
web_root: "" # BACKUPX_SERVER_WEB_ROOT — 前端构建目录
database:
path: "./data/backupx.db" # BACKUPX_DATABASE_PATH — 内嵌 SQLite
security:
jwt_secret: "" # BACKUPX_SECURITY_JWT_SECRET — 留空自动生成
jwt_expire: "24h" # BACKUPX_SECURITY_JWT_EXPIRE
encryption_key: "" # 用于加密存储配置的 AES-256-GCM 密钥
backup:
temp_dir: "/tmp/backupx" # BACKUPX_BACKUP_TEMP_DIR
max_concurrent: 2 # BACKUPX_BACKUP_MAX_CONCURRENT
retries: 3 # 单次上传的 rclone 底层重试次数
bandwidth_limit: "" # 例如 "10M" 表示限速 10 MB/s
log:
level: "info" # debug | info | warn | error
file: "./data/backupx.log"
```
## 密钥生成
如果首次启动时 `jwt_secret` 或 `encryption_key` 为空BackupX 会自动生成随机值并写入 `system_configs` 表。请妥善备份 `data/backupx.db`,一旦丢失将导致所有已加密的存储配置失效。
## 环境变量
文件和环境变量同时存在时,环境变量优先。配置路径转换规则:小写字母下划线 → 大写字母下划线:
| 配置项 | 环境变量 |
|--------|----------|
| `server.port` | `BACKUPX_SERVER_PORT` |
| `server.external_url` | `BACKUPX_SERVER_EXTERNAL_URL` |
| `server.trusted_proxies` | `BACKUPX_SERVER_TRUSTED_PROXIES`(环境变量使用逗号分隔) |
| `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 可访问地址时,才建议留空。
代理或 SSH 堡垒机场景可在安装向导中为单个 Agent 设置运行地址。公开安装链接仍使用 `server.external_url`,生成的 Agent 配置则使用该覆盖地址。
## 可信反向代理
BackupX 只接受 `server.trusted_proxies` 中来源提供的客户端转发头。默认仅允许本机 Nginx。代理运行在其他容器或主机时加入准确 IP 或网段:
```yaml
server:
trusted_proxies:
- "127.0.0.1"
- "172.18.0.0/16"
```
不要配置 `0.0.0.0/0`因为登录限流、安装令牌限流和审计日志都依赖客户端地址。BackupX 直接暴露且不应信任任何转发头时可设置空列表。

View File

@@ -0,0 +1,96 @@
---
sidebar_position: 1
title: Docker 部署
description: 带健康检查和持久化数据的加固单进程 Docker 部署。
---
# Docker 部署
官方镜像 [`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx) 支持 `linux/amd64``linux/arm64`
## Compose 文件
```yaml title="docker-compose.yml"
services:
backupx:
image: ${BACKUPX_IMAGE:-awuqing/backupx:latest}
container_name: backupx
restart: unless-stopped
init: true
stop_grace_period: 30s
ports:
- "${BACKUPX_BIND_ADDRESS:-0.0.0.0}:${BACKUPX_PORT:-8340}:8340"
volumes:
- backupx-data:/app/data
# - /var/www:/mnt/www:ro
# - /etc/nginx:/mnt/nginx-conf:ro
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- DAC_OVERRIDE
- SETGID
- SETUID
environment:
TZ: Asia/Shanghai
# BACKUPX_SERVER_EXTERNAL_URL: https://backup.example.com
BACKUPX_LOG_LEVEL: info
BACKUPX_BACKUP_MAX_CONCURRENT: "2"
healthcheck:
test: ["CMD", "su-exec", "backupx:backupx", "wget", "-q", "-T", "3", "-O", "/dev/null", "http://127.0.0.1:8340/ready"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
backupx-data:
```
```bash
docker compose up -d
docker compose ps
```
入口脚本仅以 root 完成旧镜像数据的所有权迁移,随后只运行一个非 root `backupx` 进程Compose 仅保留初始化所需的所有权与 UID/GID 切换能力。后端同时提供 API 与前端静态文件,默认不挂载 Docker Socket也不打包 Docker CLI。生产环境应把 `BACKUPX_IMAGE` 固定到明确 Release 标签。
## 备份宿主机目录
按需挂载源目录,并在任务中使用容器内路径。容器中的 `backupx` 用户必须拥有读取权限;恢复目标应使用单独且范围受限的可写挂载。特权路径优先通过远程 Agent 处理;确实需要 Master 以 root 读取时,应显式设置 `user: "0:0"` 并审核每一个挂载。
## 多节点集群
设置所有 Agent 可达的稳定地址:
```yaml
environment:
BACKUPX_SERVER_EXTERNAL_URL: https://backup.example.com
```
跨不可信网络必须使用 HTTPS。代理、私有 CA 和 SSH 堡垒机场景见 [多节点集群](../features/multi-node)。
外部反向代理运行在其他容器时,只把准确的 Docker 网桥网段加入 `BACKUPX_SERVER_TRUSTED_PROXIES`,例如 `172.18.0.0/16`,不要信任所有地址。
## 环境变量覆盖
```yaml
environment:
TZ: Asia/Shanghai
BACKUPX_LOG_LEVEL: debug
BACKUPX_BACKUP_MAX_CONCURRENT: "4"
BACKUPX_BACKUP_TEMP_DIR: /tmp/backupx
```
镜像内部端口固定为 `8340`,只通过 `BACKUPX_PORT` 修改宿主机发布端口。
## 升级与回退准备
```bash
docker compose pull
docker compose up -d
docker compose ps
```
等待状态变为 `healthy` 后再切换流量或移除旧部署。升级前应停止 Master 后做文件级复制,或对整个 `backupx-data` 卷创建原子快照。同一个数据卷必须只运行一个活动 MasterSQLite 不支持多个 Master 容器共享 `/app/data`。

View File

@@ -0,0 +1,60 @@
---
sidebar_position: 3
title: Nginx 反向代理
description: 通过 Nginx 发布 BackupXHTTPS + SSE 友好的缓冲配置)。
---
# Nginx 反向代理
生产环境可用的 Nginx 站点模板:
```nginx title="/etc/nginx/sites-available/backupx"
server {
listen 80;
server_name backup.example.com;
# 静态 UI由 /opt/backupx/web 提供)
location / {
root /opt/backupx/web;
try_files $uri $uri/ /index.html;
}
# API 反向代理
location /api/ {
proxy_pass http://127.0.0.1:8340;
proxy_http_version 1.1;
proxy_set_header Host $http_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_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
# 大文件上传(用于恢复流程)
client_max_body_size 0;
proxy_request_buffering off;
# 实时日志使用 SSE必须关闭缓冲
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
```
集群使用 Master 中转备份时必须保留 `proxy_request_buffering off`。否则 Nginx 会先把 Agent 上传的完整备份写入代理临时目录,再交给 BackupX既失去流式传输优势也可能占满代理磁盘。
如果 Nginx 运行在另一台主机或另一个容器,只把该代理的 IP 或网段加入 `server.trusted_proxies`,不要配置 `0.0.0.0/0`。登录限流、安装令牌限流和审计日志都依赖可信的客户端地址。
## certbot 配置 HTTPS
```bash
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d backup.example.com
```
certbot 会自动改写配置监听 443 并设置续期。
:::caution Agent 需要稳定的 URL
如果 Master 部署在 HTTPS 后面,远程 Agent 的 `--master` 必须使用公网 HTTPS 地址。自签名证书需加 `--insecure-tls`(仅供测试)。
:::

View File

@@ -0,0 +1,41 @@
---
sidebar_position: 2
title: 贡献指南
description: 如何反馈问题、提出改进、提交 PR。
---
# 贡献指南
BackupX 使用 Apache License 2.0 开源,欢迎提交 Issue 与 Pull Request。
## 报告 Bug
在 [github.com/Awuqing/BackupX/issues](https://github.com/Awuqing/BackupX/issues) 提交 Issue请附上
- BackupX 版本(`backupx --version`
- 部署方式Docker / 裸机 / 源码)
- 相关的备份任务类型和存储后端
- 复现步骤
- 问题发生时段的 stdout / `backupx.log` 片段
## 提议改动
对于重要功能或重构,建议先开 Issue 对齐方案,避免 PR 大改动后被 Review 回退。
## 提交 PR
1. Fork 仓库,创建主题分支(如 `fix/windows-path-escape`
2. 执行 `make test` 确认本地全通过
3. 保持每个 PR 只做一件事
4. Commit message 使用中文,格式 `类型: 简要描述`
- `功能: 新增审计日志模块`
- `修复: 目录浏览器无法进入子目录`
- `重构: 简化存储目标解密逻辑`
- 类型:`功能` / `修复` / `重构` / `文档` / `构建` / `测试`
5. PR 标题和正文同样使用中文,描述"为什么"和"怎么做",而非仅仅"做了什么"
## 代码规范
- **Go** — 所有错误必须处理(禁止 `_ = err`),日志使用现有 `zap`,禁止生产路径中出现 `fmt.Println`
- **TypeScript** — 严格模式,禁止隐式 any遵循现有 ESLint/Prettier 配置
- **Commit 粒度** — 每个 commit 一件事,不要把顺手的小修改和功能代码混在一起

View File

@@ -0,0 +1,83 @@
---
sidebar_position: 1
title: 开发环境
description: 搭建 BackupX 本地开发环境 — 后端、前端、测试。
---
# 开发环境
**环境要求:** Go ≥ 1.25Node.js ≥ 20npm。
## 克隆与依赖
```bash
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
cd web && npm install && cd ..
```
## 开发服务
开两个终端分别跑后端和前端:
```bash
# 终端 1后端监听 :8340
make dev-server
# 终端 2Vite HMR监听 :5173
make dev-web
```
Vite 配置了 `/api` 代理到 `http://127.0.0.1:8340`,浏览器直接访问 `http://localhost:5173`
## 测试
```bash
make test # 运行 Go + Web 全部测试
make test-server # 仅 Go
make test-web # 仅 Vitest
```
## 生产构建
```bash
make build # server/bin/backupx + web/dist
make docker # Docker 镜像
make docker-cn # 国内镜像加速构建
```
## 技术栈
| 组件 | 技术 |
|------|------|
| **后端** | Go · Gin · GORM · SQLite · robfig/cron · rclone |
| **前端** | React 18 · TypeScript · ArcoDesign · Vite · Zustand · ECharts |
| **存储** | rclone70+ 后端)· AWS SDK v2 · Google Drive API v3 |
| **安全** | JWT · bcrypt · AES-256-GCM |
## 目录结构
```
BackupX/
├── server/ # Go 后端
│ ├── cmd/backupx/ # 入口 + 子命令agent / backint / reset-password
│ ├── internal/
│ │ ├── agent/ # Agent CLI 逻辑
│ │ ├── app/ # 装配repo → service → handler
│ │ ├── backup/ # 备份 runnerfile / mysql / postgres / sqlite / saphana
│ │ ├── backint/ # SAP HANA Backint 协议
│ │ ├── http/ # HTTP handler + router
│ │ ├── model/ # GORM 模型
│ │ ├── repository/ # 数据访问
│ │ ├── service/ # 业务逻辑
│ │ └── storage/ # 存储 providerrclone + 直接 SDK
│ └── pkg/ # 通用工具
├── web/ # React 前端Vite
│ └── src/
│ ├── components/
│ ├── pages/
│ ├── services/
│ └── types/
├── docs-site/ # 文档站Docusaurus
├── deploy/ # install.sh / systemd unit / nginx config
└── Makefile
```

View File

@@ -0,0 +1,54 @@
---
sidebar_position: 1
title: 备份类型
description: 文件、MySQL、PostgreSQL、SQLite 和 SAP HANA — 各自的能力与配置说明。
---
# 备份类型
BackupX 支持五种内置备份类型,类型决定了用哪个 runner 执行。
当任务路由到远程 Agent 时,源路径和外部工具都会在该 Agent 主机上解析。多存储目标上传仍会逐目标记录结果;只要至少一个目标上传成功,备份记录即为成功,详情中的目标结果表会展示部分失败。
## 文件 / 目录
文件任务提供三种备份模式:
- **全量归档** — 每次生成一份可独立使用的 tar 产物
- **差异归档** — 只保存相对当前全量基线的变化,并按周期刷新全量基线
- **CDC 去重仓库** — 按稳定的 512 KiB / 1 MiB / 4 MiB 内容边界切块,将新块合并到不可变的 32 MiB pack每次运行只新增一份小型快照清单
CDC 仓库会在不同文件、不同快照之间复用相同内容。完整恢复、选择性恢复、完整性校验、下载为 tar、保留策略和垃圾回收都通过仓库索引定位分块。压缩与加密按块执行启用加密时使用带密钥的块 ID不暴露明文哈希。
当前仓库索引采用单写者模型,因此 CDC 模式仅在 Master 本机执行。如需保存多份完整仓库,请在任务中直接多选主存储目标。对象级副本复制会被禁用,因为只有快照清单、没有共享 pack 与索引并不是完整备份。
文件任务的通用选项:
- **源路径** 支持多个UI 中每行一个)
- **排除模式** 支持 gitignore 风格的通配符
- 可选跟随符号链接、保留权限
- 全量与差异模式输出 `.tar``.tar.gz``.tar.zst`
## MySQL
底层使用 `mysqldump`需要执行任务的主机Master 或 Agent`$PATH` 中有 `mysqldump`
- **主机 / 端口 / 用户 / 密码 / 数据库** — 支持多库(英文逗号分隔)
- 输出:`.sql``.sql.gz`
- 默认参数:`--single-transaction --routines --triggers --events`
## PostgreSQL
底层使用 `pg_dump`,连接字段与 MySQL 一致加数据库名。
## SQLite
直接复制数据库文件(使用一致性快照),无需外部工具。
## SAP HANA
支持两种模式 — 详见 [SAP HANA](./sap-hana) 专题页。
## 删除行为
删除备份任务时BackupX 会从所有存储目标上移除备份产物,但保留备份记录以供审计。删除任务同时拆除其 Cron 定时调度。

View File

@@ -0,0 +1,227 @@
---
sidebar_position: 4
title: 多节点集群
description: 通过直连 HTTPS、正向代理或 SSH 堡垒机部署 BackupX Agent。
---
# 多节点集群
BackupX 使用一个单活 Master 作为控制面,在每台源服务器运行 Agent。所有连接都由 Agent 主动发起:每 15 秒上报心跳,每 5 秒轮询命令,不需要为 Agent 开放入站端口。
## 架构与边界
```text
[Web 控制台] ────────> [单活 Master + SQLite]
^
| Agent 主动 HTTP(S) 轮询
+---------+---------+
| | |
[Agent B] [Agent C] [Agent D]
| | |
+----> 存储目标
```
- 每个节点有独立 Agent TokenAgent 不持有 Master 的 JWT 密钥或配置加密密钥。
- Master 超过 45 秒未收到心跳即把节点标记为离线。
- Master 持久化命令Agent 领取后在本机执行。
- 网络存储通常由 Agent 直传Master 本地存储可显式启用认证流式中转。
:::warning Master 只能单活
内置 SQLite 不是共享多写数据库。同一个数据目录只能运行一个 Master。控制面高可用应采用主备主机、持久卷快照以及稳定 DNS 或虚拟 IP故障时确保旧 Master 停止后再启动备用实例。不要让多个 Master 副本同时挂载 `/app/data` 或同一个 `backupx.db`
:::
BackupX 会设置 5 秒 SQLite busy timeout并为命令队列建立查询索引降低 Agent 并发轮询及任务更新时的锁竞争。数据库应位于本地文件系统或块存储。采用文件复制备份控制面时,先停止 Master 再复制整个数据目录;运行期间不要只复制 `backupx.db`
## 选择网络路径
| 场景 | Agent Master 地址 | Agent 代理 URL | 说明 |
| --- | --- | --- | --- |
| 可路由内网或公网服务 | `https://backup.example.com` | 留空 | 推荐,只需放行出站 TCP 443 |
| 企业正向代理 | `https://backup.example.com` | `http://proxy.internal:3128` | 支持 HTTP(S) 与 SOCKS5(H) |
| 通过堡垒机建立 SSH 动态转发 | `https://backup.internal` | `socks5h://127.0.0.1:1080` | 保留 TLS 主机名,并通过隧道解析内网 DNS |
| SSH 固定本地转发 | `http://127.0.0.1:18340` | 留空 | HTTP 链路位于 SSH 内,只能绑定回环地址 |
私有 PKI 场景请填写目标节点上预置的 PEM CA 证书绝对路径。生产环境不要使用 `--insecure-tls`
未配置显式代理时Agent 到 Master 的 HTTP 流量会遵循 `HTTP_PROXY``HTTPS_PROXY``NO_PROXY`。systemd 服务通常不会继承交互式 Shell 环境,因此 systemd 部署应在安装向导或 Agent YAML 中明确配置代理。
## 准备 Master
生成命令前先设置稳定地址:
```yaml title="/etc/backupx/config.yaml"
server:
external_url: "https://backup.example.com"
trusted_proxies:
- "127.0.0.1"
- "::1"
# 代理不在本机时,只加入准确的代理 IP 或网段。
# - "172.18.0.0/16"
```
`external_url` 是默认安装入口和 Agent 运行地址。受限节点可以让目标机侧生成的安装 URL 与 Agent 运行地址同时改用隧道或内网地址,浏览器仍继续访问公网地址。
跨不可信网络必须使用 HTTPS。Master 中转上传还要求反向代理关闭请求缓冲并允许大请求体,详见 [Nginx 反向代理](../deployment/nginx)。
Agent 必须直接配置最终 API 地址,不能依赖 HTTP 跳转到 HTTPS。Agent 会主动拒绝重定向,避免认证 Token 被转发到非预期主机。
## 部署 Agent
打开 **节点管理 → 添加节点**
1. 输入单个节点名,或在批量模式输入最多 50 个名称。
2. 选择 systemd、Docker 或前台模式以及架构、Agent Release、命令有效期和下载源。
3. 选择 **直连** 或 **代理或堡垒机**。受限网络可填写节点专用 Master 地址、代理 URL 或私有 CA 路径。
4. 把生成的命令复制到目标机,以 root 权限执行。
备份和恢复宿主机文件时推荐 systemd因为 Agent 需要访问任意本地路径。Docker Agent 只能看到显式挂载的目录;分配文件任务前,应使用只读备份源 volume并为恢复目标单独配置范围受限的可写挂载。
主命令通过一次性入口下载安装器,并在执行前校验脚本标记。向导会把所选 Agent 地址、显式代理和私有 CA 同时绑定到下载命令与安装后的 Agent 配置。如果目标网络仍无法访问安装入口,使用页面单独展示的嵌入式备用命令。嵌入式命令包含长期节点 Token必须按密钥管理。
安装器会:
1. 检测 `linux/amd64` 或 `linux/arm64`。
2. 配置显式代理时始终通过该代理下载 Release否则使用主机的正常直连或环境代理路径并在该版本提供 SHA-256 旁车文件时进行校验。
3. 以 `0600` 权限写入 `/etc/backupx-agent/config.yaml` 和 `/etc/backupx-agent/agent.token`。
4. 不把 Token 写入 systemd unit 或 Docker 环境元数据。
5. 启动 Agent并在 30 秒内轮询 `/api/v1/agent/self`。
6. 节点未上线时返回非零状态,并输出 systemd 或 Docker 排查命令。
旧版本如果没有校验文件,会显示兼容性警告后继续安装;新版本应始终发布并校验该文件。
### systemd 安装结果
```yaml title="/etc/backupx-agent/config.yaml"
master: "https://backup.example.com"
tokenFile: "/etc/backupx-agent/agent.token"
heartbeatInterval: "15s"
pollInterval: "5s"
tempDir: "/var/lib/backupx-agent/tmp"
proxyUrl: ""
caCertFile: ""
```
```ini title="/etc/systemd/system/backupx-agent.service"
[Unit]
Description=BackupX Agent
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=10
[Service]
Type=simple
ExecStart=/opt/backupx-agent/backupx agent --config /etc/backupx-agent/config.yaml
Restart=on-failure
RestartSec=10s
TimeoutStopSec=30s
UMask=0077
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
```
Agent 以 root 运行,因为文件备份和恢复路径可能属于任意系统用户。应严格限制谁能创建任务,以及谁能修改 root 所有的 Agent 配置。
## SSH 堡垒机示例
内网 Master 使用 HTTPS 时优先采用 SOCKS 隧道,这样 Master 主机名与证书校验保持不变。
先创建专用 SSH 账户,预置私钥和已经人工核对指纹的 `known_hosts`,再创建:
```sshconfig title="/etc/backupx-agent/ssh_config"
Host backupx-bastion
HostName bastion.example.com
User backupx-tunnel
IdentityFile /etc/backupx-agent/tunnel_ed25519
IdentitiesOnly yes
BatchMode yes
UserKnownHostsFile /etc/backupx-agent/known_hosts
StrictHostKeyChecking yes
DynamicForward 127.0.0.1:1080
ExitOnForwardFailure yes
ServerAliveInterval 30
ServerAliveCountMax 3
```
```ini title="/etc/systemd/system/backupx-agent-tunnel.service"
[Unit]
Description=BackupX Agent SSH tunnel
After=network-online.target
Wants=network-online.target
Before=backupx-agent.service
[Service]
Type=simple
ExecStart=/usr/bin/ssh -NT -F /etc/backupx-agent/ssh_config backupx-bastion
Restart=always
RestartSec=5s
[Install]
WantedBy=multi-user.target
```
再添加依赖覆写,让隧道不可用时 Agent 关闭失败而不是绕过堡垒机:
```ini title="/etc/systemd/system/backupx-agent.service.d/tunnel.conf"
[Unit]
Requires=backupx-agent-tunnel.service
After=backupx-agent-tunnel.service
```
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now backupx-agent-tunnel backupx-agent
```
在安装向导中保留内网 HTTPS Master 地址,把代理填写为 `socks5h://127.0.0.1:1080`。启用服务前必须通过独立渠道核对堡垒机 Host Key。
## 集中存储数据路径
| 目标 | 数据路径 |
| --- | --- |
| S3、WebDAV、FTP、云盘或其他网络后端 | Agent 直接流式上传到目标 |
| 启用 **远程备份经 Master 中转** 的 `local_disk` | Agent 通过认证 Master API 流式上传Master 写入本地挂载 |
中转不会在 Master 上额外创建一份完整临时副本恢复时走反向流式通道。Nginx 必须关闭请求缓冲,才能保持该特性。
## 运维
```bash
sudo systemctl status backupx-agent
sudo journalctl -u backupx-agent -n 100 --no-pager
sudo /opt/backupx-agent/backupx agent --config /etc/backupx-agent/config.yaml
```
从节点操作菜单轮换 Token 后,在 24 小时重叠窗口内更新 `/etc/backupx-agent/agent.token` 并重启服务。
建议监控:
- `backupx_agent_command_queue_depth`
- `backupx_agent_command_running`
- `backupx_agent_command_timeout_total`
- `backupx_node_online`
## CLI 参考
```text
backupx agent --help
-master string Master 地址
-token string Agent Token
-token-file string 从文件读取 Agent Token
-config string YAML 配置文件路径
-temp-dir string 本地临时目录
-proxy-url string HTTP(S) 或 SOCKS5(H) 代理
-ca-cert string 用于校验 Master 的 PEM CA 证书
-insecure-tls 跳过 TLS 校验(仅测试)
```
环境变量:`BACKUPX_AGENT_MASTER`、`BACKUPX_AGENT_TOKEN`、`BACKUPX_AGENT_TOKEN_FILE`、`BACKUPX_AGENT_HEARTBEAT`、`BACKUPX_AGENT_POLL`、`BACKUPX_AGENT_TEMP_DIR`、`BACKUPX_AGENT_PROXY_URL`、`BACKUPX_AGENT_CA_CERT_FILE`、`BACKUPX_AGENT_INSECURE_TLS`。
## 已知限制
- Master 使用内置 SQLite只支持单活。
- 加密备份仅支持 Master 本机执行,因为 Agent 不持有 Master 加密密钥。
- 远程目录浏览是同步队列 RPC默认超时 15 秒。
- Agent 领取后长期不更新的命令会由 Master 超时监控处理。

View File

@@ -0,0 +1,49 @@
---
sidebar_position: 5
title: 通知
description: 备份成功或失败时通过邮件、Webhook、Telegram 推送通知。
---
# 通知
BackupX 支持三种通知渠道,可为每个渠道单独配置成功/失败事件是否推送。
## 邮件SMTP
| 字段 | 说明 |
|------|------|
| SMTP 主机 / 端口 | 如 `smtp.gmail.com:587` |
| 用户名 / 密码 | 建议使用专用应用密码 |
| 发件人地址 | 邮件 `From:` 头 |
| 收件人列表 | 英文逗号分隔 |
| 使用 TLS / StartTLS | 按 SMTP 提供方要求选择 |
## Webhook
向任意 URL 发送 JSON POST请求体结构
```json
{
"event": "backup_result",
"task": {"id": 1, "name": "web-files", "type": "file"},
"record": {"id": 42, "status": "success", "fileSize": 1048576, "durationSeconds": 12},
"error": ""
}
```
适合自定义场景Slack incoming webhook、PagerDuty、自建 API 等。
## Telegram
| 字段 | 说明 |
|------|------|
| Bot Token | 在 [@BotFather](https://t.me/BotFather) 创建 |
| Chat ID | 数字型,可通过 `/start` 后调 Bot 的 `getUpdates` 获取 |
## 事件规则
每个通知配置可以指定触发范围:
- **仅成功** — 正常运行时静默
- **仅失败** — 适合高噪敏感通道
- **全部** — 初始化配置时用于验证链路

View File

@@ -0,0 +1,79 @@
---
sidebar_position: 3
title: SAP HANA 支持
description: 两种 SAP HANA 备份模式 — 控制台托管的 hdbsql Runner 和原生 Backint 协议代理。
---
# SAP HANA 支持
BackupX 提供两种 SAP HANA 备份模式,按实际运维流程选择。
## 模式一hdbsql Runner控制台托管
通过 Web 控制台创建 SAP HANA 备份任务,后端调用 `hdbsql` 执行备份。适合希望 BackupX 来管理调度的场景。
**源配置支持:**
| 字段 | 可选值 | 说明 |
|------|--------|------|
| 备份类型 | `data` / `log` | 数据备份或日志备份 |
| 备份级别 | `full` / `incremental` / `differential` | 日志备份时自动禁用 |
| 并行通道数 | `1 ~ 32` | 多路径 SQL`BACKUP DATA USING FILE ('c1', 'c2', ...)` |
| 失败重试次数 | `1 ~ 10` | 指数退避(`5s × 尝试次数²` |
| 实例编号 | 可选 | 从端口推断或手动指定 |
## 模式二Backint 协议代理HANA 原生接口)
BackupX 内置 Backint AgentSAP HANA 通过原生 `BACKUP DATA USING BACKINT` 语法调用,数据自动路由到任意 BackupX 存储目标S3 / OSS / COS / WebDAV / 70+ 后端)。
### 1. 参数文件
```ini title="/opt/backupx/backint_params.ini"
#STORAGE_TYPE = s3
#STORAGE_CONFIG_JSON = /opt/backupx/storage.json
#PARALLEL_FACTOR = 4
#COMPRESS = true
#KEY_PREFIX = hana-backup
#CATALOG_DB = /opt/backupx/backint_catalog.db
#LOG_FILE = /var/log/backupx/backint.log
```
### 2. 存储配置(与存储目标 schema 相同)
```json title="/opt/backupx/storage.json"
{
"endpoint": "https://s3.amazonaws.com",
"region": "us-east-1",
"bucket": "hana-prod",
"accessKeyId": "AKIA...",
"secretAccessKey": "..."
}
```
### 3. 创建 hdbbackint 软链接
```bash
ln -s /opt/backupx/backupx /usr/sap/<SID>/SYS/global/hdb/opt/hdbbackint
```
### 4. 在 HANA `global.ini` 中启用
```ini
[backup]
data_backup_using_backint = true
catalog_backup_using_backint = true
log_backup_using_backint = true
data_backup_parameter_file = /opt/backupx/backint_params.ini
log_backup_parameter_file = /opt/backupx/backint_params.ini
```
### 5. CLI 手动调用(用于排查)
```bash
backupx backint -f backup -i input.txt -o output.txt -p backint_params.ini
backupx backint -f restore -i input.txt -o output.txt -p backint_params.ini
backupx backint -f inquire -i input.txt -o output.txt -p backint_params.ini
backupx backint -f delete -i input.txt -o output.txt -p backint_params.ini
```
Backint Agent 使用本地 SQLite 维护 `EBID ↔ 对象键` 目录,所有操作遵循 SAP HANA Backint 协议(`#PIPE` / `#SAVED` / `#RESTORED` / `#BACKUP` / `#NOTFOUND` / `#DELETED` / `#ERROR`)。

View File

@@ -0,0 +1,40 @@
---
sidebar_position: 2
title: 存储后端
description: 70+ 存储后端 — 内置云服务商 + 任意 rclone 后端。
---
# 存储后端
BackupX 的目标是接入任何你想放置备份文件的地方。
## 内置后端
| 类型 | 必填字段 |
|------|---------|
| **阿里云 OSS** | Region + AccessKey ID/Secret + Bucketendpoint 自动组装) |
| **腾讯云 COS** | Region + SecretId/SecretKey + Bucket格式 `name-appid` |
| **七牛云 Kodo** | Region + AccessKey/SecretKey + Bucket |
| **S3 兼容** | Endpoint + AccessKey + Bucket |
| **Google Drive** | Client ID/Secret + OAuth 授权 |
| **WebDAV** | 地址 + 用户名/密码 |
| **FTP / FTPS** | 主机 + 端口 + 用户名/密码 |
| **本地磁盘** | 目标目录(绝对路径)+ 可选的远程 Agent 经 Master 中转 |
新建本地磁盘目标默认开启 **远程备份经 Master 中转**。开启时,配置目录属于 Master挂载到 Master 的存储服务器可集中接收多台源 Agent 的备份;如果该路径本就属于各 Agent请关闭此选项。升级前已有目标保持原来的 Agent 本机落盘行为,只有显式开启后才会切换。
## Rclone 后端
每一种 [rclone 后端](https://rclone.org/overview/) 都作为一等公民暴露 — SFTP、Azure Blob、Dropbox、OneDrive、Backblaze B2、Wasabi、pCloud、HDFS 等。
- 表单字段分为 **必填****高级**(高级默认折叠)
- 校验与连接测试复用 rclone 自带的探测
## 一个任务多个目标
一个备份任务可以并行上传到多个存储目标。每个目标获得相同的产物,每目标的状态会单独记录:
- 成功storage_path + 文件大小
- 失败:错误信息
如果任一目标在重试后仍失败,整条记录的状态为 `failed`,但已成功的目标产物会被保留(不回滚)。

View File

@@ -0,0 +1,87 @@
---
sidebar_position: 1
title: 安装
description: 通过 Docker、预编译包或源码安装 BackupX。
---
# 安装
BackupX 以单个静态二进制发布。三种安装方式,按实际环境选一种。
## Docker推荐
无需克隆仓库:
```bash
docker run -d --name backupx \
-p 8340:8340 \
-v backupx-data:/app/data \
awuqing/backupx:latest
```
或使用 `docker compose`
```yaml title="docker-compose.yml"
services:
backupx:
image: awuqing/backupx:latest
container_name: backupx
restart: unless-stopped
ports:
- "8340:8340"
volumes:
- backupx-data:/app/data
# 挂载需要备份的宿主机目录(按需添加):
# - /var/www:/mnt/www:ro
# - /etc/nginx:/mnt/nginx-conf:ro
environment:
- TZ=Asia/Shanghai
volumes:
backupx-data:
```
Docker Hub[`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx),支持 linux/amd64 和 linux/arm64。
## 预编译包(裸机)
从 [Releases 页面](https://github.com/Awuqing/BackupX/releases) 下载对应平台的压缩包,执行安装脚本:
```bash
sha256sum -c backupx-v*-linux-amd64.tar.gz.sha256
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
sudo ./install.sh # 创建系统用户、安装到 /opt/backupx、配置 systemd
```
安装脚本会自动:
1. 创建 `backupx` 系统用户
2. 安装二进制到 `/opt/backupx/bin/backupx`,并把 Web 控制台安装到 `/opt/backupx/web`
3. 生成 `/etc/backupx/config.yaml`(含安全默认值)
4. 注册并启用 `backupx.service` systemd 单元
5. 默认不修改 Nginx只有显式设置 `INSTALL_NGINX=1` 时才安装模板
6. 等待 `/api/auth/setup/status` 就绪;启动失败时输出 systemd 诊断并返回非零状态
## 从源码构建
依赖Go ≥ 1.25Node.js ≥ 20。
```bash
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
make build
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`。
自动安装兜底虚拟主机可能接管现有站点,因此 Nginx 模板改为显式启用。请先审核 `deploy/nginx.conf`,确认适合当前主机后再执行 `sudo INSTALL_NGINX=1 ./deploy/install.sh`。
## 验证安装
```bash
/opt/backupx/bin/backupx --version
curl -fsS http://127.0.0.1:8340/api/auth/setup/status
```
打开浏览器访问 `http://your-server:8340`,可在右上角选择 **中文****English**。全新数据库会显示“系统初始化 / System setup”在这里创建首个管理员用户名和密码。如果没有出现初始化表单请先重试上面的状态接口不要直接尝试登录。

View File

@@ -0,0 +1,61 @@
---
sidebar_position: 2
title: 快速开始
description: 部署 BackupX、添加存储目标、创建第一个备份任务。
---
# 快速开始
完成 [安装](./installation) 后,花五分钟跑通第一个备份。
## 1. 打开控制台
浏览器访问 `http://your-server:8340`。首次打开会引导创建管理员账户。
## 2. 添加存储目标
进入 **存储目标 → 添加**,选择类型并填写凭证:
| 类型 | 需要填写 |
|------|---------|
| 阿里云 OSS | Region + AccessKey ID/Secret + Bucket |
| 腾讯云 COS | Region + SecretId/SecretKey + Bucket格式 `name-appid` |
| 七牛云 Kodo | Region + AccessKey/SecretKey + Bucket |
| S3 兼容 | Endpoint + AccessKey + Bucket |
| Google Drive | Client ID/Secret → 点击「授权」完成 OAuth |
| WebDAV | 服务器地址 + 用户名/密码 |
| FTP | 主机 + 端口 + 用户名/密码 |
| 本地磁盘 | 目标目录路径 |
| SFTP / Azure / Dropbox / OneDrive 等 | 选择对应类型后填写必填项,高级配置默认折叠 |
:::tip
国内云厂商只需填 Region 和 AccessKey系统自动组装 Endpoint。Rclone 类型的配置项按"必填 / 可选"分层展示,高级选项默认折叠。
:::
添加后点击 **测试连接** 确认配置正确。
## 3. 创建备份任务
进入 **备份任务 → 新建**,三步完成:
1. **基础信息** — 任务名称、备份类型、Cron 表达式(留空则仅手动执行)
2. **源配置** — 文件备份选择源路径(支持多个),数据库备份填写连接信息
3. **存储与策略** — 选择存储目标(支持多个)、压缩策略、保留天数、是否加密
对于路由到 Agent 的任务,加密必须关闭,因为 Agent 不会拿到 Master 的加密密钥。BackupX 会在创建/更新阶段拒绝开启加密的远程节点或节点池任务。
保存后可点击 **立即执行** 测试,**备份记录** 页面实时查看执行日志。
:::note
删除备份任务时会自动清理远端存储上的备份文件,但保留备份记录以供审计追溯。
:::
## 4. 配置通知(可选)
**通知配置** 页面支持邮件、Webhook、Telegram 三种方式,可分别配置成功/失败时是否推送。
## 继续阅读
- 了解 [备份类型](/docs/features/backup-types) 和 [存储后端](/docs/features/storage-backends)
- 使用 SAP HANA参考 [SAP HANA 支持](/docs/features/sap-hana)
- 管理多台服务器?参考 [多节点集群](/docs/features/multi-node)

View File

@@ -0,0 +1,40 @@
---
id: intro
slug: /intro
sidebar_position: 1
title: 项目简介
description: BackupX——自托管服务器备份管理平台概览。
---
# BackupX
**BackupX** 是一款自托管的服务器备份管理平台:一个二进制,一条命令,管好所有服务器的所有备份。
- **单二进制 + 内嵌 SQLite** — 不依赖外部数据库或编排器
- **文件、数据库、SAP HANA** — 统一管理,可视化调度
- **70+ 存储后端** — 阿里云 OSS、腾讯云 COS、七牛、S3、Google Drive、WebDAV、FTP以及通过 rclone 接入的 SFTP / Azure Blob / Dropbox / OneDrive 等数十种
- **多节点集群** — Master-Agent 模式跨服务器管理备份Agent 在本地执行并直接上传到存储
- **默认安全** — JWT 认证、bcrypt、AES-256-GCM 加密配置、可选备份加密、完整审计日志
## 架构概览
```
[Web 控制台] ─── JWT ──→ [Master (backupx)]
│ Agent 主动 HTTP 轮询Token 认证)
[Agent (backupx agent)]
[70+ 存储后端]
```
路由到本机的任务在 Master 进程内直接执行;派到远程节点的任务通过命令队列下发,由 Agent 在本地执行。Agent 只发起出站 HTTP 连接 — 不需要任何反向连通性。
## 下一步
- **第一次使用 BackupX** 先看 [快速开始](/docs/getting-started/quick-start)
- **生产部署?** 参考 [部署指南](/docs/deployment/docker)
- **SAP HANA 用户?** 支持 `hdbsql` Runner 和原生 Backint 两种模式 — 详见 [SAP HANA](/docs/features/sap-hana)
- **管理多台服务器?** 参考 [多节点集群](/docs/features/multi-node)
- **程序化集成?** 参考 [API 参考](/docs/reference/api)

View File

@@ -0,0 +1,135 @@
---
sidebar_position: 1
title: API 参考
description: REST API 端点 — 统一以 /api 为前缀,使用 JWT Bearer 认证。
---
# API 参考
所有端点都以 `/api` 为前缀,使用 JWT Bearer 令牌认证(通过 `POST /api/auth/login` 获取。Agent 专用端点使用 `X-Agent-Token` 头认证。
## 认证
| 方法 | 端点 | 说明 |
|------|------|------|
| `GET` | `/api/auth/setup/status` | 查询是否需要初始化管理员 |
| `POST` | `/api/auth/setup` | 初始化首个管理员(仅当系统无任何用户时) |
| `POST` | `/api/auth/login` | 登录,返回 JWT |
| `POST` | `/api/auth/logout` | 登出(使当前 Token 失效) |
| `GET` | `/api/auth/profile` | 当前用户信息 |
| `PUT` | `/api/auth/password` | 修改密码 |
## 备份任务
| 方法 | 端点 | 说明 |
|------|------|------|
| `GET` | `/api/backup/tasks` | 列表 |
| `POST` | `/api/backup/tasks` | 创建 |
| `GET` | `/api/backup/tasks/:id` | 详情 |
| `PUT` | `/api/backup/tasks/:id` | 更新 |
| `DELETE` | `/api/backup/tasks/:id` | 删除 |
| `PUT` | `/api/backup/tasks/:id/toggle` | 启用 / 禁用 |
| `POST` | `/api/backup/tasks/:id/run` | 手动触发一次执行 |
## 备份记录
| 方法 | 端点 | 说明 |
|------|------|------|
| `GET` | `/api/backup/records` | 列表(支持筛选) |
| `GET` | `/api/backup/records/:id` | 记录详情 |
| `GET` | `/api/backup/records/:id/logs/stream` | 实时日志SSE |
| `GET` | `/api/backup/records/:id/download` | 下载备份产物 |
| `POST` | `/api/backup/records/:id/restore` | 恢复到原始源 |
| `DELETE` | `/api/backup/records/:id` | 删除记录 |
| `POST` | `/api/backup/records/batch-delete` | 批量删除 |
## 存储目标
| 方法 | 端点 | 说明 |
|------|------|------|
| `GET` | `/api/storage-targets` | 列表 |
| `POST` | `/api/storage-targets` | 创建 |
| `GET` | `/api/storage-targets/:id` | 详情 |
| `PUT` | `/api/storage-targets/:id` | 更新 |
| `DELETE` | `/api/storage-targets/:id` | 删除 |
| `POST` | `/api/storage-targets/test` | 用待审核配置测试连接 |
| `POST` | `/api/storage-targets/:id/test` | 重测已保存的目标 |
| `PUT` | `/api/storage-targets/:id/star` | 切换收藏状态 |
| `GET` | `/api/storage-targets/:id/usage` | 查询远端存储用量(支持此能力的后端) |
| `GET` | `/api/storage-targets/rclone/backends` | 列出可用的 rclone 后端 |
| `POST` | `/api/storage-targets/google-drive/auth-url` | 启动 Google Drive OAuth |
| `POST` | `/api/storage-targets/google-drive/complete` | 完成 OAuth 流程 |
## 节点(集群)
| 方法 | 端点 | 说明 |
|------|------|------|
| `GET` | `/api/nodes` | 节点列表 |
| `POST` | `/api/nodes` | 创建节点并返回 Token |
| `GET` | `/api/nodes/:id` | 节点详情 |
| `PUT` | `/api/nodes/:id` | 重命名 |
| `DELETE` | `/api/nodes/:id` | 删除(有关联任务时会被拒绝) |
| `GET` | `/api/nodes/:id/fs/list` | 浏览目录(远程节点走 Agent 异步 RPC |
## Agent 协议X-Agent-Token
Agent CLI 专用端点,通过 `X-Agent-Token` 头认证而非 JWT。
| 方法 | 端点 | 说明 |
|------|------|------|
| `POST` | `/api/agent/heartbeat` | 上报心跳(返回节点 ID |
| `POST` | `/api/agent/commands/poll` | 领取一条待执行命令 |
| `POST` | `/api/agent/commands/:id/result` | 上报命令结果 |
| `GET` | `/api/agent/tasks/:id` | 拉取任务规格(含解密后的存储配置) |
| `POST` | `/api/agent/records/:id` | 追加日志 / 更新记录状态 |
## 通知
| 方法 | 端点 | 说明 |
|------|------|------|
| `GET` | `/api/notifications` | 列表 |
| `POST` | `/api/notifications` | 创建 |
| `GET` | `/api/notifications/:id` | 详情 |
| `PUT` | `/api/notifications/:id` | 更新 |
| `DELETE` | `/api/notifications/:id` | 删除 |
| `POST` | `/api/notifications/test` | 用待审核配置测试 |
| `POST` | `/api/notifications/:id/test` | 重测已保存的通知器 |
## 仪表盘
| 方法 | 端点 | 说明 |
|------|------|------|
| `GET` | `/api/dashboard/stats` | 概览统计 |
| `GET` | `/api/dashboard/timeline` | 最近活动时间线 |
## 审计 / 系统 / 设置
| 方法 | 端点 | 说明 |
|------|------|------|
| `GET` | `/api/audit-logs` | 审计日志 |
| `GET` | `/api/system/info` | 系统信息 |
| `GET` | `/api/system/update-check` | 检查新版本 |
| `GET` | `/api/settings` | 系统级设置 |
| `PUT` | `/api/settings` | 更新系统设置 |
## 响应结构
成功响应统一为:
```json
{
"code": "OK",
"message": "",
"data": { /* */ }
}
```
错误返回 HTTP 4xx/5xx并带
```json
{
"code": "BACKUP_TASK_NOT_FOUND",
"message": "备份任务不存在",
"data": null
}
```

View File

@@ -0,0 +1,72 @@
---
sidebar_position: 2
title: CLI 参考
description: backupx 子命令 — server / agent / backint / reset-password。
---
# CLI 参考
`backupx` 二进制内置多个子命令。无子命令时默认启动主服务进程。
## `backupx`(默认:服务进程)
```bash
backupx --config /opt/backupx/config.yaml
backupx --version
```
| 参数 | 说明 |
|------|------|
| `--config <path>` | 配置文件路径(默认 `./config.yaml` |
| `--version` | 打印版本后退出 |
## `backupx agent`
以 Agent 模式运行,连接到 Master。详见 [多节点集群](../features/multi-node)。
```bash
backupx agent --master http://master:8340 --token <token>
```
| 参数 | 说明 |
|------|------|
| `--master <url>` | Master URL |
| `--token <token>` | Agent 认证令牌 |
| `--token-file <path>` | 从文件读取 Agent Token服务与容器部署推荐使用 |
| `--config <path>` | YAML 配置文件(优先级高于环境变量) |
| `--temp-dir <path>` | 本地临时目录(默认 `/tmp/backupx-agent` |
| `--proxy-url <url>` | 显式 HTTP(S) 或 SOCKS5(H) 代理 |
| `--ca-cert <path>` | 用于校验 Master 的 PEM CA 证书 |
| `--insecure-tls` | 跳过 TLS 校验(仅测试用) |
环境变量:`BACKUPX_AGENT_MASTER``BACKUPX_AGENT_TOKEN``BACKUPX_AGENT_TOKEN_FILE``BACKUPX_AGENT_HEARTBEAT``BACKUPX_AGENT_POLL``BACKUPX_AGENT_TEMP_DIR``BACKUPX_AGENT_PROXY_URL``BACKUPX_AGENT_CA_CERT_FILE``BACKUPX_AGENT_INSECURE_TLS`。未设置显式代理时Agent 同样遵循 `HTTP_PROXY``HTTPS_PROXY``NO_PROXY`
## `backupx backint`
SAP HANA Backint 协议代理,详见 [SAP HANA 支持](../features/sap-hana)。
```bash
backupx backint -f <function> -i <input> -o <output> -p <params>
```
| 参数 | 说明 |
|------|------|
| `-f <fn>` | `backup` / `restore` / `inquire` / `delete` |
| `-i <path>` | 输入文件 |
| `-o <path>` | 输出文件 |
| `-p <path>` | 参数文件 |
| `-u / -c / -l / -v` | 接收但忽略(兼容 SAP 约定) |
## `backupx reset-password`
直接在 SQLite 中重置管理员密码,无需重启服务。
```bash
backupx reset-password --username admin --password 'newpass123' [--config /path/to/config.yaml]
```
| 参数 | 说明 |
|------|------|
| `--username` | 目标用户名(默认 `admin` |
| `--password` | 新密码(最少 8 字符,必填) |
| `--config` | 配置文件路径(用于定位数据库文件) |

View File

@@ -0,0 +1,23 @@
{
"link.title.Docs": {"message": "文档"},
"link.title.Features": {"message": "功能"},
"link.title.More": {"message": "更多"},
"link.title.Community": {"message": "社区"},
"link.title.Sponsors": {"message": "赞助商"},
"link.item.label.Introduction": {"message": "简介"},
"link.item.label.Quick Start": {"message": "快速开始"},
"link.item.label.Installation": {"message": "安装"},
"link.item.label.SAP HANA": {"message": "SAP HANA"},
"link.item.label.Multi-Node Cluster": {"message": "多节点集群"},
"link.item.label.API Reference": {"message": "API 参考"},
"link.item.label.GitHub": {"message": "GitHub"},
"link.item.label.Releases": {"message": "Releases"},
"link.item.label.Docker Hub": {"message": "Docker Hub"},
"link.item.label.Issues": {"message": "Issues"},
"link.item.label.Contributors": {"message": "贡献者"},
"link.item.label.Pull Requests": {"message": "Pull Requests"},
"link.item.label.Sponsor": {"message": "赞助"},
"link.item.label.Sponsor BackupX": {"message": "赞助 BackupX"},
"link.item.label.Partnership": {"message": "合作伙伴"},
"link.item.label.Sponsor tiers": {"message": "赞助层级"}
}

View File

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

19445
docs-site/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
docs-site/package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "backupx-docs",
"version": "1.0.0",
"private": true,
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc"
},
"dependencies": {
"@docusaurus/core": "3.10.2",
"@docusaurus/faster": "3.10.2",
"@docusaurus/preset-classic": "3.10.2",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.10.2",
"@docusaurus/tsconfig": "3.10.2",
"@docusaurus/types": "3.10.2",
"@types/react": "^19.0.0",
"typescript": "~6.0.2"
},
"browserslist": {
"production": [
">0.5%",
"not dead",
"not op_mini all"
],
"development": [
"last 3 chrome version",
"last 3 firefox version",
"last 5 safari version"
]
},
"engines": {
"node": ">=20.0"
}
}

55
docs-site/sidebars.ts Normal file
View File

@@ -0,0 +1,55 @@
import type {SidebarsConfig} from '@docusaurus/plugin-content-docs';
const sidebars: SidebarsConfig = {
docs: [
'intro',
{
type: 'category',
label: 'Getting Started',
collapsed: false,
items: [
'getting-started/installation',
'getting-started/quick-start',
],
},
{
type: 'category',
label: 'Deployment',
items: [
'deployment/docker',
'deployment/bare-metal',
'deployment/nginx',
'deployment/configuration',
],
},
{
type: 'category',
label: 'Features',
items: [
'features/backup-types',
'features/storage-backends',
'features/sap-hana',
'features/multi-node',
'features/notifications',
],
},
{
type: 'category',
label: 'Reference',
items: [
'reference/api',
'reference/cli',
],
},
{
type: 'category',
label: 'Development',
items: [
'development/setup',
'development/contributing',
],
},
],
};
export default sidebars;

View File

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

View File

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

View File

@@ -0,0 +1,172 @@
import type {ReactNode} from 'react';
import Heading from '@theme/Heading';
import Translate from '@docusaurus/Translate';
import Link from '@docusaurus/Link';
import styles from './styles.module.css';
type FeatureItem = {
title: ReactNode;
description: ReactNode;
icon: ReactNode;
link?: string;
};
const DatabaseIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 5v6c0 1.66 4 3 9 3s9-1.34 9-3V5" />
<path d="M3 11v6c0 1.66 4 3 9 3s9-1.34 9-3v-6" />
</svg>
);
const CloudIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M18 10h-1.26A8 8 0 109 20h9a5 5 0 000-10z" />
</svg>
);
const ClockIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
);
const NetworkIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="9" y="2" width="6" height="6" rx="1" />
<rect x="2" y="16" width="6" height="6" rx="1" />
<rect x="16" y="16" width="6" height="6" rx="1" />
<path d="M12 8v4" />
<path d="M12 12H5v4" />
<path d="M12 12h7v4" />
</svg>
);
const ShieldIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 2l9 4v6c0 5-3.5 9.5-9 10-5.5-.5-9-5-9-10V6l9-4z" />
<polyline points="9 12 11 14 15 10" />
</svg>
);
const RocketIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 00-2.91-.09z" />
<path d="M12 15l-3-3a22 22 0 012-3.95A12.88 12.88 0 0122 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 01-4 2z" />
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" />
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
</svg>
);
const FEATURES: FeatureItem[] = [
{
title: <Translate id="feat.types.title">Many Backup Types</Translate>,
description: (
<Translate id="feat.types.desc">
Files and directories with multi-path sources, plus MySQL, PostgreSQL, SQLite, and SAP HANA all in one place.
</Translate>
),
icon: <DatabaseIcon />,
link: '/docs/features/backup-types',
},
{
title: <Translate id="feat.storage.title">70+ Storage Backends</Translate>,
description: (
<Translate id="feat.storage.desc">
Native Alibaba OSS, Tencent COS, Qiniu, S3, Google Drive, WebDAV, FTP plus SFTP, Azure Blob, Dropbox and more via rclone.
</Translate>
),
icon: <CloudIcon />,
link: '/docs/features/storage-backends',
},
{
title: <Translate id="feat.scheduling.title">Scheduling & Retention</Translate>,
description: (
<Translate id="feat.scheduling.desc">
Cron-based schedules with a visual editor and auto-retention (by days or count), plus empty-directory cleanup.
</Translate>
),
icon: <ClockIcon />,
},
{
title: <Translate id="feat.cluster.title">Multi-Node Cluster</Translate>,
description: (
<Translate id="feat.cluster.desc">
Outbound-only Master-Agent polling with proxy, private-CA, and SSH-bastion support. No reverse connectivity is required.
</Translate>
),
icon: <NetworkIcon />,
link: '/docs/features/multi-node',
},
{
title: <Translate id="feat.security.title">Secure by Default</Translate>,
description: (
<Translate id="feat.security.desc">
JWT auth, bcrypt passwords, AES-256-GCM encrypted config, optional backup encryption, and a full audit log.
</Translate>
),
icon: <ShieldIcon />,
},
{
title: <Translate id="feat.deploy.title">Painless Deployment</Translate>,
description: (
<Translate id="feat.deploy.desc">
Single static binary with embedded SQLite. Docker one-click or bare-metal zero external dependencies.
</Translate>
),
icon: <RocketIcon />,
link: '/docs/getting-started/installation',
},
];
function Feature({title, description, icon, link}: FeatureItem) {
const content = (
<>
<div className={styles.iconWrap}>{icon}</div>
<Heading as="h3" className={styles.featureTitle}>{title}</Heading>
<p className={styles.featureDesc}>{description}</p>
{link && (
<span className={styles.featureLink}>
<Translate id="feat.learnMore">Learn more</Translate>
<span className={styles.featureArrow} aria-hidden="true">-&gt;</span>
</span>
)}
</>
);
if (link) {
return (
<Link to={link} className={styles.featureCardLink}>
{content}
</Link>
);
}
return <div className={styles.featureCard}>{content}</div>;
}
export default function HomepageFeatures(): ReactNode {
return (
<section className={styles.section}>
<div className="container">
<div className={styles.sectionHead}>
<div className={styles.sectionTag}>
<Translate id="section.features.tag">FEATURES</Translate>
</div>
<Heading as="h2" className={styles.sectionTitle}>
<Translate id="section.features.title">Everything you need, nothing you don't</Translate>
</Heading>
<p className={styles.sectionSubtitle}>
<Translate id="section.features.subtitle">
Battle-tested building blocks backup runners, storage providers, scheduling, and clustering.
</Translate>
</p>
</div>
<div className={styles.grid}>
{FEATURES.map((feat, idx) => (
<Feature key={idx} {...feat} />
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,169 @@
.section {
padding: 5.5rem 0 4.25rem;
background: var(--ifm-background-color);
}
.sectionHead {
text-align: center;
max-width: 720px;
margin: 0 auto 3rem;
}
.sectionTag {
display: inline-flex;
align-items: center;
min-height: 28px;
font-size: 12px;
font-weight: 750;
letter-spacing: 0;
color: var(--ifm-color-primary);
padding: 4px 12px;
background: rgba(22, 93, 255, 0.08);
border: 1px solid rgba(22, 93, 255, 0.16);
border-radius: 8px;
margin-bottom: 1rem;
}
[data-theme='dark'] .sectionTag {
background: rgba(96, 126, 255, 0.18);
color: var(--ifm-color-primary-lighter);
}
.sectionTitle {
font-size: 2.35rem;
line-height: 1.2;
letter-spacing: 0;
font-weight: 750;
margin: 0 0 1rem;
color: var(--ifm-heading-color);
}
.sectionSubtitle {
font-size: 1.05rem;
line-height: 1.65;
color: var(--ifm-color-content-secondary);
margin: 0;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.25rem;
}
@media (max-width: 996px) {
.section {
padding: 3.5rem 0 2rem;
}
.sectionTitle {
font-size: 2rem;
}
.grid {
grid-template-columns: 1fr;
}
}
@media (min-width: 997px) and (max-width: 1200px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
.featureCard,
.featureCardLink {
position: relative;
display: flex;
flex-direction: column;
padding: 1.75rem;
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
text-decoration: none !important;
color: inherit;
height: 100%;
}
.featureCardLink:hover {
transform: translateY(-2px);
border-color: var(--ifm-color-primary);
box-shadow: 0 12px 30px -8px rgba(22, 93, 255, 0.18);
color: inherit;
}
[data-theme='dark'] .featureCard,
[data-theme='dark'] .featureCardLink {
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.08);
}
[data-theme='dark'] .featureCardLink:hover {
background: rgba(64, 128, 255, 0.05);
border-color: var(--ifm-color-primary);
box-shadow: 0 12px 30px -8px rgba(64, 128, 255, 0.25);
}
.iconWrap {
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(22, 93, 255, 0.1) 0%, rgba(20, 201, 201, 0.12) 100%);
color: var(--ifm-color-primary);
margin-bottom: 1.25rem;
}
[data-theme='dark'] .iconWrap {
background: linear-gradient(135deg, rgba(96, 126, 255, 0.15) 0%, rgba(20, 201, 201, 0.12) 100%);
color: var(--ifm-color-primary-lighter);
}
.featureTitle {
font-size: 1.15rem;
font-weight: 700;
margin: 0 0 0.6rem;
color: var(--ifm-heading-color);
letter-spacing: 0;
}
.featureDesc {
font-size: 0.95rem;
line-height: 1.65;
color: var(--ifm-color-content-secondary);
margin: 0;
flex: 1;
}
.featureLink {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 1rem;
font-size: 13px;
font-weight: 500;
color: var(--ifm-color-primary);
}
.featureArrow {
transition: transform 0.2s ease;
}
.featureCardLink:hover .featureArrow {
transform: translateX(4px);
}
@media (max-width: 640px) {
.sectionTitle {
font-size: 1.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.featureCard,
.featureCardLink,
.featureArrow {
transition: none;
}
}

View File

@@ -0,0 +1,120 @@
import type {ReactNode} from 'react';
import {useState} from 'react';
import clsx from 'clsx';
import Heading from '@theme/Heading';
import Translate from '@docusaurus/Translate';
import useBaseUrl from '@docusaurus/useBaseUrl';
import Link from '@docusaurus/Link';
import styles from './styles.module.css';
type Tab = {
id: string;
label: ReactNode;
image: string;
title: ReactNode;
description: ReactNode;
};
function useTabs(): Tab[] {
return [
{
id: 'dashboard',
label: <Translate id="showcase.tab.dashboard">Dashboard</Translate>,
image: useBaseUrl('/img/screenshots/dashboard.png'),
title: <Translate id="showcase.dashboard.title">Know at a glance</Translate>,
description: (
<Translate id="showcase.dashboard.desc">
Backup success rates, storage usage, recent runs and upcoming schedules all on one page with live data.
</Translate>
),
},
{
id: 'tasks',
label: <Translate id="showcase.tab.tasks">Backup Tasks</Translate>,
image: useBaseUrl('/img/screenshots/backup-tasks.png'),
title: <Translate id="showcase.tasks.title">Visual task editor</Translate>,
description: (
<Translate id="showcase.tasks.desc">
Files, MySQL, PostgreSQL, SQLite and SAP HANA with a three-step wizard. Cron editor, multi-target dispatch, retention, compression and encryption point and click.
</Translate>
),
},
{
id: 'storage',
label: <Translate id="showcase.tab.storage">Storage Targets</Translate>,
image: useBaseUrl('/img/screenshots/storage-targets.png'),
title: <Translate id="showcase.storage.title">70+ backends, one flow</Translate>,
description: (
<Translate id="showcase.storage.desc">
Alibaba OSS, Tencent COS, S3, Google Drive, WebDAV plus every rclone backend behind a uniform form. Test connection, favourite, and view live usage.
</Translate>
),
},
{
id: 'nodes',
label: <Translate id="showcase.tab.nodes">Multi-Node</Translate>,
image: useBaseUrl('/img/screenshots/nodes.png'),
title: <Translate id="showcase.nodes.title">Master-Agent in minutes</Translate>,
description: (
<Translate id="showcase.nodes.desc">
Create a node, copy the token, start the Agent on any remote host. Tasks routed to a node run locally there and upload directly to storage no reverse connectivity required.
</Translate>
),
},
];
}
export default function HomepageShowcase(): ReactNode {
const tabs = useTabs();
const [active, setActive] = useState(tabs[0].id);
const current = tabs.find(t => t.id === active) ?? tabs[0];
return (
<section className={styles.section}>
<div className="container">
<div className={styles.sectionHead}>
<div className={styles.sectionTag}>
<Translate id="showcase.tag">PRODUCT</Translate>
</div>
<Heading as="h2" className={styles.sectionTitle}>
<Translate id="showcase.title">A polished console, not a DIY script</Translate>
</Heading>
<p className={styles.sectionSubtitle}>
<Translate id="showcase.subtitle">
Every screen designed for day-2 operations visibility first, configuration second.
</Translate>
</p>
</div>
<div className={styles.tabs}>
{tabs.map(tab => (
<button
key={tab.id}
type="button"
className={clsx(styles.tabBtn, active === tab.id && styles.tabBtnActive)}
onClick={() => setActive(tab.id)}>
{tab.label}
</button>
))}
</div>
<div className={styles.stage}>
<div className={styles.browser}>
<div className={styles.browserBar}>
<span className={clsx(styles.browserDot, styles.browserDotRed)} />
<span className={clsx(styles.browserDot, styles.browserDotYellow)} />
<span className={clsx(styles.browserDot, styles.browserDotGreen)} />
<div className={styles.browserUrl}>backupx.local</div>
</div>
<img src={current.image} alt="" className={styles.screenshot} />
</div>
<div className={styles.caption}>
<Heading as="h3" className={styles.captionTitle}>{current.title}</Heading>
<p className={styles.captionDesc}>{current.description}</p>
<Link to="/docs/getting-started/quick-start" className={styles.captionLink}>
<Translate id="showcase.cta">Explore the docs</Translate>
<span aria-hidden="true"> -&gt;</span>
</Link>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,247 @@
.section {
padding: 4.5rem 0 5.5rem;
background:
linear-gradient(180deg, rgba(245, 247, 250, 0) 0%, rgba(245, 247, 250, 0.72) 100%),
var(--ifm-background-color);
}
[data-theme='dark'] .section {
background:
linear-gradient(180deg, rgba(15, 17, 21, 0) 0%, rgba(255, 255, 255, 0.03) 100%),
var(--ifm-background-color);
}
.sectionHead {
text-align: center;
max-width: 720px;
margin: 0 auto 2.5rem;
}
.sectionTag {
display: inline-flex;
align-items: center;
min-height: 28px;
font-size: 12px;
font-weight: 750;
letter-spacing: 0;
color: #0e7490;
padding: 4px 12px;
background: rgba(20, 201, 201, 0.1);
border: 1px solid rgba(20, 201, 201, 0.2);
border-radius: 8px;
margin-bottom: 1rem;
}
[data-theme='dark'] .sectionTag {
background: rgba(20, 201, 201, 0.16);
color: #67e8f9;
}
.sectionTitle {
font-size: 2.35rem;
line-height: 1.2;
letter-spacing: 0;
font-weight: 750;
margin: 0 0 1rem;
color: var(--ifm-heading-color);
}
.sectionSubtitle {
font-size: 1.05rem;
line-height: 1.65;
color: var(--ifm-color-content-secondary);
margin: 0;
}
/* Tab bar */
.tabs {
display: flex;
justify-content: center;
gap: 6px;
margin-bottom: 2rem;
flex-wrap: wrap;
padding: 6px;
background: var(--ifm-color-emphasis-100);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
}
.tabBtn {
min-height: 40px;
padding: 8px 18px;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
color: var(--ifm-color-content-secondary);
font-size: 14px;
font-weight: 650;
cursor: pointer;
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
}
.tabBtn:hover {
color: var(--ifm-color-primary);
background: var(--ifm-background-color);
}
.tabBtnActive,
.tabBtnActive:hover {
background: var(--ifm-background-color);
color: var(--ifm-color-primary) !important;
border-color: rgba(22, 93, 255, 0.18);
box-shadow: 0 6px 16px rgba(22, 93, 255, 0.12);
}
/* Stage */
.stage {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 3rem;
align-items: center;
}
@media (max-width: 996px) {
.stage {
grid-template-columns: 1fr;
gap: 1.5rem;
}
}
.browser {
background: var(--ifm-background-color);
border-radius: 8px;
overflow: hidden;
box-shadow:
0 24px 58px -22px rgba(22, 93, 255, 0.28),
0 0 0 1px var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .browser {
box-shadow:
0 30px 60px -20px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.06);
}
.browserBar {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 14px;
background: var(--ifm-color-emphasis-100);
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .browserBar {
background: rgba(255, 255, 255, 0.03);
border-bottom-color: rgba(255, 255, 255, 0.06);
}
.browserDot {
width: 11px;
height: 11px;
border-radius: 50%;
}
.browserDotRed { background: #ff5f56; }
.browserDotYellow { background: #ffbd2e; }
.browserDotGreen { background: #27c93f; }
.browserUrl {
margin: 0 auto;
padding: 3px 14px;
background: var(--ifm-background-color);
border-radius: 8px;
font-size: 12px;
color: var(--ifm-color-content-secondary);
font-family: 'SFMono-Regular', Menlo, monospace;
border: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .browserUrl {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.06);
}
.screenshot {
display: block;
width: 100%;
height: auto;
background: var(--ifm-color-emphasis-100);
}
.caption {
padding: 0 1rem;
}
@media (max-width: 996px) {
.caption {
padding: 0;
}
}
.captionTitle {
font-size: 1.7rem;
line-height: 1.2;
letter-spacing: 0;
font-weight: 750;
margin: 0 0 1rem;
color: var(--ifm-heading-color);
}
.captionDesc {
font-size: 1.05rem;
line-height: 1.7;
color: var(--ifm-color-content-secondary);
margin: 0 0 1.25rem;
}
.captionLink {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 40px;
padding: 0 12px;
border: 1px solid rgba(22, 93, 255, 0.18);
border-radius: 8px;
font-weight: 650;
color: var(--ifm-color-primary);
text-decoration: none !important;
transition: border-color 0.2s ease, background 0.2s ease;
}
.captionLink:hover {
color: var(--ifm-color-primary-dark);
background: rgba(22, 93, 255, 0.06);
border-color: var(--ifm-color-primary);
}
@media (max-width: 996px) {
.sectionTitle {
font-size: 2rem;
}
}
@media (max-width: 640px) {
.section {
padding: 3.25rem 0 4rem;
}
.sectionTitle {
font-size: 1.75rem;
}
.tabs {
justify-content: stretch;
}
.tabBtn {
flex: 1 1 130px;
}
}
@media (prefers-reduced-motion: reduce) {
.tabBtn,
.captionLink {
transition: none;
}
}

View File

@@ -0,0 +1,264 @@
/**
* BackupX 官方文档站样式
* 灵感Ant Design / Arco Design
*/
:root {
/* Primary palette (Arco blue) */
--ifm-color-primary: #165dff;
--ifm-color-primary-dark: #0e4fe6;
--ifm-color-primary-darker: #0b4bd9;
--ifm-color-primary-darkest: #093eb3;
--ifm-color-primary-light: #2f6cff;
--ifm-color-primary-lighter: #3d75ff;
--ifm-color-primary-lightest: #668eff;
/* Surfaces */
--ifm-background-color: #ffffff;
--ifm-background-surface-color: #ffffff;
--ifm-color-emphasis-100: #f5f7fa;
--ifm-color-emphasis-200: #e5e6eb;
--ifm-color-emphasis-300: #c9cdd4;
--ifm-color-emphasis-400: #a9aeb8;
/* Typography */
--ifm-font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
--ifm-font-family-monospace: 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
--ifm-heading-font-weight: 700;
--ifm-code-font-size: 92%;
--ifm-h1-font-size: 2.25rem;
--ifm-h2-font-size: 1.75rem;
--ifm-h3-font-size: 1.35rem;
--ifm-line-height-base: 1.7;
--ifm-color-content: #1d2129;
--ifm-color-content-secondary: #4e5969;
--ifm-heading-color: #1d2129;
--ifm-global-radius: 8px;
/* Navbar */
--ifm-navbar-height: 64px;
--ifm-navbar-background-color: rgba(255, 255, 255, 0.9);
--ifm-navbar-link-color: #4e5969;
--ifm-navbar-link-hover-color: var(--ifm-color-primary);
/* Sidebar */
--ifm-menu-color: #4e5969;
--ifm-menu-color-background-active: rgba(22, 93, 255, 0.08);
--ifm-menu-color-background-hover: var(--ifm-color-emphasis-100);
/* Code */
--ifm-code-background: rgba(22, 93, 255, 0.06);
--docusaurus-highlighted-code-line-bg: rgba(22, 93, 255, 0.08);
/* Hero background helper (consumed in index.module.css) */
--bx-hero-bg: transparent;
}
[data-theme='dark'] {
--ifm-color-primary: #4080ff;
--ifm-color-primary-dark: #3371f2;
--ifm-color-primary-darker: #2c6ae6;
--ifm-color-primary-darkest: #2359c7;
--ifm-color-primary-light: #5a93ff;
--ifm-color-primary-lighter: #74a5ff;
--ifm-color-primary-lightest: #9dbfff;
--ifm-background-color: #0f1115;
--ifm-background-surface-color: #16181d;
--ifm-color-emphasis-100: #1d2129;
--ifm-color-emphasis-200: #272e3b;
--ifm-color-emphasis-300: #384252;
--ifm-color-emphasis-400: #4e5969;
--ifm-color-content: #e6e9ef;
--ifm-color-content-secondary: #9aa3b2;
--ifm-heading-color: #f0f2f5;
--ifm-navbar-background-color: rgba(15, 17, 21, 0.9);
--ifm-navbar-link-color: #c9d1db;
--ifm-menu-color: #c9d1db;
--ifm-menu-color-background-active: rgba(64, 128, 255, 0.15);
--ifm-menu-color-background-hover: rgba(255, 255, 255, 0.04);
--ifm-code-background: rgba(64, 128, 255, 0.14);
--docusaurus-highlighted-code-line-bg: rgba(64, 128, 255, 0.18);
}
/* Frosted-glass navbar */
.navbar {
backdrop-filter: saturate(180%) blur(10px);
-webkit-backdrop-filter: saturate(180%) blur(10px);
border-bottom: 1px solid var(--ifm-color-emphasis-200);
box-shadow: none;
}
[data-theme='dark'] .navbar {
border-bottom-color: rgba(255, 255, 255, 0.06);
}
.navbar__title {
font-weight: 700;
letter-spacing: 0;
}
.navbar__link {
font-weight: 500;
font-size: 14px;
}
.navbar__link,
.button,
a {
transition: color 0.2s ease, background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.button {
border-radius: 8px;
font-weight: 650;
}
:focus-visible {
outline: 2px solid var(--ifm-color-primary);
outline-offset: 2px;
}
/* Sidebar tweaks */
.menu__link {
font-size: 14px;
border-radius: 8px;
padding: 6px 10px;
line-height: 1.4;
}
.menu__link--active,
.menu__link--active:hover {
font-weight: 600;
}
.theme-doc-sidebar-container {
border-right: 1px solid var(--ifm-color-emphasis-200) !important;
}
[data-theme='dark'] .theme-doc-sidebar-container {
border-right-color: rgba(255, 255, 255, 0.06) !important;
}
/* Article: better heading rhythm */
.markdown h2 {
margin-top: 2.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .markdown h2 {
border-top-color: rgba(255, 255, 255, 0.06);
}
.markdown h3 {
margin-top: 2rem;
}
/* Tables */
.markdown table {
border-radius: 8px;
overflow: hidden;
box-shadow: 0 0 0 1px var(--ifm-color-emphasis-200);
border-collapse: separate;
border-spacing: 0;
}
.markdown table thead tr {
background: var(--ifm-color-emphasis-100);
}
.markdown table th,
.markdown table td {
border: none;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
padding: 10px 14px;
}
.markdown table tr:last-child td {
border-bottom: none;
}
/* Inline code */
code {
background: var(--ifm-code-background);
border: none;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.92em;
}
/* Admonitions: softer */
.theme-admonition {
border-radius: 8px;
border-width: 1px;
border-left-width: 4px;
}
/* Footer */
.footer {
--ifm-footer-background-color: #141720;
--ifm-footer-color: #9aa3b2;
--ifm-footer-link-color: #c9d1db;
--ifm-footer-link-hover-color: #ffffff;
--ifm-footer-title-color: #f0f2f5;
padding: 3.5rem 0 2.5rem;
}
.footer__title {
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 600;
}
.footer__link-item {
font-size: 14px;
transition: color 0.15s ease;
}
.footer__bottom {
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 2rem;
margin-top: 2.5rem;
}
.footer__copyright {
font-size: 13px;
color: #6b7280;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--ifm-color-emphasis-300);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--ifm-color-emphasis-400);
}
[data-theme='dark'] ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,169 @@
import type {ReactNode} from 'react';
import clsx from 'clsx';
import Link from '@docusaurus/Link';
import Translate, {translate} from '@docusaurus/Translate';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import Layout from '@theme/Layout';
import Heading from '@theme/Heading';
import HomepageFeatures from '@site/src/components/HomepageFeatures';
import HomepageShowcase from '@site/src/components/HomepageShowcase';
import HomepageCommunity from '@site/src/components/HomepageCommunity';
import styles from './index.module.css';
function HomepageHeader() {
return (
<header className={styles.hero}>
<div className={clsx('container', styles.heroInner)}>
<div className={styles.heroContent}>
<div className={styles.badge}>
<span className={styles.badgeDot} />
<Translate id="home.badge">Open-source backup control plane · v2.2.1</Translate>
</div>
<Heading as="h1" className={styles.heroTitle}>
<Translate id="home.title.part1">Backup orchestration</Translate>
<span className={styles.heroTitleAccent}>
<Translate id="home.title.part2">for self-hosted servers.</Translate>
</span>
</Heading>
<p className={styles.heroSubtitle}>
<Translate id="home.tagline">
Run file, database, SAP HANA and remote-node backups from one clean console. Keep the control plane yours, keep the storage flexible.
</Translate>
</p>
<div className={styles.actions}>
<Link className={clsx('button button--primary button--lg', styles.primaryBtn)} to="/docs/getting-started/quick-start">
<Translate id="home.getStarted">Get Started</Translate>
<span className={styles.btnArrow} aria-hidden="true">-&gt;</span>
</Link>
<Link className={clsx('button button--lg', styles.secondaryBtn)} to="https://github.com/Awuqing/BackupX">
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style={{marginRight: 6}}>
<path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 005.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
GitHub
</Link>
</div>
<div className={styles.metrics}>
<div className={styles.metric}>
<div className={styles.metricValue}>70+</div>
<div className={styles.metricLabel}>
<Translate id="home.metric.backends">Storage backends</Translate>
</div>
</div>
<div className={styles.metricDivider} />
<div className={styles.metric}>
<div className={styles.metricValue}>Agent</div>
<div className={styles.metricLabel}>
<Translate id="home.metric.backupTypes">Remote execution</Translate>
</div>
</div>
<div className={styles.metricDivider} />
<div className={styles.metric}>
<div className={styles.metricValue}>Apache 2.0</div>
<div className={styles.metricLabel}>
<Translate id="home.metric.license">License</Translate>
</div>
</div>
</div>
</div>
<div className={styles.heroVisual}>
<div className={styles.consolePanel}>
<div className={styles.consoleHeader}>
<div>
<span className={styles.consoleEyebrow}>
<Translate id="home.visual.eyebrow">BackupX Console</Translate>
</span>
<strong>
<Translate id="home.visual.title">Operations overview</Translate>
</strong>
</div>
<span className={styles.consoleStatus}>
<Translate id="home.visual.status">Healthy</Translate>
</span>
</div>
<div className={styles.consoleGrid}>
<div>
<span className={styles.consoleLabel}>
<Translate id="home.visual.success">Success rate</Translate>
</span>
<strong>99.4%</strong>
</div>
<div>
<span className={styles.consoleLabel}>
<Translate id="home.visual.nodes">Active nodes</Translate>
</span>
<strong>12</strong>
</div>
<div>
<span className={styles.consoleLabel}>
<Translate id="home.visual.targets">Storage targets</Translate>
</span>
<strong>8</strong>
</div>
</div>
<div className={styles.timeline}>
<div className={styles.timelineRow}>
<span className={styles.timelineDotOk} />
<div>
<strong>
<Translate id="home.visual.row1.title">PostgreSQL nightly</Translate>
</strong>
<span>
<Translate id="home.visual.row1.desc">Encrypted archive uploaded to S3</Translate>
</span>
</div>
<em>02:10</em>
</div>
<div className={styles.timelineRow}>
<span className={styles.timelineDotInfo} />
<div>
<strong>
<Translate id="home.visual.row2.title">SAP HANA snapshot</Translate>
</strong>
<span>
<Translate id="home.visual.row2.desc">Running on agent-shanghai-02</Translate>
</span>
</div>
<em>68%</em>
</div>
<div className={styles.timelineRow}>
<span className={styles.timelineDotWarn} />
<div>
<strong>
<Translate id="home.visual.row3.title">Retention cleanup</Translate>
</strong>
<span>
<Translate id="home.visual.row3.desc">Next run in 4 hours</Translate>
</span>
</div>
<em>queued</em>
</div>
</div>
</div>
<div className={styles.commandCard}>
<div className={styles.commandTitle}>
<Translate id="home.command.title">Start with Docker</Translate>
</div>
<code>docker run -d -p 8340:8340 awuqing/backupx:v2.2.1</code>
</div>
</div>
</div>
</header>
);
}
export default function Home(): ReactNode {
const {siteConfig} = useDocusaurusContext();
return (
<Layout
title={translate({id: 'home.pageTitle', message: 'Backup orchestration for self-hosted servers'})}
description={siteConfig.tagline}>
<HomepageHeader />
<main>
<HomepageFeatures />
<HomepageShowcase />
<HomepageCommunity />
</main>
</Layout>
);
}

View File

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

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<defs>
<linearGradient id="bxg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#2f6cff"/>
<stop offset="100%" stop-color="#0b3eb3"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="40" height="40" rx="8" fill="url(#bxg)"/>
<path d="M16 14h10a5 5 0 0 1 0 10H16V14z" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 24h12a5 5 0 0 1 0 10H16V24z" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 639 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

12
docs-site/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
// This file is not used by "docusaurus start/build" commands.
// It is here to improve your IDE experience (type-checking, autocompletion...),
// and can also run the package.json "typecheck" script manually.
{
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"baseUrl": ".",
"ignoreDeprecations": "6.0",
"strict": true
},
"exclude": [".docusaurus", "build"]
}

View File

@@ -1,14 +1,15 @@
APP_NAME=backupx
BUILD_DIR=./bin
VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
.PHONY: build run test
build:
mkdir -p $(BUILD_DIR)
go build -o $(BUILD_DIR)/$(APP_NAME) ./cmd/backupx
go build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o $(BUILD_DIR)/$(APP_NAME) ./cmd/backupx
run:
go run ./cmd/backupx
go run -ldflags "-X main.version=$(VERSION)" ./cmd/backupx
test:
go test ./...

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,14 +6,18 @@ 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
gorm.io/gorm v1.25.12
)
@@ -21,50 +25,93 @@ require (
cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/Azure/go-ntlmssp v0.0.2-0.20251110135918-10b7b7e7cd26 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0 // indirect
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.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.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
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect
github.com/ProtonMail/go-srp v0.0.7 // indirect
github.com/ProtonMail/gopenpgp/v2 v2.9.0 // indirect
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0 // indirect
github.com/abbot/go-http-auth v0.4.0 // 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/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.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
github.com/bradenaw/juniper v0.15.3 // indirect
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
github.com/buengese/sgzip v0.1.1 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/calebcase/tmpfile v1.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudinary/cloudinary-go/v2 v2.13.0 // indirect
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc // indirect
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/colinmarc/hdfs/v2 v2.4.0 // indirect
github.com/coreos/go-semver v0.3.1 // indirect
github.com/coreos/go-systemd/v22 v22.6.0 // indirect
github.com/creasty/defaults v1.8.0 // indirect
github.com/cronokirby/saferith v0.33.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/diskfs/go-diskfs v1.7.0 // indirect
github.com/dromara/dongle v1.0.1 // indirect
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/flynn/noise v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/geoffgarside/ber v1.2.0 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
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.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
@@ -73,25 +120,44 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/go-resty/resty/v2 v2.16.5 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/pprof v0.0.0-20240509144519-723abb6459b7 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/gorilla/schema v1.4.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/internxt/rclone-adapter v0.0.0-20260220172730-613f4cc8b8fd // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/gofork v1.7.6 // indirect
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3 // indirect
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/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
github.com/kr/fs v0.1.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lanrat/extsort v1.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lpar/date v1.0.0 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
@@ -103,57 +169,93 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncw/swift/v2 v2.0.5 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/onsi/gomega v1.34.1 // indirect
github.com/onsi/ginkgo/v2 v2.19.0 // indirect
github.com/oracle/oci-go-sdk/v65 v65.104.0 // indirect
github.com/panjf2000/ants/v2 v2.11.3 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 // indirect
github.com/peterh/liner v1.2.2 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkg/sftp v1.13.10 // indirect
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/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
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 // indirect
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11 // indirect
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 // indirect
github.com/relvacode/iso8601 v1.7.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rfjakob/eme v1.1.2 // indirect
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/shirou/gopsutil/v4 v4.25.10 // indirect
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/sony/gobreaker v1.0.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spacemonkeygo/monkit/v3 v3.0.25-0.20251022131615-eb24eb109368 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/tyler-smith/go-bip39 v1.1.0 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yunify/qingstor-sdk-go/v3 v3.2.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zeebo/assert v1.3.1 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
github.com/zeebo/errs v1.4.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
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/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.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
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/validator.v2 v2.0.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
moul.io/http2curl/v2 v2.3.0 // indirect
storj.io/common v0.0.0-20251107171817-6221ae45072c // indirect
storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55 // indirect
storj.io/eventkit v0.0.0-20250410172343-61f26d3de156 // indirect
storj.io/infectious v0.0.2 // indirect
storj.io/picobuf v0.0.4 // indirect
storj.io/uplink v1.13.1 // indirect
)

View File

@@ -1,39 +1,85 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0 h1:KpMC6LFL7mqpExyMC9jVOYRiVhLmamjeZfRsUpB7l4s=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0/go.mod h1:J7MUC/wtRpfGVbQ5sIItY5/FuVWmvzlY21WAOfQnq/I=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 h1:ZJJNFaQ86GVKQ9ehwqyAFE6pIfyicpuJ8IkVaPBc6/4=
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=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
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/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
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=
github.com/IBM/go-sdk-core/v5 v5.18.5/go.mod h1:KonTFRR+8ZSgw5cxBSYo6E4WZoY1+7n1kfHM82VcjFU=
github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g=
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd h1:nzE1YQBdx1bq9IlZinHa+HVffy+NmVRoKr+wHN8fpLE=
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd/go.mod h1:C8yoIfvESpM3GD07OCHU7fqI7lhwyZ2Td1rbNbTAhnc=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/ProtonMail/bcrypt v0.0.0-20210511135022-227b4adcab57/go.mod h1:HecWFHognK8GfRDGnFQbW/LiV7A3MX3gZVs45vk5h8I=
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs69zUkSzubzjBbL+cmOXgnmt9Fyd9ug=
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9p83twszAU8LBeJKFAAMX14tISa0yk4Oo5TOqo=
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e h1:lCsqUUACrcMC83lg5rTo9Y0PnPItE61JSfvMyIcANwk=
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e/go.mod h1:Og5/Dz1MiGpCJn51XujZwxiLG7WzvvjE5PRpZBQmAHo=
github.com/ProtonMail/go-crypto v0.0.0-20230321155629-9a39f2531310/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k=
@@ -56,48 +102,51 @@ 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=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bradenaw/juniper v0.15.3 h1:RHIAMEDTpvmzV1wg1jMAHGOoI2oJUSPx3lxRldXnFGo=
@@ -108,6 +157,7 @@ github.com/buengese/sgzip v0.1.1 h1:ry+T8l1mlmiWEsDrH/YHZnCVWD2S3im1KLsyO+8ZmTU=
github.com/buengese/sgzip v0.1.1/go.mod h1:i5ZiXGF3fhV7gL1xaRRL1nDnmpNj0X061FQzOS8VMas=
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
@@ -115,14 +165,20 @@ github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCN
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/calebcase/tmpfile v1.0.3 h1:BZrOWZ79gJqQ3XbAQlihYZf/YCV0H4KPIdM5K5oMpJo=
github.com/calebcase/tmpfile v1.0.3/go.mod h1:UAUc01aHeC+pudPagY/lWvt2qS9ZO5Zzof6/tIUzqeI=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9 h1:z0uK8UQqjMVYzvk4tiiu3obv2B44+XBsvgEJREQfnO8=
github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9/go.mod h1:Jl2neWsQaDanWORdqZ4emBl50J4/aRBBS4FyyG9/PFo=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudinary/cloudinary-go/v2 v2.13.0 h1:ugiQwb7DwpWQnete2AZkTh94MonZKmxD7hDGy1qTzDs=
@@ -134,6 +190,7 @@ github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc/go.mod h1:uvR42Hb/t
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/colinmarc/hdfs/v2 v2.4.0 h1:v6R8oBx/Wu9fHpdPoJJjpGSUxo8NhHIwrwsfhFvU9W0=
github.com/colinmarc/hdfs/v2 v2.4.0/go.mod h1:0NAO+/3knbMx6+5pCv+Hcbaz4xn/Zzbn9+WIib2rKVI=
github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
@@ -150,18 +207,31 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/diskfs/go-diskfs v1.7.0 h1:vonWmt5CMowXwUc79jWyGrf2DIMeoOjkLlMnQYGVOs8=
github.com/diskfs/go-diskfs v1.7.0/go.mod h1:LhQyXqOugWFRahYUSw47NyZJPezFzB9UELwhpszLP/k=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dromara/dongle v1.0.1 h1:si/7UP/EXxnFVZok1cNos70GiMGxInAYMilHQFP5dJs=
github.com/dromara/dongle v1.0.1/go.mod h1:ebFhTaDgxaDIKppycENTWlBsxz8mWCPWOLnsEgDpMv4=
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5 h1:FT+t0UEDykcor4y3dMVKXIiWJETBpRgERYTGlmMd7HU=
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5/go.mod h1:rSS3kM9XMzSQ6pw91Qgd6yB5jdt70N4OdtrAf74As5M=
github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI=
github.com/dsnet/try v0.0.3/go.mod h1:WBM8tRpUmnXXhY1U6/S8dt6UWdHTQ7y8A5YSkRCkq40=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY=
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff h1:4N8wnS3f1hNHSmFD5zgFkWCyA4L1kCDkImPAtK7D6tg=
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
github.com/emmansun/gmsm v0.15.5/go.mod h1:2m4jygryohSWkaSduFErgCwQKab5BNjURoFrn2DNwyU=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -188,8 +258,11 @@ 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=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -214,6 +287,8 @@ github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
@@ -226,25 +301,75 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20240509144519-723abb6459b7 h1:velgFPYr1X9TDwLIfkV7fWqsFlf7TeP11M/7kPd/dVI=
github.com/google/pprof v0.0.0-20240509144519-723abb6459b7/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -256,8 +381,14 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/internxt/rclone-adapter v0.0.0-20260220172730-613f4cc8b8fd h1:dSIuz2mpJAPQfhHYtG57D0qwSkgC/vQ69gHfeyQ4kxA=
github.com/internxt/rclone-adapter v0.0.0-20260220172730-613f4cc8b8fd/go.mod h1:vdPya4AIcDjvng4ViaAzqjegJf0VHYpYHQguFx5xBp0=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
@@ -280,10 +411,16 @@ github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3 h1:ZxO6Qr2GOXPdcW80
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3/go.mod h1:dvLUr/8Fs9a2OBrEnCC5duphbkz/k/mSy5OkXg3PAgI=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/jtolio/noiseconn v0.0.0-20231127013910-f6d9ecbf1de7 h1:JcltaO1HXM5S2KYOYcKgAV7slU0xPy1OcvrVgn98sRQ=
github.com/jtolio/noiseconn v0.0.0-20231127013910-f6d9ecbf1de7/go.mod h1:MEkhEPFwP3yudWO0lj6vfYpLIB+3eIcuIW+e0AZzUQk=
github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 h1:G+9t9cEtnC9jFiTxyptEKuNIAbiN5ZCQzX2a74lj3xg=
github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004/go.mod h1:KmHnJWQrgEvbuy0vcvj00gtMqbvNn1L+3YUZLK/B92c=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@@ -296,8 +433,12 @@ github.com/koofr/go-koofrclient v0.0.0-20221207135200-cbd7fc9ad6a6 h1:FHVoZMOVRA
github.com/koofr/go-koofrclient v0.0.0-20221207135200-cbd7fc9ad6a6/go.mod h1:MRAz4Gsxd+OzrZ0owwrUHc0zLESL+1Y5syqK/sJxK2A=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
@@ -321,6 +462,8 @@ github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byF
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -338,6 +481,8 @@ github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA=
github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/oracle/oci-go-sdk/v65 v65.104.0 h1:l9awEvzWvxmYhy/97A0hZ87pa7BncYXmcO/S8+rvgK0=
@@ -356,6 +501,7 @@ github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20200914180035-5b29258ca4f7/go.mod h1:zO8QMzTeZd5cpnIkz/Gn6iK0jDfGicM1nynOkkPIl28=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
@@ -371,6 +517,7 @@ github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8=
@@ -379,12 +526,14 @@ github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 h1:Y258uzXU/potCYnQd1r6wlAnoMB68BiCkCcCnKx1SH8=
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8/go.mod h1:bSJjRokAHHOhA+XFxplld8w2R/dXLH7Z3BZ532vhFwU=
github.com/quic-go/quic-go v0.53.0 h1:QHX46sISpG2S03dPeZBgVIZp8dGagIaiu2FiVYvpCZI=
github.com/quic-go/quic-go v0.53.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11 h1:4MI2alxM/Ye2gIRBlYf28JGWTipZ4Zz7yAziPKrttjs=
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=
@@ -394,6 +543,7 @@ github.com/rfjakob/eme v1.1.2 h1:SxziR8msSOElPayZNFfQw4Tjx/Sbaeeh3eRvrHVMUs4=
github.com/rfjakob/eme v1.1.2/go.mod h1:cVvpasglm/G3ngEfcfT/Wt0GwhkuO32pf/poW6Nyk1k=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
@@ -402,12 +552,16 @@ github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsF
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shirou/gopsutil/v4 v4.25.10 h1:at8lk/5T1OgtuCp+AwrDofFRjnvosn0nkN2OLQ6g8tA=
github.com/shirou/gopsutil/v4 v4.25.10/go.mod h1:+kSwyC8DRUD9XXEHCAFjK+0nuArFJM0lva+StQAcskM=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0=
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/snabb/httpreaderat v1.0.1 h1:whlb+vuZmyjqVop8x1EKOg05l2NE4z9lsMMXjmSUCnY=
github.com/snabb/httpreaderat v1.0.1/go.mod h1:lpbGrKDWF37yvRbtRvQsbesS6Ty5c83t8ztannPoMsA=
github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ=
github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
@@ -418,6 +572,8 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
@@ -425,8 +581,13 @@ github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqj
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.3.1-0.20190311161405-34c6fa2dc709/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@@ -439,6 +600,7 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491 h1:rrGZv6xYk37hx0tW2sYfgbO0PqStbHqz6Bq6oc9Hurg=
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491/go.mod h1:ykucQyiE9Q2qx1wLlEtZkkNn1IURib/2O+Mvd25i1Fo=
github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
@@ -459,6 +621,11 @@ github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yunify/qingstor-sdk-go/v3 v3.2.0 h1:9sB2WZMgjwSUNZhrgvaNGazVltoFUUfuS9f0uCWtTr8=
github.com/yunify/qingstor-sdk-go/v3 v3.2.0/go.mod h1:KciFNuMu6F4WLk9nGwwK69sCGKLCdd9f97ac/wfumS4=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
@@ -477,22 +644,29 @@ go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
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=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
@@ -501,59 +675,369 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4=
golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
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/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
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/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/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
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.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=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
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-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.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=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
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.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=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
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.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=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
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.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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.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=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
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/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.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=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
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.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=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
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.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=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
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.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20201211185031-d93e913c1a58/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
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.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=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/api v0.255.0 h1:OaF+IbRwOottVCYV2wZan7KUq7UeNUQn1BcPc4K7lE4=
google.golang.org/api v0.255.0/go.mod h1:d1/EtvCLdtiWEV4rAEHDHGh2bCnqsWhw+M8y2ECN4a8=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/validator.v2 v2.0.1 h1:xF0KWyGWXm/LM2G1TrEjqOu4pa6coO9AlWSf3msVfDY=
gopkg.in/validator.v2 v2.0.1/go.mod h1:lIUZBlB3Im4s/eYp39Ry/wkR02yOPhZ9IwIRBjuPuG8=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -561,6 +1045,13 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
@@ -572,6 +1063,9 @@ modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk
moul.io/http2curl/v2 v2.3.0 h1:9r3JfDzWPcbIklMOs2TnIFzDYvfAZvjeavG6EzP7jYs=
moul.io/http2curl/v2 v2.3.0/go.mod h1:RW4hyBjTWSYDOxapodpNEtX0g5Eb16sxklBqmd2RHcE=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
storj.io/common v0.0.0-20251107171817-6221ae45072c h1:UDXSrdeLJe3QFouavSW10fYdpclK0YNu3KvQHzqq2+k=
storj.io/common v0.0.0-20251107171817-6221ae45072c/go.mod h1:XNX7uykja6aco92y2y8RuqaXIDRPpt1YA2OQDKlKEUk=
storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55 h1:8OE12DvUnB9lfZcHe7IDGsuhjrY9GBAr964PVHmhsro=

View File

@@ -0,0 +1,293 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"os"
"runtime"
"strings"
"sync"
"time"
"backupx/server/internal/backup"
)
// Agent 是 Agent 进程的主控制器。
type Agent struct {
cfg *Config
client *MasterClient
executor *Executor
version string
mu sync.Mutex
started bool
}
// New 构造 Agent。
func New(cfg *Config, version string) (*Agent, error) {
if err := cfg.ResolveToken(); err != nil {
return nil, err
}
if err := cfg.Validate(); err != nil {
return nil, err
}
client := NewMasterClient(cfg.Master, cfg.Token, cfg.InsecureSkipTLSVerify)
if err := client.ConfigureTransport(cfg.ProxyURL, cfg.CACertFile); err != nil {
return nil, fmt.Errorf("configure master connection: %w", err)
}
executor := NewExecutor(client, cfg.TempDir)
return &Agent{
cfg: cfg,
client: client,
executor: executor,
version: version,
}, nil
}
// Run 启动 Agent 主循环,阻塞直到 ctx 被取消。
func (a *Agent) Run(ctx context.Context) error {
a.mu.Lock()
if a.started {
a.mu.Unlock()
return fmt.Errorf("agent already started")
}
a.started = true
a.mu.Unlock()
hbInterval := parseDuration(a.cfg.HeartbeatInterval, 15*time.Second)
pollInterval := parseDuration(a.cfg.PollInterval, 5*time.Second)
// 首次握手:通过一次心跳确认 token 有效
if err := a.heartbeatOnce(ctx); err != nil {
return fmt.Errorf("initial heartbeat failed: %w", err)
}
log.Printf("[agent] connected to master %s", a.cfg.Master)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
a.heartbeatLoop(ctx, hbInterval)
}()
go func() {
defer wg.Done()
a.pollLoop(ctx, pollInterval)
}()
wg.Wait()
return ctx.Err()
}
// heartbeatLoop 定期发送心跳。
func (a *Agent) heartbeatLoop(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := a.heartbeatOnce(ctx); err != nil {
log.Printf("[agent] heartbeat failed: %v", err)
}
}
}
}
func (a *Agent) heartbeatOnce(ctx context.Context) error {
hostname, _ := os.Hostname()
req := HeartbeatRequest{
Hostname: hostname,
IPAddress: detectLocalIP(),
AgentVersion: a.version,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
}
_, err := a.client.Heartbeat(ctx, req)
return err
}
// pollLoop 定期拉取并处理待执行命令。
func (a *Agent) pollLoop(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
a.pollAndHandleOnce(ctx)
}
}
}
func (a *Agent) pollAndHandleOnce(ctx context.Context) {
cmd, err := a.client.PollCommand(ctx)
if err != nil {
log.Printf("[agent] poll command failed: %v", err)
return
}
if cmd == nil {
return
}
log.Printf("[agent] received command #%d type=%s", cmd.ID, cmd.Type)
switch cmd.Type {
case "run_task":
a.handleRunTask(ctx, cmd)
case "list_dir":
a.handleListDir(ctx, cmd)
case "restore_record":
a.handleRestoreRecord(ctx, cmd)
case "discover_db":
a.handleDiscoverDB(ctx, cmd)
case "delete_storage_object":
a.handleDeleteStorageObject(ctx, cmd)
default:
msg := fmt.Sprintf("unknown command type: %s", cmd.Type)
log.Printf("[agent] %s", msg)
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, msg, nil)
}
}
// handleRunTask 处理 run_task 命令
func (a *Agent) handleRunTask(ctx context.Context, cmd *CommandPayload) {
var payload struct {
TaskID uint `json:"taskId"`
RecordID uint `json:"recordId"`
}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
return
}
if err := a.executor.ExecuteRunTask(ctx, payload.TaskID, payload.RecordID); err != nil {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
return
}
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{
"taskId": payload.TaskID,
"recordId": payload.RecordID,
})
}
// handleRestoreRecord 处理 restore_record 命令
func (a *Agent) handleRestoreRecord(ctx context.Context, cmd *CommandPayload) {
var payload struct {
RestoreRecordID uint `json:"restoreRecordId"`
}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
return
}
if payload.RestoreRecordID == 0 {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "restoreRecordId is required", nil)
return
}
if err := a.executor.ExecuteRestore(ctx, payload.RestoreRecordID); err != nil {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
return
}
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{
"restoreRecordId": payload.RestoreRecordID,
})
}
// handleDeleteStorageObject 处理 delete_storage_object 命令:在 Agent 侧删除指定存储对象。
// 用于跨节点 local_disk 场景下的远程备份文件清理。
func (a *Agent) handleDeleteStorageObject(ctx context.Context, cmd *CommandPayload) {
var payload struct {
TargetType string `json:"targetType"`
TargetConfig map[string]any `json:"targetConfig"`
StoragePath string `json:"storagePath"`
}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
return
}
if strings.TrimSpace(payload.StoragePath) == "" {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "storagePath is required", nil)
return
}
provider, err := a.executor.storageRegistry.Create(ctx, payload.TargetType, payload.TargetConfig)
if err != nil {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "create provider: "+err.Error(), nil)
return
}
if err := provider.Delete(ctx, payload.StoragePath); err != nil {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "delete object: "+err.Error(), nil)
return
}
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{"deleted": true})
}
// handleDiscoverDB 处理 discover_db 命令:在 Agent 本机执行 mysql/psql 列出数据库。
func (a *Agent) handleDiscoverDB(ctx context.Context, cmd *CommandPayload) {
var payload struct {
Type string `json:"type"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
return
}
databases, err := backup.DiscoverDatabases(ctx, backup.NewOSCommandExecutor(), backup.DiscoverRequest{
Type: payload.Type,
Host: payload.Host,
Port: payload.Port,
User: payload.User,
Password: payload.Password,
})
if err != nil {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
return
}
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{"databases": databases})
}
// handleListDir 处理 list_dir 命令(阶段四实现)
func (a *Agent) handleListDir(ctx context.Context, cmd *CommandPayload) {
var payload struct {
Path string `json:"path"`
}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
return
}
entries, err := listLocalDir(payload.Path)
if err != nil {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
return
}
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{"entries": entries})
}
// 辅助函数
func parseDuration(s string, fallback time.Duration) time.Duration {
if strings.TrimSpace(s) == "" {
return fallback
}
if d, err := time.ParseDuration(s); err == nil {
return d
}
return fallback
}
func detectLocalIP() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return ""
}
for _, addr := range addrs {
if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
if ipNet.IP.To4() != nil {
return ipNet.IP.String()
}
}
}
return ""
}

View File

@@ -0,0 +1,368 @@
package agent
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// MasterClient 是 Agent 调用 Master HTTP API 的封装。
type MasterClient struct {
baseURL string
token string
httpClient *http.Client
}
// NewMasterClient 构造 Master 客户端。
func NewMasterClient(baseURL, token string, insecureTLS bool) *MasterClient {
transport := http.DefaultTransport.(*http.Transport).Clone()
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12}
if transport.TLSClientConfig != nil {
tlsConfig = transport.TLSClientConfig.Clone()
tlsConfig.MinVersion = tls.VersionTLS12
}
// 仅用于用户显式开启的测试模式。生产环境应配置受信 CA。
tlsConfig.InsecureSkipVerify = insecureTLS // #nosec G402
transport.TLSClientConfig = tlsConfig
return &MasterClient{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
httpClient: &http.Client{
Timeout: 120 * time.Second,
Transport: transport,
// Agent Token 是自定义认证头。禁止自动重定向,避免代理或错误
// 配置把它转发到另一个主机Master URL 必须直接指向 API。
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
}
// ConfigureTransport 应用显式代理和私有 CA。默认 Transport 已保留
// ProxyFromEnvironment因此 ProxyURL 留空时 HTTP_PROXY/HTTPS_PROXY/NO_PROXY 生效。
func (c *MasterClient) ConfigureTransport(proxyURL, caCertFile string) error {
transport, ok := c.httpClient.Transport.(*http.Transport)
if !ok {
return errors.New("agent http transport has unexpected type")
}
if strings.TrimSpace(proxyURL) != "" {
parsedProxy, err := url.Parse(strings.TrimSpace(proxyURL))
if err != nil {
return fmt.Errorf("parse proxy URL: %w", err)
}
transport.Proxy = http.ProxyURL(parsedProxy)
}
if strings.TrimSpace(caCertFile) == "" {
return nil
}
pemData, err := os.ReadFile(strings.TrimSpace(caCertFile))
if err != nil {
return fmt.Errorf("read CA certificate: %w", err)
}
roots, err := x509.SystemCertPool()
if err != nil || roots == nil {
roots = x509.NewCertPool()
}
if !roots.AppendCertsFromPEM(pemData) {
return errors.New("CA certificate file does not contain a valid PEM certificate")
}
transport.TLSClientConfig.RootCAs = roots
return nil
}
// HeartbeatRequest Agent 上报心跳的请求
type HeartbeatRequest struct {
Hostname string `json:"hostname,omitempty"`
IPAddress string `json:"ipAddress,omitempty"`
AgentVersion string `json:"agentVersion,omitempty"`
OS string `json:"os,omitempty"`
Arch string `json:"arch,omitempty"`
}
// HeartbeatResponse Master 返回的心跳响应
type HeartbeatResponse struct {
Status string `json:"status"`
NodeID uint `json:"nodeId"`
Name string `json:"name"`
}
// Heartbeat 上报心跳并获取节点元信息
func (c *MasterClient) Heartbeat(ctx context.Context, req HeartbeatRequest) (*HeartbeatResponse, error) {
var resp HeartbeatResponse
if err := c.do(ctx, http.MethodPost, "/api/agent/heartbeat", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// CommandPayload 与 service.AgentCommandPayload 对齐
type CommandPayload struct {
ID uint `json:"id"`
Type string `json:"type"`
Payload json.RawMessage `json:"payload,omitempty"`
}
// PollCommandResponse 轮询响应:无命令时 Command 为 nil
type PollCommandResponse struct {
Command *CommandPayload `json:"command"`
}
// PollCommand 拉取下一条待执行命令
func (c *MasterClient) PollCommand(ctx context.Context) (*CommandPayload, error) {
var resp PollCommandResponse
if err := c.do(ctx, http.MethodPost, "/api/agent/commands/poll", nil, &resp); err != nil {
return nil, err
}
return resp.Command, nil
}
// SubmitCommandResult 上报命令执行结果
func (c *MasterClient) SubmitCommandResult(ctx context.Context, cmdID uint, success bool, errorMsg string, result any) error {
var resultJSON json.RawMessage
if result != nil {
data, err := json.Marshal(result)
if err != nil {
return fmt.Errorf("marshal result: %w", err)
}
resultJSON = data
}
payload := map[string]any{
"success": success,
"errorMessage": errorMsg,
}
if resultJSON != nil {
payload["result"] = resultJSON
}
path := fmt.Sprintf("/api/agent/commands/%d/result", cmdID)
return c.do(ctx, http.MethodPost, path, payload, nil)
}
// TaskSpec 与 service.AgentTaskSpec 对齐
type TaskSpec struct {
TaskID uint `json:"taskId"`
Name string `json:"name"`
Type string `json:"type"`
SourcePath string `json:"sourcePath"`
SourcePaths string `json:"sourcePaths"`
ExcludePatterns string `json:"excludePatterns"`
DBHost string `json:"dbHost"`
DBPort int `json:"dbPort"`
DBUser string `json:"dbUser"`
DBPassword string `json:"dbPassword"`
DBName string `json:"dbName"`
DBPath string `json:"dbPath"`
ExtraConfig string `json:"extraConfig"`
Compression string `json:"compression"`
Encrypt bool `json:"encrypt"`
StorageTargets []StorageTargetConfig `json:"storageTargets"`
}
// StorageTargetConfig 与 service.AgentStorageTargetConfig 对齐
type StorageTargetConfig struct {
ID uint `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Config json.RawMessage `json:"config"`
TransferMode string `json:"transferMode"`
}
// GetTaskSpec 拉取任务规格
func (c *MasterClient) GetTaskSpec(ctx context.Context, taskID uint) (*TaskSpec, error) {
var spec TaskSpec
path := fmt.Sprintf("/api/agent/tasks/%d", taskID)
if err := c.do(ctx, http.MethodGet, path, nil, &spec); err != nil {
return nil, err
}
return &spec, nil
}
// RecordUpdate 与 service.AgentRecordUpdate 对齐
type RecordUpdate struct {
Status string `json:"status,omitempty"`
FileName string `json:"fileName,omitempty"`
FileSize int64 `json:"fileSize,omitempty"`
Checksum string `json:"checksum,omitempty"`
StoragePath string `json:"storagePath,omitempty"`
StorageTargetID uint `json:"storageTargetId,omitempty"`
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 上报备份记录的状态/日志
func (c *MasterClient) UpdateRecord(ctx context.Context, recordID uint, update RecordUpdate) error {
path := fmt.Sprintf("/api/agent/records/%d", recordID)
return c.do(ctx, http.MethodPost, path, update, nil)
}
// 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"`
BackupRecordID uint `json:"backupRecordId"`
TaskID uint `json:"taskId"`
TaskName string `json:"taskName"`
Type string `json:"type"`
SourcePath string `json:"sourcePath,omitempty"`
SourcePaths []string `json:"sourcePaths,omitempty"`
DBHost string `json:"dbHost,omitempty"`
DBPort int `json:"dbPort,omitempty"`
DBUser string `json:"dbUser,omitempty"`
DBPassword string `json:"dbPassword,omitempty"`
DBName string `json:"dbName,omitempty"`
DBPath string `json:"dbPath,omitempty"`
ExtraConfig string `json:"extraConfig,omitempty"`
Compression string `json:"compression"`
Encrypt bool `json:"encrypt"`
Storage StorageTargetConfig `json:"storage"`
StoragePath string `json:"storagePath"`
FileName string `json:"fileName"`
Checksum string `json:"checksum,omitempty"`
}
// RestoreUpdate 与 service.AgentRestoreUpdate 对齐
type RestoreUpdate struct {
Status string `json:"status,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
LogAppend string `json:"logAppend,omitempty"`
}
// GetRestoreSpec 拉取恢复规格
func (c *MasterClient) GetRestoreSpec(ctx context.Context, restoreRecordID uint) (*RestoreSpec, error) {
var spec RestoreSpec
path := fmt.Sprintf("/api/agent/restores/%d/spec", restoreRecordID)
if err := c.do(ctx, http.MethodGet, path, nil, &spec); err != nil {
return nil, err
}
return &spec, nil
}
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)
return c.do(ctx, http.MethodPost, path, update, nil)
}
// do 是通用 HTTP 调用。所有 Agent API 都统一走 JSON + X-Agent-Token。
func (c *MasterClient) do(ctx context.Context, method, path string, body any, out any) error {
var reqBody io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
reqBody = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody)
if err != nil {
return err
}
req.Header.Set("X-Agent-Token", c.token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("%s %s: %w", method, path, err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%s %s: http %d: %s", method, path, resp.StatusCode, string(data))
}
if out == nil {
return nil
}
// BackupX API 统一封装成 {code, data, message} 形式,需要解出 data 字段
var envelope struct {
Code string `json:"code"`
Data json.RawMessage `json:"data"`
Message string `json:"message"`
}
if err := json.Unmarshal(data, &envelope); err == nil && envelope.Data != nil {
if err := json.Unmarshal(envelope.Data, out); err != nil {
return fmt.Errorf("decode data: %w", err)
}
return nil
}
// 兼容直接返回对象的情况
return json.Unmarshal(data, out)
}

View File

@@ -0,0 +1,93 @@
package agent
import (
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
func TestMasterClientKeepsEnvironmentProxySupport(t *testing.T) {
client := NewMasterClient("https://master.example.com", "token", false)
transport := client.httpClient.Transport.(*http.Transport)
if transport.Proxy == nil {
t.Fatal("default transport proxy function must be preserved")
}
}
func TestMasterClientConfiguresExplicitProxy(t *testing.T) {
client := NewMasterClient("https://master.example.com", "token", false)
if err := client.ConfigureTransport("socks5h://127.0.0.1:1080", ""); err != nil {
t.Fatal(err)
}
transport := client.httpClient.Transport.(*http.Transport)
requestURL, _ := url.Parse("https://master.example.com")
proxyURL, err := transport.Proxy(&http.Request{URL: requestURL})
if err != nil {
t.Fatal(err)
}
if proxyURL == nil || proxyURL.String() != "socks5h://127.0.0.1:1080" {
t.Fatalf("proxy URL = %v", proxyURL)
}
}
func TestMasterClientRejectsInvalidCACertificate(t *testing.T) {
path := filepath.Join(t.TempDir(), "invalid.pem")
if err := os.WriteFile(path, []byte("not a certificate"), 0o600); err != nil {
t.Fatal(err)
}
client := NewMasterClient("https://master.example.com", "token", false)
if err := client.ConfigureTransport("", path); err == nil {
t.Fatal("expected invalid CA certificate error")
}
}
func TestMasterClientDoesNotForwardTokenThroughRedirects(t *testing.T) {
receivedToken := ""
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedToken = r.Header.Get("X-Agent-Token")
w.WriteHeader(http.StatusOK)
}))
defer target.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
}))
defer redirector.Close()
client := NewMasterClient(redirector.URL, "secret-agent-token", false)
if _, err := client.Heartbeat(context.Background(), HeartbeatRequest{}); err == nil {
t.Fatal("redirect response should not be accepted as a Master API response")
}
if receivedToken != "" {
t.Fatalf("Agent token leaked through redirect: %q", receivedToken)
}
}
func TestHeartbeatSendsTokenOnlyInAuthenticationHeader(t *testing.T) {
requestBody := ""
receivedHeader := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
requestBody = string(body)
receivedHeader = r.Header.Get("X-Agent-Token")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"status":"ok","nodeId":1,"name":"node"}}`))
}))
defer server.Close()
client := NewMasterClient(server.URL, "secret-agent-token", false)
if _, err := client.Heartbeat(context.Background(), HeartbeatRequest{Hostname: "node"}); err != nil {
t.Fatal(err)
}
if receivedHeader != "secret-agent-token" {
t.Fatalf("authentication header = %q", receivedHeader)
}
if strings.Contains(requestBody, "secret-agent-token") || strings.Contains(requestBody, `"token"`) {
t.Fatalf("heartbeat body exposed the Agent token: %s", requestBody)
}
}

View File

@@ -0,0 +1,196 @@
// Package agent 实现 BackupX 远程 Agent。
//
// Agent 是一个独立的 Go 进程,部署在远程服务器上,通过 HTTP 轮询的方式
// 与 Master 通信:定期上报心跳、拉取 Master 下发的命令、本地执行备份、
// 把执行结果和日志回报给 Master。
//
// 通信协议见 server/internal/http/agent_handler.go。
package agent
import (
"errors"
"fmt"
"net/url"
"os"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// Config 是 Agent 的运行时配置。
type Config struct {
// Master BackupX Master 的 HTTP 基础地址,例如 http://master.example.com:8340
Master string `yaml:"master"`
// Token 节点认证令牌(在 Master 创建节点时生成)
Token string `yaml:"token"`
// TokenFile 从文件读取节点认证令牌;适合 systemd 凭据和容器 secret。
// Token 与 TokenFile 同时设置时优先使用 Token。
TokenFile string `yaml:"tokenFile"`
// HeartbeatInterval 心跳间隔,默认 15s
HeartbeatInterval string `yaml:"heartbeatInterval"`
// PollInterval 命令轮询间隔,默认 5s
PollInterval string `yaml:"pollInterval"`
// TempDir 备份临时目录,默认 /var/lib/backupx-agent/tmp
TempDir string `yaml:"tempDir"`
// ProxyURL Agent 访问 Master 使用的显式代理。留空时遵循
// HTTP_PROXY、HTTPS_PROXY 与 NO_PROXY支持 http(s) 和 socks5(h)。
ProxyURL string `yaml:"proxyUrl"`
// CACertFile 私有 CA 的 PEM 文件路径,用于安全连接内网 HTTPS Master。
CACertFile string `yaml:"caCertFile"`
// InsecureSkipTLSVerify 测试环境允许跳过 TLS 证书校验
InsecureSkipTLSVerify bool `yaml:"insecureSkipTlsVerify"`
}
// Overrides 表示命令行显式提供的 Agent 配置覆盖项。
type Overrides struct {
Master string
Token string
TokenFile string
TempDir string
ProxyURL string
CACertFile string
}
// LoadConfigFile 从 YAML 文件加载 Agent 配置。
func LoadConfigFile(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read agent config: %w", err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse agent config: %w", err)
}
return applyConfigDefaults(&cfg)
}
// LoadConfigFromEnv 从环境变量加载 Agent 配置。优先级低于 --config 文件。
//
// 支持的环境变量:
// - BACKUPX_AGENT_MASTER Master URL
// - BACKUPX_AGENT_TOKEN 节点认证令牌
// - BACKUPX_AGENT_TOKEN_FILE 节点认证令牌文件
// - BACKUPX_AGENT_HEARTBEAT 心跳间隔(如 15s
// - BACKUPX_AGENT_POLL 命令轮询间隔(如 5s
// - BACKUPX_AGENT_TEMP_DIR 临时目录
// - BACKUPX_AGENT_PROXY_URL 显式 HTTP(S)/SOCKS5 代理
// - BACKUPX_AGENT_CA_CERT_FILE 私有 CA PEM 文件
// - BACKUPX_AGENT_INSECURE_TLS true / 1 跳过 TLS 校验
func LoadConfigFromEnv() (*Config, error) {
cfg := &Config{
Master: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_MASTER")),
Token: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_TOKEN")),
TokenFile: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_TOKEN_FILE")),
HeartbeatInterval: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_HEARTBEAT")),
PollInterval: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_POLL")),
TempDir: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_TEMP_DIR")),
ProxyURL: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_PROXY_URL")),
CACertFile: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_CA_CERT_FILE")),
InsecureSkipTLSVerify: strings.EqualFold(os.Getenv("BACKUPX_AGENT_INSECURE_TLS"), "true") || os.Getenv("BACKUPX_AGENT_INSECURE_TLS") == "1",
}
return applyConfigDefaults(cfg)
}
// ApplyOverrides 把命令行覆盖值合并入配置(非空覆盖)。
func (c *Config) ApplyOverrides(overrides Overrides) {
if strings.TrimSpace(overrides.Master) != "" {
c.Master = strings.TrimSpace(overrides.Master)
}
tokenProvided := strings.TrimSpace(overrides.Token) != ""
if strings.TrimSpace(overrides.TokenFile) != "" {
c.TokenFile = strings.TrimSpace(overrides.TokenFile)
if !tokenProvided {
// An explicit --token-file must override a token inherited from YAML.
c.Token = ""
}
}
if tokenProvided {
c.Token = strings.TrimSpace(overrides.Token)
}
if strings.TrimSpace(overrides.TempDir) != "" {
c.TempDir = strings.TrimSpace(overrides.TempDir)
}
if strings.TrimSpace(overrides.ProxyURL) != "" {
c.ProxyURL = strings.TrimSpace(overrides.ProxyURL)
}
if strings.TrimSpace(overrides.CACertFile) != "" {
c.CACertFile = strings.TrimSpace(overrides.CACertFile)
}
}
// ResolveToken 在所有配置源合并完成后读取 token 文件。
func (c *Config) ResolveToken() error {
if strings.TrimSpace(c.Token) != "" || strings.TrimSpace(c.TokenFile) == "" {
c.Token = strings.TrimSpace(c.Token)
return nil
}
data, err := os.ReadFile(strings.TrimSpace(c.TokenFile))
if err != nil {
return fmt.Errorf("read agent token file: %w", err)
}
c.Token = strings.TrimSpace(string(data))
if c.Token == "" {
return errors.New("agent token file is empty")
}
return nil
}
// Validate 校验必填字段。
func (c *Config) Validate() error {
masterURL, err := url.Parse(strings.TrimSpace(c.Master))
if strings.TrimSpace(c.Master) == "" {
return errors.New("master url is required (set via --master, BACKUPX_AGENT_MASTER or config file)")
}
if err != nil || (masterURL.Scheme != "http" && masterURL.Scheme != "https") || masterURL.Host == "" || masterURL.User != nil || masterURL.RawQuery != "" || masterURL.Fragment != "" {
return errors.New("master url must be an absolute http(s) URL without credentials, query or fragment")
}
if strings.TrimSpace(c.Token) == "" {
return errors.New("token is required (set via --token, --token-file, environment or config file)")
}
if c.ProxyURL != "" {
proxyURL, proxyErr := url.Parse(c.ProxyURL)
if proxyErr != nil || proxyURL.Host == "" {
return errors.New("proxy url must be an absolute URL")
}
switch proxyURL.Scheme {
case "http", "https", "socks5", "socks5h":
default:
return errors.New("proxy url scheme must be http, https, socks5 or socks5h")
}
if proxyURL.RawQuery != "" || proxyURL.Fragment != "" || (proxyURL.Path != "" && proxyURL.Path != "/") {
return errors.New("proxy url must not contain a path, query or fragment")
}
}
if c.CACertFile != "" && c.InsecureSkipTLSVerify {
return errors.New("ca cert file and insecure TLS cannot be enabled together")
}
for name, value := range map[string]string{
"heartbeat interval": c.HeartbeatInterval,
"poll interval": c.PollInterval,
} {
duration, durationErr := time.ParseDuration(value)
if durationErr != nil || duration <= 0 {
return fmt.Errorf("%s must be a positive duration", name)
}
}
return nil
}
func applyConfigDefaults(cfg *Config) (*Config, error) {
if cfg.HeartbeatInterval == "" {
cfg.HeartbeatInterval = "15s"
}
if cfg.PollInterval == "" {
cfg.PollInterval = "5s"
}
if cfg.TempDir == "" {
cfg.TempDir = "/var/lib/backupx-agent/tmp"
}
cfg.Master = strings.TrimRight(strings.TrimSpace(cfg.Master), "/")
cfg.Token = strings.TrimSpace(cfg.Token)
cfg.TokenFile = strings.TrimSpace(cfg.TokenFile)
cfg.ProxyURL = strings.TrimSpace(cfg.ProxyURL)
cfg.CACertFile = strings.TrimSpace(cfg.CACertFile)
return cfg, nil
}

View File

@@ -0,0 +1,147 @@
package agent
import (
"os"
"path/filepath"
"testing"
)
func TestLoadConfigFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "agent.yaml")
content := `master: http://master.example.com:8340/
token: abc123
tokenFile: /run/secrets/backupx_agent_token
heartbeatInterval: 20s
pollInterval: 3s
tempDir: /var/backupx-agent
proxyUrl: socks5h://127.0.0.1:1080
caCertFile: /etc/backupx-agent/ca.pem
insecureSkipTlsVerify: true
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
cfg, err := LoadConfigFile(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Master != "http://master.example.com:8340" {
t.Errorf("trailing slash should be trimmed: %q", cfg.Master)
}
if cfg.Token != "abc123" {
t.Errorf("token: %q", cfg.Token)
}
if cfg.HeartbeatInterval != "20s" || cfg.PollInterval != "3s" {
t.Errorf("intervals: %+v", cfg)
}
if !cfg.InsecureSkipTLSVerify {
t.Errorf("insecure should be true")
}
if cfg.ProxyURL != "socks5h://127.0.0.1:1080" || cfg.CACertFile != "/etc/backupx-agent/ca.pem" {
t.Errorf("connection options not loaded: %+v", cfg)
}
}
func TestLoadConfigDefaults(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "agent.yaml")
if err := os.WriteFile(path, []byte("master: http://m\ntoken: t\n"), 0644); err != nil {
t.Fatal(err)
}
cfg, err := LoadConfigFile(path)
if err != nil {
t.Fatal(err)
}
if cfg.HeartbeatInterval != "15s" || cfg.PollInterval != "5s" {
t.Errorf("default intervals not applied: %+v", cfg)
}
if cfg.TempDir != "/var/lib/backupx-agent/tmp" {
t.Errorf("default tempdir: %q", cfg.TempDir)
}
}
func TestConfigValidate(t *testing.T) {
cases := []struct {
name string
cfg Config
wantErr bool
}{
{"valid", Config{Master: "http://m", Token: "t"}, false},
{"missing master", Config{Token: "t"}, true},
{"missing token", Config{Master: "http://m"}, true},
{"invalid master scheme", Config{Master: "ssh://m", Token: "t"}, true},
{"master credentials rejected", Config{Master: "https://user:pass@m", Token: "t"}, true},
{"valid socks proxy", Config{Master: "https://m", Token: "t", ProxyURL: "socks5h://127.0.0.1:1080"}, false},
{"invalid proxy", Config{Master: "https://m", Token: "t", ProxyURL: "ftp://proxy"}, true},
{"proxy path rejected", Config{Master: "https://m", Token: "t", ProxyURL: "http://proxy/connect"}, true},
{"invalid heartbeat", Config{Master: "https://m", Token: "t", HeartbeatInterval: "never"}, true},
}
for _, c := range cases {
_, _ = applyConfigDefaults(&c.cfg)
err := c.cfg.Validate()
if (err != nil) != c.wantErr {
t.Errorf("%s: err=%v wantErr=%v", c.name, err, c.wantErr)
}
}
}
func TestMergeWithFlags(t *testing.T) {
cfg := &Config{Master: "http://old", Token: "old"}
cfg.ApplyOverrides(Overrides{Master: "http://new", TempDir: "/tmp/x", ProxyURL: "http://proxy:3128"})
if cfg.Master != "http://new" {
t.Errorf("master not overridden: %q", cfg.Master)
}
if cfg.Token != "old" {
t.Errorf("empty flag should not override: %q", cfg.Token)
}
if cfg.TempDir != "/tmp/x" {
t.Errorf("tempDir: %q", cfg.TempDir)
}
if cfg.ProxyURL != "http://proxy:3128" {
t.Errorf("proxyUrl: %q", cfg.ProxyURL)
}
}
func TestTokenFileOverrideReplacesConfiguredToken(t *testing.T) {
path := filepath.Join(t.TempDir(), "agent.token")
if err := os.WriteFile(path, []byte("file-token\n"), 0o600); err != nil {
t.Fatal(err)
}
cfg := &Config{Token: "yaml-token", TokenFile: "/old/token"}
cfg.ApplyOverrides(Overrides{TokenFile: " " + path + " "})
if err := cfg.ResolveToken(); err != nil {
t.Fatal(err)
}
if cfg.Token != "file-token" || cfg.TokenFile != path {
t.Fatalf("token file override was not applied: %+v", cfg)
}
}
func TestLoadConfigFromEnv(t *testing.T) {
t.Setenv("BACKUPX_AGENT_MASTER", "http://env-master")
t.Setenv("BACKUPX_AGENT_TOKEN", "env-token")
t.Setenv("BACKUPX_AGENT_PROXY_URL", "http://env-proxy:8080")
t.Setenv("BACKUPX_AGENT_INSECURE_TLS", "true")
cfg, err := LoadConfigFromEnv()
if err != nil {
t.Fatal(err)
}
if cfg.Master != "http://env-master" || cfg.Token != "env-token" || cfg.ProxyURL != "http://env-proxy:8080" || !cfg.InsecureSkipTLSVerify {
t.Errorf("env not picked up: %+v", cfg)
}
}
func TestResolveTokenFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "agent.token")
if err := os.WriteFile(path, []byte(" file-token\n"), 0o600); err != nil {
t.Fatal(err)
}
cfg := &Config{TokenFile: path}
if err := cfg.ResolveToken(); err != nil {
t.Fatal(err)
}
if cfg.Token != "file-token" {
t.Fatalf("token = %q", cfg.Token)
}
}

View File

@@ -0,0 +1,546 @@
package agent
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"backupx/server/internal/backup"
"backupx/server/internal/storage"
storageRclone "backupx/server/internal/storage/rclone"
"backupx/server/pkg/compress"
)
// Executor 负责在 Agent 本地执行命令。
type Executor struct {
client *MasterClient
tempDir string
backupRegistry *backup.Registry
storageRegistry *storage.Registry
}
// NewExecutor 构造执行器。预先初始化 backup runner 与 storage registry。
func NewExecutor(client *MasterClient, tempDir string) *Executor {
backupRegistry := backup.NewRegistry(
backup.NewFileRunner(),
backup.NewSQLiteRunner(),
backup.NewMySQLRunner(nil),
backup.NewPostgreSQLRunner(nil),
backup.NewSAPHANARunner(nil),
backup.NewMongoDBRunner(nil),
)
storageRegistry := storage.NewRegistry(
storageRclone.NewLocalDiskFactory(),
storageRclone.NewS3Factory(),
storageRclone.NewWebDAVFactory(),
storageRclone.NewGoogleDriveFactory(),
storageRclone.NewAliyunOSSFactory(),
storageRclone.NewTencentCOSFactory(),
storageRclone.NewQiniuKodoFactory(),
storageRclone.NewFTPFactory(),
storageRclone.NewRcloneFactory(),
)
storageRclone.RegisterAllBackends(storageRegistry)
return &Executor{
client: client,
tempDir: tempDir,
backupRegistry: backupRegistry,
storageRegistry: storageRegistry,
}
}
// ExecuteRunTask 处理 run_task 命令:拉规格 → 执行 runner → 压缩 → 上传 → 上报记录。
//
// 注意Agent 当前不支持 Encrypt=true加密密钥不下发到 Agent避免密钥扩散
// 遇到启用加密的任务会向 Master 上报失败并返回错误。
func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) error {
if err := e.ensureTempDir(); err != nil {
e.reportRecordFailure(ctx, recordID, err.Error())
return err
}
// 1) 拉取任务规格
spec, err := e.client.GetTaskSpec(ctx, taskID)
if err != nil {
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("拉取任务规格失败: %v", err))
return err
}
if spec.Encrypt {
msg := "Agent 不支持加密备份(加密密钥仅在 Master 端持有)"
e.reportRecordFailure(ctx, recordID, msg)
return fmt.Errorf("%s", msg)
}
e.appendLog(ctx, recordID, fmt.Sprintf("[agent] 开始执行任务 %s (type=%s)\n", spec.Name, spec.Type))
// 2) 构造 backup.TaskSpec 并找对应 runner
startedAt := time.Now().UTC()
backupSpec := buildBackupTaskSpec(spec, startedAt, e.tempDir)
runner, err := e.backupRegistry.Runner(backupSpec.Type)
if err != nil {
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("不支持的备份类型: %v", err))
return err
}
// 3) 运行 runner
logger := newRecordLogger(ctx, e.client, recordID)
result, err := runner.Run(ctx, backupSpec, logger)
if err != nil {
e.reportRecordFailure(ctx, recordID, err.Error())
return err
}
defer os.RemoveAll(result.TempDir)
// 4) 可选 gzip 压缩
finalPath := result.ArtifactPath
if strings.EqualFold(spec.Compression, "gzip") && !strings.HasSuffix(strings.ToLower(finalPath), ".gz") {
e.appendLog(ctx, recordID, "[agent] 开始压缩备份文件\n")
compressedPath, compressErr := compress.GzipFile(finalPath)
if compressErr != nil {
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("压缩失败: %v", compressErr))
return compressErr
}
finalPath = compressedPath
} 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 {
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("获取文件信息失败: %v", err))
return err
}
fileName := filepath.Base(finalPath)
fileSize := info.Size()
storagePath := backup.BuildRecordStorageKey(spec.Type, startedAt, recordID, fileName)
// 5) 计算 checksum一次读一次并上传到所有目标
checksum, err := computeFileSHA256(finalPath)
if err != nil {
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("计算 checksum 失败: %v", err))
return err
}
if len(spec.StorageTargets) == 0 {
e.reportRecordFailure(ctx, recordID, "没有关联的存储目标")
return fmt.Errorf("no storage targets")
}
uploadResults := make([]StorageResultItem, 0, len(spec.StorageTargets))
selectedStorageTargetID := uint(0)
selectedStorageTransferMode := ""
var uploadErrors []string
for _, target := range spec.StorageTargets {
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,
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, checksum string, taskID uint) error {
f, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("open artifact: %w", err)
}
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),
}
uploadErr := provider.Upload(ctx, objectKey, f, fileSize, meta)
return errors.Join(uploadErr, f.Close())
}
// appendLog 追加日志到 Master 记录(尽力而为,失败不中断主流程)
func (e *Executor) appendLog(ctx context.Context, recordID uint, line string) {
_ = e.client.UpdateRecord(ctx, recordID, RecordUpdate{LogAppend: line})
}
// reportRecordFailure 上报失败状态
func (e *Executor) reportRecordFailure(ctx context.Context, recordID uint, msg string) {
e.reportRecordFailureWithUploadResults(ctx, recordID, msg, nil)
}
func (e *Executor) reportRecordFailureWithUploadResults(ctx context.Context, recordID uint, msg string, uploadResults []StorageResultItem) {
_ = e.client.UpdateRecord(ctx, recordID, RecordUpdate{
Status: "failed",
ErrorMessage: msg,
StorageUploadResults: uploadResults,
LogAppend: fmt.Sprintf("[agent] 错误: %s\n", msg),
})
}
// buildBackupTaskSpec 把 AgentTaskSpec 转换为 backup.TaskSpec。
func buildBackupTaskSpec(spec *TaskSpec, startedAt time.Time, tempDir string) backup.TaskSpec {
sourcePaths := parseStringListField(spec.SourcePaths)
excludes := parseStringListField(spec.ExcludePatterns)
return backup.TaskSpec{
ID: spec.TaskID,
Name: spec.Name,
Type: spec.Type,
SourcePath: spec.SourcePath,
SourcePaths: sourcePaths,
ExcludePatterns: excludes,
Database: backup.DatabaseSpec{
Host: spec.DBHost,
Port: spec.DBPort,
User: spec.DBUser,
Password: spec.DBPassword,
Path: spec.DBPath,
Names: splitCommaOrNewline(spec.DBName),
},
Compression: spec.Compression,
Encrypt: spec.Encrypt,
StartedAt: startedAt,
TempDir: tempDir,
}
}
func (e *Executor) ensureTempDir() error {
if err := os.MkdirAll(e.tempDir, 0o755); err != nil {
return fmt.Errorf("create agent temp dir: %w", err)
}
return nil
}
func parseStringListField(value string) []string {
trimmed := strings.TrimSpace(value)
if trimmed == "" || trimmed == "[]" {
return nil
}
var jsonItems []string
if err := json.Unmarshal([]byte(trimmed), &jsonItems); err == nil {
return compactStringList(jsonItems)
}
return compactStringList(strings.FieldsFunc(trimmed, func(r rune) bool {
return r == '\n' || r == '\r'
}))
}
func compactStringList(items []string) []string {
result := make([]string, 0, len(items))
for _, item := range items {
if trimmed := strings.TrimSpace(item); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// recordLogger 把 runner 日志回传到 Master 记录。
// 实现 backup.LogWriter每条日志追加到 record.log_content。
type recordLogger struct {
ctx context.Context
client *MasterClient
recordID uint
}
func newRecordLogger(ctx context.Context, client *MasterClient, recordID uint) *recordLogger {
return &recordLogger{ctx: ctx, client: client, recordID: recordID}
}
func (l *recordLogger) WriteLine(message string) {
_ = l.client.UpdateRecord(l.ctx, l.recordID, RecordUpdate{LogAppend: message + "\n"})
}
// restoreLogger 把 runner 日志回传到 Master 恢复记录。
type restoreLogger struct {
ctx context.Context
client *MasterClient
restoreID uint
}
func newRestoreLogger(ctx context.Context, client *MasterClient, restoreID uint) *restoreLogger {
return &restoreLogger{ctx: ctx, client: client, restoreID: restoreID}
}
func (l *restoreLogger) WriteLine(message string) {
_ = l.client.UpdateRestore(l.ctx, l.restoreID, RestoreUpdate{LogAppend: message + "\n"})
}
// DeleteStorageObject 在 Agent 本机上删除指定存储对象(供跨节点清理调用)。
func (e *Executor) DeleteStorageObject(ctx context.Context, targetType string, targetConfig map[string]any, storagePath string) error {
provider, err := e.storageRegistry.Create(ctx, targetType, targetConfig)
if err != nil {
return fmt.Errorf("create provider: %w", err)
}
return provider.Delete(ctx, storagePath)
}
// ExecuteRestore 处理 restore_record 命令:拉规格 → 下载 → 解压 → 执行 runner.Restore → 上报结果。
//
// 与 ExecuteRunTask 对称,但方向相反:
// - 下载:直连共享存储,或通过 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))
return err
}
if spec.Encrypt {
msg := "Agent 不支持加密恢复(加密密钥仅在 Master 端持有)"
e.reportRestoreFailure(ctx, restoreRecordID, msg)
return fmt.Errorf("%s", msg)
}
e.appendRestoreLog(ctx, restoreRecordID, fmt.Sprintf("[agent] 开始恢复 %s (type=%s)\n", spec.TaskName, spec.Type))
tmpDir, err := os.MkdirTemp(e.tempDir, "restore-*")
if err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("创建恢复临时目录失败: %v", err))
return err
}
defer os.RemoveAll(tmpDir)
// 1) 下载
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))
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
}
if err := writeReaderToLocal(artifactPath, reader); err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("写入备份文件失败: %v", 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") {
msg := "检测到加密后缀Agent 不支持加密恢复"
e.reportRestoreFailure(ctx, restoreRecordID, msg)
return fmt.Errorf("%s", msg)
}
if strings.HasSuffix(strings.ToLower(preparedPath), ".gz") {
e.appendRestoreLog(ctx, restoreRecordID, "[agent] 解压 gzip 压缩\n")
decompressed, err := compress.GunzipFile(preparedPath)
if err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("解压失败: %v", err))
return err
}
preparedPath = decompressed
}
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)
runner, err := e.backupRegistry.Runner(taskSpec.Type)
if err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("不支持的备份类型: %v", err))
return err
}
logger := newRestoreLogger(ctx, e.client, restoreRecordID)
if err := runner.Restore(ctx, taskSpec, preparedPath, logger); err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, err.Error())
return err
}
// 5) 上报成功
return e.client.UpdateRestore(ctx, restoreRecordID, RestoreUpdate{
Status: "success",
LogAppend: "[agent] 恢复执行完成\n",
})
}
func (e *Executor) appendRestoreLog(ctx context.Context, restoreID uint, line string) {
_ = e.client.UpdateRestore(ctx, restoreID, RestoreUpdate{LogAppend: line})
}
func (e *Executor) reportRestoreFailure(ctx context.Context, restoreID uint, msg string) {
_ = e.client.UpdateRestore(ctx, restoreID, RestoreUpdate{
Status: "failed",
ErrorMessage: msg,
LogAppend: fmt.Sprintf("[agent] 错误: %s\n", msg),
})
}
// buildRestoreBackupTaskSpec 把 RestoreSpec 转成 backup.TaskSpec。
func buildRestoreBackupTaskSpec(spec *RestoreSpec, startedAt time.Time, tempDir string) backup.TaskSpec {
return backup.TaskSpec{
ID: spec.TaskID,
Name: spec.TaskName,
Type: spec.Type,
SourcePath: spec.SourcePath,
SourcePaths: spec.SourcePaths,
ExcludePatterns: nil,
Database: backup.DatabaseSpec{
Host: spec.DBHost,
Port: spec.DBPort,
User: spec.DBUser,
Password: spec.DBPassword,
Path: spec.DBPath,
Names: splitCommaOrNewline(spec.DBName),
},
Compression: spec.Compression,
Encrypt: spec.Encrypt,
StartedAt: startedAt,
TempDir: tempDir,
}
}
// writeReaderToLocal 把 reader 写到本地文件Agent 侧工具函数)。
func writeReaderToLocal(targetPath string, reader io.ReadCloser) (err error) {
defer func() {
err = errors.Join(err, reader.Close())
}()
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
file, err := os.Create(targetPath)
if err != nil {
return err
}
_, copyErr := io.Copy(file, reader)
return errors.Join(copyErr, file.Close())
}
// 辅助函数
func computeFileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func splitCommaOrNewline(s string) []string {
var result []string
for _, part := range strings.FieldsFunc(s, func(r rune) bool {
return r == ',' || r == '\n' || r == ';'
}) {
if p := strings.TrimSpace(part); p != "" {
result = append(result, p)
}
}
return result
}

View File

@@ -0,0 +1,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

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ import (
"backupx/server/internal/database"
aphttp "backupx/server/internal/http"
"backupx/server/internal/logger"
"backupx/server/internal/metrics"
"backupx/server/internal/notify"
"backupx/server/internal/repository"
"backupx/server/internal/scheduler"
@@ -59,9 +60,9 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
jwtManager := security.NewJWTManager(resolvedSecurity.JWTSecret, config.MustJWTDuration(cfg.Security))
rateLimiter := security.NewLoginRateLimiter(5, time.Minute)
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, rateLimiter)
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
configCipher := codec.NewConfigCipher(resolvedSecurity.EncryptionKey)
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, rateLimiter, configCipher)
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
storageRegistry := storage.NewRegistry(
storageRclone.NewLocalDiskFactory(),
storageRclone.NewS3Factory(),
@@ -71,57 +72,218 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
storageRclone.NewTencentCOSFactory(),
storageRclone.NewQiniuKodoFactory(),
storageRclone.NewFTPFactory(),
storageRclone.NewRcloneFactory(),
)
// 将全部 rclone 后端注册为独立存储类型sftp、azureblob、dropbox 等与 s3、ftp 完全平级)
storageRclone.RegisterAllBackends(storageRegistry)
storageTargetService := service.NewStorageTargetService(storageTargetRepo, oauthSessionRepo, storageRegistry, configCipher)
storageTargetService.SetBackupTaskRepository(backupTaskRepo)
storageTargetService.SetBackupRecordRepository(backupRecordRepo)
backupTaskService := service.NewBackupTaskService(backupTaskRepo, storageTargetRepo, configCipher)
backupRunnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil), backup.NewSAPHANARunner(nil))
backupTaskService.SetRecordsAndStorage(backupRecordRepo, storageRegistry)
// nodeRepo 在下方 Cluster 节点管理区块才实例化,这里延后注入
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)
backupExecutionService := service.NewBackupExecutionService(backupTaskRepo, backupRecordRepo, storageTargetRepo, storageRegistry, backupRunnerRegistry, logHub, retentionService, configCipher, notificationService, cfg.Backup.TempDir, cfg.Backup.MaxConcurrent)
authService.SetNotificationService(notificationService)
// 初始化 rclone 传输配置(重试 + 带宽限制)
rcloneCtx := storageRclone.ConfiguredContext(ctx, storageRclone.TransferConfig{
LowLevelRetries: cfg.Backup.Retries,
BandwidthLimit: cfg.Backup.BandwidthLimit,
})
storageRclone.StartAccounting(rcloneCtx)
backupExecutionService := service.NewBackupExecutionService(backupTaskRepo, backupRecordRepo, storageTargetRepo, storageRegistry, backupRunnerRegistry, logHub, retentionService, configCipher, notificationService, cfg.Backup.TempDir, cfg.Backup.MaxConcurrent, cfg.Backup.Retries, cfg.Backup.BandwidthLimit)
schedulerService := scheduler.NewService(backupTaskRepo, backupExecutionService, appLogger)
backupTaskService.SetScheduler(schedulerService)
// 审计日志注入延迟到 auditService 创建后(见下方)
backupRecordService := service.NewBackupRecordService(backupRecordRepo, backupExecutionService, logHub)
// 恢复服务:使用独立 LogHub 避免恢复记录与备份记录 ID 命名空间冲突
restoreRecordRepo := repository.NewRestoreRecordRepository(db)
restoreLogHub := backup.NewLogHub()
dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo)
reportService := service.NewReportService(backupTaskRepo, backupRecordRepo)
settingsService := service.NewSettingsService(systemConfigRepo)
// Audit
auditLogRepo := repository.NewAuditLogRepository(db)
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
// Database discovery(集群依赖在 agentService 创建后注入)
databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor())
// Cluster: Node management
nodeRepo := repository.NewNodeRepository(db)
nodeService := service.NewNodeService(nodeRepo)
backupTaskService.SetNodeRepository(nodeRepo)
schedulerService.SetNodeRepository(nodeRepo)
nodeService := service.NewNodeService(nodeRepo, version)
nodeService.SetTaskRepository(backupTaskRepo)
if err := nodeService.EnsureLocalNode(ctx); err != nil {
appLogger.Warn("failed to ensure local node", zap.Error(err))
}
// 启动离线检测:每 15s 扫描一次,超过 45s 未心跳的远程节点标记为离线
nodeService.StartOfflineMonitor(ctx, 15*time.Second)
// Agent 协议服务:命令队列 + 任务下发 + 记录上报
agentCmdRepo := repository.NewAgentCommandRepository(db)
nodeService.SetAgentCommandRepository(agentCmdRepo)
agentService := service.NewAgentService(nodeRepo, backupTaskRepo, backupRecordRepo, storageTargetRepo, agentCmdRepo, configCipher, storageRegistry)
agentService.SetRestoreRepository(restoreRecordRepo)
agentService.StartCommandTimeoutMonitor(ctx, 30*time.Second, 10*time.Minute)
// 一键部署install token service + 后台 GC
installTokenRepo := repository.NewAgentInstallTokenRepository(db)
installTokenService := service.NewInstallTokenService(installTokenRepo, nodeRepo)
installTokenService.StartGC(ctx, time.Hour)
// 把 Agent 下发能力注入到备份执行服务,实现多节点路由
backupExecutionService.SetClusterDependencies(nodeRepo, agentService)
// 启用远程目录浏览NodeService 通过 AgentService 做同步 RPC
nodeService.SetAgentRPC(agentService)
// 启用远程数据库发现:远程节点任务配置时 DatabasePicker 拿到的是节点视角的 DB 列表
databaseDiscoveryService.SetClusterDependencies(nodeRepo, agentService)
// 恢复服务:集群感知(本地/远程路由),依赖 agentService 入队
restoreService := service.NewRestoreService(
restoreRecordRepo,
backupRecordRepo,
backupTaskRepo,
storageTargetRepo,
nodeRepo,
storageRegistry,
backupRunnerRegistry,
restoreLogHub,
configCipher,
agentService,
cfg.Backup.TempDir,
cfg.Backup.MaxConcurrent,
)
// 验证服务:定期校验备份可恢复性(企业合规刚需)
verificationRecordRepo := repository.NewVerificationRecordRepository(db)
verifyLogHub := backup.NewLogHub()
verificationService := service.NewVerificationService(
verificationRecordRepo,
backupRecordRepo,
backupTaskRepo,
storageTargetRepo,
nodeRepo,
storageRegistry,
verifyLogHub,
configCipher,
cfg.Backup.TempDir,
cfg.Backup.MaxConcurrent,
)
// 验证失败通知:通过 NotificationService 的事件总线派发 verify_failed
verificationService.SetNotifier(service.NewVerificationEventNotifier(notificationService))
// 恢复完成/失败事件派发restore_success / restore_failed
restoreService.SetEventDispatcher(notificationService)
// 调度器接入验证演练 cron
schedulerService.SetVerifyRunner(verificationService)
// 用户管理与 API Key 服务(企业级 RBAC
userService := service.NewUserService(userRepo)
apiKeyRepo := repository.NewApiKeyRepository(db)
apiKeyService := service.NewApiKeyService(apiKeyRepo)
// SLA 后台扫描:每 15 分钟扫描违约任务,同任务 6 小时内不重复派发
dashboardService.StartSLAMonitor(ctx, notificationService, 15*time.Minute, 6*time.Hour)
// 存储目标健康扫描:每 5 分钟测试启用目标,掉线即告警
storageTargetService.StartHealthMonitor(ctx, notificationService, 5*time.Minute)
// 备份复制服务3-2-1 规则核心)
replicationRecordRepo := repository.NewReplicationRecordRepository(db)
replicationService := service.NewReplicationService(
replicationRecordRepo, backupRecordRepo, storageTargetRepo,
nodeRepo, storageRegistry, configCipher,
cfg.Backup.TempDir, cfg.Backup.MaxConcurrent,
)
replicationService.SetEventDispatcher(notificationService)
backupExecutionService.SetReplicationTrigger(replicationService)
// 备份成功后触发下游依赖任务(任务依赖链工作流)
backupExecutionService.SetDependentsResolver(backupTaskService)
// 任务模板(批量创建)
taskTemplateRepo := repository.NewTaskTemplateRepository(db)
taskTemplateService := service.NewTaskTemplateService(taskTemplateRepo, backupTaskService)
// 任务配置导入/导出JSON集群迁移 & 灾备)
taskExportService := service.NewTaskExportService(backupTaskService, backupTaskRepo, storageTargetRepo, nodeRepo)
// 全局搜索(跨任务/存储/节点/最近记录)
searchService := service.NewSearchService(backupTaskRepo, backupRecordRepo, storageTargetRepo, nodeRepo)
// 实时事件广播器SSE 推送给前端 Dashboard
// 注入 notification 后,每次 DispatchEvent 同时 broadcast 到所有 SSE 订阅者
eventBroadcaster := service.NewEventBroadcaster()
notificationService.SetBroadcaster(eventBroadcaster)
// 集群版本监控:每 30 分钟扫描,节点 24 小时内只告警一次
clusterVersionMonitor := service.NewClusterVersionMonitor(nodeRepo, version)
clusterVersionMonitor.SetEventDispatcher(notificationService)
clusterVersionMonitor.Start(ctx, 30*time.Minute, 24*time.Hour)
// Dashboard 集群概览依赖注入
dashboardService.SetClusterDependencies(nodeRepo, version)
// Prometheus 指标采集Counter/Histogram 由业务服务实时写入;
// Gauge 类存储用量、节点在线、SLA 违约)由 Collector 每 30s 异步刷新,
// 避免 /metrics 请求路径做慢 IO。
appMetrics := metrics.New(version)
backupExecutionService.SetMetrics(appMetrics)
restoreService.SetMetrics(appMetrics)
verificationService.SetMetrics(appMetrics)
replicationService.SetMetrics(appMetrics)
metricsCollector := metrics.NewCollector(
appMetrics,
metrics.NewRepoSource(storageTargetRepo, backupRecordRepo, nodeRepo, backupTaskRepo, agentCmdRepo),
30*time.Second,
)
metricsCollector.Start(ctx)
router := aphttp.NewRouter(aphttp.RouterDependencies{
Config: cfg,
Version: version,
Logger: appLogger,
AuthService: authService,
SystemService: systemService,
StorageTargetService: storageTargetService,
BackupTaskService: backupTaskService,
BackupExecutionService: backupExecutionService,
BackupRecordService: backupRecordService,
NotificationService: notificationService,
DashboardService: dashboardService,
SettingsService: settingsService,
Context: ctx,
Config: cfg,
Version: version,
Logger: appLogger,
AuthService: authService,
SystemService: systemService,
StorageTargetService: storageTargetService,
BackupTaskService: backupTaskService,
BackupExecutionService: backupExecutionService,
BackupRecordService: backupRecordService,
RestoreService: restoreService,
VerificationService: verificationService,
ReplicationService: replicationService,
TaskTemplateService: taskTemplateService,
TaskExportService: taskExportService,
SearchService: searchService,
EventBroadcaster: eventBroadcaster,
UserService: userService,
ApiKeyService: apiKeyService,
NotificationService: notificationService,
DashboardService: dashboardService,
ReportService: reportService,
SettingsService: settingsService,
NodeService: nodeService,
AgentService: agentService,
DatabaseDiscoveryService: databaseDiscoveryService,
AuditService: auditService,
AuditService: auditService,
JWTManager: jwtManager,
UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo,
UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo,
InstallTokenService: installTokenService,
MasterExternalURL: cfg.Server.ExternalURL,
DB: db,
Metrics: appMetrics,
})
httpServer := &stdhttp.Server{

View File

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

View File

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

View File

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

View File

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

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