HTTPS 记录里明明有 alpn=h3,Chrome 却始终显示 No alternative endpoints。 问题出在记录的名字上:Chrome 按名字把 HTTPS 记录的元数据(metadata)与 A/AAAA 地址配对, 这次 CNAME 恰好让两者落在不同名字下,无法组装出可用的替代端点(alternative endpoint)。

用 clean-dns 把记录压平到同一个名字后,https://www.reddit.com 在我的网络环境里无需代理就能打开,走的还是 HTTP/3。

如果想先了解自建 DoH、本机 DNS 接入和 DNS 污染验证,可以从 《自建 DoH,顺便把三家公共 DoH 实测了一遍》 读起; 本文继续追查 Chrome 收到 HTTPS 记录后,为什么仍然用不上 h3。

一、现象

本机的 DoH 由 clean-dns 提供,只监听回环地址,支持 h2 和 h3。Chrome 配好 DoH 后, 仍然打不开 Reddit:A 记录解析正常,拿到了 Fastly 的 IP,但 Chrome 没有可用的 HTTP/3 端点,只能回落到 TCP,而当前网络到这个地址的 TCP 443 连接被阻断。 chrome://net-internals/#dns 里是这样:

Resolved IP addresses of "www.reddit.com": ["199.232.149.140"].
No alternative endpoints

用 doggo 直接查询同一个解析器,却能拿到 HTTPS 记录:

$ doggo @127.0.0.1 www.reddit.com HTTPS
NAME             TYPE   CLASS  TTL  ADDRESS        NAMESERVER
www.reddit.com.  HTTPS  IN     60s  1 . alpn="h3"  127.0.0.1

解析器返回了 alpn=h3,Chrome 却说没有可用端点。接下来要确认的是:Chrome 收到了什么,又是怎么处理的。

这里的 HTTPS 指 DNS 的 HTTPS 记录,类型编号为 65;与它结构相同的 SVCB 记录编号为 64。Chrome 查询的是前者。

二、从 Chrome 日志追到源码

用 chrome://net-export 抓一份 netlog,重点看两个事件。

HOST_RESOLVER_DNS_TASK_EXTRACTION_RESULTS:

metadata: domain_name=www.reddit.com
          supported_protocol_alpns=["h3","http/1.1"]
          target_name=www.reddit.com
A:        alias www.reddit.com -> reddit.map.fastly.net
          data  domain_name=reddit.map.fastly.net, address=199.232.149.140

HOST_RESOLVER_SERVICE_ENDPOINTS_UPDATED 出现 0 次,日志中没有端点更新事件。

记录确实收到了,alpn 也解析出来了,但地址挂在 reddit.map.fastly.net 名下, metadata 挂在 www.reddit.com 名下。Chromium 拼装 endpoint 时按名字分组 (net/dns/dns_task_results_manager.cc):

GetOrCreatePerDomainResult(result->domain_name());  // 地址:reddit.map.fastly.net
GetOrCreatePerDomainResult(metadata.target_name);   // metadata:www.reddit.com

两组对不上,www.reddit.com 这一组没有地址,就被跳过了。源码中已有对应的 TODO:

// TODO(crbug.com/41493696): Just adding per-domain metadata does not work
// properly when the target name of HTTPS is an alias, e.g:
//   example.com.     60 IN CNAME svc.example.com.

另外,extractHttpsResults 的注释也提到:Chrome does not yet support followup queries。 这里的 follow-up 指 HTTPS/SVCB 服务发现过程中,对目标名字发起的后续查询, 不能与递归解析器处理普通 CNAME 的过程混为一谈。相关实现历史见第六节。

三、三种行为

当前配置:在原始域名下合成 HTTPS 记录,配对失败

这里把浏览器最初查询的域名称为原始域名(origin)。问题出在两类应答的名字不一致:

sequenceDiagram
    participant C as Chrome
    participant R as 解析器

    C->>R: A www.reddit.com
    R-->>C: CNAME reddit.map.fastly.net + A 199.232.149.140
    C->>R: HTTPS www.reddit.com
    R-->>C: HTTPS 1 . alpn=h3,owner 为 www.reddit.com
    Note over C: metadata 叫 www.reddit.com<br/>地址叫 reddit.map.fastly.net<br/>配对失败

标准解析路径:递归器带回 CNAME 链与链尾记录

按 CNAME 的基本规则,存在 CNAME 的名字不能同时拥有普通的其他类型记录(RFC 1034 §3.6.2)。 在这条解析链中,HTTPS 记录应放在链尾。递归解析器沿 CNAME 链继续查询, 将别名链和最终结果一起返回(RFC 1034 §4.3.2);如果应答已经包含所需记录,客户端就不必追加查询。

sequenceDiagram
    participant C as RFC 客户端
    participant R as 递归解析器
    participant A as 权威

    C->>R: HTTPS www.reddit.com
    R->>A: HTTPS www.reddit.com
    A-->>R: CNAME 指向 reddit.map.fastly.net
    R->>A: HTTPS reddit.map.fastly.net
    A-->>R: HTTPS 1 . alpn=h3
    R-->>C: CNAME 链 + 链尾 HTTPS 记录
    Note over C: metadata 与地址都叫 reddit.map.fastly.net<br/>配对成功

但这条路径首先要求链尾实际存在 HTTPS 记录。这次查询中,reddit.map.fastly.net 并没有发布它。 即使浏览器支持追加查询,也不能凭空得到一条不存在的记录。这里的 alpn=h3 来自 clean-dns 的本地合成, 要解决的是如何让这条元数据与已有地址配对。

我的做法:clean-dns 把记录压平到 origin

既然这里的配对依赖名字一致,就在 clean-dns 中把相关应答统一到原始域名:

sequenceDiagram
    participant C as Chrome
    participant D as clean-dns
    participant U as 上游

    C->>D: A www.reddit.com
    D->>U: A www.reddit.com
    U-->>D: CNAME reddit.map.fastly.net + A 199.232.149.140
    D-->>C: A 199.232.149.140,owner 改写为 www.reddit.com
    C->>D: HTTPS www.reddit.com
    D->>U: CNAME www.reddit.com
    U-->>D: CNAME reddit.map.fastly.net
    Note over D: records 命中链尾的 +.fastly.net
    D-->>C: HTTPS 1 . alpn=h3,owner 改写为 www.reddit.com
    C->>D: CNAME www.reddit.com
    D-->>C: NOERROR + 空 answer
    Note over C: metadata 与地址都叫 www.reddit.com<br/>配对成功,首次访问即走 h3

具体处理分为三类:

查询 处理
A / AAAA 转发回来后丢掉 CNAME/RRSIG,地址 owner 改成 origin,TTL 取链上最小
HTTPS / SVCB origin 未在 -records 命中时逐级查 CNAME,链尾记录(records 或链尾自己的上游记录)压平到 origin
CNAME 链尾有记录时回 NODATA,对外不再暴露别名

四、-records 是什么

-records 用于指定一份纯文本静态记录表,记录写法类似 zone file,域名匹配支持 mihomo 通配符。 查询命中配置的域名与类型时,直接按表中内容应答,不查上游;文件每 2 秒检查一次变更,无需重启。

# h3 白名单:裸域与所有子域都回一条 HTTPS 记录
+.reddit.com   HTTPS  1 . alpn=h3
+.fastly.net   HTTPS  1 . alpn=h3

# 名字只在某个类型上接管,其余类型继续走上游
doh.example.com   HTTPS  1 . alpn=h3

HTTPS 1 . alpn=h3 的读法:priority=1(ServiceMode)、target 是 .(就是域名自己)、 支持的 ALPN 为 h3。它向客户端声明:这个域名提供 HTTP/3 服务,可以尝试通过 QUIC 连接。

启动 clean-dns 时带上它:

clean-dns -flatten-cname -records dns-records.txt \
  -upstream <上游 DNS> -clean-upstream <干净上游> \
  -listen 127.0.0.1:<port> -https-listen 127.0.0.1:<port> --http3 \
  -tls-crt <cert.pem> -tls-key <key.pem>

五、结果

清掉 Chrome 的主机缓存后,再访问 Reddit,net-internals 的结果变成:

Resolved IP addresses of "www.reddit.com": ["199.232.149.140"].
Alternative endpoint: {"alpns":["h3","http/1.1"],"ip_endpoints":["199.232.149.140"]}

DevTools 的 Protocol 列也显示 h3。无需代理,https://www.reddit.com 就能直接打开。 在这次测试的网络环境中,到 Fastly 边缘的 UDP 443 可用,浏览器成功建立了 HTTP/3 连接。 DNS 只负责告诉 Chrome「这里支持 h3」,真正的 QUIC 连接仍由浏览器直接发起。

顺带验证了几件事:

  • 本次测试中,没有 IPv6 地址的 AAAA 查询在压平后返回空应答,不再透传 CNAME
  • 压平结果进独立缓存,第二次查询不再打上游

六、这个 TODO 背后,究竟跟进了什么

顺着源码里的编号追下去,会遇到几条范围不同的工单。截至 2026 年 11 月 9 日, 底层端点 API 一直有人维护,完整 HTTPS-RR 支持也已有外部实现,但后者仍未获得明确的评审与交付承诺。 把它概括成「官方明确不修」并不准确;更贴近公开记录的说法是:是否支持仍未决定,目前不承诺投入。

41493696:端点 API 的改造任务,不是单独的 CNAME 修复单

41493696:Implement DNS ServiceEndpoint Request APIs 创建于 2024 年 1 月 23 日,旧编号是 1520720。它的目标是改变 DNS 解析结果的交付方式: 旧的 ResolveHostRequest 等待全部查询结束或超时;新的 ServiceEndpointRequest 在部分结果已经足够建立连接时,就向调用方提供候选端点,后续再持续更新。

这也是它与 Happy Eyeballs、连接调度有关的原因:A、AAAA、HTTPS 的返回时间不同, 浏览器需要决定什么时候尝试连接、如何排序,以及何时认为加密握手所需的信息已经齐全。 工单中的主要节点如下,日期按香港时区整理:

时间 进展 与本文的关系
2024-01-23 增加逐次 DNS 响应提取结果的 NetLog 事件 本文使用的 HOST_RESOLVER_DNS_TASK_EXTRACTION_RESULTS 就来自这项工作
2024-03-15 引入 DnsTaskResultsManager,聚合中间结果,并协调 AAAA 的等待时机 本文追到的按域名分组与端点组装逻辑在这里
2024-04-15~16 加入 ServiceEndpointRequest,并向公共 HostResolver API 暴露创建接口 让上层连接逻辑消费逐步更新的端点
2024-05~09 补充错误信息、优先级调整、IPv6 可达性检查和任务中止处理 完善 API 生命周期和兼容行为
2025-04-21 修正没有最终端点时的 EndpointsCryptoReady() 返回值 修复握手就绪状态判断
2026-07-08~10 修复异步 IPv6 检查后的完成通知、上下文关闭处理,以及同步错误码归一化 继续修复请求时序与生命周期问题
2026-09-04 调整为逐个 DNS 事务排序,再向调用方交付中间结果 改进地址优先级,不等于补齐别名处理

所以,这条工单有实质进展,而且持续了两年多。可它推进的是端点解析基础设施, 不能把其中任何一个提交当作「CNAME 与 HTTPS 元数据配对问题已修复」。

截至查阅时,主干源码中的 TODO 仍然存在: 当 HTTPS 的目标名字本身是别名时,正确处理需要沿别名树寻找对应关系。 这比单纯按字符串把 metadata 和地址放进同一个桶更复杂。 源码中的例子是在链尾发布 HTTPS、再将 TargetName 指向别名;本文则是在原始域名合成 HTTPS, 同时保留地址查询中的 CNAME 链。两者的记录形状不同,但都触及名字与地址之间的别名映射。

40257146:完整 HTTPS-RR 支持,有实现,尚无落地承诺

更直接跟踪协议能力的是 40257146:Fully implement HTTPS-RR。 这条工单至少从 2023 年 2 月就在跟进,截至查阅时为 P3 Feature Request、New,未分配负责人,也没有目标里程碑。

时间 公开记录
2023-02-17 从 Bug 调整为 Feature;2 月 21 日曾被提交信息误关,随后立即重新打开
2023-03-09 Eric Orth 表示此前已在缓慢推进必要的 HostCache 改动
2025-09~2026-06 外部贡献者多次接手;2026 年 6 月 22 日表示新的缓存解析器已就位,准备再次尝试
2026-08-11 社区要求提高优先级,并列举 TargetName、AliasMode、替代端口与后续地址查询等缺失能力;贡献者表示已准备新一轮 CL
2026-08-14 贡献者报告端到端实现可用,拆成五个 CL,提供公开 DNS 测试和预编译包
2026-08-16~17 补充原始域名完全没有 A/AAAA、仅靠地址提示连接,以及 IPv6 测试案例
2026-08-20 bashi 澄清支持范围仍未决定,目前无法承诺承担实现或评审这些补丁
2026-08-30 贡献者表示暂缓推进,等待 bashi 的工作落地、架构和可贡献范围明朗
2026-11-03 批量清理长期未更新的赏金工单负责人,状态回到 New;这是管理操作,不是新的技术裁决

五个 CL 分别处理地址提示、AliasMode、不同的 TargetName、后续查询和替代端口: 7984025、 7984026、 7984027、 7984028、 8259314。 工单仍将这五项列在待处理代码变更中;贡献者报告测试成功,不能据此认为正式版已经具备这些能力。

这轮讨论的关键是 bashi 的措辞:AliasMode、后续查询和不匹配的 TargetName 属于 「support is still undecided」,并非仅仅被连接层重写阻塞。 团队需要承担内部评审、安全验证和长期维护成本,因此要求直接产品需求,或足够强的生态必要性证据, 才可能为这项工作安排资源。他同时保留了未来重新评估、或由其他 Chromium 团队承接的可能。 这是一项尚未获准投入的功能提案,不能写成永久拒绝支持。原话见 2026 年 8 月 20 日回复。

我的理解是,当前缺口已经不只是「有没有人会写」:外部贡献者愿意拆分、适配架构、补测试并继续维护, 但仍需要项目内部确认支持范围、负责人和评审资源。公开记录没有给出发布时间,不能承诺短期修复, 也不能从中推导出永远不会修复。

相关工单分别说明了什么

工单 范围与可确认的结论
40156536 2020 年的「复杂情况下未提取 HTTPS 记录」。曾误标 Fixed 后重新打开;公开历史有多次关联变更,但不足以认定本文场景已修复
388545139 「实现仍需要 A 或 AAAA」。2025 年 1 月,报告者反馈其他浏览器可用;bashi 确认嵌套 HTTPS RR 与不同 TargetName 尚不支持,且当时不在优先事项中
40222704 用 HTTPS DNS 记录发现并尝试 HTTP/3 的功能本体。2022 年已有实现提交,2024 年 2 月标记 Fixed;它完成不代表完整 HTTPS-RR 服务发现已经完成
501792454 Windows 异步地址排序中的迭代器失效 UAF,2026 年 4 月报告,5 月并入 497588858,8 月解除访问限制。它属于排序与取消操作的生命周期问题

最后一条尤其容易看错时间。报告者在 501792454 的评论 2 将引入迭代器捕获的代码追溯到 2024 年 1 月的事务排序改动;41493696 中上述排序策略调整则发生在 2026 年 9 月。 因此不能把它写成「2026 年 8 月,41493696 的那次修复带出了 UAF」。 它确实体现了异步排序、取消和回调之间的维护难度,但公开记录不足以证明它导致团队暂缓完整 HTTPS-RR 支持。

回到本文:压平绕过了哪一层限制

普通 CNAME、HTTPS AliasMode 和 ServiceMode 的 TargetName 是三种不同的机制: CNAME 由 DNS 解析过程追踪;AliasMode 用优先级为 0 的 HTTPS 记录指向下一处服务绑定; ServiceMode 则在优先级大于 0 的记录里描述服务目标与参数。后两者的规则见 RFC 9460 §2.4。 支持普通 CNAME,不代表已经支持完整的 HTTPS-RR 别名与目标解析。

本文遇到的是本地合成 HTTPS 记录与上游地址记录之间的名字不一致。 clean-dns 通过统一 owner,让 Chrome 可以沿现有的同名配对路径得到 HTTP/3 端点; 它没有替浏览器实现 AliasMode、任意 TargetName、替代端口或完整的后续查询。 即使 40257146 将来有补丁落地,也仍要单独验证本文这组「合成 HTTPS + 地址 CNAME」应答, 不能只看工单变成 Fixed 就关闭压平。

七、收尾

这次排查碰到了 Chromium 源码已经注明的别名处理限制,也暴露了本地合成记录与上游 CNAME 链之间的不一致。 暂时无法修改浏览器时,在解析器一侧把名字对齐,是一个直接可用的绕行办法。 代价是客户端不再看到 CNAME 链:本次浏览器访问已验证可用,依赖别名做后续解析的场景则需要单独评估。

等 Chrome 修复相关的别名处理逻辑后,再验证能否关闭压平开关。