Skip to content

C 语言怎么查 IP 归属地?libcurl + cJSON 写一个能丢到跳板机上的小工具(完整可编译代码) ​

上周帮朋友看一台老跳板机,Debian 8,只装了 gcc 和 curl。上面堆着两年的 nginx 日志,安全组通知说有人从境外扫端口,他想把日志里的 IP 按省份拉个分布,看看到底是从哪几个地方来的。机器上没 Python,也没 Node,pip install 的源早就 404 了。

这种场景其实不少:工控网关、老版本 CentOS、精简过的容器镜像(alpine 只有几十兆)、还有那些装个运行时比写代码还麻烦的嵌入式板子。你能依赖的就是 libc,最多再加个 libcurl。

所以这篇就把 C 语言这条路走通:一个 150 行的 ipq,接参数查 IP,或者直接从标准输入吃日志行,输出「省份 + 城市 + 运营商」。代码在本机 gcc 编过、跑过,下面贴的输出都是实测的。

一、先搞清楚接口的三种失败方式 ​

调 https://ip9.com.cn/get?ip=<ip> 之前,我把返回值摸了一遍。它有三种失败,处理方式完全不一样:

第一种,IP 写错了。 比如 999.1.1.1,HTTP 状态码本身就是 400,返回体是:

json
{"data":[],"ret":400}

注意 data 是个空数组,不是对象。用 C 的结构体或者 Go 的 struct 直接反序列化,这一步就会崩。C 里用 cJSON 的话,cJSON_IsObject(data) 是假,取 prov 拿到 NULL,不判断就是段错误。

第二种,被限速。 免费版限制 60 次/分钟/IP,超了返回 ret=429。批量跑日志的时候这个一定会遇上。

第三种,网络层面的失败。 超时、DNS 解析不出来、TLS 握手失败 —— 这些 curl_easy_perform 会返回非 CURLE_OK,得单独兜。

还有个容易忽略的点:HTTP 状态码和 body 里的 ret 是两套错误。999.1.1.1 那个例子,如果你只看 body 里的 ret,程序会把「非法 IP」当成正常响应往下走;反过来只看 HTTP 200,就漏了 ret=429。两个都得看。

二、方案设计 ​

整体很轻:libcurl 负责 HTTPS 请求,cJSON 负责取字段,业务逻辑就三件事 —— 去重、限速、错误分类。

  • 去重:一次运行里同一个 IP 只查一次。日志里同一台爬虫的 IP 可能出现几百次,不缓存的话 60 次/分钟的额度瞬间见底。用一个数组线性扫就够了,几百条数据谈哈希表是过度设计。
  • 限速:每次成功查询后 sleep(1.1)。接口是 1 秒 1 次的节奏,留 0.1 秒余量。
  • 重试:只有 ret=429 值得重试,等 2 秒再来一次。非法 IP 重试没有意义,直接报出来。

写回调函数的时候有个必踩的坑:CURLOPT_WRITEFUNCTION 的返回值必须是收到的字节数(不是拷贝进缓冲区的字节数)。缓冲区满了要截断,但返回值仍然得是 libcurl 给你的原始长度,否则 libcurl 会认为写失败,报 CURLE_WRITE_ERROR,你会以为是网络问题查半天。

三、完整代码 ​

c
/*
 * 编译: gcc -O2 -o ipq ipq.c $(pkg-config --cflags --libs libcurl) -lcjson
 * 用法: ./ipq 114.114.114.114 8.8.8.8
 *       cat access.log | ./ipq
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <curl/curl.h>
#include <cjson/cJSON.h>

#define API_URL   "https://ip9.com.cn/get?ip="
#define BUF_SIZE  8192
#define MAX_ITEMS 256
#define RATE_WAIT 1.1        /* 免费版 60 次/分钟,留出余量 */

struct buf { char s[BUF_SIZE]; size_t n; };

static size_t on_write(void *p, size_t sz, size_t nm, void *ud)
{
    struct buf *b = ud;
    size_t m = sz * nm;
    if (b->n + m >= BUF_SIZE)
        m = BUF_SIZE - 1 - b->n;
    memcpy(b->s + b->n, p, m);
    b->n += m;
    return sz * nm;                  /* 必须返回原始长度,否则 libcurl 报写失败 */
}

/* 查一个 IP,成功返回 0 并填充 prov/city/isp,失败返回接口错误码或 -1 */
static int query_once(CURL *h, const char *ip, char *prov, char *city, char *isp)
{
    char url[256];
    struct buf b;
    long code = 0;
    int ret = -1;

    snprintf(url, sizeof(url), "%s%s", API_URL, ip);
    memset(&b, 0, sizeof(b));

    curl_easy_setopt(h, CURLOPT_URL, url);
    curl_easy_setopt(h, CURLOPT_WRITEFUNCTION, on_write);
    curl_easy_setopt(h, CURLOPT_WRITEDATA, &b);
    curl_easy_setopt(h, CURLOPT_USERAGENT, "ipq/1.0");
    curl_easy_setopt(h, CURLOPT_CONNECTTIMEOUT, 3L);
    curl_easy_setopt(h, CURLOPT_TIMEOUT, 6L);
    curl_easy_setopt(h, CURLOPT_TCP_KEEPALIVE, 1L);

    if (curl_easy_perform(h) != CURLE_OK)
        return -1;
    curl_easy_getinfo(h, CURLINFO_RESPONSE_CODE, &code);
    if (code != 200)
        return -1;
    b.s[b.n] = '\0';

    cJSON *root = cJSON_Parse(b.s);
    if (!root)
        return -1;
    cJSON *jret = cJSON_GetObjectItem(root, "ret");
    if (cJSON_IsNumber(jret))
        ret = jret->valueint;

    /* ret != 200 时 data 是空数组,不能直接当对象取字段 */
    cJSON *data = cJSON_GetObjectItem(root, "data");
    if (ret == 200 && cJSON_IsObject(data)) {
        const char *keys[3] = { "prov", "city", "isp" };
        char *dst[3] = { prov, city, isp };
        for (int i = 0; i < 3; i++) {
            cJSON *v = cJSON_GetObjectItem(data, keys[i]);
            snprintf(dst[i], 64, "%s", (cJSON_IsString(v) && v->valuestring) ? v->valuestring : "");
        }
        ret = 0;                     /* 成功统一返回 0,错误码原样往上抛 */
    } else if (ret == 200) {
        ret = -1;                    /* 静默 ret=200 但 data 不是对象:当异常处理 */
    }
    cJSON_Delete(root);
    return ret;
}

/* 带一次限速重试的外层封装 */
static int lookup(CURL *h, const char *ip, char *prov, char *city, char *isp)
{
    int r = query_once(h, ip, prov, city, isp);
    if (r == 429) {                  /* 被限速:等两秒再试一次 */
        sleep(2);
        r = query_once(h, ip, prov, city, isp);
    }
    return r;
}

int main(int argc, char **argv)
{
    char *items[MAX_ITEMS];
    int n = 0;
    char line[512];
    CURL *h;

    if (argc > 1) {
        for (int i = 1; i < argc && n < MAX_ITEMS; i++)
            items[n++] = strdup(argv[i]);
    } else {
        while (n < MAX_ITEMS && fgets(line, sizeof(line), stdin)) {
            char *p = strtok(line, " \t\r\n");   /* 日志行里第一个 token */
            if (p)
                items[n++] = strdup(p);
        }
    }

    curl_global_init(CURL_GLOBAL_DEFAULT);
    h = curl_easy_init();
    if (!h)
        return 1;

    for (int i = 0; i < n; i++) {
        char prov[64] = "", city[64] = "", isp[64] = "";
        int dup = 0;

        /* 同一次运行里,重复 IP 只查一次 */
        for (int j = 0; j < i; j++)
            if (strcmp(items[i], items[j]) == 0) { dup = 1; break; }
        if (dup) {
            printf("%-24s  (本次已查过,跳过)\n", items[i]);
            continue;
        }

        int r = lookup(h, items[i], prov, city, isp);
        if (r > 0) {
            printf("%-24s  查询失败(ret=%d)\n", items[i], r);
        } else if (r < 0) {
            printf("%-24s  查询失败(网络异常或非法 IP)\n", items[i]);
        } else if (strcmp(prov, "") == 0 && strcmp(city, "") == 0) {
            printf("%-24s  非中国大陆 IP / 无省市信息(isp=%s)\n", items[i], isp);
        } else {
            printf("%-24s  %s %s  %s\n", items[i], prov, city, isp);
        }
        fflush(stdout);
        sleep(RATE_WAIT);
    }

    curl_easy_cleanup(h);
    curl_global_cleanup();
    for (int i = 0; i < n; i++)
        free(items[i]);
    return 0;
}

依赖两个库,Ubuntu/Debian 上一条命令:apt install libcurl4-openssl-dev libcjson-dev(CentOS 是 libcurl-devel、cjson-devel)。编译命令已经写在文件头注释里,$(pkg-config ...) 那一段负责带上 curl 的头文件路径。

四、实测输出 ​

先把手工准备的几个 IP 跑一遍:

$ ./ipq 114.114.114.114 183.204.111.150 8.8.8.8 192.168.1.1 999.1.1.1 114.114.114.114 240e:ff:e02c:1:0:ff:b0e4:20f 45.153.160.2
114.114.114.114           江苏 南京  114DNS
183.204.111.150           河南 新乡  中国移动
8.8.8.8                   非中国大陆 IP / 无省市信息(isp=Google Cloud)
192.168.1.1               非中国大陆 IP / 无省市信息(isp=内网地址)
999.1.1.1                 查询失败(网络异常或非法 IP)
114.114.114.114           (本次已查过,跳过)
240e:ff:e02c:1:0:ff:b0e4:20f  广东 广州  中国电信
45.153.160.2              非中国大陆 IP / 无省市信息(isp=AT&T)

real    0m8.896s

八个 IP,跑完不到 9 秒,时间基本都花在限速等待上。几个值得注意的地方:

  • IPv6 是直接支持的,240e:ff:e02c:... 那条查出了广东广州、中国电信。代码里不用给 IPv6 做任何特殊处理,把字符串丢给接口就行。
  • 45.153.160.2 的 operator 是 AT&T,接口返回的原始 JSON 里其实是 AT\u0026T(& 被转义了)。cJSON 会自动还原成 &,这是用 cJSON 而不是手写字符串查找的一个实在好处。
  • 内网地址不会报错,192.168.1.1 返回 country=保留、isp=内网地址,省市字段是空的。日志里混着内网 IP 是常态,程序靠「省市都为空」把这类记录摘出来,别让它污染地域统计。
  • 海外 IP 只有国家,没有省市。8.8.8.8 是 美国 + Google Cloud,prov、city 都空。做分布统计的时候,海外那一坨只能按国家聚合。

再试日志管道模式,这是实际最常用的用法:

$ cat access.log | ./ipq
203.0.113.9               非中国大陆 IP / 无省市信息(isp=文档地址)
114.114.114.114           江苏 南京  114DNS
220.181.38.148            北京 北京  百度/蜘蛛/电信

real    0m3.712s

203.0.113.9 是 RFC 5737 里的文档示例网段,接口把它标成了 文档地址,挺细的。爬虫的 IP 也能看出运营商,百度/蜘蛛/电信 这种标注对判断「是人还是蜘蛛」直接有用。

五、落地要注意的四点 ​

限速按 IP 算,不按你算。 免费版是 60 次/分钟/IP,如果跳板机出口 IP 是固定的,你和同事同时在跑这个工具,额度就是共享的。真要批量刷几十万条日志,要么控制到一秒一次慢慢跑(一天能跑 8 万多条,其实够用),要么上 VIP 版(18 万次/分钟)。

别把省份当唯一判据。 移动网络和机房 IP 的归属地跟实际位置对不上是常态,运营商出口 IP 尤其明显。做攻击溯源的时候,省份只能作为「倾向性」参考,同一时间段的多个维度(User-Agent、请求路径、频次)得一起看。

编译成静态二进制,分发才省事。 加 -static(curl 要静态版本)编出来一个几百 KB 的文件,scp 到目标机器上直接跑,不用担心对方缺库。跳板机、容器、嵌入式板子上这一条能省掉一半沟通成本。

要做区县和 IP 类型,得看 VIP 版。 免费版给的是国家/省市/邮编/区号/运营商/经纬度(城市中心点)、long_ip、大区;VIP 版才有 area(区县)和 ip_type(ISP家庭 / BUS企业 / IDC机房)。风控场景里 ip_type 判断「这个 IP 是家用宽带还是机房」比城市更有价值,但那是另一个话题了。

如果 C 不是你日常的语言,接口本身不挑客户端 —— GET https://ip9.com.cn/get?ip=<你的IP> 换成任意语言、任意 HTTP 工具都是同一个结果,官网 https://www.ip9.com.cn 上有字段说明和免费额度。上面这套代码可以直接拿去改:把输出改成 CSV、接个 Redis 做跨进程缓存、或者挂到 crontab 里每天夜里扫一遍昨天的新 IP,都是几行的事。