Files
tamakyi d690355cdc feat(honeypot): 蜜罐安全状况周期汇总(周报/月报 + 地图 + 邮件)
以周/月为单位汇总蜜罐命中,把来源 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 个渲染测试失败。
2026-10-30 16:40:44 +08:00

264 lines
9.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}