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 个渲染测试失败。
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
// 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 db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// IPGeoRecord 是一个来源 IP 的地理定位缓存。
|
||||
//
|
||||
// 为什么必须落库而不是每次现查:
|
||||
// - 免费 IP 库都有配额(ip-api 免费版 45 次/分钟)。一次周报可能有几百上千个
|
||||
// 不同来源 IP,现查必然撞限流,撞了就是「报告里一半 IP 没有位置」;
|
||||
// - 同一个攻击者会在多份报告里反复出现,缓存能让第二次之后零外部请求;
|
||||
// - 定位结果本身变化很慢(国家级几乎不变,城市级极少变),没必要重复付费。
|
||||
//
|
||||
// 缓存键用 shared.LimitKey(IPv4 精确、IPv6 折叠 /64)而不是原始 IP:
|
||||
// 攻击者常在一个 /64 里轮换地址,按精确地址缓存会把同一个 C 段查上几十次,
|
||||
// 既浪费配额又得到一模一样的结果。
|
||||
type IPGeoRecord struct {
|
||||
// IPKey 是缓存键(shared.LimitKey 的产物)。主键,不用自增 id:
|
||||
// 这张表是纯 KV 缓存,没有任何按 id 遍历或引用的需求。
|
||||
IPKey string `gorm:"size:64;primarykey"`
|
||||
|
||||
Country string `gorm:"size:64"`
|
||||
CountryCode string `gorm:"size:8"`
|
||||
Region string `gorm:"size:128"`
|
||||
City string `gorm:"size:128"`
|
||||
// ISP 运营商 / 网络名("China Telecom"、"Amazon.com, Inc.")。肉鸡报告里
|
||||
// 这个字段比国家更有信息量:能看出是家宽还是云主机。
|
||||
ISP string `gorm:"size:160"`
|
||||
// ASN 自治域号("AS4134")。同 ASN 大量命中通常意味着同一片被控网络。
|
||||
ASN string `gorm:"size:64"`
|
||||
|
||||
// Lat / Lon 为定位坐标。坐标系随来源不同:
|
||||
// tencent → GCJ-02(火星坐标,与腾讯地图对齐)
|
||||
// ipapi → WGS-84
|
||||
// 两者相差几百米,在世界地图气泡图尺度上无影响;放大到街道级才有意义。
|
||||
Lat float64
|
||||
Lon float64
|
||||
// HasCoords 标记坐标是否可用。IP 库对国家级、部分省级结果不给坐标,
|
||||
// 用 0,0 表示「未知」会被画到几内亚湾去 —— 必须显式区分。
|
||||
HasCoords bool
|
||||
|
||||
// Source 记录这条数据来自哪个库(tencent / ipapi),便于事后判断精度。
|
||||
Source string `gorm:"size:16"`
|
||||
// Failed 标记查询失败。失败也要缓存:某个 IP 查不出来通常是确定性的
|
||||
// (保留地址、库未收录),反复重试只会白白消耗配额。
|
||||
Failed bool
|
||||
|
||||
ResolvedAt time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
// TableName 固定表名。
|
||||
func (IPGeoRecord) TableName() string { return "ip_geo_cache" }
|
||||
|
||||
// IPGeos 地理定位缓存全局 store。
|
||||
var IPGeos IPGeoStore
|
||||
|
||||
// IPGeoStore 地理定位缓存的读写。
|
||||
type IPGeoStore interface {
|
||||
// GetMany 批量取缓存,返回 map[IPKey]*IPGeoRecord。未命中的键不出现在结果里。
|
||||
GetMany(ctx context.Context, keys []string) (map[string]*IPGeoRecord, error)
|
||||
// UpsertMany 批量写入或覆盖。
|
||||
UpsertMany(ctx context.Context, records []*IPGeoRecord) error
|
||||
// PurgeResolvedBefore 删除 resolved_at 早于 before 的记录(缓存过期)。
|
||||
//
|
||||
// 注意「过期」与「失败」的 TTL 不同,由调用方分两次调:
|
||||
// 成功结果留得久(90 天),失败结果留得短(3 天)—— 失败可能是临时故障
|
||||
// (对方 API 超时),留太久等于永久放弃这个 IP。
|
||||
PurgeResolvedBefore(ctx context.Context, before time.Time, onlyFailed bool) (int64, error)
|
||||
// Count 返回缓存条目数。
|
||||
Count(ctx context.Context) (int64, error)
|
||||
}
|
||||
|
||||
type ipGeos struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewIPGeoStore 构造缓存 store。务必传 db.DB 而不是复用什么包级单例。
|
||||
func NewIPGeoStore(db *gorm.DB) IPGeoStore {
|
||||
return &ipGeos{db: db}
|
||||
}
|
||||
|
||||
func (s *ipGeos) GetMany(ctx context.Context, keys []string) (map[string]*IPGeoRecord, error) {
|
||||
out := make(map[string]*IPGeoRecord, len(keys))
|
||||
if len(keys) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 分批 IN 查询:PostgreSQL 的占位符上限是 65535,MySQL 受 max_allowed_packet
|
||||
// 约束,一次塞上千个键虽然多数情况能过,但没必要赌。
|
||||
const batch = 500
|
||||
for start := 0; start < len(keys); start += batch {
|
||||
end := start + batch
|
||||
if end > len(keys) {
|
||||
end = len(keys)
|
||||
}
|
||||
var rows []*IPGeoRecord
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("ip_key IN ?", keys[start:end]).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
out[r.IPKey] = r
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ipGeos) UpsertMany(ctx context.Context, records []*IPGeoRecord) error {
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
// 只更新定位字段,不更新 ip_key 本身(它是冲突判定列)。
|
||||
cols := []string{
|
||||
"country", "country_code", "region", "city", "isp", "asn",
|
||||
"lat", "lon", "has_coords", "source", "failed", "resolved_at",
|
||||
}
|
||||
return s.db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "ip_key"}},
|
||||
DoUpdates: clause.AssignmentColumns(cols),
|
||||
}).
|
||||
CreateInBatches(records, 100).Error
|
||||
}
|
||||
|
||||
func (s *ipGeos) PurgeResolvedBefore(ctx context.Context, before time.Time, onlyFailed bool) (int64, error) {
|
||||
q := s.db.WithContext(ctx).Where("resolved_at < ?", before)
|
||||
if onlyFailed {
|
||||
q = q.Where("failed = ?", true)
|
||||
} else {
|
||||
// 成功记录那一轮不要连失败的一起删:两者的 TTL 不同。
|
||||
q = q.Where("failed = ?", false)
|
||||
}
|
||||
res := q.Delete(&IPGeoRecord{})
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
|
||||
func (s *ipGeos) Count(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
err := s.db.WithContext(ctx).Model(&IPGeoRecord{}).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
Reference in New Issue
Block a user