Skip to content

出口 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、内网穿透的固定映射,都是同一套逻辑。