252 lines
9.0 KiB
Go
252 lines
9.0 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 (
|
||
"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),
|
||
"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
|
||
}
|