diff --git a/conf/app.sample.ini b/conf/app.sample.ini index 3b7555e..ac21a79 100644 --- a/conf/app.sample.ini +++ b/conf/app.sample.ini @@ -1,9 +1,14 @@ +; ── 关于配置键名 ────────────────────────────────────────────────────────── +; ⚠️ 键名写错时,程序启动会 WARN 提示「存在无法识别的键」并给出最接近的合法键名。 +; 对于少量已知的常见误拼(如 honeypot_retention_days → honeypot_retain_days), +; 程序会自动纠正并在日志里说明纠正了什么。 +; 但请优先写对键名 —— 自动纠正只覆盖已知的误拼,不认识的键一律不猜。 + [app] production = true external_url = "https://box.shiroko.one" ; 【必填】站点对外访问地址。用于生成 og:url 与邮件中的全部链接 ; (找回密码、私信通知等),必须以 http(s):// 开头、结尾不带斜杠 ; (程序会自动 TrimRight "/")。域名迁移时只改这一处即可全局生效。 -title = "NekoBox" icp = "" default_lang = "" ; 站点默认语言:zh-CN / zh-TW / en / ja,留空默认简体中文(访客未设置语言时的兜底) uptrace_dsn = "" @@ -79,12 +84,18 @@ enable_honeypot = false ; 开启后,扫描器探测后台路径( ; 只在「没有任何路由匹配」的路径上生效,真实业务路由零交集; ; 假页面只含 canary 标记与假凭据,绝不含任何真实信息。 honeypot_paths = ; 追加自定义诱饵路径(逗号分隔),与内置清单取并集。 -honeypot_retain_days = 30 ; 蜜罐命中记录的保留天数(取证用途),到期可用 CLI 清理。 +honeypot_retain_days = 30 ; 蜜罐命中记录的保留天数(取证用途),到期自动清理。 + ; ⚠️ 键名是 honeypot_retain_days(没有 tion)。写错会被静默忽略, + ; 程序会尝试自动纠正并在日志里提示,但别指望它每次都猜对。 + ; 缺省 30 天;写 0 或负数会回退 30(不表示「永不清理」)。 + ; 要关闭自动清理请用 honeypot_auto_clean = false。 ; 可用 TamaBox honeypot size 查看行数与磁盘占用。 honeypot_auto_clean = true ; 是否启用蜜罐记录的后台自动保留期清理(每小时一轮)。 ; honeypot_hits 是只追加的审计表,写入完全由外部扫描流量 ; 驱动,不清理的话表体积只增不减。关闭后仍可手动 prune。 -honeypot_max_rows = 200000 ; 蜜罐命中记录的行数上限(第二道闸)。留空或 0 = 不限。 + ; 缺省为 true(开启)。 +honeypot_max_rows = 200000 ; 蜜罐命中记录的行数上限(第二道闸)。缺省 200000。 + ; 显式写 0 = 不限(会告警提醒表只增不减)。 ; 保留期是时间维度上界,但攻击者可用海量源 IP 在保留期内 ; 把表撑爆。实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。 ; 实测数据:连续删 12000 插 12000,行数恒定 5 万,文件稳定在 15 MB 不再增长 @@ -111,8 +122,6 @@ listen = salt = "" # ⚠️ 必填:密码哈希盐,留空会导致启动失败(已在 conf.Init 强制校验) xsrf_key = "" -# ⚠️ 必填:CSRF token 密钥,留空会导致启动失败(已强制校验) -xsrf_expire = 3600 [database] type = "postgres" diff --git a/internal/conf/conf.go b/internal/conf/conf.go index 35e17d9..8426955 100644 --- a/internal/conf/conf.go +++ b/internal/conf/conf.go @@ -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 diff --git a/internal/conf/honeypot_defaults.go b/internal/conf/honeypot_defaults.go new file mode 100644 index 0000000..eb44da7 --- /dev/null +++ b/internal/conf/honeypot_defaults.go @@ -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 + } +} diff --git a/internal/conf/keynorm.go b/internal/conf/keynorm.go new file mode 100644 index 0000000..2710a24 --- /dev/null +++ b/internal/conf/keynorm.go @@ -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 +} diff --git a/internal/conf/keynorm_test.go b/internal/conf/keynorm_test.go new file mode 100644 index 0000000..95aaac4 --- /dev/null +++ b/internal/conf/keynorm_test.go @@ -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 = "a@b.c" + +[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) + } + } +} diff --git a/internal/conf/static.go b/internal/conf/static.go index 3995e29..1c58760 100644 --- a/internal/conf/static.go +++ b/internal/conf/static.go @@ -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"` diff --git a/internal/conf/unknown.go b/internal/conf/unknown.go new file mode 100644 index 0000000..c9456aa --- /dev/null +++ b/internal/conf/unknown.go @@ -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 +} diff --git a/internal/db/honeypot_retention.go b/internal/db/honeypot_retention.go index e990fc5..784f240 100644 --- a/internal/db/honeypot_retention.go +++ b/internal/db/honeypot_retention.go @@ -65,7 +65,11 @@ func (r *HoneypotRetention) Start(stop <-chan struct{}) { return } if r.retainDays <= 0 { - logrus.Warn("蜜罐自动清理未启用:honeypot_retention_days <= 0,honeypot_hits 表将只增不减(可手动执行 TamaBox honeypot prune)") + // ⚠️ 键名是 honeypot_retain_days(没有 tion)。这里曾经写成 + // honeypot_retention_days,把运维引到了错误的键名上 —— 现场确实踩了这个坑。 + // 正常路径下 conf.sanitizeHoneypot 已保证保留期 > 0(缺省 30 天), + // 能走到这里说明配置被显式写成了 <= 0。 + logrus.Warn("蜜罐自动清理未启用:honeypot_retain_days <= 0,honeypot_hits 表将只增不减(可手动执行 TamaBox honeypot prune)") return } go func() {