Appearance
出口 IP 换了城市,平台就提示异地登录?用 Go 写一个出口 IP 归属地漂移监控(单文件、无第三方依赖)
事情的起因很普通:客户把我们的出口 IP 报备到他们那边的白名单里,用了大半年。某个周一早上,接口开始报「来源 IP 未授权」。查了半小时,结论是运营商那边做了链路割接,出口 IP 换了一段,虽然还是同一个城市,但白名单是按单个 IP 写的,于是全部失效。
后来复盘,真正让我在意的是另一件事:如果是换到了别的城市,麻烦就不止改白名单了。银行的交易风控、政务平台的实名登录、短信通道的签名报备,很多系统都会把 IP 归属地当作「登录地」的判断依据。IP 变了但还在同城,多数平台会放行;一旦跨了省,直接触发异地登录核验,甚至锁账号。
也就是说,IP 变了不可怕,归属地变了才要命。这种事靠人盯是盯不住,得有个东西定时采一次、跟基线比对。这篇就是这个东西:一个 Go 单文件,没有第三方依赖,编出来丢到内网任意一台机器上,crontab 每十分钟跑一次。
一、为什么是「归属地」而不是「IP」
先说清思路,不然后面代码会看着别扭。
出口 IP 是一串数字,比对变化很容易:存下来,下次不一样就告警。问题是它一年要变好几次,而且大部分变化是无害的 —— 云主机重建、弹性 IP 漂移、运营商重播,都会让 IP 变,但出口还在同一个机房、同一个城市,业务侧的感受是「什么都没发生」。这种告警发多了就没人看了。
归属地就不一样。城市级归属地没变,说明网络位置没变,白名单、风控规则、登录地判断全都不用动;城市变了,说明这份「网络身份」真的变了,该做的三件事(更新报备、检查风控阈值、通知对端)一件都不能少。判断信噪比比裸比 IP 高一个量级。
还有个细节:接口 GET https://ip9.com.cn/get 不带参数时,返回的就是当前请求方 IP 和它的归属地。一次请求同时拿到「我是谁」和「我在哪」,不用先查 IP 再查归属地两次,这个特性正好对上监控的需求。
二、方案设计
程序要能当监控用,所以几个约束得先定下来:
- 采样一次就退出,用
crontab驱动,不写常驻进程。监控脚本最怕的就是「自己也挂了没人知道」,一次性运行 + 退出码才是能接进现有告警体系的形态。 - 退出码语义:
0正常、1采样失败、2归属地漂移。Zabbix、Prometheus 的 textfile collector、或者最土的那种crontab 里 && 发邮件,都能直接吃这个退出码。 - 基线单独存,首次运行时把当前值写成基线,之后一直跟它比。为什么不跟上一次比?因为中间态误报会刷屏,跟报备过的基线比才对应真实业务语义。
- 历史逐次追加到 jsonl,将来要看「这个月出口飘了几次」不用重新采。
- 429 要退避重试,接口免费版是 60 次/分钟,正常十分钟采一次根本碰不到限速,但多机同时启动或者你手动连着跑就会撞上。
三、完整代码
go
// 出口 IP 归属地漂移监控:单文件,只依赖标准库
//
// go build -o egressmon egress.go
// ./egressmon # 采样一次,和上次基线比对
// ./egressmon -ip 1.2.3.4 # 顺便核验某个报备 IP 的当前归属地
// ./egressmon -state /var/lib/egressmon/state.json
package main
import (
"encoding/json"
"flag"
"fmt"
"net/http"
"os"
"path/filepath"
"time"
)
const apiURL = "https://ip9.com.cn/get"
type geoData struct {
IP string `json:"ip"`
Country string `json:"country"`
Prov string `json:"prov"`
City string `json:"city"`
ISP string `json:"isp"`
}
type apiResp struct {
Ret int `json:"ret"`
// 注意:ret != 200 时接口返回的 data 是空数组([]),
// 直接反序列化到结构体会报错,所以先用原始 JSON 接住再判断
RawData json.RawMessage `json:"data"`
}
var client = &http.Client{Timeout: 6 * time.Second}
// 查一次归属地,被限速时退避重试
func lookup(ip string) (*geoData, error) {
url := apiURL
if ip != "" {
url += "?ip=" + ip
}
for attempt := 1; attempt <= 3; attempt++ {
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("User-Agent", "egressmon/1.0")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("请求失败: %w", err)
}
var r apiResp
dec := json.NewDecoder(resp.Body)
perr := dec.Decode(&r)
resp.Body.Close()
if perr != nil {
return nil, fmt.Errorf("解析失败(HTTP %d): %w", resp.StatusCode, perr)
}
switch r.Ret {
case 200:
var d geoData
if err := json.Unmarshal(r.RawData, &d); err != nil {
return nil, fmt.Errorf("data 结构异常: %w", err)
}
return &d, nil
case 429: // 超过 60 次/分钟:退避后重试
time.Sleep(time.Duration(attempt) * 2 * time.Second)
continue
default: // 400 非法 IP 等,重试没意义
return nil, fmt.Errorf("接口返回 ret=%d", r.Ret)
}
}
return nil, fmt.Errorf("连续被限速,本次采样放弃")
}
type state struct {
CheckedAt string `json:"checked_at"`
LastIP string `json:"last_ip"`
LastProv string `json:"last_prov"`
LastCity string `json:"last_city"`
LastISP string `json:"last_isp"`
Baseline geoData `json:"baseline"`
Changes int `json:"city_changes"`
}
func main() {
ipFlag := flag.String("ip", "", "额外核验的 IP(可选)")
stateFile := flag.String("state", "", "状态文件路径")
history := flag.String("history", "", "历史记录 jsonl 路径")
flag.Parse()
if *stateFile == "" {
home, _ := os.UserHomeDir()
*stateFile = filepath.Join(home, ".egressmon", "state.json")
}
if *history == "" {
home, _ := os.UserHomeDir()
*history = filepath.Join(home, ".egressmon", "history.jsonl")
}
os.MkdirAll(filepath.Dir(*stateFile), 0o755)
now := time.Now().Format(time.RFC3339)
cur, err := lookup("") // 不传 ip 参数 = 查当前请求方 IP 及其归属地
if err != nil {
fmt.Printf("[%s] 采样失败: %v\n", now, err)
os.Exit(1)
}
// 读上一次的基线
var st state
if b, err := os.ReadFile(*stateFile); err == nil {
json.Unmarshal(b, &st)
}
drift := st.Baseline.City != "" && (st.Baseline.City != cur.City || st.Baseline.Prov != cur.Prov)
switch {
case st.Baseline.City == "": // 首次采样:把当前值写成报备基线
fmt.Printf("[%s] 首次采样,把当前出口 %s(%s %s %s)写成报备基线\n",
now, cur.IP, cur.Prov, cur.City, cur.ISP)
case drift:
st.Changes++
fmt.Printf("[%s] ⚠️ 出口归属地漂移: %s %s %s → %s %s %s(报备基线:%s %s)\n",
now, st.Baseline.Prov, st.Baseline.City, st.Baseline.ISP,
cur.Prov, cur.City, cur.ISP, st.Baseline.Prov, st.Baseline.City)
default:
fmt.Printf("[%s] 出口 IP %s 归属地 %s %s %s(与基线一致)\n",
now, cur.IP, cur.Prov, cur.City, cur.ISP)
}
st.CheckedAt = now
st.LastIP, st.LastProv, st.LastCity, st.LastISP = cur.IP, cur.Prov, cur.City, cur.ISP
if st.Baseline.City == "" {
st.Baseline = *cur
}
// 逐次追加历史,方便事后画曲线
if f, err := os.OpenFile(*history, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644); err == nil {
line, _ := json.Marshal(map[string]string{
"ts": now, "ip": cur.IP, "prov": cur.Prov, "city": cur.City, "isp": cur.ISP,
})
f.Write(append(line, '\n'))
f.Close()
}
b, _ := json.MarshalIndent(st, "", " ")
os.WriteFile(*stateFile, b, 0o644)
// 顺便核验一个报备过的 IP(比如防火墙白名单里填的那个)
if *ipFlag != "" {
if d, err := lookup(*ipFlag); err == nil {
same := d.City == cur.City && d.Prov == cur.Prov
fmt.Printf("[%s] 报备 IP %s 当前归属 %s %s %s,与当前出口%s\n",
now, d.IP, d.Prov, d.City, d.ISP, map[bool]string{true: "同城", false: "不同城"}[same])
} else {
fmt.Printf("[%s] 报备 IP 核验失败: %v\n", now, err)
}
}
if drift {
os.Exit(2) // 退出码 2 给监控系统用:非零即告警
}
}go build -o egressmon egress.go 编出一个七八兆的二进制(纯标准库,没有依赖树),拷到目标机器直接跑。
四、实测输出
第一次运行,还没有基线,它把当前出口记下来:
$ ./egressmon -state state.json -history history.jsonl
[2026-09-20T08:06:56+08:00] 首次采样,把当前出口 43.133.110.148(曼谷 曼谷 腾讯云)写成报备基线
退出码=0(这台机器在境外云上,所以出口归属地是曼谷 —— 顺手也验证了它对海外 IP 的处理,prov 和 city 都给的是城市名。)
把基线改成假设的「江苏南京」模拟一次漂移,加上 -ip 核验一个报备过的地址:
$ ./egressmon -state state.json -history history.jsonl -ip 58.213.1.1
[2026-09-20T08:06:57+08:00] ⚠️ 出口归属地漂移: 江苏 南京 中国电信 → 曼谷 曼谷 腾讯云(报备基线:江苏 南京)
[2026-09-20T08:06:57+08:00] 报备 IP 58.213.1.1 当前归属 江苏 南京 中国电信,与当前出口不同城
退出码=2退出码 2 就是漂移信号。历史文件也按预期在追加:
$ cat history.jsonl
{"city":"曼谷","ip":"43.133.110.148","isp":"腾讯云","prov":"曼谷","ts":"2026-09-20T08:06:56+08:00"}
{"city":"曼谷","ip":"43.133.110.148","isp":"腾讯云","prov":"曼谷","ts":"2026-09-20T08:06:57+08:00"}五、真正上线要补的几件事
每台出口机器都要跑一份。 多出口的企业网络里,只有出口设备自己知道自己用的是哪条线。脚本放在业务机器上采出来的是「业务机器看到的出口」,跟防火墙上的出口配置未必一致 —— 两个都采,才算把链路看全。
采样频率十分钟一次足够。 一天 144 次,离免费版 60 次/分钟的限额差得远。但如果有一百台机器同时跑,crontab 的时间点要打散(分钟数加上机器 ID 取模),否则整点一起打接口。
漂移告警要分级。 同城换 IP(只有 ip 变,prov/city 没变)算提示级,发个通知改白名单就行;city 变了才是告警级,得人工介入。代码里现在只对城市变化退出码 2,同城的变化可以自己在 default 分支里加一条「IP 变了但城市没变」的记录,别一律告警。
别拿归属地当安全判据。 归属地是「网络出口的大致位置」,家宽动态 IP、企业代理、云主机都会让它跟实际使用地不一致。监控它,是为了在业务出错之前先知道网络身份变了,不是为了判断「这个人是不是在异地」—— 后者交给多因素认证。
要用到更细的维度时(区县、ip_type 是家庭宽带还是 IDC 机房、AS 号),那是 VIP 版的字段。做出口监控只判断城市,免费版完全够:GET https://ip9.com.cn/get 不传参就是当前出口,返回里 prov、city、isp、country、经纬度(城市中心点)、big_area 都有,官网 https://www.ip9.com.cn 上是完整的字段清单。整套脚本的思路可以照搬去监控别的「身份型 IP」:给对端报备的白名单、短信通道签名绑定的 IP、内网穿透的固定映射,都是同一套逻辑。