现场故障:配置写 `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 无输出。
405 lines
13 KiB
Go
405 lines
13 KiB
Go
// 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)
|
||
}
|
||
}
|
||
}
|