Files
tamabox/internal/db/ipgeo.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

152 lines
5.6 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 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
}