以周/月为单位汇总蜜罐命中,把来源 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 个渲染测试失败。
501 lines
18 KiB
Go
501 lines
18 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
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// rewriteTransport 把真实域名的请求改投到本地测试服务器。
|
|
//
|
|
// 用改投而不是给生产代码加一个 baseURL 字段:生产代码里不该为了测试多出
|
|
// 「可以被指向别处」的开关,那类开关一旦被配置错就是把攻击者 IP 发到未知主机。
|
|
type rewriteTransport struct {
|
|
target *url.URL
|
|
}
|
|
|
|
func (t *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
next := req.Clone(req.Context())
|
|
next.URL.Scheme = t.target.Scheme
|
|
next.URL.Host = t.target.Host
|
|
return http.DefaultTransport.RoundTrip(next)
|
|
}
|
|
|
|
// newTestClient 返回一个把所有出站请求改投到 srv 的 client。
|
|
func newTestClient(t *testing.T, srv *httptest.Server) *http.Client {
|
|
t.Helper()
|
|
u, err := url.Parse(srv.URL)
|
|
require.NoError(t, err)
|
|
return &http.Client{Transport: &rewriteTransport{target: u}}
|
|
}
|
|
|
|
// ipAPIServer 起一个假的 ip-api 批量接口。
|
|
//
|
|
// respond 收到本批请求的地址列表,返回对应的 ipAPIResult 切片;
|
|
// status 非 0 时直接返回该 HTTP 状态码(用于模拟网关故障)。
|
|
func ipAPIServer(t *testing.T, respond func(addrs []string) ([]ipAPIResult, int)) (*httptest.Server, *int32) {
|
|
t.Helper()
|
|
var calls int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
if req.URL.Path != "/batch" {
|
|
http.NotFound(w, req)
|
|
return
|
|
}
|
|
if req.Method != http.MethodPost {
|
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
atomic.AddInt32(&calls, 1)
|
|
var addrs []string
|
|
if err := json.NewDecoder(req.Body).Decode(&addrs); err != nil {
|
|
http.Error(w, "bad body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
results, code := respond(addrs)
|
|
if code != 0 {
|
|
http.Error(w, "upstream failure", code)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(results)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv, &calls
|
|
}
|
|
|
|
// okIP 构造一条成功的 ip-api 结果。
|
|
func okIP(addr, country, code, city string, lat, lon float64, isp, as string) ipAPIResult {
|
|
return ipAPIResult{
|
|
Query: addr, Status: "success", Country: country, CountryCode: code,
|
|
City: city, Lat: lat, Lon: lon, ISP: isp, AS: as,
|
|
}
|
|
}
|
|
|
|
// TestLookupSuccess 三态里的「查到了」与「服务明确说没有」。
|
|
func TestLookupSuccess(t *testing.T) {
|
|
srv, calls := ipAPIServer(t, func(addrs []string) ([]ipAPIResult, int) {
|
|
return []ipAPIResult{
|
|
okIP(addrs[0], "德国", "DE", "Berlin", 52.52, 13.4, "Hetzner", "AS24940 Hetzner Online GmbH"),
|
|
{Query: addrs[1], Status: "fail", Message: "reserved range"},
|
|
}, 0
|
|
})
|
|
|
|
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
|
|
res := r.Lookup(context.Background(), []string{"5.9.1.1", "203.0.113.7"})
|
|
|
|
require.Equal(t, int32(1), atomic.LoadInt32(calls))
|
|
require.Equal(t, 1, res.Requests)
|
|
require.Equal(t, 0, res.Failures)
|
|
|
|
g, ok := res.Geo["5.9.1.1"]
|
|
require.True(t, ok)
|
|
assert.Equal(t, "德国", g.Country)
|
|
assert.Equal(t, "DE", g.CountryCode)
|
|
assert.Equal(t, "Berlin", g.City)
|
|
assert.Equal(t, "AS24940", g.ASN, "AS 字段必须截成纯 ASN,否则国家统计里全是长串")
|
|
assert.True(t, g.HasCoords)
|
|
assert.Equal(t, ProviderIPAPI, g.Source)
|
|
|
|
// status != success 才是「服务明确说没有」,才可以被缓存成查不到
|
|
assert.True(t, res.Unresolved["203.0.113.7"])
|
|
assert.NotContains(t, res.Geo, "203.0.113.7")
|
|
}
|
|
|
|
// TestLookupNetworkFailure 三态里的「没查成」:绝不能进 Unresolved。
|
|
//
|
|
// 这条断言是本包最重要的回归保护:一旦失败也进 Unresolved,上层就会把它
|
|
// 写进缓存(3 天),此后每份报告的定位覆盖率都会莫名其妙变成 0。
|
|
func TestLookupNetworkFailure(t *testing.T) {
|
|
srv, calls := ipAPIServer(t, func([]string) ([]ipAPIResult, int) {
|
|
return nil, http.StatusBadGateway
|
|
})
|
|
|
|
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
|
|
res := r.Lookup(context.Background(), []string{"8.8.4.4", "1.0.0.1"})
|
|
|
|
assert.Equal(t, int32(ipAPIAttempts), atomic.LoadInt32(calls), "必须重试,但只重试一次")
|
|
assert.Equal(t, 1, res.Requests)
|
|
assert.Equal(t, 1, res.Failures)
|
|
assert.Empty(t, res.Geo)
|
|
assert.Empty(t, res.Unresolved, "网络失败不能记成「这个 IP 没有位置」")
|
|
}
|
|
|
|
// TestLookupRetrySucceeds 第一次抖动、第二次正常:不该丢数据。
|
|
func TestLookupRetrySucceeds(t *testing.T) {
|
|
var attempt int32
|
|
srv, _ := ipAPIServer(t, func(addrs []string) ([]ipAPIResult, int) {
|
|
if atomic.AddInt32(&attempt, 1) == 1 {
|
|
return nil, http.StatusGatewayTimeout
|
|
}
|
|
return []ipAPIResult{okIP(addrs[0], "日本", "JP", "Tokyo", 35.68, 139.69, "", "")}, 0
|
|
})
|
|
|
|
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
|
|
res := r.Lookup(context.Background(), []string{"133.242.0.1"})
|
|
|
|
require.Equal(t, 0, res.Failures)
|
|
assert.Equal(t, "日本", res.Geo["133.242.0.1"].Country)
|
|
}
|
|
|
|
// TestLookupBatches 超过 100 个地址必须分批,而不是一次塞进去被拒。
|
|
func TestLookupBatches(t *testing.T) {
|
|
srv, calls := ipAPIServer(t, func(addrs []string) ([]ipAPIResult, int) {
|
|
assert.LessOrEqual(t, len(addrs), ipAPIBatchSize, "单批不能超过 ip-api 的硬上限")
|
|
out := make([]ipAPIResult, 0, len(addrs))
|
|
for _, a := range addrs {
|
|
out = append(out, okIP(a, "美国", "US", "Ashburn", 39.0, -77.5, "", ""))
|
|
}
|
|
return out, 0
|
|
})
|
|
|
|
addrs := make([]string, 0, 150)
|
|
for i := 0; i < 150; i++ {
|
|
addrs = append(addrs, fmt.Sprintf("198.51.%d.%d", i/256, i%256))
|
|
}
|
|
|
|
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
|
|
res := r.Lookup(context.Background(), addrs)
|
|
|
|
assert.Equal(t, int32(2), atomic.LoadInt32(calls))
|
|
assert.Equal(t, 2, res.Requests)
|
|
assert.Len(t, res.Geo, 150)
|
|
}
|
|
|
|
// TestLookupSkipsPrivate 私网 / 回环 / CGNAT 一律不外发:既不泄露内网结构,
|
|
// 也省下配额(这类地址查了也是空的)。
|
|
func TestLookupSkipsPrivate(t *testing.T) {
|
|
srv, calls := ipAPIServer(t, func([]string) ([]ipAPIResult, int) {
|
|
return nil, http.StatusInternalServerError
|
|
})
|
|
|
|
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
|
|
res := r.Lookup(context.Background(), []string{
|
|
"10.0.0.1", "192.168.1.1", "172.16.0.9", "127.0.0.1",
|
|
"100.64.3.4", "169.254.1.1", "::1", "fe80::1", "fd00::1",
|
|
})
|
|
|
|
assert.Equal(t, int32(0), atomic.LoadInt32(calls), "不可查询的地址不该产生任何请求")
|
|
assert.Equal(t, 0, res.Requests)
|
|
assert.Empty(t, res.Geo)
|
|
assert.Empty(t, res.Unresolved)
|
|
}
|
|
|
|
// TestLookupNone provider = none:一个字节都不外发。
|
|
func TestLookupNone(t *testing.T) {
|
|
srv, calls := ipAPIServer(t, func([]string) ([]ipAPIResult, int) {
|
|
return nil, http.StatusInternalServerError
|
|
})
|
|
r := New(Options{Provider: ProviderNone, HTTPClient: newTestClient(t, srv)})
|
|
|
|
assert.False(t, r.Enabled())
|
|
res := r.Lookup(context.Background(), []string{"1.2.3.4"})
|
|
assert.Equal(t, int32(0), atomic.LoadInt32(calls))
|
|
assert.Empty(t, res.Geo)
|
|
}
|
|
|
|
// TestLookupEmptyKeys 空输入不该发请求。
|
|
func TestLookupEmptyKeys(t *testing.T) {
|
|
srv, calls := ipAPIServer(t, func([]string) ([]ipAPIResult, int) {
|
|
return nil, http.StatusInternalServerError
|
|
})
|
|
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
|
|
assert.Empty(t, r.Lookup(context.Background(), nil).Geo)
|
|
assert.Equal(t, int32(0), atomic.LoadInt32(calls))
|
|
}
|
|
|
|
// TestLookupIPv6Prefix IPv6 的 "/64" 缓存键要还原成基址去查。
|
|
func TestLookupIPv6Prefix(t *testing.T) {
|
|
srv, _ := ipAPIServer(t, func(addrs []string) ([]ipAPIResult, int) {
|
|
require.Len(t, addrs, 1)
|
|
assert.Equal(t, "2001:4860:4860::", addrs[0], "带 /64 后缀的键必须剥掉后缀再查")
|
|
return []ipAPIResult{okIP(addrs[0], "美国", "US", "Mountain View", 37.4, -122.0, "Google", "AS15169 Google LLC")}, 0
|
|
})
|
|
|
|
r := New(Options{Provider: ProviderIPAPI, HTTPClient: newTestClient(t, srv)})
|
|
res := r.Lookup(context.Background(), []string{"2001:4860:4860::/64"})
|
|
|
|
g, ok := res.Geo["2001:4860:4860::/64"]
|
|
require.True(t, ok, "结果键必须仍是原来的缓存键,不能变成查询用的地址")
|
|
assert.Equal(t, "US", g.CountryCode)
|
|
assert.Equal(t, "AS15169", g.ASN)
|
|
}
|
|
|
|
// tencentServer 起一个假的腾讯 IP 定位接口。
|
|
func tencentServer(t *testing.T, respond func(ip string) (tencentResult, int)) (*httptest.Server, *int32) {
|
|
t.Helper()
|
|
var calls int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
if req.URL.Path != "/ws/location/v1/ip" {
|
|
http.NotFound(w, req)
|
|
return
|
|
}
|
|
atomic.AddInt32(&calls, 1)
|
|
tr, code := respond(req.URL.Query().Get("ip"))
|
|
if code != 0 {
|
|
http.Error(w, "upstream failure", code)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(tr)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv, &calls
|
|
}
|
|
|
|
// combinedServer 同时提供两条通道的测试服务器(auto 模式要两条都通)。
|
|
func combinedServer(t *testing.T, ipapi func([]string) ([]ipAPIResult, int), tencent func(ip string) (tencentResult, int)) (*httptest.Server, *int32, *int32) {
|
|
t.Helper()
|
|
var ipCalls, txCalls int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
switch req.URL.Path {
|
|
case "/batch":
|
|
atomic.AddInt32(&ipCalls, 1)
|
|
var addrs []string
|
|
if err := json.NewDecoder(req.Body).Decode(&addrs); err != nil {
|
|
http.Error(w, "bad body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
results, code := ipapi(addrs)
|
|
if code != 0 {
|
|
http.Error(w, "upstream failure", code)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(results)
|
|
case "/ws/location/v1/ip":
|
|
atomic.AddInt32(&txCalls, 1)
|
|
tr, code := tencent(req.URL.Query().Get("ip"))
|
|
if code != 0 {
|
|
http.Error(w, "upstream failure", code)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(tr)
|
|
default:
|
|
http.NotFound(w, req)
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv, &ipCalls, &txCalls
|
|
}
|
|
|
|
func tencentOK(nation, province, city string, lat, lon float64) tencentResult {
|
|
var tr tencentResult
|
|
tr.Status = 0
|
|
tr.Result.Location.Lat, tr.Result.Location.Lng = lat, lon
|
|
tr.Result.AdInfo.Nation = nation
|
|
tr.Result.AdInfo.Province = province
|
|
tr.Result.AdInfo.City = city
|
|
return tr
|
|
}
|
|
|
|
// TestLookupAutoOnlyRefinesChina auto 模式只对国内 IP 调腾讯:
|
|
// 海外 IP 腾讯给不出省市,查了也是白花配额。
|
|
func TestLookupAutoOnlyRefinesChina(t *testing.T) {
|
|
srv, _, txCalls := combinedServer(t,
|
|
func(addrs []string) ([]ipAPIResult, int) {
|
|
return []ipAPIResult{
|
|
okIP(addrs[0], "中国", "CN", "", 0, 0, "", ""),
|
|
okIP(addrs[1], "美国", "US", "Ashburn", 39.0, -77.5, "", ""),
|
|
}, 0
|
|
},
|
|
func(ip string) (tencentResult, int) {
|
|
return tencentOK("中国", "北京市", "北京市", 39.9087, 116.3975), 0
|
|
})
|
|
|
|
r := New(Options{Provider: ProviderAuto, TencentKey: "k", HTTPClient: newTestClient(t, srv)})
|
|
res := r.Lookup(context.Background(), []string{"114.114.114.114", "45.32.0.1"})
|
|
|
|
assert.Equal(t, int32(1), atomic.LoadInt32(txCalls), "只该精修 1 个国内 IP")
|
|
|
|
cn := res.Geo["114.114.114.114"]
|
|
assert.Equal(t, "北京市", cn.Region)
|
|
assert.Equal(t, "北京市", cn.City)
|
|
assert.Equal(t, "CN", cn.CountryCode)
|
|
assert.InDelta(t, 39.9087, cn.Lat, 1e-9)
|
|
assert.Equal(t, ProviderTencent, cn.Source)
|
|
|
|
us := res.Geo["45.32.0.1"]
|
|
assert.Equal(t, "US", us.CountryCode)
|
|
assert.Equal(t, "Ashburn", us.City, "海外 IP 应保留 ip-api 的结果")
|
|
assert.Equal(t, ProviderIPAPI, us.Source)
|
|
}
|
|
|
|
// TestLookupTencentRefineCap 精修数量必须有上限,否则周报会跑十几分钟。
|
|
//
|
|
// 顺带守住一个容易踩的坑:精修必须按调用方给的顺序取前 N 个。报告是按命中
|
|
// 次数从高到低传进来的,若这里按 map 遍历顺序取,每次运行精修到的 IP 都不一样,
|
|
// 配额会花在长尾地址上,「主要从哪个城市来」这个问题就答不准了。
|
|
func TestLookupTencentRefineCap(t *testing.T) {
|
|
var mu sync.Mutex
|
|
var refined []string
|
|
srv, _, _ := combinedServer(t,
|
|
func(addrs []string) ([]ipAPIResult, int) {
|
|
out := make([]ipAPIResult, 0, len(addrs))
|
|
for _, a := range addrs {
|
|
out = append(out, okIP(a, "中国", "CN", "", 0, 0, "", ""))
|
|
}
|
|
return out, 0
|
|
},
|
|
func(ip string) (tencentResult, int) {
|
|
mu.Lock()
|
|
refined = append(refined, ip)
|
|
mu.Unlock()
|
|
return tencentOK("中国", "广东省", "深圳市", 22.5, 114.0), 0
|
|
})
|
|
|
|
addrs := []string{"1.1.1.1", "2.2.2.2", "3.3.3.3", "4.4.4.4", "5.5.5.5"}
|
|
r := New(Options{
|
|
Provider: ProviderAuto, TencentKey: "k", TencentRefineCap: 2,
|
|
HTTPClient: newTestClient(t, srv),
|
|
})
|
|
res := r.Lookup(context.Background(), addrs)
|
|
|
|
require.Len(t, refined, 2)
|
|
assert.ElementsMatch(t, []string{"1.1.1.1", "2.2.2.2"}, refined,
|
|
"必须精修前 2 个(最活跃的),而不是随机的 2 个")
|
|
|
|
// 精修到的用腾讯结果
|
|
assert.Equal(t, ProviderTencent, res.Geo["1.1.1.1"].Source)
|
|
// 没精修到的仍保留 ip-api 的国家
|
|
assert.Equal(t, "CN", res.Geo["5.5.5.5"].CountryCode)
|
|
assert.Equal(t, ProviderIPAPI, res.Geo["5.5.5.5"].Source)
|
|
}
|
|
|
|
// TestLookupTencentFailureNotCached 腾讯配额用尽 / key 无效属于「没查成」,
|
|
// 不能把整批 IP 记成「查不到」。ip-api 已经给出的结果也不能被抹掉。
|
|
func TestLookupTencentFailureNotCached(t *testing.T) {
|
|
srv, _, _ := combinedServer(t,
|
|
func(addrs []string) ([]ipAPIResult, int) {
|
|
return []ipAPIResult{okIP(addrs[0], "中国", "CN", "", 0, 0, "", "")}, 0
|
|
},
|
|
func(string) (tencentResult, int) {
|
|
var tr tencentResult
|
|
tr.Status = 311
|
|
tr.Message = "key格式错误"
|
|
return tr, 0
|
|
})
|
|
|
|
r := New(Options{Provider: ProviderAuto, TencentKey: "bad", HTTPClient: newTestClient(t, srv)})
|
|
res := r.Lookup(context.Background(), []string{"114.114.114.114"})
|
|
|
|
assert.Equal(t, 1, res.Failures)
|
|
assert.Empty(t, res.Unresolved, "配额/key 问题不是「这个 IP 没数据」")
|
|
assert.Equal(t, "CN", res.Geo["114.114.114.114"].CountryCode, "ip-api 的结果不能被腾讯的失败抹掉")
|
|
}
|
|
|
|
// TestLookupTencentNoData 状态成功但没有任何行政区划 = 真的没数据。
|
|
// 但若 ip-api 已经定位到了,也不能反过来把这条记成「查不到」。
|
|
func TestLookupTencentNoData(t *testing.T) {
|
|
srv, _, _ := combinedServer(t,
|
|
func(addrs []string) ([]ipAPIResult, int) {
|
|
return []ipAPIResult{okIP(addrs[0], "中国", "CN", "", 0, 0, "", "")}, 0
|
|
},
|
|
func(string) (tencentResult, int) { return tencentOK("中国", "", "", 0, 0), 0 })
|
|
|
|
r := New(Options{Provider: ProviderAuto, TencentKey: "k", HTTPClient: newTestClient(t, srv)})
|
|
res := r.Lookup(context.Background(), []string{"114.114.114.114"})
|
|
|
|
assert.Equal(t, 0, res.Failures)
|
|
assert.NotContains(t, res.Unresolved, "114.114.114.114", "已定位的 IP 不该被降级成查不到")
|
|
assert.Equal(t, "CN", res.Geo["114.114.114.114"].CountryCode)
|
|
}
|
|
|
|
// TestTencentOnly 只用腾讯(provider = tencent):不经 ip-api,全部走腾讯。
|
|
func TestTencentOnly(t *testing.T) {
|
|
srv, _, txCalls := combinedServer(t,
|
|
func([]string) ([]ipAPIResult, int) {
|
|
t.Error("provider = tencent 时不该请求 ip-api")
|
|
return nil, 0
|
|
},
|
|
func(string) (tencentResult, int) {
|
|
return tencentOK("中国", "浙江省", "杭州市", 30.27, 120.15), 0
|
|
})
|
|
|
|
r := New(Options{Provider: ProviderTencent, TencentKey: "k", HTTPClient: newTestClient(t, srv)})
|
|
res := r.Lookup(context.Background(), []string{"115.236.1.1"})
|
|
|
|
assert.Equal(t, int32(1), atomic.LoadInt32(txCalls))
|
|
assert.Equal(t, "杭州市", res.Geo["115.236.1.1"].City)
|
|
assert.Equal(t, "CN", res.Geo["115.236.1.1"].CountryCode)
|
|
}
|
|
|
|
// TestNewFallbacks 配置错误不能表现为「静默不工作」。
|
|
func TestNewFallbacks(t *testing.T) {
|
|
// 只选 tencent 却没 key → 降级 ipapi
|
|
r := New(Options{Provider: ProviderTencent})
|
|
assert.Equal(t, ProviderIPAPI, r.provider)
|
|
assert.True(t, r.Enabled())
|
|
|
|
// 空值 → auto
|
|
assert.Equal(t, ProviderAuto, New(Options{}).provider)
|
|
|
|
// 不认识的值 → auto
|
|
assert.Equal(t, ProviderAuto, New(Options{Provider: "maxmind"}).provider)
|
|
|
|
// none 不工作
|
|
assert.False(t, New(Options{Provider: ProviderNone}).Enabled())
|
|
|
|
// refineCap 缺省
|
|
assert.Equal(t, defaultTencentRefineCap, New(Options{}).refineCap)
|
|
}
|
|
|
|
// TestDescribe 描述会写进报告,读者靠它判断坐标可信度。
|
|
func TestDescribe(t *testing.T) {
|
|
assert.Contains(t, New(Options{Provider: ProviderAuto, TencentKey: "k"}).Describe(), "腾讯")
|
|
assert.Contains(t, New(Options{Provider: ProviderAuto}).Describe(), "未配置腾讯 key")
|
|
assert.Contains(t, New(Options{Provider: ProviderIPAPI}).Describe(), "ip-api")
|
|
assert.Contains(t, New(Options{Provider: ProviderNone}).Describe(), "未启用")
|
|
}
|
|
|
|
// TestQueryAddr 缓存键还原。
|
|
func TestQueryAddr(t *testing.T) {
|
|
cases := []struct {
|
|
key string
|
|
want string
|
|
ok bool
|
|
}{
|
|
{"8.8.8.8", "8.8.8.8", true},
|
|
{"2001:4860:4860::/64", "2001:4860:4860::", true},
|
|
{"2001:4860:4860::8888", "2001:4860:4860::8888", true},
|
|
{"10.1.2.3", "", false},
|
|
{"::ffff:8.8.8.8", "8.8.8.8", true},
|
|
{"not-an-ip", "", false},
|
|
{"", "", false},
|
|
}
|
|
for _, c := range cases {
|
|
got, ok := queryAddr(c.key)
|
|
assert.Equal(t, c.ok, ok, "key = %q", c.key)
|
|
assert.Equal(t, c.want, got, "key = %q", c.key)
|
|
}
|
|
}
|
|
|
|
// TestNormalizeASN ASN 只留编号。
|
|
func TestNormalizeASN(t *testing.T) {
|
|
assert.Equal(t, "AS15169", normalizeASN("AS15169 Google LLC"))
|
|
assert.Equal(t, "AS4134", normalizeASN(" AS4134 "))
|
|
assert.Equal(t, "AS4134", normalizeASN("AS4134"))
|
|
assert.Equal(t, "", normalizeASN(""))
|
|
}
|
|
|
|
// TestTencentCountryCode 不能把海外 IP 算成中国来源。
|
|
func TestTencentCountryCode(t *testing.T) {
|
|
assert.Equal(t, "CN", tencentCountryCode("中国"))
|
|
assert.Equal(t, "", tencentCountryCode("中国香港"))
|
|
assert.Equal(t, "", tencentCountryCode("美国"))
|
|
assert.Equal(t, "", tencentCountryCode(""))
|
|
}
|