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
+250
View File
@@ -0,0 +1,250 @@
// 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 (
"reflect"
"strings"
"github.com/sirupsen/logrus"
"gopkg.in/ini.v1"
)
// 配置键名归一化:容忍运维手写时的常见失误,避免「配了但不生效」。
//
// 背景:ini 库对未知键是**静默忽略**的 —— 写错一个字母,程序照常启动、
// 照常跑,但那个配置根本没生效,现场还看不出来。本次就踩了这个坑:
// 写 `honeypot_retention_days`(多一个 t)而代码读 `honeypot_retain_days`,
// 表现为「自动清理未启用」警告,而根因是键名不匹配。
//
// 这里做两层兜底:
//
// 1. 键名归一化(normalizeKeyName):修剪首尾空白、剥掉尾随的
// `<` `>` `=` 等比较符号残留(`honeypot_retention_days <= 30` 会被 ini
// 解析成键名 "honeypot_retention_days <",不是我们想要的键名)。
// 2. 别名表(keyAliases):把已知的常见误拼映射到正确键名。
//
// 归一化只影响**键名**,不改动值。且归一化后的键名必须在该段的合法键名
// 白名单内才会被采用 —— 避免把真正写错、无法识别的键「猜」成某个配置项。
// keyAliases 常见误拼 → 正确键名。
//
// 只收录「意图明确、写法唯一」的误拼。收录标准:
// - 去掉别名后一定是该段某个合法键名(由调用方用白名单校验);
// - 不会与任何合法键名或彼此产生歧义。
//
// 收录历史教训:honeypot_retain_days 被写成 honeypot_retention_days。
var keyAliases = map[string]string{
"honeypot_retention_days": "honeypot_retain_days",
"honeypot_retain_day": "honeypot_retain_days",
"honeypot_retention": "honeypot_retain_days",
"honeypot_maxrow": "honeypot_max_rows",
"honeypot_max_row": "honeypot_max_rows",
"honeypot_maxrow_count": "honeypot_max_rows",
"honeypot_autoclean": "honeypot_auto_clean",
"honeypot_auto_cleaning": "honeypot_auto_clean",
"enable_honeypot_hit": "enable_honeypot",
"honeypot_retaindays": "honeypot_retain_days",
"honeypot_retentionday": "honeypot_retain_days",
"honeypot_keep_days": "honeypot_retain_days",
"honeypot_retain_max_days": "honeypot_retain_days",
}
// normalizeKeyName 归一化配置键名。
//
// 处理两类问题:
// 1. 首尾空白与尾随比较符号残留。
// `honeypot_retention_days <= 30` 在 ini 里被解析为键名
// "honeypot_retention_days <"(值 "30")—— 键名带了尾随的 "<",
// 直接与任何配置项都对不上。剥掉这些字符后才是用户真正想写的名字。
// 2. 常见误拼别名(见 keyAliases)。
//
// 大小写不归一:ini 的键名匹配本来就是大小写敏感的,
// 顺手转小写反而会让「键名写错大小写」这类问题更难发现。
func normalizeKeyName(raw string) string {
name := strings.TrimSpace(raw)
// 剥掉尾随的空白与比较符号残留(可能叠加多个,如 "key <=" 或 "key ==")。
for len(name) > 0 {
last := name[len(name)-1]
if last == ' ' || last == '\t' || last == '<' || last == '>' || last == '=' {
name = name[:len(name)-1]
continue
}
break
}
// 剥完后可能又暴露出新的尾随空白,继续削。
name = strings.TrimSpace(name)
if name == "" {
return ""
}
// 别名映射(只做一次,不递归,避免别名链产生意外结果)。
if canonical, ok := keyAliases[name]; ok {
return canonical
}
return name
}
// legalKeysOf 收集一个段在代码中真实存在的合法键名。
//
// 通过反射读取结构体的 `ini` tag —— 单一事实来源,避免「白名单与代码脱节」
// 导致新增配置项后被误报为未知键。
//
// 返回的键名集合同时包含 ini tag 原文与其归一化形式(归一化形式一般等于原文,
// 因为 tag 本身不带空白与比较符号)。
func legalKeysOf(v interface{}) map[string]bool {
legal := map[string]bool{}
add := func(name string) {
if name == "" || name == "-" {
return
}
legal[name] = true
}
collect := func(v interface{}) {
rv := reflect.ValueOf(v)
for rv.Kind() == reflect.Ptr {
rv = rv.Elem()
}
if rv.Kind() != reflect.Struct {
return
}
rt := rv.Type()
for i := 0; i < rt.NumField(); i++ {
field := rt.Field(i)
tag := field.Tag.Get("ini")
if tag == "-" {
continue
}
// ini tag 形如 "name" 或 "name,opt1,opt2",取第一段。
name := tag
if idx := strings.Index(tag, ","); idx >= 0 {
name = tag[:idx]
}
if name == "" {
// 无 ini tag 时 ini 用字段名(小写化后的 snake_case 近似),
// 保守起见不登记 —— 未登记只会导致漏报,不会误报。
continue
}
add(name)
add(normalizeKeyName(name))
}
}
collect(v)
return legal
}
// sectionKeySets 配置文件中各段 → 该段代码里真实存在的合法键名。
//
// 由反射从结构体 ini tag 得出,是单一事实来源:新增配置项后自动纳入,
// 不会出现「白名单与代码脱节」导致的误报。
//
// 注意:ini 对**无 ini tag** 的字段用字段名做键名,这部分无法通过 tag
// 枚举,为避免误报这里不登记(漏报优于误报)。
func sectionKeySets() map[string]map[string]bool {
return map[string]map[string]bool{
"app": legalKeysOf(&App),
"theme": legalKeysOf(&Theme),
"security": legalKeysOf(&Security),
"server": legalKeysOf(&Server),
"database": legalKeysOf(&Database),
"redis": legalKeysOf(&Redis),
"recaptcha": legalKeysOf(&Recaptcha),
"pixel": legalKeysOf(&Pixel),
"upload": legalKeysOf(&Upload),
"limits": legalKeysOf(&Limits),
"mail": legalKeysOf(&Mail),
"service": legalKeysOf(&Service),
}
}
// normalizeAllSectionKeys 归一化配置文件中所有段的键名,并告警未知键。
//
// 做两件事:
// 1. 把可识别的误拼键名(带尾随垃圾字符 / 命中别名表)改写成正确键名,
// 改写会记 INFO 日志,便于运维确认「我配的到底生效了没」。
// 2. 把无法识别的键名 WARN 出来(附最接近的合法键名建议)。
// 这是防住「配了但不生效」的关键 —— ini 本身对未知键完全静默。
//
// 只告警不报错:未知键不应该阻止服务启动(可能只是运维写了无关的键),
// 但必须让人看见。
func normalizeAllSectionKeys(f *ini.File) {
if f == nil {
return
}
legal := sectionKeySets()
// 1) 键名归一化(改写 ini 文件里的键名,后续 MapTo 才能读到)。
for section, keys := range legal {
sec := f.Section(section)
if sec == nil {
continue
}
renamed := normalizeSectionKeys(sec, keys)
for old, new := range renamed {
logrus.WithFields(logrus.Fields{
"section": section,
"from": old,
"to": new,
}).Info("配置键名已自动纠正(该键原本会被静默忽略)")
}
}
// 2) 未知键告警。
for _, u := range detectUnknownSections(f, legal) {
for _, key := range u.Keys {
fields := logrus.Fields{"section": u.Section, "key": key}
if s, ok := u.Suggestions[key]; ok {
fields["suggestion"] = s
logrus.WithFields(fields).Warn("配置文件中存在无法识别的键(已被忽略,该项配置不会生效)")
continue
}
logrus.WithFields(fields).Warn("配置文件中存在无法识别的键(已被忽略,该项配置不会生效)")
}
}
}
// normalizeSectionKeys 归一化一个段内的键名,并把归一化结果写回 ini 文件。
//
// 行为:
// - 遍历段内所有键,计算归一化键名;
// - 若归一化键名与原键名不同,且归一化键名在该段白名单内 → 用归一化名替换;
// - 若归一化键名是别名且白名单内 → 用正确键名替换;
// - 否则保留原样(交给未知键告警处理,不擅自猜测)。
//
// 返回被改写的键名对(原名 → 新名),供调用方记日志。
func normalizeSectionKeys(sec *ini.Section, legal map[string]bool) map[string]string {
renamed := map[string]string{}
if sec == nil {
return renamed
}
type rename struct {
old string
new string
}
var pending []rename
for _, key := range sec.Keys() {
old := key.Name()
norm := normalizeKeyName(old)
if norm == "" || norm == old {
continue
}
if !legal[norm] {
// 归一化后的名字不是本段的合法键名 —— 可能是真写错了,
// 也可能是别段的键。不要擅自猜测,交给未知键告警。
continue
}
// 目标键名已存在时保留原键(显式的正确写法优先于需要猜测的写法)。
if sec.HasKey(norm) {
continue
}
pending = append(pending, rename{old: old, new: norm})
}
for _, r := range pending {
val := sec.Key(r.old).Value()
sec.DeleteKey(r.old)
sec.NewKey(r.new, val)
renamed[r.old] = r.new
}
return renamed
}