mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-06 20:02:41 +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 的误判
206 lines
7.1 KiB
Go
206 lines
7.1 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"crypto/hmac"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"backupx/server/internal/apperror"
|
||
"backupx/server/internal/model"
|
||
"backupx/server/internal/repository"
|
||
)
|
||
|
||
// ApiKeyPrefix 所有 API Key 的明文前缀,用于中间件快速识别。
|
||
const ApiKeyPrefix = "bax_"
|
||
|
||
// ApiKeyService 管理 API Key 生命周期。
|
||
// 创建时生成 32 字节随机密钥 → 明文一次性返回 → 仅存储 SHA-256 哈希。
|
||
// 验证时计算输入的 SHA-256 查表,避免时序攻击和泄漏。
|
||
type ApiKeyService struct {
|
||
repo repository.ApiKeyRepository
|
||
}
|
||
|
||
func NewApiKeyService(repo repository.ApiKeyRepository) *ApiKeyService {
|
||
return &ApiKeyService{repo: repo}
|
||
}
|
||
|
||
// ApiKeyCreateInput 创建 API Key 的输入参数。
|
||
type ApiKeyCreateInput struct {
|
||
Name string `json:"name" binding:"required,min=1,max=128"`
|
||
Role string `json:"role" binding:"required,oneof=admin operator viewer"`
|
||
TTLHours int `json:"ttlHours"` // 0 表示永不过期
|
||
}
|
||
|
||
// ApiKeyCreateResult 创建后返回给调用者一次。
|
||
// PlainKey 只此一次,前端需要告知用户立即保存。
|
||
type ApiKeyCreateResult struct {
|
||
ApiKey ApiKeySummary `json:"apiKey"`
|
||
PlainKey string `json:"plainKey"`
|
||
}
|
||
|
||
// ApiKeySummary 列表项(无明文)。
|
||
type ApiKeySummary struct {
|
||
ID uint `json:"id"`
|
||
Name string `json:"name"`
|
||
Role string `json:"role"`
|
||
Prefix string `json:"prefix"`
|
||
CreatedBy string `json:"createdBy"`
|
||
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||
Disabled bool `json:"disabled"`
|
||
CreatedAt time.Time `json:"createdAt"`
|
||
}
|
||
|
||
func (s *ApiKeyService) Create(ctx context.Context, createdBy string, input ApiKeyCreateInput) (*ApiKeyCreateResult, error) {
|
||
name := strings.TrimSpace(input.Name)
|
||
if name == "" {
|
||
return nil, apperror.BadRequest("API_KEY_INVALID", "名称不能为空", nil)
|
||
}
|
||
if !model.IsValidRole(input.Role) {
|
||
return nil, apperror.BadRequest("API_KEY_INVALID", "非法的角色", nil)
|
||
}
|
||
rawToken, err := generateApiKeyPlain()
|
||
if err != nil {
|
||
return nil, apperror.Internal("API_KEY_GEN_FAILED", "无法生成 API Key", err)
|
||
}
|
||
hash := hashApiKey(rawToken)
|
||
// Prefix 取前 12 字符供 UI 区分,不泄漏足够信息
|
||
prefix := rawToken
|
||
if len(prefix) > 12 {
|
||
prefix = prefix[:12]
|
||
}
|
||
key := &model.ApiKey{
|
||
Name: name,
|
||
Role: input.Role,
|
||
KeyHash: hash,
|
||
Prefix: prefix,
|
||
CreatedBy: strings.TrimSpace(createdBy),
|
||
}
|
||
if input.TTLHours > 0 {
|
||
expires := time.Now().UTC().Add(time.Duration(input.TTLHours) * time.Hour)
|
||
key.ExpiresAt = &expires
|
||
}
|
||
if err := s.repo.Create(ctx, key); err != nil {
|
||
return nil, apperror.Internal("API_KEY_CREATE_FAILED", "无法创建 API Key", err)
|
||
}
|
||
return &ApiKeyCreateResult{ApiKey: toApiKeySummary(key), PlainKey: rawToken}, nil
|
||
}
|
||
|
||
func (s *ApiKeyService) List(ctx context.Context) ([]ApiKeySummary, error) {
|
||
items, err := s.repo.List(ctx)
|
||
if err != nil {
|
||
return nil, apperror.Internal("API_KEY_LIST_FAILED", "无法获取 API Key 列表", err)
|
||
}
|
||
result := make([]ApiKeySummary, 0, len(items))
|
||
for i := range items {
|
||
result = append(result, toApiKeySummary(&items[i]))
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// Revoke 撤销指定 API Key(物理删除,保持 db 紧凑)。
|
||
func (s *ApiKeyService) Revoke(ctx context.Context, id uint) error {
|
||
key, err := s.repo.FindByID(ctx, id)
|
||
if err != nil {
|
||
return apperror.Internal("API_KEY_GET_FAILED", "无法获取 API Key", err)
|
||
}
|
||
if key == nil {
|
||
return apperror.New(404, "API_KEY_NOT_FOUND", "API Key 不存在", nil)
|
||
}
|
||
if err := s.repo.Delete(ctx, id); err != nil {
|
||
return apperror.Internal("API_KEY_DELETE_FAILED", "无法删除 API Key", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ToggleDisabled 启用/停用 API Key(保留记录便于审计)。
|
||
func (s *ApiKeyService) ToggleDisabled(ctx context.Context, id uint, disabled bool) error {
|
||
key, err := s.repo.FindByID(ctx, id)
|
||
if err != nil {
|
||
return apperror.Internal("API_KEY_GET_FAILED", "无法获取 API Key", err)
|
||
}
|
||
if key == nil {
|
||
return apperror.New(404, "API_KEY_NOT_FOUND", "API Key 不存在", nil)
|
||
}
|
||
key.Disabled = disabled
|
||
return s.repo.Update(ctx, key)
|
||
}
|
||
|
||
// Authenticate 实现 http.ApiKeyAuthenticator 接口。
|
||
// 返回 (subject, role, error)。subject 形如 "api_key:<id>:<name>",供审计记录。
|
||
func (s *ApiKeyService) Authenticate(ctx context.Context, rawKey string) (string, string, error) {
|
||
rawKey = strings.TrimSpace(rawKey)
|
||
if !strings.HasPrefix(rawKey, ApiKeyPrefix) {
|
||
return "", "", apperror.Unauthorized("AUTH_INVALID_TOKEN", "无效的 API Key 格式", nil)
|
||
}
|
||
hash := hashApiKey(rawKey)
|
||
key, err := s.repo.FindByHash(ctx, hash)
|
||
if err != nil {
|
||
return "", "", apperror.Internal("API_KEY_LOOKUP_FAILED", "无法验证 API Key", err)
|
||
}
|
||
if key == nil {
|
||
return "", "", apperror.Unauthorized("AUTH_INVALID_TOKEN", "API Key 无效", nil)
|
||
}
|
||
if key.Disabled {
|
||
return "", "", apperror.Unauthorized("AUTH_KEY_DISABLED", "API Key 已被停用", nil)
|
||
}
|
||
if key.ExpiresAt != nil && time.Now().UTC().After(*key.ExpiresAt) {
|
||
return "", "", apperror.Unauthorized("AUTH_KEY_EXPIRED", "API Key 已过期", nil)
|
||
}
|
||
// 更新 last_used_at,失败忽略
|
||
_ = s.repo.MarkUsed(ctx, key.ID, time.Now().UTC())
|
||
subject := fmt.Sprintf("api_key:%d:%s", key.ID, key.Name)
|
||
return subject, key.Role, nil
|
||
}
|
||
|
||
func toApiKeySummary(key *model.ApiKey) ApiKeySummary {
|
||
return ApiKeySummary{
|
||
ID: key.ID,
|
||
Name: key.Name,
|
||
Role: key.Role,
|
||
Prefix: key.Prefix,
|
||
CreatedBy: key.CreatedBy,
|
||
LastUsedAt: key.LastUsedAt,
|
||
ExpiresAt: key.ExpiresAt,
|
||
Disabled: key.Disabled,
|
||
CreatedAt: key.CreatedAt,
|
||
}
|
||
}
|
||
|
||
// generateApiKeyPlain 生成 bax_<32hex> 格式的密钥。
|
||
func generateApiKeyPlain() (string, error) {
|
||
buf := make([]byte, 24)
|
||
if _, err := rand.Read(buf); err != nil {
|
||
return "", err
|
||
}
|
||
return ApiKeyPrefix + hex.EncodeToString(buf), nil
|
||
}
|
||
|
||
// apiKeyHashPepper 用于 HMAC-SHA256 的应用级 pepper(固定常量)。
|
||
//
|
||
// 为什么安全:
|
||
// - API Key 明文是 192 位随机值(24 字节),pepper 提供额外 256 位应用级 entropy
|
||
// - 数据库泄漏场景下,攻击者即便拿到 key_hash 也无法离线反推(需同时泄漏二进制)
|
||
// - HMAC-SHA256 是 RFC 2104 标准构造,广泛用于 API token 签名验证
|
||
//
|
||
// 为什么不使用 bcrypt/argon2:
|
||
// - API Key 不是用户密码,而是系统生成的高熵 token(2^192 暴力枚举不可能)
|
||
// - 慢哈希会让每次 API 调用引入 100ms+ 延迟,严重影响 Dashboard 实时 SSE / CI 脚本
|
||
// - 业界方案(GitHub PAT、Stripe Key)也使用快速哈希 + 高熵原值
|
||
//
|
||
// 部署建议:若需要跨实例共享 key 数据库,通过环境变量覆盖 pepper(未来可扩展)。
|
||
var apiKeyHashPepper = []byte("backupx-api-key-hmac-pepper-v1")
|
||
|
||
// hashApiKey 对 API Key token 做 HMAC-SHA256,作为数据库存储指纹。
|
||
// 绝不用于用户密码(用户密码走 bcrypt 在 security/password.go)。
|
||
func hashApiKey(rawToken string) string {
|
||
mac := hmac.New(sha256.New, apiKeyHashPepper)
|
||
mac.Write([]byte(rawToken))
|
||
return hex.EncodeToString(mac.Sum(nil))
|
||
}
|