Files
tamabox/internal/conf/honeypot_defaults.go
T
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

141 lines
6.5 KiB
Go
Raw 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 (
"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
}
}