以周/月为单位汇总蜜罐命中,把来源 IP 通过公共服务查地理位置,画到地图上,
生成 HTML 报告并发邮件。同时补齐 ipinfo 无法回答「这个 IP 在哪儿」的问题。
新增 IP 地理定位层 internal/security/geoip:
- 与 internal/security/ipinfo 正交。ipinfo 走 cdncheck 离线指纹,只认云厂商/CDN/WAF,
答的是「是不是代理」;肉鸡(家宽、企业宽带、移动网络)必然被判为
「未识别(普通网络)」,所以「在哪儿」必须另建一套。
- ip-api.com 批量打底(100 个/请求、1.4s 节流)+ 腾讯位置服务精修国内
(一 IP 一请求,只精修命中数最高的前 N 个,受 ip_geo_tencent_key 控制)。
- Result{Geo, Unresolved, Requests, Failures} 三态。服务明确回答「没有这个地址」
才允许写失败缓存;请求超时/限流/5xx 一律不写 —— 否则一次抖动会把整批 IP
钉成「查不到」三天,此后每份报告的定位覆盖率都莫名变成 0。
- addrs 按调用方给的顺序构建(不是 map 遍历):腾讯精修只取前 N 个,乱序会让
每次运行精修到不同 IP,配额花在长尾上。
报告生成 internal/security/report:
- period:PreviousWeek / PreviousMonth,窗口一律 [start, end) 左闭右开,
相邻两期不重叠也不漏边界整点;period_key(week:2026-W44)做幂等。
- aggregate:按 shared.LimitKey 合并(IPv4 精确 / IPv6 折 /64);国家、城市、
路径、每日趋势;显式统计未定位命中数(覆盖率 60% 的图看起来和 100% 一样)。
- mapdraw:自绘等距圆柱投影气泡图。零海岸线零国界 —— 国界/台湾/南海诸岛绘制
必须符合国家标准,自绘极易画错,真实底图交给腾讯。经纬网 + 度数刻度 +
标签防重叠(同一都市圈两城市坐标差 0.01°,曾把「AU 55」和「AU 30」叠成
看起来像 AU 305 的假数字)。
- html:自包含报告页,内嵌 SVG + 腾讯交互地图(无 key 时给申请指引,
代码里不预置任何 key)。
- countryname:港澳台统一显示「中国香港/中国澳门/中国台湾」,常见国家用中文名。
存储与调度:
- ip_geo_cache(主键 = LimitKey,成功 TTL 90 天 / 明确无数据 TTL 3 天)
- security_report_logs(period_key 唯一索引 → 重启、错过时间窗都能正确补发,
失败重试上限 5 次后停止)
- honeypot report --period week|month [--to] [--dir] [--no-email]
- web 启动时按配置挂 30 分钟一轮的调度器(与保留期清理共用停止信号,
defer 移出 if 外,避免「清理关掉、报告开着」时 goroutine 泄漏)
配置([security]):ip_geo_provider=auto|ipapi|tencent|none、ip_geo_tencent_key、
honeypot_report_enabled / _weekly / _monthly / _email / _dir。
顺带修复:
- route/honeypot_test.go 的 captureStore 缺 AggregateSources/AggregatePaths/
TimestampsBetween,整个 route 包编译不过。
- templates/render_test.go 的 mockLoggedUser 缺 TelegramChatID,3 个渲染测试失败。
264 lines
9.6 KiB
Go
264 lines
9.6 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",
|
||
// 地理定位与周期报告:这几个键都是新增的,最容易被写成不同的词。
|
||
"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
|
||
}
|