Files
tamakyi 566b13eedd fix(conf): 样例配置去掉行尾注释(会被并入值导致静默失效)
conf.Init 用 ini.LoadOptions{IgnoreInlineComment: true},该选项的语义是
**不剥离**行尾注释:`port = 1440 ; 注释` 解析出来的值是 "1440 ; 注释",
int 解析失败,而 MapTo 仍然返回 nil error —— 配置项静默回退零值。

实测照抄 app.sample.ini 的后果:
- text_censor_source 的值带注释,被 validateTextCensorSource 拒绝,直接起不来;
- ip_blacklist / security_alert_email 变成带注释的垃圾字符串;
- honeypot_max_rows / honeypot_auto_clean 静默失效(后者靠 sanitize 兜底才没出事)。

改动:
- 17 处 `key = value ; 注释` 改成「注释独占一行 + 裸值」,保留 CRLF;
- 蜜罐段补 honeypot_cred_dir,以及「提交凭据才封禁」的语义说明;
- 新增 TestSampleIniValuesHaveNoInlineComment:遍历所有段的键,断言值不含 ';'
  (比逐字段断言彻底,新增配置项自动纳入)。
  变异验证:给 ban_window_seconds 加回行尾注释,测试按预期失败;
- keynorm_test 补 HoneypotBanMinutes 的缺省值断言(缺省 1440 / 显式 0 交给 autoban 回退)。
2026-10-21 15:43:13 +08:00

463 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
wantBan int
}{
{
name: "全未配置时回退安全默认值",
src: "[security]\nenable_honeypot = true\n",
wantRetain: DefaultHoneypotRetainDays,
wantMaxRows: DefaultHoneypotMaxRows,
wantAutoClean: true,
wantThreshold: DefaultHoneypotThreshold,
wantBan: DefaultHoneypotBanMinutes,
},
{
name: "显式关闭自动清理被尊重",
src: "[security]\nhoneypot_auto_clean = false\n",
wantRetain: DefaultHoneypotRetainDays,
wantMaxRows: DefaultHoneypotMaxRows,
wantAutoClean: false,
wantThreshold: DefaultHoneypotThreshold,
wantBan: DefaultHoneypotBanMinutes,
},
{
name: "显式保留期被采用",
src: "[security]\nhoneypot_retain_days = 7\n",
wantRetain: 7,
wantMaxRows: DefaultHoneypotMaxRows,
wantAutoClean: true,
wantThreshold: DefaultHoneypotThreshold,
wantBan: DefaultHoneypotBanMinutes,
},
{
name: "保留期写成 0 回退默认而非永不清理",
src: "[security]\nhoneypot_retain_days = 0\n",
wantRetain: DefaultHoneypotRetainDays,
wantMaxRows: DefaultHoneypotMaxRows,
wantAutoClean: true,
wantThreshold: DefaultHoneypotThreshold,
wantBan: DefaultHoneypotBanMinutes,
},
{
name: "行数上限显式 0 表示不限",
src: "[security]\nhoneypot_max_rows = 0\n",
wantRetain: DefaultHoneypotRetainDays,
wantMaxRows: 0,
wantAutoClean: true,
wantThreshold: DefaultHoneypotThreshold,
wantBan: DefaultHoneypotBanMinutes,
},
{
name: "显式封禁时长被采用",
src: "[security]\nhoneypot_ban_minutes = 90\n",
wantRetain: DefaultHoneypotRetainDays,
wantMaxRows: DefaultHoneypotMaxRows,
wantAutoClean: true,
wantThreshold: DefaultHoneypotThreshold,
wantBan: 90,
},
{
// 0 是逃生口:留着让 autoban 回退 ban_duration_minutes,
// sanitize 不做覆盖(只告警),故期望值仍是 0。
name: "封禁时长写 0 交给 autoban 回退 ban_duration_minutes",
src: "[security]\nhoneypot_ban_minutes = 0\n",
wantRetain: DefaultHoneypotRetainDays,
wantMaxRows: DefaultHoneypotMaxRows,
wantAutoClean: true,
wantThreshold: DefaultHoneypotThreshold,
wantBan: 0,
},
}
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
Security.HoneypotBanMinutes = 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)
}
if Security.HoneypotBanMinutes != c.wantBan {
t.Errorf("HoneypotBanMinutes = %d, want %d", Security.HoneypotBanMinutes, c.wantBan)
}
})
}
}
// 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)
}
}
}
// TestSampleIniValuesHaveNoInlineComment 守住「行尾注释会被并入值」的陷阱。
//
// conf.Init 用 ini.LoadOptions{IgnoreInlineComment: true},该选项的语义是
// **不剥离**行尾注释(实测 `port = 1440 ; 注释` → 值 "1440 ; 注释",
// int 解析失败,而 MapTo 仍然返回 nil error)。后果是该配置项静默回退零值,
// 日志里只会说「配置值不对」,根本联想不到是注释造成的。
//
// 样例配置曾经全线是 `key = value ; 注释` 的写法:text_censor_source 因此
// 让 conf.Init 直接拒绝启动,ip_blacklist / security_alert_email 变成带注释的
// 垃圾字符串,honeypot_max_rows / honeypot_auto_clean 静默失效。
//
// 直接检查每个键的值里没有 ';',比逐个字段断言更彻底,也不会漏掉新加的配置项。
func TestSampleIniValuesHaveNoInlineComment(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)
}
for _, sec := range f.Sections() {
for _, k := range sec.Keys() {
if strings.Contains(k.Value(), ";") {
t.Errorf("样例配置 [%s] %s 的值含 ';':%q —— 行尾注释会被并入值,该项将静默失效",
sec.Name(), k.Name(), k.Value())
}
}
}
}
// 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)
}
}
}