保存日志审计改造中间状态,准备兼容三种数据库
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-08 22:07:46 +08:00
parent 3af9250ffe
commit 9bdf866a87
7 changed files with 142 additions and 4 deletions

View File

@ -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)
}

View File

@ -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,

View File

@ -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 {

View File

@ -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"`

View File

@ -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,
})
}

View File

@ -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

View File

@ -0,0 +1,26 @@
# 日志存储与可视化改造
## 已确认需求
- 操作记录、配置审计、收发信记录写入独立 MySQL,邮件业务数据库不随之迁移。
- 设置导航在插件配置后增加日志入口,支持分类、时间筛选、分页与详情。
- 运行日志保存在 `log/运行日志`,按天写入;每周一压缩已经结束的自然周,停机后启动补做。
- 压缩后的运行日志仍可在后台查看;仅在压缩校验通过后替换对应明文文件。
- 配置审计展示操作者、时间、具体字段及修改前后值;秘密只显示配置状态。
- 旧配置审计幂等迁移并保留原文件,不伪造历史缺失的变更详情。
- 不自动删除历史记录;15 GB 是容量预算而不是容量保证。
- 配置仍在原有五个 JSON 文件中,日志连接保存在 `config.json`,密码加密保存。
## 实施状态
- [x] 已完成,等待复审:需求确认与修改前 Git 检查点;推送认证失败,未声称远程更新。
- [~] 进行中:MySQL 存储、连接配置及旧配置审计迁移。
- [ ] 未开始:操作及收发信记录接入。
- [ ] 未开始:运行日志轮转、每周压缩及只读查询。
- [ ] 未开始:具体配置变更明细与脱敏。
- [ ] 未开始:日志管理界面及原审计界面优化。
- [ ] 未开始:隔离测试、最新构建部署及浏览器验收。
## 验收边界
生产 MySQL 连接必须由部署者配置,不能猜测或复用其他项目的密码。未配置时保留旧审计路径并明确显示未启用,不能把 SQLite 或内存存储称为 MySQL 已启用。已配置后的写入故障必须可观察,不能无限排队或静默丢弃。