PAYATHON 2026

macOS 如何强制指定 res_query(resolver(3))使用的 DNS nameserver?

技术老齐

结论

可以在程序中覆盖 nameserver:先初始化 resolver 状态,再调用 macOS <resolv.h> 提供的 res_setservers(),然后使用该状态查询。

如果保留现有的 res_query(),需要修改它使用的 _res:

/* res_init() 成功之后、res_query() 之前 */
res_setservers(&_res, &server, 1);

新代码建议使用独立的 struct __res_state,配合 res_ninit()、res_setservers() 和 res_nquery(),这样更便于控制配置、排查问题。在 Apple 的实现中,res_query() 最终也会调用使用 _res 的 res_nquery()。参见 Apple 的实现

LOCALDOMAIN 不能指定 nameserver。如果抓到了正确响应,查询却仍然失败,尤其是出现了 recvfrom: Invalid argument,还需要单独调查,不能直接归因于服务器选错。

LOCALDOMAIN 中的 token 是域名

resolver(3) 所说的 “blank-separated tokens”,是以空格或制表符分隔的搜索域名。例如:

LOCALDOMAIN='corp.example example.net' ./mini-dns-lookup

这表示域名搜索列表,类似配置文件中的:

search corp.example example.net

把 nameserver 10.0.0.53 放进 LOCALDOMAIN,不会按 resolv.conf 的语法解释。

搜索列表主要影响 res_search() 这类执行后缀搜索的接口。示例调用的是 res_query("www.google.com", ...),不会按搜索列表逐个追加域名。修改 LOCALDOMAIN 无法用来指定服务器。参见 Apple 的 resolver(3)

在程序中指定服务器

下面的程序使用独立的 resolver 状态,只配置一个 IPv4 nameserver。服务器地址从命令行传入,无需修改系统 DNS。

res_setservers() 会更新服务器地址和相关内部状态,比只修改 _res.nsaddr_list[0] 更合适。Apple 的实现还维护了扩展地址存储,用于支持 IPv6 等情况。参见 res_setservers() 的实现

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <arpa/nameser.h>
#include <resolv.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char **argv)
{
    if (argc != 3) {
        fprintf(stderr, "Usage: %s <DNS-IPv4> <name>\n", argv[0]);
        return EXIT_FAILURE;
    }

    union res_sockaddr_union server = {0};
    server.sin.sin_len = sizeof(server.sin); /* Darwin sockaddr */
    server.sin.sin_family = AF_INET;
    server.sin.sin_port = htons(53);

    if (inet_pton(AF_INET, argv[1], &server.sin.sin_addr) != 1) {
        fprintf(stderr, "Invalid IPv4 address: %s\n", argv[1]);
        return EXIT_FAILURE;
    }

    struct __res_state state = {0};
    if (res_ninit(&state) != 0) {
        fprintf(stderr, "res_ninit failed\n");
        return EXIT_FAILURE;
    }

    /* 必须在初始化之后覆盖,避免初始化重新载入服务器配置。 */
    res_setservers(&state, &server, 1);
    state.options |= RES_DEBUG;

    /* 对照测试 TCP 时,可启用下一行。 */
    /* state.options |= RES_USEVC; */

    unsigned char answer[65535];
    errno = 0;

    int len = res_nquery(&state, argv[2], ns_c_in, ns_t_a,
                         answer, sizeof(answer));
    int saved_errno = errno;
    int result = EXIT_FAILURE;

    if (len < 0) {
        fprintf(stderr,
                "res_nquery failed: res_h_errno=%d, errno=%d (%s)\n",
                state.res_h_errno, saved_errno, strerror(saved_errno));
        goto done;
    }

    if ((size_t)len > sizeof(answer)) {
        fprintf(stderr, "Response exceeds answer buffer\n");
        goto done;
    }

    ns_msg msg;
    if (ns_initparse(answer, len, &msg) < 0) {
        perror("ns_initparse");
        goto done;
    }

    res_pquery(&state, answer, len, stdout);
    result = EXIT_SUCCESS;

done:
    res_ndestroy(&state);
    return result;
}

编译和运行:

cc -Wall -Wextra -g -o mini-dns-lookup mini-dns-lookup.c -lresolv

# 将示例地址替换为实际要测试的 DNS 服务器
./mini-dns-lookup 10.0.0.53 www.google.com.

这些扩展接口的声明见 Apple 的 resolv.h。它们不保证跨平台通用,使用时应以目标 macOS SDK 为准。

如果必须保留 res_query(),可以沿用上面的 server 构造代码,按以下顺序调用:

if (res_init() != 0) {
    fprintf(stderr, "res_init failed\n");
    return EXIT_FAILURE;
}

res_setservers(&_res, &server, 1);
_res.options |= RES_DEBUG;

int len = res_query("www.google.com.", ns_c_in, ns_t_a,
                    answer, sizeof(answer));

如果后续再次初始化 resolver,需要重新设置服务器。这个设置只控制 DNS 查询的目标服务器,实际流量仍受 VPN 路由和过滤规则影响。

为什么 scutil、dig 与 res_query 的表现不同

scutil --dns 显示的是系统 DNS 配置,可能包括默认 resolver、按域名匹配的 resolver,以及绑定接口的配置。其中所有的 nameserver 行,并不构成一个供所有 API 依次使用的列表。

macOS 的系统解析策略支持多个 resolver,会按查询域名选择匹配的配置。/etc/resolv.conf 无法完整表达这些配置,Apple 也明确说明该文件由系统自动维护,不应手工修改。参见 resolver(5)

传统 resolver 接口未必都完整执行这套系统策略。例如,在 Apple 公布的 libresolv 源码中,初始化过程会从系统配置的 resolver[0] 读取服务器,res_nquery() 则使用传入的状态发送查询。因此,_res 中的服务器列表未必与全部 scutil --dns 输出推导出的列表一致。具体行为仍需核对目标系统对应的实现。初始化代码、查询代码

排查时,应让 dig 显式查询同一台服务器:

dig @10.0.0.53 www.google.com. A
dig @10.0.0.53 www.google.com. A +tcp

不带 @server 的 dig 或 host 查询成功,不足以证明它们与测试程序使用了相同的配置和查询路径。

recvfrom 的 EINVAL 排查方向

调试日志中的 recvfrom,不一定表示失败发生在同名系统调用中。

在 Apple 公布的 res_send.c 中,这条日志对应内部函数 internal_recvfrom()。该函数通过 recvmsg() 接收报文,并检查 ancillary data,即随报文返回的控制信息。如果控制信息长度不足,或者出现 MSG_CTRUNC,函数会自行设置 errno = EINVAL 并返回失败。参见 internal_recvfrom()

对于“DNS 响应已经到达,但库仍报告读取失败”的情况,可以沿这条线索检查:

  • 核对目标系统对应源码中的 internal_recvfrom()。
  • 如果能调试到该位置,检查 recvmsg() 的返回值,以及 msg_controllen、msg_flags。
  • 比较 VPN 连接前后是否只有 UDP 接收路径失败,并检查返回的接口控制信息。

这些检查尚不足以断定 VPN 丢失了控制信息,也不能据此认定某个 macOS 版本存在已确认缺陷。

建议的对照测试与注意事项

先修正原测试程序的错误处理:res_query() 返回负值时必须停止,不能继续将该长度传给 ns_initparse()。还要检查 ns_initparse()、ns_parserr() 和 ns_sprintrr() 的返回值。查询出错时,除 errno 外,还应记录 h_errno;使用独立状态时记录 state.res_h_errno。

然后在 VPN 连接前后分别比较:

  1. 原程序使用默认服务器时的行为。
  2. 显式指定同一台服务器的测试程序与 dig @server 的表现。
  3. UDP 与 TCP 的差异。程序可用 RES_USEVC 强制 TCP;TCP 成功有助于缩小排查范围,但不能直接证明服务器选择正确或 VPN 配置有误。

需要对照系统 DNS 服务的查询路径时,可以运行:

scutil --dns
dns-sd -Q www.google.com. A IN

dns-sd -Q 调用的是 DNSServiceQueryRecord(),可作为另一条查询路径的对照。它可能持续运行,观察结果后按 Ctrl-C 结束。参见 Apple 的 dns-sd 实现

抓包时还要核对响应的源地址、源端口、目标端口和 DNS transaction ID,并确认观察的是 VPN 隧道接口还是物理接口。即使抓到内容正确的响应,resolver 也未必会接受它。

如果需要提交问题报告,最有用的材料是同一服务器、同一查询名在 VPN 连接前后的对照结果,以及系统版本、VPN 客户端版本、完整调试日志和对应抓包。这些材料有助于区分服务器选择问题、报文匹配问题和接收路径本身的失败。

备注:内容仅供参考。