From 9bdf866a870e36ad9305a693544c3d31e4ab6397 Mon Sep 17 00:00:00 2001 From: smail Date: Tue, 8 Sep 2026 22:07:46 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E6=97=A5=E5=BF=97=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E6=94=B9=E9=80=A0=E4=B8=AD=E9=97=B4=E7=8A=B6=E6=80=81?= =?UTF-8?q?=EF=BC=8C=E5=87=86=E5=A4=87=E5=85=BC=E5=AE=B9=E4=B8=89=E7=A7=8D?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/config/audit_changes.go | 101 +++++++++++++++++++++++++++ backend/config/database_migration.go | 2 + backend/config/manager_v2.go | 13 ++-- backend/config/security.go | 2 + backend/config/ssl_commit.go | 1 + backend/controllers/config.go | 1 + docs/日志改造任务.md | 26 +++++++ 7 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 backend/config/audit_changes.go create mode 100644 docs/日志改造任务.md diff --git a/backend/config/audit_changes.go b/backend/config/audit_changes.go new file mode 100644 index 0000000..a0de157 --- /dev/null +++ b/backend/config/audit_changes.go @@ -0,0 +1,101 @@ +package config + +import ( + "encoding/json" + "reflect" + "sort" + "strings" +) + +// ConfigAuditChange 只保存安全的显示值,不保存密文或请求原文。 +type ConfigAuditChange struct { + Field string `json:"field"` + Before string `json:"before"` + After string `json:"after"` + Sensitive bool `json:"sensitive,omitempty"` +} + +// AuditChanges 对比业务字段,排除代次、摘要和内部持久化元数据。 +func AuditChanges(before, after RuntimeConfig) []ConfigAuditChange { + left := auditFields(before) + right := auditFields(after) + keys := make(map[string]bool) + for key := range left { keys[key] = true } + for key := range right { keys[key] = true } + ordered := make([]string, 0, len(keys)) + for key := range keys { ordered = append(ordered, key) } + sort.Strings(ordered) + changes := make([]ConfigAuditChange, 0) + for _, key := range ordered { + if reflect.DeepEqual(left[key], right[key]) { continue } + change := ConfigAuditChange{Field: key, Before: auditDisplay(left[key]), After: auditDisplay(right[key])} + if auditSensitive(key) { + change.Sensitive = true + change.Before, change.After = "未配置", "未配置" + if auditConfigured(left[key]) { change.Before = "已配置(隐藏)" } + if auditConfigured(right[key]) { change.After = "已配置(隐藏,已更新)" } + } + changes = append(changes, change) + } + return changes +} + +func auditFields(runtime RuntimeConfig) map[string]any { + result := map[string]any{} + for key, value := range map[string]any{ + "root": runtime.Root, "smtp": runtime.SMTP, "email": runtime.Email, + "ssl": runtime.SSL, "plugins": runtime.Plugins, + } { + data, _ := json.Marshal(value) + var object any + _ = json.Unmarshal(data, &object) + flattenAudit(result, key, object) + } + return result +} + +func flattenAudit(result map[string]any, path string, value any) { + name := path[strings.LastIndex(path, ".")+1:] + switch name { + case "meta", "sections", "generation", "updatedAt", "schemaVersion", "configVersion", "version": + return + } + if path == "root.smtp" || path == "root.email" || path == "root.ssl" || path == "root.plugins" { return } + if auditSensitive(path) { result[path] = value; return } + if object, ok := value.(map[string]any); ok { + for key, item := range object { flattenAudit(result, path+"."+key, item) } + return + } + result[path] = value +} + +func auditSensitive(path string) bool { + name := strings.ToLower(path[strings.LastIndex(path, ".")+1:]) + for _, word := range []string{"password", "secret", "token", "apikey", "api_key", "credential", "ciphertext", "authorization", "cookie"} { + if strings.Contains(name, word) { return true } + } + // 插件设置是自由结构,未知键也不能作为明文审计输出。 + return strings.HasSuffix(path, ".settings") || strings.HasSuffix(path, ".encryptionKeyRef") +} + +func auditConfigured(value any) bool { + if value == nil || value == "" { return false } + if object, ok := value.(map[string]any); ok { + if ciphertext, exists := object["ciphertext"]; exists { return ciphertext != "" } + return len(object) > 0 + } + return true +} + +func auditDisplay(value any) string { + if value == nil { return "未设置" } + if text, ok := value.(string); ok { + if len([]rune(text)) > 512 { return string([]rune(text)[:512])+"…" } + return text + } + if flag, ok := value.(bool); ok { if flag { return "开启" }; return "关闭" } + data, _ := json.Marshal(value) + text := []rune(string(data)) + if len(text) > 512 { return string(text[:512])+"…" } + return string(text) +} diff --git a/backend/config/database_migration.go b/backend/config/database_migration.go index bb49c5a..b4527b2 100644 --- a/backend/config/database_migration.go +++ b/backend/config/database_migration.go @@ -96,6 +96,8 @@ func (m *ConfigManager) SwitchDatabase(ctx context.Context, expectedGeneration u Section: SectionRoot, ActorID: actor.UserID, ActorRole: actor.Role, + ActorName: actor.Name, + Changes: AuditChanges(current, candidate), FromGeneration: current.Generation, ToGeneration: candidate.Generation, TargetGeneration: candidate.Generation, diff --git a/backend/config/manager_v2.go b/backend/config/manager_v2.go index cf006c3..0d3e97b 100644 --- a/backend/config/manager_v2.go +++ b/backend/config/manager_v2.go @@ -56,6 +56,7 @@ type UpdateRequest struct { type AuditActor struct { UserID string + Name string Role string SessionID string RecentAuth bool @@ -469,6 +470,8 @@ func (m *ConfigManager) apply(ctx context.Context, generation uint64, actor Audi Section: pending.Section, ActorID: pending.Actor.UserID, ActorRole: pending.Actor.Role, + ActorName: pending.Actor.Name, + Changes: AuditChanges(current, candidate), FromGeneration: current.Generation, ToGeneration: candidate.Generation, ChangedFields: changedFields(current, candidate, pending.Section), @@ -726,6 +729,8 @@ func (m *ConfigManager) rollbackWithState(ctx context.Context, generation uint64 Section: SectionRoot, ActorID: actor.UserID, ActorRole: actor.Role, + ActorName: actor.Name, + Changes: AuditChanges(current, candidate), FromGeneration: current.Generation, ToGeneration: candidate.Generation, TargetGeneration: generation, @@ -771,10 +776,9 @@ func (m *ConfigManager) writeAudit(ctx context.Context, event ConfigAuditEvent) } func changedFields(current, candidate RuntimeConfig, section Section) []string { - if current.Generation == candidate.Generation && section == "" { - return nil - } - return []string{string(section)} + var fields []string + for _, change := range AuditChanges(current, candidate) { fields = append(fields, change.Field) } + return fields } func servicesForSection(section Section) []ServiceName { @@ -1075,6 +1079,7 @@ func (m *ConfigManager) MigrateLegacyPluginSecrets(ctx context.Context, importer FromGeneration: current.Generation, ToGeneration: candidate.Generation, ChangedFields: []string{"plugins.legacySecret"}, + Changes: AuditChanges(current, candidate), Result: "success", CreatedAt: now, }); err != nil { diff --git a/backend/config/security.go b/backend/config/security.go index 8164846..7ed554f 100644 --- a/backend/config/security.go +++ b/backend/config/security.go @@ -31,6 +31,8 @@ type ConfigAuditEvent struct { Section Section `json:"section,omitempty"` ActorID string `json:"actorId"` ActorRole string `json:"actorRole"` + ActorName string `json:"actorName,omitempty"` + Changes []ConfigAuditChange `json:"changes,omitempty"` FromGeneration uint64 `json:"fromGeneration"` ToGeneration uint64 `json:"toGeneration"` TargetGeneration uint64 `json:"targetGeneration,omitempty"` diff --git a/backend/config/ssl_commit.go b/backend/config/ssl_commit.go index 0aab0f0..e10745f 100644 --- a/backend/config/ssl_commit.go +++ b/backend/config/ssl_commit.go @@ -139,6 +139,7 @@ func WriteSSLConfig(expected, next *Config) error { return manager.writeAudit(context.Background(), ConfigAuditEvent{ Operation: ConfigOperationUpdate, Section: SectionSSL, ActorID: "system", ActorRole: "system", FromGeneration: current.Generation, ToGeneration: candidate.Generation, + Changes: AuditChanges(current, candidate), ChangedFields: []string{"tls"}, Result: "success", CreatedAt: now, }) } diff --git a/backend/controllers/config.go b/backend/controllers/config.go index 662fada..02f190f 100644 --- a/backend/controllers/config.go +++ b/backend/controllers/config.go @@ -369,6 +369,7 @@ func configSectionFromPath(path string) (config.Section, bool) { func configActor(ctx *appcontext.Context, req *http.Request) config.AuditActor { actor := config.AuditActor{UserID: strconv.Itoa(ctx.UserID), Role: string(ctx.EffectiveRole()), RecentAuth: ctx.RecentAuth, RecoveryCodeConfigured: ctx.RecoveryCodeConfigured} + actor.Name = ctx.UserAccount if sessionManager := session.Instance; sessionManager != nil { if cookie, err := req.Cookie(sessionManager.Cookie.Name); err == nil { actor.SessionID = cookie.Value diff --git a/docs/日志改造任务.md b/docs/日志改造任务.md new file mode 100644 index 0000000..a17d372 --- /dev/null +++ b/docs/日志改造任务.md @@ -0,0 +1,26 @@ +# 日志存储与可视化改造 + +## 已确认需求 + +- 操作记录、配置审计、收发信记录写入独立 MySQL,邮件业务数据库不随之迁移。 +- 设置导航在插件配置后增加日志入口,支持分类、时间筛选、分页与详情。 +- 运行日志保存在 `log/运行日志`,按天写入;每周一压缩已经结束的自然周,停机后启动补做。 +- 压缩后的运行日志仍可在后台查看;仅在压缩校验通过后替换对应明文文件。 +- 配置审计展示操作者、时间、具体字段及修改前后值;秘密只显示配置状态。 +- 旧配置审计幂等迁移并保留原文件,不伪造历史缺失的变更详情。 +- 不自动删除历史记录;15 GB 是容量预算而不是容量保证。 +- 配置仍在原有五个 JSON 文件中,日志连接保存在 `config.json`,密码加密保存。 + +## 实施状态 + +- [x] 已完成,等待复审:需求确认与修改前 Git 检查点;推送认证失败,未声称远程更新。 +- [~] 进行中:MySQL 存储、连接配置及旧配置审计迁移。 +- [ ] 未开始:操作及收发信记录接入。 +- [ ] 未开始:运行日志轮转、每周压缩及只读查询。 +- [ ] 未开始:具体配置变更明细与脱敏。 +- [ ] 未开始:日志管理界面及原审计界面优化。 +- [ ] 未开始:隔离测试、最新构建部署及浏览器验收。 + +## 验收边界 + +生产 MySQL 连接必须由部署者配置,不能猜测或复用其他项目的密码。未配置时保留旧审计路径并明确显示未启用,不能把 SQLite 或内存存储称为 MySQL 已启用。已配置后的写入故障必须可观察,不能无限排队或静默丢弃。