// 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 "" }