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
+7
View File
@@ -29,6 +29,10 @@ func Init() error {
return errors.Wrapf(err, "parse %q", configFile)
}
// 键名归一化必须在 MapTo **之前**做:ini 对未知键是静默忽略的,
// 等 MapTo 跑完,错拼的键早就被丢掉了,再也认不出来。
normalizeAllSectionKeys(File)
if err := File.Section("app").MapTo(&App); err != nil {
return errors.Wrap(err, "map 'server'")
}
@@ -42,6 +46,9 @@ func Init() error {
return errors.Wrap(err, "map 'security'")
}
// 蜜罐配置归一化:把「未配置」与「显式关闭」区分开,回退安全默认值。
sanitizeHoneypot()
if err := validateTextCensorSource(Security.EnableTextCensor, Security.TextCensorSource,
App.QiniuAccessKey, App.QiniuAccessSecret, App.AliyunAccessKey, App.AliyunAccessKeySecret); err != nil {
return err
+81
View File
@@ -0,0 +1,81 @@
// 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 (
"github.com/sirupsen/logrus"
)
// 蜜罐相关配置的默认值。
//
// 为什么要显式兜底:ini 的 MapTo 在「键缺失」或「值非法」时**不覆盖**目标字段,
// 所以结构体的零值就是「未配置」的表现。而零值对这几个开关恰好是最危险的那个:
//
// - HoneypotAutoClean 零值 false → 未配置的站点自动清理是关的,
// honeypot_hits 只增不减;
// - HoneypotRetainDays 零值 0 → 保留期 0 天 = 永不清理;
// - HoneypotMaxRows 零值 0 → 无行数上限。
//
// 现场就踩过这个坑:配置写成了 honeypot_retention_days(多一个 t),
// 键名不匹配被 ini 静默忽略,保留期成了 0,自动清理形同关闭。
const (
// DefaultHoneypotRetainDays 蜜罐记录默认保留天数。
DefaultHoneypotRetainDays = 30
// DefaultHoneypotMaxRows 蜜罐记录默认行数上限。
// 实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
DefaultHoneypotMaxRows = 200000
// DefaultHoneypotThreshold 蜜罐命中多少次即封禁。默认 1(一次即封):
// 蜜罐路径与真实业务路由零交集,正常访客不可能命中。
DefaultHoneypotThreshold = 1
)
// sanitizeHoneypot 归一化蜜罐相关配置,修正「未配置」与「显式关闭」的歧义。
//
// 语义约定(两个开关各管一件事,不再互相牵连):
//
// honeypot_auto_clean 是否启用后台自动清理。缺省 true(开)。
// honeypot_retain_days 保留多少天。缺省 30 天。
//
// 也就是说:**要关闭自动清理请写 honeypot_auto_clean = false**,
// 而不要靠把 retain_days 写成 0 —— 保留 0 天意味着「记录一进来就删」,
// 既不是任何人的真实意图,也会让「自动清理到底开没开」变得无法判断。
//
// 显式写 honeypot_max_rows = 0 仍表示「不设行数上限」(这是明确的逃生口),
// 但会告警提醒:没有时间闸也没有数量闸的表只增不减。
func sanitizeHoneypot() {
sec := File.Section("security")
hasRetain := sec.HasKey("honeypot_retain_days")
hasMaxRows := sec.HasKey("honeypot_max_rows")
// 保留期:未配置或显式 <= 0 → 回退默认。
// 显式 0 的语义是「立刻删除」,与「不自动清理」混淆,判为误配。
if !hasRetain {
Security.HoneypotRetainDays = DefaultHoneypotRetainDays
} else if Security.HoneypotRetainDays <= 0 {
logrus.WithField("default", DefaultHoneypotRetainDays).
Warn("honeypot_retain_days <= 0 已被忽略,改用默认值(如需关闭自动清理请设 honeypot_auto_clean = false)")
Security.HoneypotRetainDays = DefaultHoneypotRetainDays
}
// 行数上限:未配置 → 默认;显式 0 → 不设上限(逃生口,但告警)。
if !hasMaxRows {
Security.HoneypotMaxRows = DefaultHoneypotMaxRows
} else if Security.HoneypotMaxRows <= 0 {
logrus.Warn("honeypot_max_rows = 0 表示不设行数上限:蜜罐表将只增不减,请确认这是有意为之")
}
// 自动清理:缺省开启。
// 判据用 HasKey 而不是值 —— 因为 ini 对 bool 的解析是「有键才赋值」,
// 缺键时零值 false 会让「未配置」与「显式关闭」无法区分。
// 显式 false 是合法的关闭意图,予以尊重(与 retain_days 的处理不同)。
if !sec.HasKey("honeypot_auto_clean") {
Security.HoneypotAutoClean = true
}
// 封禁阈值:缺省 1。
if !sec.HasKey("honeypot_threshold") {
Security.HoneypotThreshold = DefaultHoneypotThreshold
}
}
+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
}
+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)
}
}
}
+11 -4
View File
@@ -85,20 +85,27 @@ var (
EnableHoneypot bool `ini:"enable_honeypot"`
// HoneypotPaths 追加自定义诱饵路径(逗号分隔),与内置清单并集。
HoneypotPaths string `ini:"honeypot_paths"`
// HoneypotRetainDays 蜜罐命中记录的保留天数,到期由 CLI 清理。
// HoneypotRetainDays 蜜罐命中记录的保留天数,到期自动清理。
// ⚠️ 键名是 honeypot_retain_days(没有 tion)。写成 honeypot_retention_days
// 会被 ini 静默忽略 —— 现场踩过这个坑,表现为「自动清理未启用」。
// 缺省 30 天(见 sanitizeHoneypot);显式 <= 0 也回退 30。
// 要关闭自动清理请用 honeypot_auto_clean = false。
HoneypotRetainDays int `ini:"honeypot_retain_days"`
// HoneypotAutoClean 是否启用蜜罐记录的后台自动保留期清理。
// 默认 true:honeypot_hits 是只追加的审计表,写入完全由外部扫描流量驱动,
// 缺省 true:honeypot_hits 是只追加的审计表,写入完全由外部扫描流量驱动,
// 没有自动清理的话表体积只增不减(这是「数据库被扫描撑爆」的真实路径)。
// 关闭后仍可手动执行 TamaBox honeypot prune。
// ⚠️ 缺省值靠 HasKey 判定(见 sanitizeHoneypot):ini 的 bool 解析是
// 「有键才赋值」,若只看零值就无法区分「未配置」与「显式关闭」。
HoneypotAutoClean bool `ini:"honeypot_auto_clean"`
// HoneypotMaxRows 蜜罐命中记录的行数上限。留空或 <=0 表示不设上限。
// HoneypotMaxRows 蜜罐命中记录的行数上限。缺省 200000(约 53 MB)。
// 显式写 0 表示不设上限(逃生口,会告警提醒表只增不减)。
// 保留期是时间维度的上界,但攻击者可以用海量源 IP 在保留期内把表撑爆,
// 这是第二道闸。实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
HoneypotMaxRows int `ini:"honeypot_max_rows"`
// HoneypotThreshold 窗口内蜜罐命中多少次即封禁。默认 1(一次即封):
// 蜜罐路径与真实业务路由零交集,正常访客不可能命中,一次即足以判定扫描器。
// 留空回退 1。
// 缺省回退 1(见 sanitizeHoneypot)。
HoneypotThreshold int `ini:"honeypot_threshold"`
// HoneypotBanMinutes 蜜罐封禁时长(分钟)。留空则复用 ban_duration_minutes。
HoneypotBanMinutes int `ini:"honeypot_ban_minutes"`
+124
View File
@@ -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
}