From daa7aa49af0211fb29382747c61ddbc631d901dd Mon Sep 17 00:00:00 2001 From: Awuqing <3184394176@qq.com> Date: Tue, 6 Oct 2026 18:52:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(storage):=20=E6=94=AF=E6=8C=81=20MinIO=20?= =?UTF-8?q?=E9=9B=86=E7=BE=A4=E5=AE=B9=E9=87=8F=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过 MinIO 监控接口补充 S3 后端的容量能力,页面与容量预警明确标注集群物理容量。 监控 Token 加密脱敏并留在 Master,备份与恢复下发统一过滤;失败查询不伪装为零容量。 补充认证、指标校验、告警和凭据边界测试,并同步中英文文档。 Closes #169 --- docs-site/docs/features/storage-backends.md | 6 +- .../current/features/storage-backends.md | 6 +- server/go.mod | 2 +- server/internal/service/agent_service.go | 4 + server/internal/service/restore_service.go | 4 + .../service/storage_target_service.go | 19 ++- .../service/storage_target_service_test.go | 150 ++++++++++++++++++ server/internal/storage/agent_config.go | 21 +++ server/internal/storage/agent_config_test.go | 27 ++++ server/internal/storage/rclone/factory.go | 19 ++- .../internal/storage/rclone/minio_capacity.go | 96 +++++++++++ .../storage/rclone/minio_capacity_test.go | 148 +++++++++++++++++ server/internal/storage/rclone/provider.go | 9 +- server/internal/storage/types.go | 17 +- .../StorageTargetFormDrawer.tsx | 12 +- .../storage-targets/field-config.ts | 14 ++ .../StorageTargetsPage.test.tsx | 17 +- .../storage-targets/StorageTargetsPage.tsx | 15 +- web/src/services/storage-targets.ts | 2 + 19 files changed, 560 insertions(+), 28 deletions(-) create mode 100644 server/internal/service/storage_target_service_test.go create mode 100644 server/internal/storage/agent_config.go create mode 100644 server/internal/storage/agent_config_test.go create mode 100644 server/internal/storage/rclone/minio_capacity.go create mode 100644 server/internal/storage/rclone/minio_capacity_test.go diff --git a/docs-site/docs/features/storage-backends.md b/docs-site/docs/features/storage-backends.md index 22a0b11..45afee4 100644 --- a/docs-site/docs/features/storage-backends.md +++ b/docs-site/docs/features/storage-backends.md @@ -27,7 +27,11 @@ New local-disk targets enable **Relay remote backups through Master** by default Physical storage usage is shown only when the backend returns total capacity and used bytes. S3-compatible storage, including MinIO, does not provide this through the standard S3 API or rclone About capability. Missing capacity is not treated as zero. -For these targets, BackupX displays the size tracked by backup records. Set a **backup quota** on the storage target to display a backup quota percentage. This soft quota is enforced by BackupX; it is not the bucket quota or the total disk capacity of the MinIO cluster. The tracked size does not include objects created by other applications or untracked object versions. Use the MinIO console or its monitoring interfaces for physical cluster and bucket capacity. +For these targets, BackupX displays the size tracked by backup records. Set a **backup quota** on the storage target to display a backup quota percentage. This soft quota is enforced by BackupX; it is not the bucket quota or the total disk capacity of the MinIO cluster. The tracked size does not include objects created by other applications or untracked object versions. For MinIO, enable **Query MinIO cluster capacity** on the S3 target and supply the monitoring `bearer_token` generated by `mc admin prometheus generate `. The token is optional only for an existing anonymous metrics endpoint. BackupX reads `/minio/v2/metrics/cluster` on the configured S3 Endpoint, including any reverse-proxy path prefix. The page explicitly displays the entire cluster’s physical capacity, calculated from its raw total and free bytes; it is not bucket usage or the usable capacity after erasure coding. + +The monitoring token is encrypted and masked like other secrets, and remains on the Master when backup or restore configurations are sent to Agents. Capacity queries have a timeout and reject redirects. If permission is denied, the endpoint is unavailable, or metrics are invalid, the page reports a query failure rather than zero capacity. Ordinary S3 targets retain the backup-record/quota display unless this MinIO option is enabled. Bucket usage and bucket quotas still require MinIO’s console or other monitoring tools. + +See the [MinIO metrics reference](https://github.com/minio/minio/blob/master/docs/metrics/prometheus/list.md) and [monitoring authentication guide](https://github.com/minio/minio/blob/master/docs/metrics/prometheus/README.md). ## Rclone backends diff --git a/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/storage-backends.md b/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/storage-backends.md index 3233117..530cc0d 100644 --- a/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/storage-backends.md +++ b/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/storage-backends.md @@ -27,7 +27,11 @@ BackupX 的目标是接入任何你想放置备份文件的地方。 只有后端返回总容量和已用字节时,才显示真实存储空间使用率。包括 MinIO 在内的 S3 兼容存储不通过标准 S3 API 或 rclone About 能力提供这些数据;未获取到容量不代表容量为零。 -此类目标会显示备份记录统计的大小。可在存储目标中设置 **备份配额**,显示备份配额使用率。这是 BackupX 执行的软限额,不是桶配额,也不是 MinIO 集群磁盘总容量;统计不包含其他应用写入的对象或未被记录的对象版本。集群物理容量与桶容量请使用 MinIO 控制台或监控接口查询。 +此类目标会显示备份记录统计的大小。可在存储目标中设置 **备份配额**,显示备份配额使用率。这是 BackupX 执行的软限额,不是桶配额,也不是 MinIO 集群磁盘总容量;统计不包含其他应用写入的对象或未被记录的对象版本。使用 MinIO 时,可在 S3 存储目标中开启 **查询 MinIO 集群容量**,填写 `mc admin prometheus generate ` 生成的监控 `bearer_token`。只有监控接口已允许匿名访问时才可留空。BackupX 会访问所配置 S3 Endpoint 下的 `/minio/v2/metrics/cluster`,保留反向代理的路径前缀。页面明确标为整个集群的物理容量,使用原始总容量与剩余容量计算;不代表当前桶用量,也不代表扣除纠删码开销后的可用容量。 + +监控 Token 会加密保存并脱敏展示,仅留在 Master,不随备份或恢复配置下发给 Agent。容量请求有超时限制,并拒绝重定向。权限不足、接口不可达或指标无效时,页面会提示查询失败,不会显示为零容量。未开启此选项的普通 S3 目标仍显示备份记录大小及配额。桶用量和桶配额仍需使用 MinIO 控制台或其他监控工具查询。 + +接口与鉴权见 [MinIO 指标参考](https://github.com/minio/minio/blob/master/docs/metrics/prometheus/list.md) 和 [监控鉴权说明](https://github.com/minio/minio/blob/master/docs/metrics/prometheus/README.md)。 ## Rclone 后端 diff --git a/server/go.mod b/server/go.mod index 730d9c9..bec0561 100644 --- a/server/go.mod +++ b/server/go.mod @@ -12,6 +12,7 @@ require ( github.com/natefinch/lumberjack v2.0.0+incompatible github.com/pquerna/otp v1.5.0 github.com/prometheus/client_golang v1.24.1 + github.com/prometheus/common v0.70.1 github.com/rclone/rclone v1.75.0 github.com/robfig/cron/v3 v3.0.1 github.com/shirou/gopsutil/v4 v4.26.6 @@ -190,7 +191,6 @@ require ( 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_model v0.6.2 // indirect - github.com/prometheus/common v0.70.1 // indirect github.com/prometheus/procfs v0.21.1 // indirect github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 // indirect github.com/quic-go/qpack v0.6.0 // indirect diff --git a/server/internal/service/agent_service.go b/server/internal/service/agent_service.go index 9f5f010..6325fff 100644 --- a/server/internal/service/agent_service.go +++ b/server/internal/service/agent_service.go @@ -239,6 +239,10 @@ func (s *AgentService) GetTaskSpec(ctx context.Context, node *model.Node, taskID if err != nil { return nil, fmt.Errorf("decrypt storage config: %w", err) } + configRaw, err = storage.ConfigForAgent(configRaw) + if err != nil { + return nil, err + } transferMode := storage.TransferModeDirect if strings.EqualFold(target.Type, storage.TypeLocalDisk) { var localConfig storage.LocalDiskConfig diff --git a/server/internal/service/restore_service.go b/server/internal/service/restore_service.go index 1f62072..2f5febd 100644 --- a/server/internal/service/restore_service.go +++ b/server/internal/service/restore_service.go @@ -647,6 +647,10 @@ func (s *RestoreService) GetAgentRestoreSpec(ctx context.Context, node *model.No if err != nil { return nil, fmt.Errorf("decrypt storage config: %w", err) } + configRaw, err = storage.ConfigForAgent(configRaw) + if err != nil { + return nil, err + } } return &AgentRestoreSpec{ RestoreRecordID: restore.ID, diff --git a/server/internal/service/storage_target_service.go b/server/internal/service/storage_target_service.go index 56ccf6e..68cdbd8 100644 --- a/server/internal/service/storage_target_service.go +++ b/server/internal/service/storage_target_service.go @@ -355,6 +355,9 @@ func (s *StorageTargetService) runCapacityCheckOnce(ctx context.Context, dispatc func (s *StorageTargetService) dispatchCapacityWarning(ctx context.Context, dispatcher EventDispatcher, target *model.StorageTarget, info *storage.StorageUsageInfo, usage float64) { title := "BackupX 存储容量预警" + if info.Scope == "minio_cluster" { + title = "BackupX MinIO 集群物理容量预警" + } usedGB := float64(*info.Used) / (1 << 30) totalGB := float64(*info.Total) / (1 << 30) body := fmt.Sprintf("存储目标:%s (类型: %s)\n使用率:%.1f%%\n已用:%.2f GB / 总量:%.2f GB\n建议清理旧备份或扩容。", @@ -367,6 +370,9 @@ func (s *StorageTargetService) dispatchCapacityWarning(ctx context.Context, disp "usedBytes": *info.Used, "totalBytes": *info.Total, } + if info.Scope != "" { + fields["capacityScope"] = info.Scope + } _ = dispatcher.DispatchEvent(ctx, model.NotificationEventStorageCapacity, title, body, fields) } @@ -738,11 +744,12 @@ func cloneMap(source map[string]any) map[string]any { } type StorageTargetUsage struct { - TargetID uint `json:"targetId"` - TargetName string `json:"targetName"` - RecordCount int64 `json:"recordCount"` - TotalSize int64 `json:"totalSize"` - DiskUsage *storage.StorageUsageInfo `json:"diskUsage,omitempty"` + TargetID uint `json:"targetId"` + TargetName string `json:"targetName"` + RecordCount int64 `json:"recordCount"` + TotalSize int64 `json:"totalSize"` + DiskUsage *storage.StorageUsageInfo `json:"diskUsage,omitempty"` + CapacityError string `json:"capacityError,omitempty"` } func (s *StorageTargetService) GetUsage(ctx context.Context, id uint) (*StorageTargetUsage, error) { @@ -772,6 +779,8 @@ func (s *StorageTargetService) GetUsage(ctx context.Context, id uint) (*StorageT if abouter, ok := provider.(storage.StorageAbout); ok { if diskUsage, aboutErr := abouter.About(ctx); aboutErr == nil { result.DiskUsage = diskUsage + } else if storage.ParseProviderType(target.Type) == storage.TypeS3 && configMap["minioCapacity"] == true { + result.CapacityError = "MinIO 容量查询失败,请检查监控 Token、访问权限和网络连接。" } } } diff --git a/server/internal/service/storage_target_service_test.go b/server/internal/service/storage_target_service_test.go new file mode 100644 index 0000000..35353ee --- /dev/null +++ b/server/internal/service/storage_target_service_test.go @@ -0,0 +1,150 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "backupx/server/internal/model" + "backupx/server/internal/storage" + storageRclone "backupx/server/internal/storage/rclone" +) + +func TestStorageTargetMinIOCapacityAndCredentials(t *testing.T) { + svc, _, _, _, owner, _ := newAgentServicePoolTestHarness(t) + var denied atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/minio/v2/metrics/cluster" || r.Header.Get("Authorization") != "Bearer monitoring-token" { + t.Error("invalid monitoring request") + } + if denied.Load() { + w.WriteHeader(http.StatusForbidden) + return + } + if _, err := fmt.Fprint(w, "# TYPE minio_cluster_capacity_raw_total_bytes gauge\nminio_cluster_capacity_raw_total_bytes 1024\n# TYPE minio_cluster_capacity_raw_free_bytes gauge\nminio_cluster_capacity_raw_free_bytes 128\n"); err != nil { + t.Errorf("write metrics: %v", err) + } + })) + defer server.Close() + ctx := context.Background() + target, err := svc.storageRepo.FindByID(ctx, 1) + if err != nil { + t.Fatal(err) + } + target.Type = storage.TypeS3 + target.ConfigCiphertext, err = svc.cipher.EncryptJSON(map[string]any{"endpoint": server.URL, "bucket": "backups", "accessKeyId": "backup-key", "secretAccessKey": "backup-secret", "minioCapacity": true, "minioMetricsToken": "monitoring-token"}) + if err != nil { + t.Fatal(err) + } + if err := svc.storageRepo.Update(ctx, target); err != nil { + t.Fatal(err) + } + registry := storage.NewRegistry(storageRclone.NewS3Factory()) + targets := NewStorageTargetService(svc.storageRepo, nil, registry, svc.cipher) + detail, err := targets.Get(ctx, 1) + if err != nil { + t.Fatal(err) + } + if detail.Config["minioMetricsToken"] != "********" { + t.Fatal("monitoring token exposed in target details") + } + usage, err := targets.GetUsage(ctx, 1) + if err != nil { + t.Fatal(err) + } + if usage.DiskUsage == nil || *usage.DiskUsage.Total != 1024 || *usage.DiskUsage.Used != 896 || usage.DiskUsage.Scope != "minio_cluster" { + t.Fatalf("unexpected usage: %+v", usage) + } + warning := &minioCapacityWarningCapture{} + var mu sync.Mutex + targets.runCapacityCheckOnce(ctx, warning, &mu, map[uint]bool{}) + if warning.title != "BackupX MinIO 集群物理容量预警" || warning.fields["capacityScope"] != "minio_cluster" { + t.Fatalf("capacity warning omitted cluster scope: %+v", warning) + } + denied.Store(true) + usage, err = targets.GetUsage(ctx, 1) + if err != nil { + t.Fatal(err) + } + if usage.DiskUsage != nil || usage.CapacityError == "" || strings.Contains(usage.CapacityError, "monitoring-token") { + t.Fatalf("invalid failure result: %+v", usage) + } + // 备份下发只能包含 S3 传输凭据,不能带上 Master 的监控权限。 + spec, err := svc.GetTaskSpec(ctx, owner, 1) + if err != nil { + t.Fatal(err) + } + assertTransferConfig := func(raw json.RawMessage) { + t.Helper() + var config map[string]any + if err := json.Unmarshal(raw, &config); err != nil { + t.Fatal(err) + } + if _, ok := config["minioMetricsToken"]; ok { + t.Fatal("monitoring token forwarded to Agent") + } + if _, ok := config["minioCapacity"]; ok { + t.Fatal("Agent should not query cluster capacity") + } + if config["accessKeyId"] != "backup-key" || config["secretAccessKey"] != "backup-secret" { + t.Fatal("S3 transfer credentials were lost") + } + } + assertTransferConfig(spec.StorageTargets[0].Config) + // 恢复下发使用相同的过滤规则。 + h := newRestoreTestHarness(t, true) + restoreTarget, err := h.service.targets.FindByID(ctx, 1) + if err != nil { + t.Fatal(err) + } + restoreTarget.Type = target.Type + var config map[string]any + if err := svc.cipher.DecryptJSON(target.ConfigCiphertext, &config); err != nil { + t.Fatal(err) + } + restoreTarget.ConfigCiphertext, err = h.service.cipher.EncryptJSON(config) + if err != nil { + t.Fatal(err) + } + if err := h.service.targets.Update(ctx, restoreTarget); err != nil { + t.Fatal(err) + } + task, err := h.tasks.FindByID(ctx, 1) + if err != nil { + t.Fatal(err) + } + restoreOwner, err := h.nodes.FindByID(ctx, task.NodeID) + if err != nil { + t.Fatal(err) + } + record := &model.BackupRecord{TaskID: task.ID, StorageTargetID: 1, NodeID: restoreOwner.ID, Status: model.BackupRecordStatusSuccess, FileName: "backup.tar.gz", StoragePath: "backup.tar.gz", StartedAt: time.Now().UTC()} + if err := h.records.Create(ctx, record); err != nil { + t.Fatal(err) + } + restore := &model.RestoreRecord{BackupRecordID: record.ID, TaskID: task.ID, NodeID: restoreOwner.ID, Status: model.RestoreRecordStatusRunning, StartedAt: time.Now().UTC()} + if err := h.restores.Create(ctx, restore); err != nil { + t.Fatal(err) + } + restoreSpec, err := h.service.GetAgentRestoreSpec(ctx, restoreOwner, restore.ID) + if err != nil { + t.Fatal(err) + } + assertTransferConfig(restoreSpec.Storage.Config) +} + +type minioCapacityWarningCapture struct { + title string + fields map[string]any +} + +func (c *minioCapacityWarningCapture) DispatchEvent(_ context.Context, _, title, _ string, fields map[string]any) error { + c.title, c.fields = title, fields + return nil +} diff --git a/server/internal/storage/agent_config.go b/server/internal/storage/agent_config.go new file mode 100644 index 0000000..a1e7010 --- /dev/null +++ b/server/internal/storage/agent_config.go @@ -0,0 +1,21 @@ +package storage + +import ( + "encoding/json" + "fmt" +) + +// ConfigForAgent 保留传输配置,MinIO 监控 Token 和容量查询只留在 Master。 +func ConfigForAgent(raw []byte) ([]byte, error) { + var cfg map[string]json.RawMessage + if err := json.Unmarshal(raw, &cfg); err != nil { + return nil, fmt.Errorf("decode agent storage config: %w", err) + } + delete(cfg, "minioCapacity") + delete(cfg, "minioMetricsToken") + result, err := json.Marshal(cfg) + if err != nil { + return nil, fmt.Errorf("encode agent storage config: %w", err) + } + return result, nil +} diff --git a/server/internal/storage/agent_config_test.go b/server/internal/storage/agent_config_test.go new file mode 100644 index 0000000..353aecd --- /dev/null +++ b/server/internal/storage/agent_config_test.go @@ -0,0 +1,27 @@ +package storage + +import ( + "encoding/json" + "testing" +) + +func TestConfigForAgentPreservesTransferFieldsAndRemovesMonitoring(t *testing.T) { + raw := []byte(`{"root":"/backups","masterRelay":true,"customNumber":9007199254740993,"secretAccessKey":"transfer-secret","minioCapacity":true,"minioMetricsToken":"monitoring-token"}`) + result, err := ConfigForAgent(raw) + if err != nil { + t.Fatal(err) + } + var config map[string]json.RawMessage + if err := json.Unmarshal(result, &config); err != nil { + t.Fatal(err) + } + if config["minioCapacity"] != nil || config["minioMetricsToken"] != nil { + t.Fatal("monitoring configuration was forwarded") + } + if string(config["root"]) != `"/backups"` || string(config["masterRelay"]) != "true" || string(config["customNumber"]) != "9007199254740993" || string(config["secretAccessKey"]) != `"transfer-secret"` { + t.Fatal("transfer configuration changed") + } + if _, err := ConfigForAgent([]byte("invalid")); err == nil { + t.Fatal("invalid storage configuration was accepted") + } +} diff --git a/server/internal/storage/rclone/factory.go b/server/internal/storage/rclone/factory.go index 08e08af..de6082c 100644 --- a/server/internal/storage/rclone/factory.go +++ b/server/internal/storage/rclone/factory.go @@ -66,7 +66,9 @@ type S3Factory struct{} func NewS3Factory() S3Factory { return S3Factory{} } func (S3Factory) Type() storage.ProviderType { return storage.ProviderTypeS3 } -func (S3Factory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} } +func (S3Factory) SensitiveFields() []string { + return []string{"accessKeyId", "secretAccessKey", "minioMetricsToken"} +} func (S3Factory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) { cfg, err := storage.DecodeConfig[storage.S3Config](rawConfig) @@ -79,7 +81,20 @@ func (S3Factory) New(ctx context.Context, rawConfig map[string]any) (storage.Sto if strings.TrimSpace(cfg.AccessKeyID) == "" || strings.TrimSpace(cfg.SecretAccessKey) == "" { return nil, fmt.Errorf("s3 credentials are required") } - return newFs(ctx, storage.ProviderTypeS3, buildS3Remote("Other", cfg.AccessKeyID, cfg.SecretAccessKey, cfg.Endpoint, cfg.Region, cfg.Bucket, cfg.ForcePathStyle)) + metricsURL := "" + if cfg.MinIOCapacity { + metricsURL, err = minioMetricsURL(cfg.Endpoint, cfg.MinIOMetricsToken) + if err != nil { + return nil, err + } + } + p, err := newFs(ctx, storage.ProviderTypeS3, buildS3Remote("Other", cfg.AccessKeyID, cfg.SecretAccessKey, cfg.Endpoint, cfg.Region, cfg.Bucket, cfg.ForcePathStyle)) + if err != nil { + return nil, err + } + p.minioMetricsURL = metricsURL + p.minioMetricsToken = strings.TrimSpace(cfg.MinIOMetricsToken) + return p, nil } // buildS3Remote 构建 S3 兼容存储的 rclone 连接字符串。 diff --git a/server/internal/storage/rclone/minio_capacity.go b/server/internal/storage/rclone/minio_capacity.go new file mode 100644 index 0000000..1918eca --- /dev/null +++ b/server/internal/storage/rclone/minio_capacity.go @@ -0,0 +1,96 @@ +package rclone + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "math" + "net/http" + "net/url" + "strings" + "time" + + "backupx/server/internal/storage" + "github.com/prometheus/common/expfmt" + "github.com/prometheus/common/model" +) + +var minioMetricsClient = &http.Client{ + Timeout: 10 * time.Second, + // 监控 Token 只发送到配置的 MinIO;重定向不能改变凭据接收方。 + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, +} + +func minioMetricsURL(endpoint, token string) (string, error) { + u, err := url.Parse(strings.TrimSpace(endpoint)) + if err != nil || u == nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return "", errors.New("MinIO 容量查询需要不含凭据、查询参数或片段的 HTTP(S) Endpoint") + } + if strings.ContainsAny(strings.TrimSpace(token), "\r\n") { + return "", errors.New("MinIO 监控 Token 不能包含换行") + } + u.Path = strings.TrimRight(u.Path, "/") + "/minio/v2/metrics/cluster" + u.RawPath = "" + return u.String(), nil +} + +func minioCapacity(ctx context.Context, endpoint, token string) (*storage.StorageUsageInfo, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("create MinIO metrics request: %w", err) + } + req.Header.Set("Accept", "text/plain; version=0.0.4") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := minioMetricsClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request MinIO capacity: %w", err) + } + const maxMetricsBytes = 8 << 20 + payload, readErr := io.ReadAll(io.LimitReader(resp.Body, maxMetricsBytes+1)) + closeErr := resp.Body.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, fmt.Errorf("read MinIO metrics: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("MinIO capacity returned HTTP %d", resp.StatusCode) + } + if len(payload) > maxMetricsBytes { + return nil, errors.New("MinIO metrics response is too large") + } + parser := expfmt.NewTextParser(model.UTF8Validation) + families, err := parser.TextToMetricFamilies(bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("parse MinIO metrics: %w", err) + } + values := make([]int64, 2) + for i, name := range []string{"minio_cluster_capacity_raw_total_bytes", "minio_cluster_capacity_raw_free_bytes"} { + family := families[name] + if family == nil || len(family.Metric) == 0 { + return nil, fmt.Errorf("MinIO metrics missing %s", name) + } + for j, metric := range family.Metric { + if metric.Gauge == nil { + return nil, fmt.Errorf("MinIO capacity metric %s is not a gauge", name) + } + value := metric.Gauge.GetValue() + if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || value >= math.Exp2(63) || math.Trunc(value) != value { + return nil, fmt.Errorf("MinIO capacity metric %s has an invalid value", name) + } + // 集群指标可能带 server 标签;相同总量不能按节点重复相加。 + if j > 0 && values[i] != int64(value) { + return nil, fmt.Errorf("MinIO capacity metric %s has inconsistent samples", name) + } + values[i] = int64(value) + } + } + total, free := values[0], values[1] + if total <= 0 || free > total { + return nil, errors.New("MinIO capacity metrics are unavailable or inconsistent") + } + used := total - free + return &storage.StorageUsageInfo{Total: &total, Used: &used, Free: &free, Scope: "minio_cluster"}, nil +} diff --git a/server/internal/storage/rclone/minio_capacity_test.go b/server/internal/storage/rclone/minio_capacity_test.go new file mode 100644 index 0000000..a576d39 --- /dev/null +++ b/server/internal/storage/rclone/minio_capacity_test.go @@ -0,0 +1,148 @@ +package rclone + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "backupx/server/internal/storage" + "backupx/server/internal/storage/codec" +) + +func capacityMetrics(total, free string) string { + return "# TYPE minio_cluster_capacity_raw_total_bytes gauge\nminio_cluster_capacity_raw_total_bytes{server=\"minio:9000\"} " + total + + "\n# TYPE minio_cluster_capacity_raw_free_bytes gauge\nminio_cluster_capacity_raw_free_bytes{server=\"minio:9000\"} " + free + "\n" +} + +func TestS3MinIOCapacity(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/minio/v2/metrics/cluster" || r.Header.Get("Authorization") != "Bearer monitoring-token" { + t.Errorf("unexpected metrics request path or authorization") + w.WriteHeader(http.StatusForbidden) + return + } + if _, err := fmt.Fprint(w, capacityMetrics("1.024e+3", "768")); err != nil { + t.Errorf("write metrics: %v", err) + } + })) + defer server.Close() + factory := NewS3Factory() + raw := map[string]any{"endpoint": server.URL, "bucket": "backups", "accessKeyId": "backup-key", "secretAccessKey": "backup-secret", "minioCapacity": true, "minioMetricsToken": "monitoring-token"} + provider, err := factory.New(context.Background(), raw) + if err != nil { + t.Fatal(err) + } + usage, err := provider.(storage.StorageAbout).About(context.Background()) + if err != nil { + t.Fatal(err) + } + if *usage.Total != 1024 || *usage.Free != 768 || *usage.Used != 256 || usage.Scope != "minio_cluster" { + t.Fatalf("unexpected capacity: %+v", usage) + } + if codec.MaskConfig(raw, factory.SensitiveFields())["minioMetricsToken"] != "********" { + t.Fatal("monitoring token must be masked") + } + raw["minioCapacity"] = false + provider, err = factory.New(context.Background(), raw) + if err != nil { + t.Fatal(err) + } + if _, err := provider.(storage.StorageAbout).About(context.Background()); err == nil { + t.Fatal("ordinary S3 must not probe MinIO metrics") + } +} + +func TestMinIOCapacityValidation(t *testing.T) { + cases := []struct { + name, metrics string + status int + wantError bool + }{ + {"public", capacityMetrics("1024", "1024"), 200, false}, + {"duplicate cluster samples", capacityMetrics("1024", "768") + "minio_cluster_capacity_raw_total_bytes{server=\"other:9000\"} 1024\n", 200, false}, + {"unauthorized", "do not return response bodies", 401, true}, + {"forbidden", "private metrics", 403, true}, + {"missing", "# HELP unrelated data\n", 200, true}, + {"zero capacity", capacityMetrics("0", "0"), 200, true}, + {"negative", capacityMetrics("1024", "-1"), 200, true}, + {"invalid free", capacityMetrics("1024", "2048"), 200, true}, + {"NaN", capacityMetrics("NaN", "10"), 200, true}, + {"overflow", capacityMetrics("9.223372036854776e18", "10"), 200, true}, + {"inconsistent replicas", capacityMetrics("1024", "768") + "minio_cluster_capacity_raw_total_bytes{server=\"other:9000\"} 2048\n", 200, true}, + {"invalid payload", "not metrics!", 200, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "" { + t.Error("public endpoint must not receive S3 credentials") + } + w.WriteHeader(tc.status) + if _, err := fmt.Fprint(w, tc.metrics); err != nil { + t.Errorf("write metrics: %v", err) + } + })) + defer server.Close() + usage, err := minioCapacity(context.Background(), server.URL, "") + if (err != nil) != tc.wantError { + t.Fatalf("usage=%+v error=%v", usage, err) + } + if tc.name == "duplicate cluster samples" && *usage.Total != 1024 { + t.Fatal("cluster capacity was counted more than once") + } + }) + } +} + +func TestMinIOCapacityDoesNotRedirectCredentials(t *testing.T) { + var requests atomic.Int32 + receiver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1) })) + defer receiver.Close() + redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, receiver.URL, http.StatusTemporaryRedirect) + })) + defer redirector.Close() + if _, err := minioCapacity(context.Background(), redirector.URL, "monitoring-token"); err == nil { + t.Fatal("expected redirect failure") + } + if requests.Load() != 0 { + t.Fatal("metrics client followed redirect") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := minioCapacity(ctx, redirector.URL, ""); !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation lost: %v", err) + } +} + +func TestMinIOMetricsURL(t *testing.T) { + got, err := minioMetricsURL("https://minio.example.com/proxy/", "token") + if err != nil || got != "https://minio.example.com/proxy/minio/v2/metrics/cluster" { + t.Fatalf("got %q, %v", got, err) + } + for _, endpoint := range []string{"", "minio:9000", "ftp://minio", "https://user:pass@minio", "https://minio?token=secret", "https://minio#fragment"} { + if _, err := minioMetricsURL(endpoint, ""); err == nil { + t.Fatalf("accepted invalid endpoint %q", endpoint) + } + } + if _, err := minioMetricsURL("https://minio", "token\r\ninjected: header"); err == nil { + t.Fatal("accepted invalid token") + } +} + +func TestMinIOCapacityResponseLimit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := fmt.Fprint(w, strings.Repeat(" ", (8<<20)+1)); err != nil { + t.Logf("client stopped oversized response: %v", err) + } + })) + defer server.Close() + if _, err := minioCapacity(context.Background(), server.URL, ""); err == nil { + t.Fatal("accepted oversized response") + } +} diff --git a/server/internal/storage/rclone/provider.go b/server/internal/storage/rclone/provider.go index ff33147..6ee9704 100644 --- a/server/internal/storage/rclone/provider.go +++ b/server/internal/storage/rclone/provider.go @@ -18,8 +18,10 @@ import ( // Provider 包装 rclone fs.Fs,实现 storage.StorageProvider 接口。 type Provider struct { - providerType storage.ProviderType - rfs fs.Fs + providerType storage.ProviderType + rfs fs.Fs + minioMetricsURL string + minioMetricsToken string } func newProvider(providerType storage.ProviderType, rfs fs.Fs) *Provider { @@ -156,6 +158,9 @@ func (p *Provider) List(ctx context.Context, prefix string) ([]storage.ObjectInf // About 查询远端存储空间。并非所有 rclone 后端都支持。 func (p *Provider) About(ctx context.Context) (*storage.StorageUsageInfo, error) { + if p.minioMetricsURL != "" { + return minioCapacity(ctx, p.minioMetricsURL, p.minioMetricsToken) + } about := p.rfs.Features().About if about == nil { return nil, fmt.Errorf("rclone about: backend %s does not support About", p.providerType) diff --git a/server/internal/storage/types.go b/server/internal/storage/types.go index 1376ab1..b32a44f 100644 --- a/server/internal/storage/types.go +++ b/server/internal/storage/types.go @@ -71,13 +71,14 @@ type ProviderFactory interface { } // StorageAbout 是可选能力接口,支持查询远端存储空间。 -// 并非所有后端都支持(如 S3/FTP 不支持),通过 type assertion 检测。 +// 并非所有后端都支持;MinIO 可通过专用监控接口查询集群容量。 type StorageAbout interface { About(ctx context.Context) (*StorageUsageInfo, error) } // StorageUsageInfo 描述远端存储的空间使用情况。 type StorageUsageInfo struct { + Scope string `json:"scope,omitempty"` // minio_cluster 表示整个集群的物理容量 Total *int64 `json:"total,omitempty"` // 总空间(字节) Used *int64 `json:"used,omitempty"` // 已用空间 Free *int64 `json:"free,omitempty"` // 可用空间 @@ -114,12 +115,14 @@ type LocalDiskConfig struct { } type S3Config struct { - Endpoint string `json:"endpoint"` - Region string `json:"region"` - Bucket string `json:"bucket"` - AccessKeyID string `json:"accessKeyId"` - SecretAccessKey string `json:"secretAccessKey"` - ForcePathStyle bool `json:"forcePathStyle"` + Endpoint string `json:"endpoint"` + Region string `json:"region"` + Bucket string `json:"bucket"` + AccessKeyID string `json:"accessKeyId"` + SecretAccessKey string `json:"secretAccessKey"` + ForcePathStyle bool `json:"forcePathStyle"` + MinIOCapacity bool `json:"minioCapacity"` + MinIOMetricsToken string `json:"minioMetricsToken"` } type WebDAVConfig struct { diff --git a/web/src/components/storage-targets/StorageTargetFormDrawer.tsx b/web/src/components/storage-targets/StorageTargetFormDrawer.tsx index 66bbeeb..6a00935 100644 --- a/web/src/components/storage-targets/StorageTargetFormDrawer.tsx +++ b/web/src/components/storage-targets/StorageTargetFormDrawer.tsx @@ -211,11 +211,13 @@ export function StorageTargetFormDrawer({ {field.description} )} - {initialValue?.maskedFields?.includes(field.key) && !draft.config[field.key] && ( - - 已存在敏感配置,留空则保持不变。 - - )} + {initialValue?.maskedFields?.includes(field.key) && + initialValue.config[field.key] && + !draft.config[field.key] && ( + + 已存在敏感配置,留空则保持不变。 + + )} ) }) diff --git a/web/src/components/storage-targets/field-config.ts b/web/src/components/storage-targets/field-config.ts index 2d26301..493acf3 100644 --- a/web/src/components/storage-targets/field-config.ts +++ b/web/src/components/storage-targets/field-config.ts @@ -53,6 +53,20 @@ const BUILTIN_FIELD_CONFIG: Record = { type: 'switch', description: 'MinIO 等兼容存储需要开启。', }, + { + key: 'minioCapacity', + label: '查询 MinIO 集群容量', + type: 'switch', + description: '使用 MinIO 监控接口查询整个集群的物理容量;普通 S3 存储无需开启。', + }, + { + key: 'minioMetricsToken', + label: 'MinIO 监控 Token', + type: 'password', + sensitive: true, + description: + '填写 mc admin prometheus generate 生成的 bearer_token;仅监控接口允许匿名访问时可留空。', + }, ], webdav: [ { diff --git a/web/src/pages/storage-targets/StorageTargetsPage.test.tsx b/web/src/pages/storage-targets/StorageTargetsPage.test.tsx index 83a9551..fb4db5b 100644 --- a/web/src/pages/storage-targets/StorageTargetsPage.test.tsx +++ b/web/src/pages/storage-targets/StorageTargetsPage.test.tsx @@ -73,10 +73,23 @@ describe('storage usage', () => { targetName: 'MinIO', recordCount: 0, totalSize: 512, - diskUsage: { total: 4096, used: 1024 }, + diskUsage: { total: 4096, used: 1024, scope: 'minio_cluster' }, }) render() - expect(await screen.findByText('使用率 25%')).toBeInTheDocument() + expect(await screen.findByText('MinIO 集群物理容量使用率 25%')).toBeInTheDocument() expect(screen.queryByText(/备份配额使用率/)).not.toBeInTheDocument() }) + it('shows a failed capacity query without reporting zero capacity', async () => { + mockTarget() + vi.mocked(getStorageTargetUsage).mockResolvedValue({ + targetId: 1, + targetName: 'MinIO', + recordCount: 0, + totalSize: 0, + capacityError: 'MinIO 容量查询失败,请检查监控 Token、访问权限和网络连接。', + }) + render() + expect(await screen.findByText(/MinIO 容量查询失败/)).toBeInTheDocument() + expect(screen.queryByText(/使用率.*%/)).not.toBeInTheDocument() + }) }) diff --git a/web/src/pages/storage-targets/StorageTargetsPage.tsx b/web/src/pages/storage-targets/StorageTargetsPage.tsx index bc65423..84b0c2c 100644 --- a/web/src/pages/storage-targets/StorageTargetsPage.tsx +++ b/web/src/pages/storage-targets/StorageTargetsPage.tsx @@ -310,7 +310,12 @@ export function StorageTargetsPage() {
- {hasDiskCapacity ? '使用率' : '备份配额使用率'} {percent}% + {hasDiskCapacity + ? disk.scope === 'minio_cluster' + ? 'MinIO 集群物理容量使用率' + : '使用率' + : '备份配额使用率'}{' '} + {percent}% {formatBytes(used)} / {formatBytes(total)} @@ -327,6 +332,11 @@ export function StorageTargetsPage() { size="small" showText={false} /> + {usage.capacityError && ( + + {usage.capacityError} + + )} {!hasDiskCapacity && ( 按备份记录大小与设置配额计算,不代表存储物理容量。 @@ -341,7 +351,8 @@ export function StorageTargetsPage() { 备份记录大小:{formatBytes(usage.totalSize)} - 未获取到存储总容量,无法计算物理空间使用率;可设置备份配额。 + {usage.capacityError ?? + '未获取到存储总容量,无法计算物理空间使用率;可设置备份配额。'} ) diff --git a/web/src/services/storage-targets.ts b/web/src/services/storage-targets.ts index 709a9f3..09b1e91 100644 --- a/web/src/services/storage-targets.ts +++ b/web/src/services/storage-targets.ts @@ -84,6 +84,7 @@ export async function completeGoogleDriveAuth(queryString: string) { } export interface StorageDiskUsage { + scope?: string total?: number used?: number free?: number @@ -96,6 +97,7 @@ export interface StorageTargetUsage { recordCount: number totalSize: number diskUsage?: StorageDiskUsage + capacityError?: string } export async function toggleStorageTargetStar(id: number) {