现场故障:配置写 `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 无输出。
156 lines
5.4 KiB
Go
156 lines
5.4 KiB
Go
// Copyright 2022 E99p1ant. 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 (
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/pkg/errors"
|
|
"gopkg.in/ini.v1"
|
|
)
|
|
|
|
// File 是解析后的配置对象。
|
|
var File *ini.File
|
|
|
|
func Init() error {
|
|
configFile := os.Getenv("NEKOBOX_CONFIG_PATH")
|
|
if configFile == "" {
|
|
configFile = "conf/app.ini"
|
|
}
|
|
|
|
var err error
|
|
File, err = ini.LoadSources(ini.LoadOptions{
|
|
IgnoreInlineComment: true,
|
|
}, configFile)
|
|
if err != nil {
|
|
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'")
|
|
}
|
|
|
|
if App.ExternalURL == "" {
|
|
return errors.New("app.external_url is required")
|
|
}
|
|
App.ExternalURL = strings.TrimRight(App.ExternalURL, "/")
|
|
|
|
if err := File.Section("security").MapTo(&Security); err != nil {
|
|
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
|
|
}
|
|
|
|
if err := File.Section("server").MapTo(&Server); err != nil {
|
|
return errors.Wrap(err, "map 'server'")
|
|
}
|
|
|
|
// XSRFKey 为空会导致 CSRF token 以空密钥生成、可被预测,
|
|
// 使删题/置顶/拉黑/改密等所有 POST 操作的 CSRF 防护形同虚设。启动时强制校验。
|
|
if Server.XSRFKey == "" {
|
|
return errors.New("server.xsrf_key is required (used to generate CSRF tokens, must not be empty)")
|
|
}
|
|
|
|
// Salt 为空会让密码哈希以空盐计算(HmacSha1(password, "")),
|
|
// 相同密码产生相同哈希且可被离线字典/彩虹表攻击。启动时强制校验。
|
|
if Server.Salt == "" {
|
|
return errors.New("server.salt is required (used to hash user passwords, must not be empty)")
|
|
}
|
|
|
|
if err := File.Section("database").MapTo(&Database); err != nil {
|
|
return errors.Wrap(err, "map 'database'")
|
|
}
|
|
|
|
if err := File.Section("redis").MapTo(&Redis); err != nil {
|
|
return errors.Wrap(err, "map 'redis'")
|
|
}
|
|
|
|
if err := File.Section("recaptcha").MapTo(&Recaptcha); err != nil {
|
|
return errors.Wrap(err, "map 'recaptcha'")
|
|
}
|
|
|
|
if err := File.Section("pixel").MapTo(&Pixel); err != nil {
|
|
return errors.Wrap(err, "map 'pixel'")
|
|
}
|
|
|
|
if err := File.Section("upload").MapTo(&Upload); err != nil {
|
|
return errors.Wrap(err, "map 'upload'")
|
|
}
|
|
|
|
if err := File.Section("limits").MapTo(&Limits); err != nil {
|
|
return errors.Wrap(err, "map 'limits'")
|
|
}
|
|
// ini 键留空会被 int 解析为 0;<1 的值一律回退内置默认,防止上限被静默置零。
|
|
sanitizeLimits(&Limits)
|
|
|
|
// 主题:默认 slug 与启用清单。段缺失时 MapTo 得到空值,运行期回退内置 default,
|
|
// 因此老配置文件不写 [theme] 也能正常启动。
|
|
if err := File.Section("theme").MapTo(&Theme); err != nil {
|
|
return errors.Wrap(err, "map 'theme'")
|
|
}
|
|
|
|
if err := File.Section("mail").MapTo(&Mail); err != nil {
|
|
return errors.Wrap(err, "map 'mail'")
|
|
}
|
|
|
|
serviceSections := File.Section("service").ChildSections()
|
|
for _, serviceSection := range serviceSections {
|
|
serviceSection := serviceSection
|
|
var backend struct {
|
|
Prefix string `ini:"prefix"`
|
|
ForwardURL string `ini:"forward_url"`
|
|
}
|
|
if err := serviceSection.MapTo(&backend); err != nil {
|
|
return errors.Wrapf(err, "map 'service.%s'", serviceSection.Name())
|
|
}
|
|
Service.Backends = append(Service.Backends, backend)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateTextCensorSource 校验文本审核通道([security] text_censor_source)与凭据的搭配。
|
|
// 抽成纯函数(只依赖入参、不读全局)以便单测覆盖,也让 Init 保持轻薄。
|
|
//
|
|
// - 审核整体关闭(enabled=false)→ 不校验,不需要任何凭据。
|
|
// - 留空 / auto → 通过。auto 是历史默认(七牛优先、阿里云兜底),两者都无凭据时
|
|
// 维持既有运行期 fail-closed 行为,不阻断启动(否则 app.sample.ini 原样拷贝无法启动)。
|
|
// - 显式选 qiniu / aliyun → 必须配齐该通道凭据,否则启动即失败:缺凭据时运行期审核必失败,
|
|
// 而审核失败是 fail-closed(拒绝提交),会让发布/私信整站不可用且现象隐蔽。
|
|
// - 其它取值 → 直接报错,不做静默回退:拼写错误若被当成 auto,运维会误以为已切通道。
|
|
func validateTextCensorSource(enabled bool, source, qiniuKey, qiniuSecret, aliyunKey, aliyunSecret string) error {
|
|
if !enabled {
|
|
return nil
|
|
}
|
|
|
|
switch strings.ToLower(strings.TrimSpace(source)) {
|
|
case "", CensorSourceAuto:
|
|
return nil
|
|
case CensorSourceQiniu:
|
|
if qiniuKey == "" || qiniuSecret == "" {
|
|
return errors.New("security.text_censor_source = qiniu requires app.qiniu_access_key and app.qiniu_access_secret")
|
|
}
|
|
return nil
|
|
case CensorSourceAliyun:
|
|
if aliyunKey == "" || aliyunSecret == "" {
|
|
return errors.New("security.text_censor_source = aliyun requires app.aliyun_access_key and app.aliyun_access_key_secret")
|
|
}
|
|
return nil
|
|
default:
|
|
return errors.Errorf("security.text_censor_source must be one of qiniu / aliyun / auto, got %q", source)
|
|
}
|
|
}
|