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
+404
View File
@@ -0,0 +1,404 @@
// 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 (
"path/filepath"
"strings"
"testing"
"gopkg.in/ini.v1"
)
// TestNormalizeKeyName 覆盖键名归一化的各种手写失误。
//
// 现场背景:运维写成 `honeypot_retention_days`(多一个 t)且写成
// `honeypot_retention_days <= 30`,ini 把键名解析成
// "honeypot_retention_days <"(带尾随比较符),完全匹配不上任何配置项,
// 静默失效 —— 表现为「自动清理未启用」,现场毫无线索。
func TestNormalizeKeyName(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"正常键名不变", "honeypot_retain_days", "honeypot_retain_days"},
{"多一个 t 的误拼", "honeypot_retention_days", "honeypot_retain_days"},
{"误拼加尾随比较符", "honeypot_retention_days <", "honeypot_retain_days"},
{"尾随 <= ", "honeypot_retain_days<=", "honeypot_retain_days"},
{"首尾空白", " honeypot_retain_days ", "honeypot_retain_days"},
{"空白加比较符", " honeypot_retain_days < ", "honeypot_retain_days"},
{"多个尾随符号", "honeypot_retain_days<< ", "honeypot_retain_days"},
{"空串", "", ""},
{"纯符号", "<<<", ""},
{"大小写不归一", "HONEYPOT_RETAIN_DAYS", "HONEYPOT_RETAIN_DAYS"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := normalizeKeyName(c.in); got != c.want {
t.Errorf("normalizeKeyName(%q) = %q, want %q", c.in, got, c.want)
}
})
}
}
// TestNormalizeSectionKeys 验证段内键名改写:错拼的键被纠正到正确名字。
func TestNormalizeSectionKeys(t *testing.T) {
src := `
[security]
enable_honeypot = true
honeypot_retention_days <= 30
honeypot_auto_clean = true
`
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
if err != nil {
t.Fatalf("parse ini: %v", err)
}
sec := f.Section("security")
legal := legalKeysOf(&Security)
renamed := normalizeSectionKeys(sec, legal)
if len(renamed) == 0 {
t.Fatalf("期望有键名被改写,实际没有")
}
if !sec.HasKey("honeypot_retain_days") {
t.Errorf("改写后应存在 honeypot_retain_days,实际键: %v", sec.KeyStrings())
}
// 值必须原样保留(30),不能因为改写丢值。
if got := sec.Key("honeypot_retain_days").Value(); got != "30" {
t.Errorf("改写后值 = %q, want %q", got, "30")
}
// 归一化后的键名必须真的能被 MapTo 读到 —— 这是整个修复的落点。
if err := sec.MapTo(&Security); err != nil {
t.Fatalf("map security: %v", err)
}
if Security.HoneypotRetainDays != 30 {
t.Errorf("HoneypotRetainDays = %d, want 30", Security.HoneypotRetainDays)
}
}
// TestNormalizeSectionKeysKeepsExplicit 验证正确键名存在时不会被别名的值覆盖。
//
// 显式写对的键优先级必须高于需要猜测的别名 —— 否则运维改了正确写法反而失效。
func TestNormalizeSectionKeysKeepsExplicit(t *testing.T) {
src := `
[security]
honeypot_retain_days = 7
honeypot_retention_days = 30
`
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
if err != nil {
t.Fatalf("parse ini: %v", err)
}
sec := f.Section("security")
normalizeSectionKeys(sec, legalKeysOf(&Security))
if got := sec.Key("honeypot_retain_days").Value(); got != "7" {
t.Errorf("显式键应保留原值, got %q, want %q", got, "7")
}
}
// TestNormalizeSectionKeysUnknownNotGuessed 验证无法识别的键不会被「猜」成某个配置项。
//
// 归一化只能纠正「意图明确」的写法;对真正拼错的键必须保持原样,
// 交由未知键告警提示 —— 擅自猜测会让配置静默变成另一个意思,比不猜更危险。
func TestNormalizeSectionKeysUnknownNotGuessed(t *testing.T) {
src := `
[security]
totally_bogus_key = 1
`
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
if err != nil {
t.Fatalf("parse ini: %v", err)
}
sec := f.Section("security")
normalizeSectionKeys(sec, legalKeysOf(&Security))
if !sec.HasKey("totally_bogus_key") {
t.Errorf("无法识别的键应保持原样,不应被改写;实际键: %v", sec.KeyStrings())
}
}
// TestDetectUnknownSections 验证未知键被检出并给出建议。
func TestDetectUnknownSections(t *testing.T) {
src := `
[security]
enable_honeypot = true
honeypot_retention_days = 30
honeypot_totally_bogus = 1
`
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
if err != nil {
t.Fatalf("parse ini: %v", err)
}
unknown := detectUnknownSections(f, sectionKeySets())
var sec *unknownSection
for i := range unknown {
if unknown[i].Section == "security" {
sec = &unknown[i]
}
}
if sec == nil {
t.Fatalf("应检出 security 段的未知键,实际: %+v", unknown)
}
found := false
for _, k := range sec.Keys {
if k == "honeypot_totally_bogus" {
found = true
}
}
if !found {
t.Errorf("未知键列表应含 honeypot_totally_bogus,实际: %v", sec.Keys)
}
}
// TestDetectUnknownSectionsNoFalsePositive 验证合法键不会被误报为未知。
//
// 误报比漏报更烦人:每次启动都刷一屏 WARN,运维会麻木。
func TestDetectUnknownSectionsNoFalsePositive(t *testing.T) {
src := `
[app]
external_url = "http://127.0.0.1:8080"
production = true
[security]
enable_honeypot = true
honeypot_auto_clean = true
honeypot_max_rows = 200000
honeypot_retain_days = 30
honeypot_threshold = 1
honeypot_ban_minutes = 15
honeypot_paths = "/a,/b"
enable_auto_ban = true
ban_threshold_sensitive = 5
security_alert_email = "[email protected]"
[server]
port = 8080
salt = "s"
xsrf_key = "k"
[database]
type = "postgres"
name = "x"
schema = "public"
[limits]
max_tags = 5
`
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
if err != nil {
t.Fatalf("parse ini: %v", err)
}
unknown := detectUnknownSections(f, sectionKeySets())
for _, u := range unknown {
// [service] 段是子段结构,合法键在前缀段上,不在本段。
if u.Section == "service" {
continue
}
t.Errorf("合法键被误报为未知: 段 %s 键 %v", u.Section, u.Keys)
}
}
// TestClosestKey 验证建议算法能给出有用的近似键名。
func TestClosestKey(t *testing.T) {
legal := map[string]bool{
"honeypot_retain_days": true,
"honeypot_max_rows": true,
"enable_honeypot": true,
}
if got := closestKey("honeypot_retention_days", legal); got != "honeypot_retain_days" {
t.Errorf("closestKey = %q, want %q", got, "honeypot_retain_days")
}
if got := closestKey("enable_honeypot_hit", legal); got != "enable_honeypot" {
t.Errorf("closestKey = %q, want %q", got, "enable_honeypot")
}
// 毫无关系的键不应给建议(避免噪音)。
if got := closestKey("zzzzzz", legal); got != "" {
t.Errorf("closestKey 应返回空, got %q", got)
}
}
// TestSanitizeHoneypotDefaults 验证蜜罐配置的缺省值与语义修正。
//
// 核心是三件事:
// 1. 未配置保留期 → 回退 30 天(而不是 0 = 永不清理);
// 2. 显式 honeypot_auto_clean = false → 尊重为关闭(这是明确的关闭意图);
// 3. 未配置自动清理 → 开启(ini 的 bool 零值是 false,若不区分就等于默认关闭)。
func TestSanitizeHoneypotDefaults(t *testing.T) {
cases := []struct {
name string
src string
wantRetain int
wantMaxRows int
wantAutoClean bool
wantThreshold int
}{
{
name: "全未配置时回退安全默认值",
src: "[security]\nenable_honeypot = true\n",
wantRetain: DefaultHoneypotRetainDays,
wantMaxRows: DefaultHoneypotMaxRows,
wantAutoClean: true,
wantThreshold: DefaultHoneypotThreshold,
},
{
name: "显式关闭自动清理被尊重",
src: "[security]\nhoneypot_auto_clean = false\n",
wantRetain: DefaultHoneypotRetainDays,
wantMaxRows: DefaultHoneypotMaxRows,
wantAutoClean: false,
wantThreshold: DefaultHoneypotThreshold,
},
{
name: "显式保留期被采用",
src: "[security]\nhoneypot_retain_days = 7\n",
wantRetain: 7,
wantMaxRows: DefaultHoneypotMaxRows,
wantAutoClean: true,
wantThreshold: DefaultHoneypotThreshold,
},
{
name: "保留期写成 0 回退默认而非永不清理",
src: "[security]\nhoneypot_retain_days = 0\n",
wantRetain: DefaultHoneypotRetainDays,
wantMaxRows: DefaultHoneypotMaxRows,
wantAutoClean: true,
wantThreshold: DefaultHoneypotThreshold,
},
{
name: "行数上限显式 0 表示不限",
src: "[security]\nhoneypot_max_rows = 0\n",
wantRetain: DefaultHoneypotRetainDays,
wantMaxRows: 0,
wantAutoClean: true,
wantThreshold: DefaultHoneypotThreshold,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(c.src))
if err != nil {
t.Fatalf("parse ini: %v", err)
}
// 重置全局,避免用例间互相污染。
Security.HoneypotRetainDays = 0
Security.HoneypotMaxRows = 0
Security.HoneypotAutoClean = false
Security.HoneypotThreshold = 0
File = f
if err := f.Section("security").MapTo(&Security); err != nil {
t.Fatalf("map security: %v", err)
}
sanitizeHoneypot()
if Security.HoneypotRetainDays != c.wantRetain {
t.Errorf("HoneypotRetainDays = %d, want %d", Security.HoneypotRetainDays, c.wantRetain)
}
if Security.HoneypotMaxRows != c.wantMaxRows {
t.Errorf("HoneypotMaxRows = %d, want %d", Security.HoneypotMaxRows, c.wantMaxRows)
}
if Security.HoneypotAutoClean != c.wantAutoClean {
t.Errorf("HoneypotAutoClean = %v, want %v", Security.HoneypotAutoClean, c.wantAutoClean)
}
if Security.HoneypotThreshold != c.wantThreshold {
t.Errorf("HoneypotThreshold = %d, want %d", Security.HoneypotThreshold, c.wantThreshold)
}
})
}
}
// TestLegalKeysOfCoversStruct 验证合法键集合确实覆盖了结构体的所有 ini tag。
//
// 这是「未知键告警」不误报的前提:如果 legalKeysOf 漏了某个 tag,
// 每次启动都会误报该键。
func TestLegalKeysOfCoversStruct(t *testing.T) {
legal := legalKeysOf(&Security)
for _, want := range []string{
"enable_honeypot",
"honeypot_paths",
"honeypot_retain_days",
"honeypot_auto_clean",
"honeypot_max_rows",
"honeypot_threshold",
"honeypot_ban_minutes",
} {
if !legal[want] {
t.Errorf("合法键集合缺少 %q", want)
}
}
}
// TestSampleIniHasNoUnknownKeys 用官方样例配置文件做回归检测。
//
// 意义:conf/app.sample.ini 是运维照抄的模板,它里面的每个键都必须能被代码识别。
// 一旦结构体改了字段名而 sample.ini 没同步(或反之),这个测试就会失败 ——
// 而不是等到某次部署时启动日志里飘出一堆「无法识别的键」让人摸不着头脑。
//
// 同时也保证未知键检测不会对官方模板误报。
func TestSampleIniHasNoUnknownKeys(t *testing.T) {
path := filepath.Join("..", "..", "conf", "app.sample.ini")
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, path)
if err != nil {
t.Skipf("跳过:无法读取样例配置 %s(%v)", path, err)
}
unknown := detectUnknownSections(f, sectionKeySets())
for _, u := range unknown {
// [service] 是子段结构(每个后端一个子段),键在子段上,不在本段。
if u.Section == "service" {
continue
}
for _, k := range u.Keys {
t.Errorf("样例配置中存在无法识别的键: 段 %s 键 %q", u.Section, k)
}
}
}
// TestNormalizeAllSectionKeysEndToEnd 走完整链路:
// 加载带错拼键的配置 → 归一化 → MapTo → 拿到正确的保留期。
//
// 这正是现场故障的完整复现路径。
func TestNormalizeAllSectionKeysEndToEnd(t *testing.T) {
src := `
[security]
enable_honeypot = true
honeypot_auto_clean = true
honeypot_max_rows = 200000
honeypot_retention_days <= 30
`
f, err := ini.LoadSources(ini.LoadOptions{IgnoreInlineComment: true}, []byte(src))
if err != nil {
t.Fatalf("parse ini: %v", err)
}
File = f
Security.HoneypotRetainDays = 0
Security.HoneypotAutoClean = false
Security.HoneypotMaxRows = 0
Security.HoneypotThreshold = 0
normalizeAllSectionKeys(f)
if err := f.Section("security").MapTo(&Security); err != nil {
t.Fatalf("map security: %v", err)
}
sanitizeHoneypot()
if Security.HoneypotRetainDays != 30 {
t.Errorf("HoneypotRetainDays = %d, want 30(错拼键应被纠正并生效)", Security.HoneypotRetainDays)
}
if !Security.HoneypotAutoClean {
t.Errorf("HoneypotAutoClean = false, want true")
}
if Security.HoneypotMaxRows != 200000 {
t.Errorf("HoneypotMaxRows = %d, want 200000", Security.HoneypotMaxRows)
}
// 归一化后不应再残留「带比较符」的怪键名。
for _, k := range f.Section("security").KeyStrings() {
if strings.ContainsAny(k, "<>") {
t.Errorf("残留带比较符的键名: %q", k)
}
}
}