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:
@@ -45,6 +45,10 @@ var AllTables = []interface{}{
|
||||
&UserRestriction{}, &ContentReport{},
|
||||
// 蜜罐命中记录(审计证据,只追加不软删)
|
||||
&HoneypotHit{},
|
||||
// 来源 IP 的地理定位缓存(安全汇总报告用,纯 KV 缓存)
|
||||
&IPGeoRecord{},
|
||||
// 周期安全汇总的报告台账(period_key 唯一,保证重启不会重复发信)
|
||||
&SecurityReportLog{},
|
||||
// 自动封禁记录(内存态封禁的落库账本,用于事后解释与取证)
|
||||
&BanRecord{},
|
||||
// 箱主数据面板的访问流水(每 访客×天×箱 一行,Unscoped 语义,见 BoxView 注释)
|
||||
|
||||
@@ -97,6 +97,20 @@ type HoneypotStore interface {
|
||||
// 而不是只能靠开发一次性汇报。Bytes 取整个关系的总大小(含索引),
|
||||
// 因为磁盘压力来自索引和 TOAST,不只是堆表。
|
||||
Size(ctx context.Context) (HoneypotSize, error)
|
||||
// AggregateSources 统计 [since, until) 内按来源 IP 聚合的命中数。
|
||||
//
|
||||
// 安全汇总报告专用:报告要先按 IP 聚合再拿去地理定位,而不是把几十万条
|
||||
// 命中明细全捞进内存再 group —— 明细只在「命中数」这一个维度上有用,
|
||||
// 聚合能把它压到「独立 IP 数」的量级。
|
||||
AggregateSources(ctx context.Context, since, until time.Time) ([]*HoneypotSourceAgg, error)
|
||||
// AggregatePaths 统计 [since, until) 内被探测最多的路径。
|
||||
AggregatePaths(ctx context.Context, since, until time.Time, limit int) ([]*HoneypotPathStat, error)
|
||||
// TimestampsBetween 返回 [since, until) 内所有命中的时间戳(仅这一列)。
|
||||
//
|
||||
// 用于画每日趋势。刻意不在 SQL 里按天分组:DATE() 的返回类型在
|
||||
// PostgreSQL(date)与 MySQL([]byte)之间不一致,跨方言解析很脆;
|
||||
// 而这一列的体积极小(时间戳 8 字节),取回来在 Go 里按天归并更稳。
|
||||
TimestampsBetween(ctx context.Context, since, until time.Time) ([]time.Time, error)
|
||||
// LegacyCredentialColumns 返回仍残留的旧版凭据列(空表示已清理)。
|
||||
LegacyCredentialColumns(ctx context.Context) ([]string, error)
|
||||
// PurgeLegacyCredentialColumns 删除旧版遗留的凭据列(不可逆)。
|
||||
@@ -121,6 +135,20 @@ type HoneypotSourceStat struct {
|
||||
Provider string
|
||||
}
|
||||
|
||||
// HoneypotSourceAgg 是带「处置结果」的来源聚合行(安全汇总报告用)。
|
||||
//
|
||||
// 与 HoneypotSourceStat 的区别:那个是给 CLI 排行看的(只要次数),
|
||||
// 这个要给报告回答「打了多少次 / 有多少次被拦下 / 有多少次真的在试密码」。
|
||||
type HoneypotSourceAgg struct {
|
||||
IP string
|
||||
Hits int64
|
||||
Banned int64
|
||||
// Creds 带有凭据字段的命中数(真实破解尝试,区别于纯路径探测)。
|
||||
Creds int64
|
||||
// Provider 是该 IP 最近一次命中时的 cdncheck 标签(云厂商/CDN/WAF)。
|
||||
Provider string
|
||||
}
|
||||
|
||||
// HoneypotPathStat 路径聚合行。
|
||||
type HoneypotPathStat struct {
|
||||
Path string
|
||||
@@ -234,6 +262,55 @@ func (h *honeypots) TopPaths(ctx context.Context, since time.Time, limit int) ([
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// AggregateSources 统计 [since, until) 内按来源 IP 聚合的命中数。
|
||||
//
|
||||
// [since, until) 左闭右开:周期报告的相邻两期不能有重叠,也不能漏掉
|
||||
// 恰好落在整点上的记录 —— 用闭区间会让周一的 00:00:00 同时算进上周和本周。
|
||||
func (h *honeypots) AggregateSources(ctx context.Context, since, until time.Time) ([]*HoneypotSourceAgg, error) {
|
||||
var rows []*HoneypotSourceAgg
|
||||
err := h.db.WithContext(ctx).
|
||||
Table("honeypot_hits").
|
||||
// SUM(CASE WHEN ...) 是 PG 与 MySQL 都支持的写法(boolean 在 PG 里
|
||||
// 不能直接 sum,转 1/0 才跨方言)。
|
||||
Select("ip AS ip, count(*) AS hits, "+
|
||||
"sum(case when banned then 1 else 0 end) AS banned, "+
|
||||
"sum(case when had_fields then 1 else 0 end) AS creds, "+
|
||||
"max(provider) AS provider").
|
||||
Where("created_at >= ? AND created_at < ?", since, until).
|
||||
Group("ip").
|
||||
Order("hits DESC").
|
||||
Scan(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// AggregatePaths 统计 [since, until) 内被探测最多的路径。
|
||||
func (h *honeypots) AggregatePaths(ctx context.Context, since, until time.Time, limit int) ([]*HoneypotPathStat, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 20
|
||||
}
|
||||
var rows []*HoneypotPathStat
|
||||
err := h.db.WithContext(ctx).
|
||||
Table("honeypot_hits").
|
||||
Select("path AS path, count(*) AS count").
|
||||
Where("created_at >= ? AND created_at < ?", since, until).
|
||||
Group("path").
|
||||
Order("count DESC").
|
||||
Limit(limit).
|
||||
Scan(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// TimestampsBetween 返回 [since, until) 内所有命中的时间戳。
|
||||
func (h *honeypots) TimestampsBetween(ctx context.Context, since, until time.Time) ([]time.Time, error) {
|
||||
var ts []time.Time
|
||||
err := h.db.WithContext(ctx).
|
||||
Model(&HoneypotHit{}).
|
||||
Where("created_at >= ? AND created_at < ?", since, until).
|
||||
Order("created_at ASC").
|
||||
Pluck("created_at", &ts).Error
|
||||
return ts, err
|
||||
}
|
||||
|
||||
func (h *honeypots) DeleteBefore(ctx context.Context, before time.Time) (int64, error) {
|
||||
res := h.db.WithContext(ctx).Where("created_at < ?", before).Delete(&HoneypotHit{})
|
||||
return res.RowsAffected, res.Error
|
||||
|
||||
@@ -159,3 +159,73 @@ func TestHoneypotSize(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), info2.Rows)
|
||||
}
|
||||
|
||||
// TestHoneypotAggregateRange 覆盖安全汇总报告用的区间聚合。
|
||||
//
|
||||
// 三个方法共同回答「这一周谁在打、打了哪儿」。重点验证:
|
||||
// - [since, until) 左闭右开,相邻两期不能重叠;
|
||||
// - SUM(CASE WHEN ...) 在 PostgreSQL / MySQL 上都能算出处置数;
|
||||
// - 只取时间戳那一列时不夹带整行(报告不需要 UA / 路径以外的字段)。
|
||||
func TestHoneypotAggregateRange(t *testing.T) {
|
||||
database, cleanup := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Cleanup(func() {
|
||||
if err := cleanup("honeypot_hits"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
store := NewHoneypotStore(database)
|
||||
loc := time.Local
|
||||
start := time.Date(2026, 10, 19, 0, 0, 0, 0, loc)
|
||||
end := start.AddDate(0, 0, 7)
|
||||
|
||||
rows := []*HoneypotHit{
|
||||
// 窗口内:IP A 三次(两次被拦、一次带凭据)、IP B 一次
|
||||
{IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, HadFields: false, Provider: "Azure", CreatedAt: start.Add(time.Hour)},
|
||||
{IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, HadFields: true, Provider: "Azure", CreatedAt: start.Add(2 * time.Hour)},
|
||||
{IP: "203.0.113.1", Path: "/.env", Banned: true, HadFields: false, Provider: "Azure", CreatedAt: start.Add(3 * time.Hour)},
|
||||
{IP: "198.51.100.2", Path: "/wp-admin", Banned: false, HadFields: false, CreatedAt: start.Add(4 * time.Hour)},
|
||||
// 窗口外(右侧开区间边界):不能算进本期
|
||||
{IP: "203.0.113.1", Path: "/wp-login.php", Banned: true, CreatedAt: end},
|
||||
// 窗口外(早于起点)
|
||||
{IP: "192.0.2.9", Path: "/phpmyadmin", Banned: true, CreatedAt: start.Add(-time.Minute)},
|
||||
}
|
||||
for _, r := range rows {
|
||||
require.NoError(t, store.Create(ctx, r))
|
||||
}
|
||||
|
||||
sources, err := store.AggregateSources(ctx, start, end)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, sources, 2)
|
||||
|
||||
// 按命中数降序:203.0.113.1 在前
|
||||
require.Equal(t, "203.0.113.1", sources[0].IP)
|
||||
require.Equal(t, int64(3), sources[0].Hits)
|
||||
require.Equal(t, int64(3), sources[0].Banned)
|
||||
require.Equal(t, int64(1), sources[0].Creds)
|
||||
require.Equal(t, "Azure", sources[0].Provider)
|
||||
|
||||
require.Equal(t, "198.51.100.2", sources[1].IP)
|
||||
require.Equal(t, int64(1), sources[1].Hits)
|
||||
require.Equal(t, int64(0), sources[1].Banned)
|
||||
|
||||
paths, err := store.AggregatePaths(ctx, start, end, 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, paths, 3)
|
||||
require.Equal(t, "/wp-login.php", paths[0].Path)
|
||||
require.Equal(t, int64(2), paths[0].Count)
|
||||
|
||||
stamps, err := store.TimestampsBetween(ctx, start, end)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, stamps, 4, "右边界那条不能算进来")
|
||||
for _, ts := range stamps {
|
||||
require.True(t, ts.Before(end), "所有时间戳都应严格早于 end")
|
||||
}
|
||||
|
||||
// 空窗口必须返回空而不是报错(报告要能生成「本周无人扫描」)
|
||||
empty, err := store.AggregateSources(ctx, start.AddDate(0, 0, 30), start.AddDate(0, 0, 37))
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, empty)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// 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"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestIPGeoStore 覆盖地理定位缓存的读写、批量取、覆盖与过期清理。
|
||||
//
|
||||
// 这张表是配额的保护层:同一批攻击者会在多份报告里反复出现,
|
||||
// 「查一次、之后全靠缓存」能不能成立全看这里的语义对不对。
|
||||
func TestIPGeoStore(t *testing.T) {
|
||||
database, cleanup := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Cleanup(func() {
|
||||
if err := cleanup("ip_geo_cache"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
store := NewIPGeoStore(database)
|
||||
now := time.Now()
|
||||
|
||||
records := []*IPGeoRecord{
|
||||
{
|
||||
IPKey: "203.0.113.1", Country: "美国", CountryCode: "US",
|
||||
Region: "Virginia", City: "Ashburn", ISP: "Amazon", ASN: "AS14618",
|
||||
Lat: 39.04, Lon: -77.49, HasCoords: true, Source: "ipapi", ResolvedAt: now,
|
||||
},
|
||||
{
|
||||
IPKey: "2001:db8::/64", Country: "中国", CountryCode: "CN",
|
||||
Region: "北京市", City: "北京", Lat: 39.9, Lon: 116.4,
|
||||
HasCoords: true, Source: "tencent", ResolvedAt: now,
|
||||
},
|
||||
{
|
||||
// 查不到:Failed 标记要能落库,否则每次报告都会重试同一批地址
|
||||
IPKey: "198.51.100.9", Failed: true, ResolvedAt: now,
|
||||
},
|
||||
}
|
||||
require.NoError(t, store.UpsertMany(ctx, records))
|
||||
|
||||
got, err := store.GetMany(ctx, []string{"203.0.113.1", "2001:db8::/64", "198.51.100.9", "10.0.0.1"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 3, "不存在的键不应出现在结果里")
|
||||
require.Equal(t, "Ashburn", got["203.0.113.1"].City)
|
||||
require.Equal(t, "AS14618", got["203.0.113.1"].ASN)
|
||||
require.True(t, got["203.0.113.1"].HasCoords)
|
||||
require.Equal(t, "tencent", got["2001:db8::/64"].Source)
|
||||
require.True(t, got["198.51.100.9"].Failed)
|
||||
|
||||
// 覆盖写:同一个键再写一次应当更新而不是报主键冲突
|
||||
require.NoError(t, store.UpsertMany(ctx, []*IPGeoRecord{{
|
||||
IPKey: "203.0.113.1", Country: "美国", CountryCode: "US",
|
||||
City: "Reston", Lat: 38.96, Lon: -77.35, HasCoords: true,
|
||||
Source: "tencent", ResolvedAt: now.Add(time.Hour),
|
||||
}}))
|
||||
got2, err := store.GetMany(ctx, []string{"203.0.113.1"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Reston", got2["203.0.113.1"].City)
|
||||
require.Equal(t, "tencent", got2["203.0.113.1"].Source)
|
||||
|
||||
n, err := store.Count(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(3), n)
|
||||
}
|
||||
|
||||
// TestIPGeoStorePurge 覆盖两套 TTL 分开清理:成功留 90 天、失败留 3 天。
|
||||
func TestIPGeoStorePurge(t *testing.T) {
|
||||
database, cleanup := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Cleanup(func() {
|
||||
if err := cleanup("ip_geo_cache"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
store := NewIPGeoStore(database)
|
||||
now := time.Now()
|
||||
|
||||
require.NoError(t, store.UpsertMany(ctx, []*IPGeoRecord{
|
||||
{IPKey: "1.0.0.1", Country: "澳大利亚", ResolvedAt: now.AddDate(0, 0, -100)}, // 成功且过期
|
||||
{IPKey: "1.0.0.2", Country: "日本", ResolvedAt: now.AddDate(0, 0, -10)}, // 成功且新鲜
|
||||
{IPKey: "1.0.0.3", Failed: true, ResolvedAt: now.AddDate(0, 0, -4)}, // 失败且过期
|
||||
{IPKey: "1.0.0.4", Failed: true, ResolvedAt: now}, // 失败且新鲜
|
||||
}))
|
||||
|
||||
// 清失败的旧记录:只删 1.0.0.3
|
||||
n, err := store.PurgeResolvedBefore(ctx, now.AddDate(0, 0, -3), true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), n)
|
||||
|
||||
// 清成功的旧记录:只删 1.0.0.1(不能连失败的一起删,两者 TTL 不同)
|
||||
n, err = store.PurgeResolvedBefore(ctx, now.AddDate(0, 0, -90), false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), n)
|
||||
|
||||
left, err := store.GetMany(ctx, []string{"1.0.0.1", "1.0.0.2", "1.0.0.3", "1.0.0.4"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, left, 2)
|
||||
require.Contains(t, left, "1.0.0.2")
|
||||
require.Contains(t, left, "1.0.0.4")
|
||||
}
|
||||
|
||||
// TestSecurityReportStore 覆盖报告台账的幂等语义。
|
||||
//
|
||||
// 这是「重启不会重复发同一期周报」的唯一保证:period_key 唯一索引 + upsert。
|
||||
func TestSecurityReportStore(t *testing.T) {
|
||||
database, cleanup := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Cleanup(func() {
|
||||
if err := cleanup("security_report_logs"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
store := NewSecurityReportStore(database)
|
||||
start := time.Date(2026, 10, 19, 0, 0, 0, 0, time.Local)
|
||||
end := start.AddDate(0, 0, 7)
|
||||
|
||||
// 首次:不存在
|
||||
rec, err := store.Get(ctx, "week:2026-W43")
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, rec)
|
||||
|
||||
require.NoError(t, store.Save(ctx, &SecurityReportLog{
|
||||
PeriodKey: "week:2026-W43", Kind: "week",
|
||||
WindowStart: start, WindowEnd: end,
|
||||
Hits: 42, UniqueSources: 7, Countries: 3,
|
||||
Recipient: "[email protected]", Attempts: 1, Error: "smtp timeout",
|
||||
}))
|
||||
|
||||
rec, err = store.Get(ctx, "week:2026-W43")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rec)
|
||||
require.False(t, rec.Sent)
|
||||
require.Equal(t, 1, rec.Attempts)
|
||||
|
||||
// 第二次:同一 period_key 再写应当覆盖,而不是插入第二行 ——
|
||||
// 否则「这一期发过没」会被多行记录搞乱。
|
||||
require.NoError(t, store.Save(ctx, &SecurityReportLog{
|
||||
PeriodKey: "week:2026-W43", Kind: "week",
|
||||
WindowStart: start, WindowEnd: end,
|
||||
Hits: 42, UniqueSources: 7, Countries: 3,
|
||||
Recipient: "[email protected]", Attempts: 2, Sent: true,
|
||||
}))
|
||||
|
||||
rec, err = store.Get(ctx, "week:2026-W43")
|
||||
require.NoError(t, err)
|
||||
require.True(t, rec.Sent)
|
||||
require.Equal(t, 2, rec.Attempts)
|
||||
|
||||
recent, err := store.Recent(ctx, 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, recent, 1, "同一期只能有一行台账")
|
||||
|
||||
// 不同周期应各自一行
|
||||
require.NoError(t, store.Save(ctx, &SecurityReportLog{
|
||||
PeriodKey: "month:2026-10", Kind: "month",
|
||||
WindowStart: start, WindowEnd: end, Sent: true,
|
||||
}))
|
||||
recent, err = store.Recent(ctx, 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, recent, 2)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// SecurityReportLog 是「周期安全汇总已发送」的台账。
|
||||
//
|
||||
// 为什么需要一张表而不是在内存里记一个时间戳:
|
||||
// 服务会被重启(发版、OOM、机器迁移)。内存标记一重启就丢,而报告窗口是一整个
|
||||
// 自然周/月 —— 周一凌晨发过以后重启一次,下一次 tick 就会把同一周的周报再发一遍,
|
||||
// 连着发三次的情况在真实运维里很常见(发布窗口经常撞在周一)。
|
||||
//
|
||||
// 用 period_key 唯一索引做幂等:无论触发多少次、重启多少回,同一周期只发一次。
|
||||
type SecurityReportLog struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
|
||||
// PeriodKey 周期标识,形如 "week:2026-W44" / "month:2026-10"。
|
||||
// 唯一索引是幂等的唯一保证 —— 不要改成「按窗口时间范围查重」,
|
||||
// 那样跨月边界(一个月 4 周 vs 5 周)会出现漏发或重发的灰色地带。
|
||||
PeriodKey string `gorm:"size:32;uniqueIndex"`
|
||||
|
||||
// Kind 报告类型(week / month),便于按类型统计。
|
||||
Kind string `gorm:"size:8"`
|
||||
// WindowStart / WindowEnd 报告覆盖的时间窗口 [start, end)。
|
||||
WindowStart time.Time
|
||||
WindowEnd time.Time
|
||||
|
||||
// 发送时的关键数字快照。台账要能独立回答「那周的结论是什么」,
|
||||
// 而不是事后还得回去翻蜜罐表 —— 蜜罐表有保留期,过期就被清掉了。
|
||||
Hits int64
|
||||
UniqueSources int64
|
||||
Banned int64
|
||||
Countries int
|
||||
|
||||
Recipient string `gorm:"size:255"`
|
||||
// FilePath 报告 HTML 的落盘路径(留空表示只发了邮件、没留文件)。
|
||||
FilePath string `gorm:"size:512"`
|
||||
|
||||
// Sent 是否成功发出。失败的行保留下来,既是审计线索,
|
||||
// 也用来做「最多重试几次」的计数。
|
||||
Sent bool
|
||||
// Attempts 尝试次数。发送失败会重试,但必须设上限:
|
||||
// 一个配错的 SMTP 会让每次 tick 都失败,无限重试只是把日志刷满。
|
||||
Attempts int
|
||||
// Error 最近一次失败的原因(成功时清空)。
|
||||
Error string `gorm:"size:512"`
|
||||
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// TableName 固定表名。
|
||||
func (SecurityReportLog) TableName() string { return "security_report_logs" }
|
||||
|
||||
// SecurityReports 报告台账全局 store。
|
||||
var SecurityReports SecurityReportStore
|
||||
|
||||
// SecurityReportStore 报告台账的读写。
|
||||
type SecurityReportStore interface {
|
||||
// Get 按 period_key 取一条,未找到返回 (nil, nil)。
|
||||
Get(ctx context.Context, periodKey string) (*SecurityReportLog, error)
|
||||
// Save 按 period_key upsert。
|
||||
Save(ctx context.Context, rec *SecurityReportLog) error
|
||||
// Recent 返回最近 n 条,按窗口结束时间倒序。
|
||||
Recent(ctx context.Context, n int) ([]*SecurityReportLog, error)
|
||||
}
|
||||
|
||||
type securityReports struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewSecurityReportStore 构造台账 store。
|
||||
func NewSecurityReportStore(db *gorm.DB) SecurityReportStore {
|
||||
return &securityReports{db: db}
|
||||
}
|
||||
|
||||
func (s *securityReports) Get(ctx context.Context, periodKey string) (*SecurityReportLog, error) {
|
||||
var rec SecurityReportLog
|
||||
err := s.db.WithContext(ctx).Where("period_key = ?", periodKey).First(&rec).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
func (s *securityReports) Save(ctx context.Context, rec *SecurityReportLog) error {
|
||||
return s.db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "period_key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"kind", "window_start", "window_end", "hits", "unique_sources",
|
||||
"banned", "countries", "recipient", "file_path",
|
||||
"sent", "attempts", "error", "updated_at",
|
||||
}),
|
||||
}).
|
||||
Create(rec).Error
|
||||
}
|
||||
|
||||
func (s *securityReports) Recent(ctx context.Context, n int) ([]*SecurityReportLog, error) {
|
||||
if n <= 0 {
|
||||
n = 12
|
||||
}
|
||||
var rows []*SecurityReportLog
|
||||
err := s.db.WithContext(ctx).Order("window_end DESC").Limit(n).Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
Reference in New Issue
Block a user