// 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", // 地理定位与周期报告:这几个键都是新增的,最容易被写成不同的词。 "ipgeo_provider": "ip_geo_provider", "ip_geolocation_provider": "ip_geo_provider", "geo_provider": "ip_geo_provider", "ip_geo_key": "ip_geo_tencent_key", "ip_geo_tencent_apikey": "ip_geo_tencent_key", "tencent_map_key": "ip_geo_tencent_key", "honeypot_report": "honeypot_report_enabled", "honeypot_report_mail": "honeypot_report_email", "honeypot_report_to": "honeypot_report_email", "honeypot_reports_dir": "honeypot_report_dir", "honeypot_report_path": "honeypot_report_dir", } // 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), "announcement": legalKeysOf(&Announcement), "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 }