fix(conf): 配置键名容错 + 未知键告警 + 蜜罐保留期缺省值修正

现场故障:配置写 `honeypot_retention_days <= 30`,日志却报
「蜜罐自动清理未启用:honeypot_retention_days <= 0」。

根因(实测 ini v1.67.0 行为确认):
1. 键名多一个 t(retention vs retain)→ ini 对未知键**静默忽略**,保留期成 0;
2. `<= 30` 这种写法让 ini 把键名解析成 "honeypot_retention_days <"
   (带尾随比较符),更加匹配不上;
3. 警告文案里写的也是错键名,等于在误导运维往错的键上配。

修复(三层):
- 键名归一化(internal/conf/keynorm.go):修剪首尾空白、剥掉尾随 `<` `>`
  `=` 等比较符残留,并按别名表纠正常见误拼。归一化结果必须命中该段
  合法键名白名单才会被采用 —— 不认识的键不猜,交给未知键告警。
- 未知键告警(internal/conf/unknown.go):启动时逐段比对键名,无法识别的
  键 WARN 出来并给出最接近的合法键名建议。合法键名由反射从结构体 ini tag
  得出(单一事实来源,新增配置项自动纳入,不会脱节)。
- 缺省值语义修正(internal/conf/honeypot_defaults.go):
  * 保留期缺省 30 天(原先零值 0 = 永不清理);显式 <= 0 也回退 30。
    要关闭自动清理请用 honeypot_auto_clean = false。
  * honeypot_auto_clean 缺省 true。原先靠 bool 零值,ini 的 bool 解析是
    「有键才赋值」,未配置与显式关闭无法区分 → 老配置升级上来自动清理
    实际是关着的。现改用 HasKey 判定。
  * honeypot_max_rows 缺省 200000;显式 0 = 不限(会告警提醒只增不减)。
  * honeypot_threshold 缺省 1。

实测 ini 行为(探针程序,已删):
- 键缺失或值非法时 MapTo **不覆盖**预置默认值 → 可安全预置缺省值;
- 键名大小写敏感(Honeypot_Auto_Clean 不生效);
- HasKey 能区分「缺失」与「显式 0」;
- 段不存在时 MapTo 返回 nil 且不修改目标。

顺带清理 app.sample.ini 里的两条死配置(app.title / server.xsrf_expire):
代码中从未读取(站点标题走 i18n),留着会让新检测对官方模板误报。

验证:
- 用现场原始配置端到端复现 → 归一化后保留期 30 天、自动清理开启、上限 20 万,
  日志明确说明「配置键名已自动纠正」;
- 误拼键(honeypot_autoclean / honeypot_max_row)自动纠正;未知键(totally_bogus)
  WARN 告警;
- 显式 honeypot_auto_clean = false 被尊重为关闭;完全未配置时缺省开启;
- 新增 TestSampleIniHasNoUnknownKeys 回归检测:官方样例配置零误报;
- go build ./... + go test -timeout 600s ./... 全绿,go vet 无输出。
This commit is contained in:
tamakyi
2026-10-21 13:41:19 +08:00
parent cbb0c51a14
commit d300167a30
8 changed files with 896 additions and 10 deletions
+81
View File
@@ -0,0 +1,81 @@
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package conf
import (
"github.com/sirupsen/logrus"
)
// 蜜罐相关配置的默认值。
//
// 为什么要显式兜底:ini 的 MapTo 在「键缺失」或「值非法」时**不覆盖**目标字段,
// 所以结构体的零值就是「未配置」的表现。而零值对这几个开关恰好是最危险的那个:
//
// - HoneypotAutoClean 零值 false → 未配置的站点自动清理是关的,
// honeypot_hits 只增不减;
// - HoneypotRetainDays 零值 0 → 保留期 0 天 = 永不清理;
// - HoneypotMaxRows 零值 0 → 无行数上限。
//
// 现场就踩过这个坑:配置写成了 honeypot_retention_days(多一个 t),
// 键名不匹配被 ini 静默忽略,保留期成了 0,自动清理形同关闭。
const (
// DefaultHoneypotRetainDays 蜜罐记录默认保留天数。
DefaultHoneypotRetainDays = 30
// DefaultHoneypotMaxRows 蜜罐记录默认行数上限。
// 实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
DefaultHoneypotMaxRows = 200000
// DefaultHoneypotThreshold 蜜罐命中多少次即封禁。默认 1(一次即封):
// 蜜罐路径与真实业务路由零交集,正常访客不可能命中。
DefaultHoneypotThreshold = 1
)
// sanitizeHoneypot 归一化蜜罐相关配置,修正「未配置」与「显式关闭」的歧义。
//
// 语义约定(两个开关各管一件事,不再互相牵连):
//
// honeypot_auto_clean 是否启用后台自动清理。缺省 true(开)。
// honeypot_retain_days 保留多少天。缺省 30 天。
//
// 也就是说:**要关闭自动清理请写 honeypot_auto_clean = false**,
// 而不要靠把 retain_days 写成 0 —— 保留 0 天意味着「记录一进来就删」,
// 既不是任何人的真实意图,也会让「自动清理到底开没开」变得无法判断。
//
// 显式写 honeypot_max_rows = 0 仍表示「不设行数上限」(这是明确的逃生口),
// 但会告警提醒:没有时间闸也没有数量闸的表只增不减。
func sanitizeHoneypot() {
sec := File.Section("security")
hasRetain := sec.HasKey("honeypot_retain_days")
hasMaxRows := sec.HasKey("honeypot_max_rows")
// 保留期:未配置或显式 <= 0 → 回退默认。
// 显式 0 的语义是「立刻删除」,与「不自动清理」混淆,判为误配。
if !hasRetain {
Security.HoneypotRetainDays = DefaultHoneypotRetainDays
} else if Security.HoneypotRetainDays <= 0 {
logrus.WithField("default", DefaultHoneypotRetainDays).
Warn("honeypot_retain_days <= 0 已被忽略,改用默认值(如需关闭自动清理请设 honeypot_auto_clean = false)")
Security.HoneypotRetainDays = DefaultHoneypotRetainDays
}
// 行数上限:未配置 → 默认;显式 0 → 不设上限(逃生口,但告警)。
if !hasMaxRows {
Security.HoneypotMaxRows = DefaultHoneypotMaxRows
} else if Security.HoneypotMaxRows <= 0 {
logrus.Warn("honeypot_max_rows = 0 表示不设行数上限:蜜罐表将只增不减,请确认这是有意为之")
}
// 自动清理:缺省开启。
// 判据用 HasKey 而不是值 —— 因为 ini 对 bool 的解析是「有键才赋值」,
// 缺键时零值 false 会让「未配置」与「显式关闭」无法区分。
// 显式 false 是合法的关闭意图,予以尊重(与 retain_days 的处理不同)。
if !sec.HasKey("honeypot_auto_clean") {
Security.HoneypotAutoClean = true
}
// 封禁阈值:缺省 1。
if !sec.HasKey("honeypot_threshold") {
Security.HoneypotThreshold = DefaultHoneypotThreshold
}
}