以周/月为单位汇总蜜罐命中,把来源 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 个渲染测试失败。
637 lines
21 KiB
Go
637 lines
21 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 geoip 把 IP 解析成地理位置(国家 / 省 / 市 / 运营商 / 坐标)。
|
||
//
|
||
// 与 internal/security/ipinfo 的分工:ipinfo 用离线指纹判断「这个 IP 是不是
|
||
// 云厂商 / CDN / WAF 的出口」,对家宽、企业宽带、移动网络一律返回
|
||
// 「未识别(普通网络)」—— 它回答的是「是不是代理」,不是「在哪儿」。
|
||
// 肉鸡恰恰全是后一类地址,所以安全汇总必须另有一套真正的 IP 地理位置库。
|
||
//
|
||
// 数据源策略(多源回退):
|
||
// - ip-api.com:批量接口一次 100 个 IP,全球覆盖好,免费版无需 key。
|
||
// 用它做批量主干 —— 免费版限 45 次/分钟,但一次请求覆盖 100 个 IP,
|
||
// 实际吞吐足够,且把「N 个 IP」压成「N/100 次请求」。
|
||
// - 腾讯位置服务:一 IP 一请求,国内 IP 的省市准确度更高、坐标是 GCJ-02
|
||
// (与腾讯地图底图对齐)。只用来**精修**国内的 IP,不做批量主干 ——
|
||
// 拿它打批量会把几百个 IP 变成几百次 HTTP,既慢又容易撞配额。
|
||
//
|
||
// 隐私边界:会把攻击者 IP 发给上述第三方。这是刻意取舍(不查就画不出地图),
|
||
// 但调用方必须清楚 —— 本包只应处理攻击者地址,绝不要拿它去查真实用户 IP。
|
||
package geoip
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net"
|
||
"net/http"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/pkg/errors"
|
||
"github.com/sirupsen/logrus"
|
||
)
|
||
|
||
// Provider 常量。
|
||
const (
|
||
// ProviderAuto 缺省:ip-api 批量打底 + 腾讯精修国内。
|
||
ProviderAuto = "auto"
|
||
// ProviderIPAPI 只用 ip-api.com。
|
||
ProviderIPAPI = "ipapi"
|
||
// ProviderTencent 只用腾讯位置服务(无 key 时自动降级为 ipapi)。
|
||
ProviderTencent = "tencent"
|
||
// ProviderNone 不查询任何外部服务。
|
||
ProviderNone = "none"
|
||
)
|
||
|
||
const (
|
||
// ipAPIBatchSize ip-api 批量接口的硬上限。
|
||
ipAPIBatchSize = 100
|
||
// ipAPIMinInterval ip-api 免费版限流 45 次/分钟。取 1.4s 间隔(≈43 次/分钟)
|
||
// 留一点余量:多个报告同时跑、或用户手动跑 CLI 时不会互相挤爆。
|
||
ipAPIMinInterval = 1400 * time.Millisecond
|
||
// defaultTencentRefineCap 腾讯精修最多处理多少个 IP。
|
||
//
|
||
// 必须有上限:周报可能有两千个不同来源 IP,其中一半是国内 —— 逐个请求
|
||
// 腾讯会跑十几分钟并吃掉当日免费配额,而报告的价值并不取决于把最后一千个
|
||
// 长尾 IP 精确到市。取命中次数最高的前 N 个,够回答「主要从哪儿来」。
|
||
defaultTencentRefineCap = 200
|
||
// defaultTimeout 单次外部请求超时。
|
||
//
|
||
// ⚠️ 不能设小。实测 ip-api 免费版的批量接口「22 个 IP」要 4–10 秒,
|
||
// 且同一批重复请求会更慢(免费额度在按 IP 做节流)。最早设 15 秒时
|
||
// 第一轮真实数据就超时了 —— 免费服务不是快服务,超时要按最坏情况给。
|
||
defaultTimeout = 45 * time.Second
|
||
// ipAPIAttempts 单个批次最多尝试几次。
|
||
// 免费接口偶发慢响应/网关抖动很常见,重试一次成本可接受(有 1.4s 节流兜底),
|
||
// 但重试多了只会放大超时。
|
||
ipAPIAttempts = 2
|
||
// ipAPIRetryDelay 重试前的等待。
|
||
ipAPIRetryDelay = 3 * time.Second
|
||
)
|
||
|
||
// Geo 一个 IP 的地理定位结果。
|
||
type Geo struct {
|
||
Country string
|
||
CountryCode string
|
||
Region string
|
||
City string
|
||
ISP string
|
||
ASN string
|
||
Lat float64
|
||
Lon float64
|
||
HasCoords bool
|
||
Source string
|
||
}
|
||
|
||
// Options 解析器配置。
|
||
type Options struct {
|
||
// Provider auto / ipapi / tencent / none,空视为 auto。
|
||
Provider string
|
||
// TencentKey 腾讯位置服务 key,留空则跳过精修。
|
||
TencentKey string
|
||
// TencentRefineCap 腾讯精修上限,<=0 用默认值。
|
||
TencentRefineCap int
|
||
// HTTPClient 可注入(测试用),nil 用带超时的默认 client。
|
||
HTTPClient *http.Client
|
||
}
|
||
|
||
// Resolver 按配置解析 IP 地理位置。
|
||
type Resolver struct {
|
||
provider string
|
||
tencent string
|
||
refineCap int
|
||
client *http.Client
|
||
// lastReq 串行化 ip-api 请求以遵守限流(多个报告并发时也不会超频)。
|
||
mu sync.Mutex
|
||
lastReq time.Time
|
||
}
|
||
|
||
// New 构造解析器。provider 值不认识时回退 auto 并告警 ——
|
||
// 静默回退会让人以为配置生效了,必须留痕。
|
||
func New(opts Options) *Resolver {
|
||
p := strings.ToLower(strings.TrimSpace(opts.Provider))
|
||
switch p {
|
||
case ProviderAuto, ProviderIPAPI, ProviderTencent, ProviderNone:
|
||
case "":
|
||
p = ProviderAuto
|
||
default:
|
||
logrus.WithField("value", opts.Provider).
|
||
Warn("ip_geo_provider 取值无法识别,已回退 auto(可选值:auto / ipapi / tencent / none)")
|
||
p = ProviderAuto
|
||
}
|
||
|
||
// 只选了 tencent 却没配 key:无法工作,降级 ipapi 而不是返回空结果。
|
||
if p == ProviderTencent && strings.TrimSpace(opts.TencentKey) == "" {
|
||
logrus.Warn("ip_geo_provider = tencent 但未配置 ip_geo_tencent_key,已降级为 ipapi")
|
||
p = ProviderIPAPI
|
||
}
|
||
|
||
cap := opts.TencentRefineCap
|
||
if cap <= 0 {
|
||
cap = defaultTencentRefineCap
|
||
}
|
||
client := opts.HTTPClient
|
||
if client == nil {
|
||
client = &http.Client{Timeout: defaultTimeout}
|
||
}
|
||
return &Resolver{
|
||
provider: p,
|
||
tencent: strings.TrimSpace(opts.TencentKey),
|
||
refineCap: cap,
|
||
client: client,
|
||
}
|
||
}
|
||
|
||
// Enabled 报告解析器是否真的会去查外部服务。
|
||
func (r *Resolver) Enabled() bool { return r != nil && r.provider != ProviderNone }
|
||
|
||
// Describe 返回人类可读的数据源描述(写进报告,让读者知道坐标有多可信)。
|
||
func (r *Resolver) Describe() string {
|
||
if r == nil {
|
||
return "未启用地理定位"
|
||
}
|
||
switch r.provider {
|
||
case ProviderNone:
|
||
return "未启用地理定位(ip_geo_provider = none)"
|
||
case ProviderIPAPI:
|
||
return "ip-api.com(批量,城市级)"
|
||
case ProviderTencent:
|
||
return "腾讯位置服务(城市级,GCJ-02)"
|
||
default:
|
||
if r.tencent != "" {
|
||
return "ip-api.com 批量 + 腾讯位置服务精修国内 IP"
|
||
}
|
||
return "ip-api.com(批量,城市级;未配置腾讯 key,国内 IP 未精修)"
|
||
}
|
||
}
|
||
|
||
// Result 一次批量定位的结果。
|
||
//
|
||
// ⚠️ 必须区分「查不到」与「没查成」:这是本包最容易埋错的地方。
|
||
// - Geo[key] 存在 = 查到了;
|
||
// - Unresolved[key] = 服务**明确回答**没有这个 IP 的数据(可以短期缓存,
|
||
// 免得每份报告都去重试一个注定查不到的地址);
|
||
// - 两者都没有 = 请求本身失败了(超时 / 限流 / 网关抖动)。
|
||
// 此时**绝不能**写缓存 —— 把网络故障记成「这个 IP 没有位置」,
|
||
// 会让接下来三天的报告都缺这一批 IP,而且没有任何线索说明为什么。
|
||
type Result struct {
|
||
Geo map[string]Geo
|
||
Unresolved map[string]bool
|
||
// Requests / Failures 本次发出的请求数与失败的请求数(日志用)。
|
||
Requests int
|
||
Failures int
|
||
}
|
||
|
||
// Lookup 批量解析。keys 是 shared.LimitKey 的产物(IPv4 精确地址 / IPv6 的
|
||
// "xxxx::/64")。
|
||
//
|
||
// 任何失败都只记日志并返回已拿到的部分结果 —— 报告不该因为外部 API 抖动而失败。
|
||
func (r *Resolver) Lookup(ctx context.Context, keys []string) Result {
|
||
res := Result{Geo: make(map[string]Geo, len(keys)), Unresolved: make(map[string]bool)}
|
||
if r == nil || r.provider == ProviderNone || len(keys) == 0 {
|
||
return res
|
||
}
|
||
|
||
// 把缓存键还原成可查询的地址:IPv6 键带 "/64" 后缀,查询时用网络地址
|
||
// (geo 库对前缀同样能回答,用基址是常见做法)。
|
||
//
|
||
// ⚠️ 必须按 keys 的原始顺序建 addrs:调用方(报告)是按命中次数从高到低
|
||
// 传进来的,腾讯精修只取前 refineCap 个。如果这里遍历 map 取地址,
|
||
// Go 的 map 迭代顺序是随机的 —— 每次运行精修到的 IP 都不一样,
|
||
// 配额会花在长尾地址上,而「主要从哪个城市来」恰恰取决于最活跃的那批。
|
||
queryable := make(map[string]string, len(keys)) // 查询地址 -> 原始键
|
||
addrs := make([]string, 0, len(keys))
|
||
for _, key := range keys {
|
||
addr, ok := queryAddr(key)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if _, dup := queryable[addr]; dup {
|
||
continue
|
||
}
|
||
queryable[addr] = key
|
||
addrs = append(addrs, addr)
|
||
}
|
||
if len(addrs) == 0 {
|
||
return res
|
||
}
|
||
|
||
if r.provider == ProviderAuto || r.provider == ProviderIPAPI {
|
||
got, unresolved, reqs, fails := r.lookupIPAPI(ctx, addrs)
|
||
res.Requests += reqs
|
||
res.Failures += fails
|
||
for addr, g := range got {
|
||
if key, ok := queryable[addr]; ok {
|
||
res.Geo[key] = g
|
||
}
|
||
}
|
||
for addr := range unresolved {
|
||
if key, ok := queryable[addr]; ok {
|
||
res.Unresolved[key] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// 腾讯精修:auto 只精修国内,tencent 覆盖全部。
|
||
if r.tencent != "" && r.provider != ProviderIPAPI {
|
||
var refine []string
|
||
for _, addr := range addrs {
|
||
key := queryable[addr]
|
||
if r.provider == ProviderAuto {
|
||
g, ok := res.Geo[key]
|
||
// 只精修 ip-api 已判为中国的条目:海外 IP 腾讯给不出省市,
|
||
// 查了也是白白消耗配额。
|
||
if !ok || !isChina(g.CountryCode) {
|
||
continue
|
||
}
|
||
}
|
||
refine = append(refine, addr)
|
||
}
|
||
if len(refine) > r.refineCap {
|
||
refine = refine[:r.refineCap]
|
||
}
|
||
tgot, tunresolved, treqs, tfails := r.lookupTencent(ctx, refine)
|
||
res.Requests += treqs
|
||
res.Failures += tfails
|
||
for addr, g := range tgot {
|
||
if key, ok := queryable[addr]; ok {
|
||
// 保留 ip-api 给的国家(腾讯对海外 IP 的 nation 常常是空的),
|
||
// 用腾讯的省市与坐标覆盖。
|
||
prev := res.Geo[key]
|
||
if g.Country == "" {
|
||
g.Country, g.CountryCode = prev.Country, prev.CountryCode
|
||
}
|
||
res.Geo[key] = g
|
||
// 精修成功即视为已定位,撤掉可能存在的「查不到」标记。
|
||
delete(res.Unresolved, key)
|
||
}
|
||
}
|
||
for addr := range tunresolved {
|
||
if key, ok := queryable[addr]; ok {
|
||
if _, done := res.Geo[key]; !done {
|
||
res.Unresolved[key] = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return res
|
||
}
|
||
|
||
// isChina 判断国家码是否为中国。
|
||
//
|
||
// 只看 CN:港澳台在多数 IP 库里是 HK/MO/TW 单独编码,它们的城市名当前
|
||
// 不会进入腾讯精修 —— 不做特判,宁可少精修也不要把地区归属搞错。
|
||
func isChina(code string) bool { return strings.EqualFold(code, "CN") }
|
||
|
||
// queryAddr 把缓存键还原为可查询的地址;不可查询(私网/回环/非法)返回 false。
|
||
func queryAddr(key string) (string, bool) {
|
||
base := key
|
||
if i := strings.IndexByte(base, '/'); i >= 0 {
|
||
base = base[:i]
|
||
}
|
||
ip := net.ParseIP(base)
|
||
if ip == nil || !isPublic(ip) {
|
||
return "", false
|
||
}
|
||
return ip.String(), true
|
||
}
|
||
|
||
// isPublic 排除私网 / 回环 / 链路本地 / 唯一本地地址。
|
||
// 这些地址查不出位置,发出去也毫无意义(还会浪费配额)。
|
||
func isPublic(ip net.IP) bool {
|
||
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
|
||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() {
|
||
return false
|
||
}
|
||
// IPv4 映射到 IPv6 的形式(::ffff:1.2.3.4)统一按 v4 处理。
|
||
if v4 := ip.To4(); v4 != nil {
|
||
return !(v4[0] == 0 || v4[0] == 10 || v4[0] == 127 ||
|
||
(v4[0] == 169 && v4[1] == 254) ||
|
||
(v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31) ||
|
||
(v4[0] == 192 && v4[1] == 168) ||
|
||
(v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127))
|
||
}
|
||
return true
|
||
}
|
||
|
||
// ---- ip-api.com ----
|
||
|
||
type ipAPIResult struct {
|
||
Query string `json:"query"`
|
||
Status string `json:"status"`
|
||
Message string `json:"message"`
|
||
Country string `json:"country"`
|
||
CountryCode string `json:"countryCode"`
|
||
RegionName string `json:"regionName"`
|
||
City string `json:"city"`
|
||
Lat float64 `json:"lat"`
|
||
Lon float64 `json:"lon"`
|
||
ISP string `json:"isp"`
|
||
AS string `json:"as"`
|
||
}
|
||
|
||
// lookupIPAPI 分批查询 ip-api.com。
|
||
//
|
||
// 返回三样东西:
|
||
// - got:查到的地址 → Geo;
|
||
// - unresolved:服务明确回答「这个地址没有数据」的地址;
|
||
// - requests / failures:发出的请求数与失败数。
|
||
//
|
||
// 失败的批次整批都不进 unresolved —— 区别于「服务说没有这个 IP 的数据」。
|
||
//
|
||
// 免费版只支持 http(https 要 pro)—— 这是该服务的既定限制,不是笔误。
|
||
// 请求体里只有攻击者 IP,不含任何站点数据。
|
||
func (r *Resolver) lookupIPAPI(ctx context.Context, addrs []string) (map[string]Geo, map[string]bool, int, int) {
|
||
got := make(map[string]Geo, len(addrs))
|
||
unresolved := make(map[string]bool)
|
||
requests, failures := 0, 0
|
||
|
||
for start := 0; start < len(addrs); start += ipAPIBatchSize {
|
||
end := start + ipAPIBatchSize
|
||
if end > len(addrs) {
|
||
end = len(addrs)
|
||
}
|
||
batch := addrs[start:end]
|
||
|
||
res, err := r.ipAPIBatchWithRetry(ctx, batch)
|
||
requests++
|
||
if err != nil {
|
||
failures++
|
||
logrus.WithError(err).WithField("count", len(batch)).
|
||
Warn("ip-api 批量查询失败:这批 IP 本次不定位,且不会写入缓存(下次运行会重试)")
|
||
continue
|
||
}
|
||
for addr, g := range res.geo {
|
||
got[addr] = g
|
||
}
|
||
for addr := range res.unresolved {
|
||
unresolved[addr] = true
|
||
}
|
||
}
|
||
return got, unresolved, requests, failures
|
||
}
|
||
|
||
// batchOutcome 一个批次的解析结果。
|
||
type batchOutcome struct {
|
||
geo map[string]Geo
|
||
unresolved map[string]bool
|
||
}
|
||
|
||
// ipAPIBatchWithRetry 带一次重试的批量请求。
|
||
//
|
||
// 为什么要重试:ip-api 免费版对同一来源有节流,偶发慢响应/网关 5xx 很常见。
|
||
// 不重试的话,一次抖动就会让整份报告缺掉最多 100 个 IP(而报告一周只出一次,
|
||
// 没有「下个小时再看」的机会)。重试一次的成本是 3 秒等待,值得。
|
||
func (r *Resolver) ipAPIBatchWithRetry(ctx context.Context, addrs []string) (*batchOutcome, error) {
|
||
var lastErr error
|
||
for attempt := 1; attempt <= ipAPIAttempts; attempt++ {
|
||
res, err := r.ipAPIBatch(ctx, addrs)
|
||
if err == nil {
|
||
return res, nil
|
||
}
|
||
lastErr = err
|
||
if attempt < ipAPIAttempts {
|
||
logrus.WithError(err).WithField("attempt", attempt).Debug("ip-api 批量查询失败,准备重试")
|
||
select {
|
||
case <-ctx.Done():
|
||
return nil, ctx.Err()
|
||
case <-time.After(ipAPIRetryDelay):
|
||
}
|
||
}
|
||
}
|
||
return nil, lastErr
|
||
}
|
||
|
||
func (r *Resolver) ipAPIBatch(ctx context.Context, addrs []string) (*batchOutcome, error) {
|
||
// 按序返回:ip-api 的批量接口保证结果顺序与输入一致,用下标对应最稳
|
||
// (不要拿返回的 query 字段当键 —— 它对 IPv6 会做规范化,和输入不逐字相同)。
|
||
body, err := json.Marshal(addrs)
|
||
if err != nil {
|
||
return nil, errors.Wrap(err, "marshal batch")
|
||
}
|
||
|
||
if err := r.waitTurn(); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
url := "http://ip-api.com/batch?fields=status,message,query,country,countryCode,regionName,city,lat,lon,isp,as"
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||
if err != nil {
|
||
return nil, errors.Wrap(err, "build request")
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
|
||
resp, err := r.client.Do(req)
|
||
if err != nil {
|
||
return nil, errors.Wrap(err, "request")
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||
return nil, errors.Errorf("ip-api status %d: %s", resp.StatusCode, string(snippet))
|
||
}
|
||
|
||
var results []ipAPIResult
|
||
if err := json.NewDecoder(resp.Body).Decode(&results); err != nil {
|
||
return nil, errors.Wrap(err, "decode")
|
||
}
|
||
|
||
out := &batchOutcome{geo: make(map[string]Geo, len(results)), unresolved: make(map[string]bool)}
|
||
for i, item := range results {
|
||
if i >= len(addrs) {
|
||
break
|
||
}
|
||
if item.Status != "success" {
|
||
// 服务明确回答了这个地址没有数据(保留地址 / 未收录)。
|
||
// 只有这种情况才允许进 unresolved(也就是允许被缓存成「查不到」)。
|
||
out.unresolved[addrs[i]] = true
|
||
continue
|
||
}
|
||
out.geo[addrs[i]] = Geo{
|
||
Country: item.Country,
|
||
CountryCode: item.CountryCode,
|
||
Region: item.RegionName,
|
||
City: item.City,
|
||
ISP: item.ISP,
|
||
ASN: normalizeASN(item.AS),
|
||
Lat: item.Lat,
|
||
Lon: item.Lon,
|
||
// ip-api 对国家级结果也会给一个中心点坐标(精度差但可用),
|
||
// 只要不是 (0,0) 就认为有坐标。
|
||
HasCoords: item.Lat != 0 || item.Lon != 0,
|
||
Source: ProviderIPAPI,
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// waitTurn 串行地保证两次 ip-api 请求间隔不小于 ipAPIMinInterval。
|
||
func (r *Resolver) waitTurn() error {
|
||
r.mu.Lock()
|
||
defer r.mu.Unlock()
|
||
if !r.lastReq.IsZero() {
|
||
if wait := ipAPIMinInterval - time.Since(r.lastReq); wait > 0 {
|
||
time.Sleep(wait)
|
||
}
|
||
}
|
||
r.lastReq = time.Now()
|
||
return nil
|
||
}
|
||
|
||
// normalizeASN 把 ip-api 的 "AS15169 Google LLC" 拆成 "AS15169"。
|
||
func normalizeASN(as string) string {
|
||
as = strings.TrimSpace(as)
|
||
if as == "" {
|
||
return ""
|
||
}
|
||
if i := strings.IndexByte(as, ' '); i > 0 {
|
||
return as[:i]
|
||
}
|
||
return as
|
||
}
|
||
|
||
// ---- 腾讯位置服务 ----
|
||
|
||
type tencentResult struct {
|
||
Status int `json:"status"`
|
||
Message string `json:"message"`
|
||
Result struct {
|
||
IP string `json:"ip"`
|
||
Location struct {
|
||
Lat float64 `json:"lat"`
|
||
Lng float64 `json:"lng"`
|
||
} `json:"location"`
|
||
AdInfo struct {
|
||
Nation string `json:"nation"`
|
||
Province string `json:"province"`
|
||
City string `json:"city"`
|
||
District string `json:"district"`
|
||
} `json:"ad_info"`
|
||
} `json:"result"`
|
||
}
|
||
|
||
// tencentOutcome 单个地址的查询结果分类。
|
||
type tencentOutcome int
|
||
|
||
const (
|
||
// tencentFound 查到并给出了行政区划。
|
||
tencentFound tencentOutcome = iota
|
||
// tencentNoData 服务明确回答没有这个地址的数据(status != 0)。
|
||
tencentNoData
|
||
// tencentFailed 网络 / 解析失败 —— 与「没有数据」不是一回事,不能缓存。
|
||
tencentFailed
|
||
)
|
||
|
||
// lookupTencent 逐个查询腾讯 IP 定位(并发度 2,别把免费配额打爆)。
|
||
func (r *Resolver) lookupTencent(ctx context.Context, addrs []string) (map[string]Geo, map[string]bool, int, int) {
|
||
got := make(map[string]Geo, len(addrs))
|
||
unresolved := make(map[string]bool)
|
||
if len(addrs) == 0 || r.tencent == "" {
|
||
return got, unresolved, 0, 0
|
||
}
|
||
|
||
const workers = 2
|
||
var (
|
||
mu sync.Mutex
|
||
wg sync.WaitGroup
|
||
sem = make(chan struct{}, workers)
|
||
failures int
|
||
)
|
||
for _, addr := range addrs {
|
||
wg.Add(1)
|
||
go func(addr string) {
|
||
defer wg.Done()
|
||
select {
|
||
case sem <- struct{}{}:
|
||
defer func() { <-sem }()
|
||
case <-ctx.Done():
|
||
return
|
||
}
|
||
g, oc := r.tencentOne(ctx, addr)
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
switch oc {
|
||
case tencentFound:
|
||
got[addr] = g
|
||
case tencentNoData:
|
||
unresolved[addr] = true
|
||
default:
|
||
failures++
|
||
}
|
||
}(addr)
|
||
}
|
||
wg.Wait()
|
||
return got, unresolved, len(addrs), failures
|
||
}
|
||
|
||
func (r *Resolver) tencentOne(ctx context.Context, addr string) (Geo, tencentOutcome) {
|
||
url := fmt.Sprintf("https://apis.map.qq.com/ws/location/v1/ip?ip=%s&key=%s", addr, r.tencent)
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||
if err != nil {
|
||
return Geo{}, tencentFailed
|
||
}
|
||
resp, err := r.client.Do(req)
|
||
if err != nil {
|
||
return Geo{}, tencentFailed
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return Geo{}, tencentFailed
|
||
}
|
||
|
||
var tr tencentResult
|
||
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
|
||
return Geo{}, tencentFailed
|
||
}
|
||
// status != 0 表示查询失败(配额用尽、key 无效、IP 无数据等)。
|
||
// 只记 Debug:几千个 IP 逐个刷警告会把日志淹掉。
|
||
if tr.Status != 0 {
|
||
logrus.WithFields(logrus.Fields{"status": tr.Status, "message": tr.Message}).
|
||
Debug("腾讯 IP 定位返回非 0 状态")
|
||
// 配额用尽 / key 无效属于「配置或额度问题」,不是「这个 IP 没数据」。
|
||
// 把它们混为一谈会把整批 IP 缓存成「查不到」,此后三天都不再精修。
|
||
return Geo{}, tencentFailed
|
||
}
|
||
|
||
ad := tr.Result.AdInfo
|
||
city := ad.City
|
||
if city == "" {
|
||
city = ad.District
|
||
}
|
||
if ad.Province == "" && city == "" {
|
||
// 状态成功但没有任何行政区划信息:这才是「这个地址没数据」。
|
||
return Geo{}, tencentNoData
|
||
}
|
||
return Geo{
|
||
Country: ad.Nation,
|
||
CountryCode: tencentCountryCode(ad.Nation),
|
||
Region: ad.Province,
|
||
City: city,
|
||
Lat: tr.Result.Location.Lat,
|
||
Lon: tr.Result.Location.Lng,
|
||
HasCoords: tr.Result.Location.Lat != 0 || tr.Result.Location.Lng != 0,
|
||
Source: ProviderTencent,
|
||
}, tencentFound
|
||
}
|
||
|
||
// tencentCountryCode 从腾讯返回的 nation 推国家码。
|
||
//
|
||
// 不能无脑写 "CN":provider = tencent 时(不先过 ip-api)所有地址都会走这条路,
|
||
// 海外 IP 的 nation 会是「美国」之类,硬编码 CN 会把它们统计成中国来源 ——
|
||
// 而「攻击来自哪些国家」正是这份报告的核心结论之一。
|
||
//
|
||
// 只认「中国」这个精确值:港澳台在腾讯的 nation 里是「中国香港」等,
|
||
// 与 ip-api 的 HK/MO/TW 编码对不上,留空交给上层按国家名兜底更安全。
|
||
func tencentCountryCode(nation string) string {
|
||
if strings.TrimSpace(nation) == "中国" {
|
||
return "CN"
|
||
}
|
||
return ""
|
||
}
|