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:
@@ -0,0 +1,124 @@
|
||||
// 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 (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// 未知配置键检测:把「配了但不生效」的键在启动时喊出来。
|
||||
//
|
||||
// 为什么必须做:ini 库对未知键是静默忽略的。运维写错一个字母,
|
||||
// 程序照常启动、照常服务,但那个配置完全没生效 —— 现场没有任何线索。
|
||||
// 蜜罐保留期就踩过这个坑:写成 honeypot_retention_days(多一个 t),
|
||||
// 代码读 honeypot_retain_days,表现为「自动清理未启用」,
|
||||
// 而日志里没有任何「你的键名不认识」的提示。
|
||||
//
|
||||
// 检测方式:把 ini 文件里每一段的键名,与代码中该段结构体的 ini tag
|
||||
// 逐一比对(合法键名由 legalKeysOf 从反射中得出,是单一事实来源)。
|
||||
// 报不出来的风险点:
|
||||
// - 无 ini tag 的字段(ini 用字段名做键名)→ 保守起见不登记,
|
||||
// 会漏报但不会误报;
|
||||
// - 段名本身写错 → 整段被忽略,这由「未知段」检测覆盖(见 unknownSections)。
|
||||
|
||||
// unknownSection 描述一个无法识别的配置段或键。
|
||||
type unknownSection struct {
|
||||
// Section 段名。
|
||||
Section string
|
||||
// Keys 该段内无法识别的键名(已排序)。
|
||||
Keys []string
|
||||
// Suggestions 键名 → 最接近的合法键名建议。
|
||||
Suggestions map[string]string
|
||||
}
|
||||
|
||||
// detectUnknownSections 检测配置文件中无法识别的段与键。
|
||||
//
|
||||
// 合法段名与合法键名均由调用方通过反射传入(单一事实来源)。
|
||||
func detectUnknownSections(f *ini.File, knownSections map[string]map[string]bool) []unknownSection {
|
||||
var out []unknownSection
|
||||
if f == nil {
|
||||
return out
|
||||
}
|
||||
for _, sec := range f.Sections() {
|
||||
name := sec.Name()
|
||||
legal, known := knownSections[name]
|
||||
if !known {
|
||||
// 未登记的段:可能是拼错,也可能是本项目不关心的段。
|
||||
// 只在段内确实有键时才提示(空段无所谓)。
|
||||
keys := sec.KeyStrings()
|
||||
if len(keys) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Strings(keys)
|
||||
out = append(out, unknownSection{Section: name, Keys: keys})
|
||||
continue
|
||||
}
|
||||
var bad []string
|
||||
suggestions := map[string]string{}
|
||||
for _, key := range sec.KeyStrings() {
|
||||
if legal[key] {
|
||||
continue
|
||||
}
|
||||
bad = append(bad, key)
|
||||
if s := closestKey(key, legal); s != "" {
|
||||
suggestions[key] = s
|
||||
}
|
||||
}
|
||||
if len(bad) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Strings(bad)
|
||||
out = append(out, unknownSection{Section: name, Keys: bad, Suggestions: suggestions})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// closestKey 在合法键名中找出与 unknown 最接近的一个。
|
||||
//
|
||||
// 用「公共前缀长度 + 包含关系」这种无需导入编辑距离算法的启发式:
|
||||
// 配置键名通常是同族词(honeypot_xxx),拼错时往往仍共享较长前缀,
|
||||
// 或者其中一个是另一个的子串(retain_days vs retention_days)。
|
||||
func closestKey(unknown string, legal map[string]bool) string {
|
||||
best := ""
|
||||
bestScore := 0
|
||||
for name := range legal {
|
||||
score := 0
|
||||
if strings.Contains(unknown, name) || strings.Contains(name, unknown) {
|
||||
// 包含关系:得分按较短者的长度算,取一半折扣。
|
||||
short := len(name)
|
||||
if len(unknown) < short {
|
||||
short = len(unknown)
|
||||
}
|
||||
score = short / 2
|
||||
} else {
|
||||
score = commonPrefixLen(unknown, name)
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
best = name
|
||||
}
|
||||
}
|
||||
// 至少要有 6 个字符的公共部分才给建议,否则噪音大于价值。
|
||||
if bestScore < 6 {
|
||||
return ""
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// commonPrefixLen 返回两个字符串的公共前缀长度。
|
||||
func commonPrefixLen(a, b string) int {
|
||||
n := len(a)
|
||||
if len(b) < n {
|
||||
n = len(b)
|
||||
}
|
||||
i := 0
|
||||
for i < n && a[i] == b[i] {
|
||||
i++
|
||||
}
|
||||
return i
|
||||
}
|
||||
Reference in New Issue
Block a user