mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-12 02:20:36 +08:00
* 功能: 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 的误判
181 lines
4.8 KiB
Go
181 lines
4.8 KiB
Go
package backup
|
||
|
||
import (
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// MaintenanceWindow 描述一个允许执行备份的时段。
|
||
// 格式语义:
|
||
// - Days 为 "0..6" 的字符串集合(0=周日,6=周六);空 = 每天
|
||
// - StartMinutes / EndMinutes 为"午夜起计算的分钟数",0 ≤ v < 1440
|
||
// - 跨午夜窗口:Start > End 表示跨夜(如 22:00-06:00)
|
||
//
|
||
// 多个窗口是 OR 语义:只要 now 落入任一窗口即允许执行。
|
||
type MaintenanceWindow struct {
|
||
Days map[int]bool
|
||
StartMinutes int
|
||
EndMinutes int
|
||
}
|
||
|
||
// ParseMaintenanceWindows 解析用户配置(CSV 每项形如 "days=mon,tue|time=22:00-06:00")。
|
||
// 简化语法:多个窗口以 ';' 分隔,每个窗口按 "[days=xxx;]time=HH:MM-HH:MM" 格式。
|
||
// Days 缺省 = 全周;若不合法,跳过该段而非抛错(让调用方尽力工作)。
|
||
// 示例:
|
||
// "time=01:00-05:00" 每天 1 点到 5 点
|
||
// "days=sat,sun;time=00:00-23:59" 仅周末全天
|
||
// "time=22:00-06:00" 每天跨夜
|
||
// "days=mon,tue,wed,thu,fri;time=22:00-06:00" 工作日跨夜
|
||
func ParseMaintenanceWindows(value string) []MaintenanceWindow {
|
||
v := strings.TrimSpace(value)
|
||
if v == "" {
|
||
return nil
|
||
}
|
||
segments := strings.Split(v, ";")
|
||
var windows []MaintenanceWindow
|
||
for _, segment := range segments {
|
||
segment = strings.TrimSpace(segment)
|
||
if segment == "" {
|
||
continue
|
||
}
|
||
window, ok := parseSingleWindow(segment)
|
||
if !ok {
|
||
continue
|
||
}
|
||
windows = append(windows, window)
|
||
}
|
||
return windows
|
||
}
|
||
|
||
func parseSingleWindow(segment string) (MaintenanceWindow, bool) {
|
||
// "days=xxx,time=HH:MM-HH:MM" 或 "time=..."
|
||
fields := strings.Split(segment, ",")
|
||
days := map[int]bool{}
|
||
var timeExpr string
|
||
for _, field := range fields {
|
||
field = strings.TrimSpace(field)
|
||
if field == "" {
|
||
continue
|
||
}
|
||
if strings.HasPrefix(field, "days=") {
|
||
daysPart := strings.TrimPrefix(field, "days=")
|
||
for _, day := range strings.Split(daysPart, "|") {
|
||
if idx := parseDayToken(strings.TrimSpace(day)); idx >= 0 {
|
||
days[idx] = true
|
||
}
|
||
}
|
||
} else if strings.HasPrefix(field, "time=") {
|
||
timeExpr = strings.TrimPrefix(field, "time=")
|
||
}
|
||
}
|
||
start, end, ok := parseTimeRange(strings.TrimSpace(timeExpr))
|
||
if !ok {
|
||
return MaintenanceWindow{}, false
|
||
}
|
||
return MaintenanceWindow{Days: days, StartMinutes: start, EndMinutes: end}, true
|
||
}
|
||
|
||
var dayTokens = map[string]int{
|
||
"sun": 0, "sunday": 0, "0": 0,
|
||
"mon": 1, "monday": 1, "1": 1,
|
||
"tue": 2, "tuesday": 2, "2": 2,
|
||
"wed": 3, "wednesday": 3, "3": 3,
|
||
"thu": 4, "thursday": 4, "4": 4,
|
||
"fri": 5, "friday": 5, "5": 5,
|
||
"sat": 6, "saturday": 6, "6": 6,
|
||
}
|
||
|
||
func parseDayToken(value string) int {
|
||
v := strings.ToLower(strings.TrimSpace(value))
|
||
if v == "" {
|
||
return -1
|
||
}
|
||
if idx, ok := dayTokens[v]; ok {
|
||
return idx
|
||
}
|
||
return -1
|
||
}
|
||
|
||
// parseTimeRange 解析 "HH:MM-HH:MM",返回起止分钟数。
|
||
func parseTimeRange(value string) (int, int, bool) {
|
||
parts := strings.SplitN(value, "-", 2)
|
||
if len(parts) != 2 {
|
||
return 0, 0, false
|
||
}
|
||
start, ok := parseHHMM(parts[0])
|
||
if !ok {
|
||
return 0, 0, false
|
||
}
|
||
end, ok := parseHHMM(parts[1])
|
||
if !ok {
|
||
return 0, 0, false
|
||
}
|
||
return start, end, true
|
||
}
|
||
|
||
func parseHHMM(value string) (int, bool) {
|
||
parts := strings.Split(strings.TrimSpace(value), ":")
|
||
if len(parts) != 2 {
|
||
return 0, false
|
||
}
|
||
h, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||
if err != nil || h < 0 || h > 23 {
|
||
return 0, false
|
||
}
|
||
m, err := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||
if err != nil || m < 0 || m > 59 {
|
||
return 0, false
|
||
}
|
||
return h*60 + m, true
|
||
}
|
||
|
||
// IsWithinWindow 判断 t 是否落入任一窗口。windows 为空或 nil 时总是返回 true(不限制)。
|
||
func IsWithinWindow(t time.Time, windows []MaintenanceWindow) bool {
|
||
if len(windows) == 0 {
|
||
return true
|
||
}
|
||
minutes := t.Hour()*60 + t.Minute()
|
||
weekday := int(t.Weekday())
|
||
for _, w := range windows {
|
||
if len(w.Days) > 0 && !w.Days[weekday] {
|
||
continue
|
||
}
|
||
if w.StartMinutes == w.EndMinutes {
|
||
continue
|
||
}
|
||
if w.StartMinutes < w.EndMinutes {
|
||
// 同日窗口
|
||
if minutes >= w.StartMinutes && minutes < w.EndMinutes {
|
||
return true
|
||
}
|
||
} else {
|
||
// 跨午夜:[start, 1440) ∪ [0, end)
|
||
if minutes >= w.StartMinutes || minutes < w.EndMinutes {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// ValidateMaintenanceWindows 用户输入合法性校验(返回人可读的错误)。
|
||
func ValidateMaintenanceWindows(value string) error {
|
||
v := strings.TrimSpace(value)
|
||
if v == "" {
|
||
return nil
|
||
}
|
||
segments := strings.Split(v, ";")
|
||
for _, segment := range segments {
|
||
segment = strings.TrimSpace(segment)
|
||
if segment == "" {
|
||
continue
|
||
}
|
||
if _, ok := parseSingleWindow(segment); !ok {
|
||
return fmt.Errorf("无效的维护窗口配置: %q(期望格式如 time=22:00-06:00 或 days=sat,sun,time=00:00-23:59)", segment)
|
||
}
|
||
}
|
||
return nil
|
||
}
|