以周/月为单位汇总蜜罐命中,把来源 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 个渲染测试失败。
141 lines
6.5 KiB
Go
141 lines
6.5 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 (
|
||
"strings"
|
||
|
||
"github.com/sirupsen/logrus"
|
||
)
|
||
|
||
// 蜜罐相关配置的默认值。
|
||
//
|
||
// 为什么要显式兜底:ini 的 MapTo 在「键缺失」或「值非法」时**不覆盖**目标字段,
|
||
// 所以结构体的零值就是「未配置」的表现。而零值对这几个开关恰好是最危险的那个:
|
||
//
|
||
// - HoneypotAutoClean 零值 false → 未配置的站点自动清理是关的,
|
||
// honeypot_hits 只增不减;
|
||
// - HoneypotRetainDays 零值 0 → 保留期 0 天 = 永不清理;
|
||
// - HoneypotMaxRows 零值 0 → 无行数上限。
|
||
//
|
||
// 现场就踩过这个坑:配置写成了 honeypot_retention_days(多一个 t),
|
||
// 键名不匹配被 ini 静默忽略,保留期成了 0,自动清理形同关闭。
|
||
const (
|
||
// DefaultHoneypotRetainDays 蜜罐记录默认保留天数。
|
||
DefaultHoneypotRetainDays = 30
|
||
// DefaultHoneypotMaxRows 蜜罐记录默认行数上限。
|
||
// 实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
|
||
DefaultHoneypotMaxRows = 200000
|
||
// DefaultHoneypotThreshold 「提交凭据」多少次即封禁。默认 1:
|
||
// 往假后台里填账号密码是证据最强的行为,抓到一次就足够。
|
||
// 纯路径探测不走这个阈值(见 route.honeypotHandler.Serve)。
|
||
DefaultHoneypotThreshold = 1
|
||
// DefaultHoneypotBanMinutes 蜜罐封禁的默认时长(分钟)。
|
||
//
|
||
// 刻意远长于常规 ban_duration_minutes(15 分钟):蜜罐命中的不是「访问了一个
|
||
// 可疑路径」,而是「往假后台里填了账号密码」—— 对方已经付出破解成本、
|
||
// 且拿到了一次响应(知道「后台存在」)。15 分钟不足以让对方放弃,
|
||
// 24 小时能覆盖一轮扫描活动周期。
|
||
DefaultHoneypotBanMinutes = 1440
|
||
// DefaultHoneypotCredDir 凭据取证文件的默认目录(相对进程工作目录)。
|
||
//
|
||
// 不放在数据库所在目录:这两者的权限、备份策略、留存周期都应该分开管。
|
||
DefaultHoneypotCredDir = "data/honeypot-creds"
|
||
// DefaultIPGeoProvider 默认的地理定位数据源:auto(ip-api 打底 + 腾讯精修)。
|
||
DefaultIPGeoProvider = "auto"
|
||
// DefaultHoneypotReportDir 安全汇总报告文件的默认目录。
|
||
//
|
||
// 与凭据目录分开:报告里有攻击者 IP 明细,但它是可以外发/归档的材料;
|
||
// 凭据目录里是明文账号密码,永不外发。放在一起容易误打包。
|
||
DefaultHoneypotReportDir = "data/reports"
|
||
)
|
||
|
||
// sanitizeHoneypot 归一化蜜罐相关配置,修正「未配置」与「显式关闭」的歧义。
|
||
//
|
||
// 语义约定(两个开关各管一件事,不再互相牵连):
|
||
//
|
||
// honeypot_auto_clean 是否启用后台自动清理。缺省 true(开)。
|
||
// honeypot_retain_days 保留多少天。缺省 30 天。
|
||
//
|
||
// 也就是说:**要关闭自动清理请写 honeypot_auto_clean = false**,
|
||
// 而不要靠把 retain_days 写成 0 —— 保留 0 天意味着「记录一进来就删」,
|
||
// 既不是任何人的真实意图,也会让「自动清理到底开没开」变得无法判断。
|
||
//
|
||
// 显式写 honeypot_max_rows = 0 仍表示「不设行数上限」(这是明确的逃生口),
|
||
// 但会告警提醒:没有时间闸也没有数量闸的表只增不减。
|
||
func sanitizeHoneypot() {
|
||
sec := File.Section("security")
|
||
hasRetain := sec.HasKey("honeypot_retain_days")
|
||
hasMaxRows := sec.HasKey("honeypot_max_rows")
|
||
|
||
// 保留期:未配置或显式 <= 0 → 回退默认。
|
||
// 显式 0 的语义是「立刻删除」,与「不自动清理」混淆,判为误配。
|
||
if !hasRetain {
|
||
Security.HoneypotRetainDays = DefaultHoneypotRetainDays
|
||
} else if Security.HoneypotRetainDays <= 0 {
|
||
logrus.WithField("default", DefaultHoneypotRetainDays).
|
||
Warn("honeypot_retain_days <= 0 已被忽略,改用默认值(如需关闭自动清理请设 honeypot_auto_clean = false)")
|
||
Security.HoneypotRetainDays = DefaultHoneypotRetainDays
|
||
}
|
||
|
||
// 行数上限:未配置 → 默认;显式 0 → 不设上限(逃生口,但告警)。
|
||
if !hasMaxRows {
|
||
Security.HoneypotMaxRows = DefaultHoneypotMaxRows
|
||
} else if Security.HoneypotMaxRows <= 0 {
|
||
logrus.Warn("honeypot_max_rows = 0 表示不设行数上限:蜜罐表将只增不减,请确认这是有意为之")
|
||
}
|
||
|
||
// 自动清理:缺省开启。
|
||
// 判据用 HasKey 而不是值 —— 因为 ini 对 bool 的解析是「有键才赋值」,
|
||
// 缺键时零值 false 会让「未配置」与「显式关闭」无法区分。
|
||
// 显式 false 是合法的关闭意图,予以尊重(与 retain_days 的处理不同)。
|
||
if !sec.HasKey("honeypot_auto_clean") {
|
||
Security.HoneypotAutoClean = true
|
||
}
|
||
|
||
// 封禁阈值:缺省 1。
|
||
if !sec.HasKey("honeypot_threshold") {
|
||
Security.HoneypotThreshold = DefaultHoneypotThreshold
|
||
}
|
||
|
||
// 封禁时长:缺省 24 小时(见 DefaultHoneypotBanMinutes 的理由)。
|
||
// 显式写 <= 0 是逃生口:交给 autoban 回退到 ban_duration_minutes,
|
||
// 但那样「蜜罐按常规时长封」的意图不明显,故明确告警。
|
||
if !sec.HasKey("honeypot_ban_minutes") {
|
||
Security.HoneypotBanMinutes = DefaultHoneypotBanMinutes
|
||
} else if Security.HoneypotBanMinutes <= 0 {
|
||
logrus.WithField("fallback_key", "ban_duration_minutes").
|
||
Warn("honeypot_ban_minutes <= 0 已忽略,蜜罐封禁将回退为 ban_duration_minutes")
|
||
}
|
||
|
||
// 凭据文件目录:未配置或留空都用默认值。
|
||
// 这里不回退「不保存」——用户显式开启蜜罐就是想要取证,
|
||
// 目录写错会在 route 层触发「不可用 → 不保存凭据」的告警,而不是静默丢弃。
|
||
if strings.TrimSpace(Security.HoneypotCredDir) == "" {
|
||
Security.HoneypotCredDir = DefaultHoneypotCredDir
|
||
}
|
||
|
||
// 地理定位数据源:未配置 → auto。
|
||
// 显式写了不认识的值时保持原样,由 geoip 层解析并告警 ——
|
||
// 在配置层悄悄改成 auto 会让人以为配置生效了。
|
||
if strings.TrimSpace(Security.IPGeoProvider) == "" {
|
||
Security.IPGeoProvider = DefaultIPGeoProvider
|
||
}
|
||
|
||
// 周期报告:三个开关都用 HasKey 判定缺省值(同 HoneypotAutoClean 的理由)。
|
||
if !sec.HasKey("honeypot_report_enabled") {
|
||
Security.HoneypotReportEnabled = true
|
||
}
|
||
if !sec.HasKey("honeypot_report_weekly") {
|
||
Security.HoneypotReportWeekly = true
|
||
}
|
||
if !sec.HasKey("honeypot_report_monthly") {
|
||
Security.HoneypotReportMonthly = true
|
||
}
|
||
if strings.TrimSpace(Security.HoneypotReportDir) == "" {
|
||
Security.HoneypotReportDir = DefaultHoneypotReportDir
|
||
}
|
||
}
|