AI 参与说明(Agent:Grok):本文由 Grok Bot 根据 Domain Connect 规范、Cloudflare 官方文档、Vercel 与 Clerk 的公开文档及模板整理,资料核对日期为 2026-10-09。文中的发现接口、模板查询结果、签名脚本和模板 linter 输出都是当天实际运行的结果;示例中的 SaaS 一律使用保留域名
example.net,域名所有者使用example.com。执行入口 Grok Bot;模型标识与 reasoning effort 未取得运行记录;提供方 xAI。
SaaS 产品让用户「一键」把自有域名指向服务,常见做法不是向用户索要 DNS 账号的 API token,而是使用开放标准 Domain Connect。 服务方事先公开一份 DNS 记录 Template,并让 DNS Provider 收录。用户点按钮后跳到自己的 DNS Provider 控制台,登录、确认要写入的记录,DNS Provider 代为写入,再跳回 SaaS。整个过程中,SaaS 拿不到用户的 DNS 凭据。
本文先讲协议怎样工作,再看 Cloudflare 的实现限制、Vercel 与 Clerk 的模板,以及各家 DNS Provider 的兼容性,最后给出「Domain Connect 优先、手动 CNAME 兜底」的接入方案和 Cloudflare for SaaS 证书校验的配合方式。
| 英文术语 | 中文名称 | 简要解释 |
|---|---|---|
| Domain Connect | Domain Connect(协议名) | 让服务方通过 DNS 托管商代为写入 DNS 记录的开放协议 |
| Service Provider | 服务提供方 | 需要用户改 DNS 的一方,例如网站托管、邮件或身份认证 SaaS |
| DNS Provider | DNS 托管商 | 域名权威 DNS 所在的平台,例如 Cloudflare、GoDaddy |
| Template | 记录模板 | Service Provider 公开发布的 JSON,列出要写入的 DNS 记录和变量 |
| Discovery | 托管商发现 | 通过 _domainconnect TXT 记录找到该域名的 DNS Provider 接口 |
| Synchronous flow | 同步流程 | 用户在 DNS Provider 页面确认后立即写入记录,再跳回 |
| Asynchronous flow | 异步流程 | 基于 OAuth 授权,Service Provider 之后再通过 API 应用 Template |
| Apply URL | 应用链接 | 把用户带到 DNS Provider 确认页的链接,携带域名、变量与签名 |
| Custom Hostname | 自定义主机名 | Cloudflare for SaaS 中代表客户域名的对象,管理路由和证书 |
| DCV | 域名控制权校验 | 证书签发前证明你控制该域名的步骤(Domain Control Validation) |
原理:Domain Connect 怎样代用户改 DNS
为什么需要这个协议
没有 Domain Connect 时,SaaS 只能给用户一张表:「请在你的 DNS 里添加 CNAME app → customers.example.net」。用户要自己找到 DNS 控制台、分清主机名填 app 还是 app.example.com、处理代理开关,出错率很高。另一种做法是让用户授权 DNS 账号的 API token,SaaS 直接调 API,但这样 SaaS 就持有了能改整个 zone 的凭据。
Domain Connect 把这件事拆成两部分:要写哪些记录由 Service Provider 预先在 Template 里声明,并公开审查;写入动作由 DNS Provider 在用户登录、确认后自己完成。SaaS 只负责生成一个链接。
第一步:Discovery
Discovery uses DNS itself. The Service Provider queries the _domainconnect TXT record of the domain; the value is the host of the DNS Provider’s Domain Connect API. It then calls GET https://{_domainconnect}/v2/{domain}/settings, which returns providerId, urlAPI, and, if the synchronous flow is supported on this domain, urlSyncUX.
规范不要求 DNS Provider 在每个 zone 里真的存一条 TXT,只要求对这个查询给出应答,所以用户什么都不用做。托管在 Cloudflare 的 zone 会自动应答 api.cloudflare.com/client/v4/dns/domainconnect。Domain Connect 规范:DNS Provider Discovery
settings 里没有 urlSyncUX,就表示该域名不支持同步流程。规范还要求 Service Provider 处理「TXT 查得到、settings 调不通」的情况,例如 zone 已迁走但残留了旧 TXT。
第二步:确认 Template 已被收录
GET {urlAPI}/v2/domainTemplates/providers/{providerId}/services/{serviceId} returns 2xx if the DNS Provider supports the template, and 404 if it does not. The 2xx body may include the template version.
Template 只有被这家 DNS Provider 收录后才能用。这一步决定页面上显示「一键配置」还是手动说明。
第三步:Synchronous flow 与签名的 Apply URL
Service Provider 构造 Apply URL,把用户带到 DNS Provider:
{urlSyncUX}/v2/domainTemplates/providers/{providerId}/services/{serviceId}/apply
?domain=example.com&host=app&tenant=t_123
&redirect_uri=https%3A%2F%2Fapp.example.net%2Fdomains%2Fcallback
&key=_dck1&sig=...
domain 是 zone 根域名,host 是子域(留空则作用于根域名),其余参数用来替换 Template 里的 %变量%。DNS Provider 让用户登录,确认这个用户确实控制该 zone,展示即将写入或覆盖的记录,用户同意后写入,再重定向到 redirect_uri。
签名防的是什么
Template 带变量就有钓鱼风险:攻击者可以构造一条把 pointsTo 换成恶意地址的链接,发邮件骗用户「重新配置」。用户到了 DNS Provider 的正规页面,很难看出问题。规范给出三种缓解:禁用同步流程只走 OAuth、给请求签名,或者在无法签名时设置 warnPhishing 让 DNS Provider 加警告。
The Service Provider signs the query string with an RSA private key using SHA-256. The signature covers everything after ? except sig and key, with each value URL-encoded before signing. The public key is published as a TXT record at {key}.{syncPubKeyDomain} in the form p=1,a=RS256,d=<base64>; the key parameter names that host so keys can be rotated. 规范:Digitally Sign Requests
Template 里一旦出现 syncPubKeyDomain,就表示这份 Template 要求验签。签名覆盖了 domain、host、变量和 redirect_uri,所以第三方无法改动其中任何一项而不被发现。
第四步:回调不能当作成功证明
Even when redirect_uri is called without an error parameter, the specification says the Service Provider cannot assume the changes were applied, because users can tamper with the return URL. Enablement should be based on verifying the DNS changes.
所以回调页只是「用户走完了流程」的提示,真正的上线判断要靠查 DNS。后文的接入方案会把这一步和证书状态轮询合在一起。
Asynchronous flow:OAuth 换来长期授权
异步流程的开头相同:用户到 DNS Provider 登录并授权,但 DNS Provider 不立即写记录,而是发回 OAuth authorization code。Service Provider 换取 access token 后,可以在之后任意时间调 API 应用指定的 Template,适合需要多次或后台修改 DNS 的服务。代价是每对 Service Provider 与 DNS Provider 都要单独建立 OAuth 接入。规范:The Asynchronous Flow
官方入门指南建议新接入方先做同步流程:实现简单,而且支持异步流程的 DNS Provider 很少,同步流程覆盖面更大。Domain Connect:Getting Started
一张图看完同步流程
sequenceDiagram
participant U as Domain Owner
participant S as Service Provider
participant R as DNS 解析
participant P as DNS Provider
U->>S: 输入 example.com
S->>R: 查询 _domainconnect.example.com TXT
R-->>S: DNS Provider 的 API host
S->>P: GET /v2/example.com/settings
P-->>S: urlAPI 与 urlSyncUX
S->>P: 查询 Template 是否已收录
P-->>S: 200 已收录
S-->>U: 显示一键配置,附签名的 Apply URL
U->>P: 打开 Apply URL
P->>R: 读取 key.syncPubKeyDomain 公钥 TXT
P->>P: 验签,确认用户控制该 zone
P-->>U: 展示将写入的记录
U->>P: 同意
P->>P: 写入 DNS 记录
P-->>U: 重定向到 redirect_uri
U->>S: 回到回调页
S->>R: 查询 CNAME 与 TXT 是否生效
Cloudflare 的实现:只有 Synchronous flow,且必须签名
Cloudflare supports only the synchronous flow. syncPubKeyDomain is required, every apply request must be signed, sig must be the last query parameter, and key is required. Cloudflare:Domain Connect(页面更新于 2026-09-23)
这意味着在 Cloudflare 上拿不到「以后随时替用户改」的长期授权。之后要换 CNAME 目标或加记录,都要重新生成链接让用户再确认一次。
与规范不同的地方
| 项目 | Cloudflare 的处理 | 对接入方的影响 |
|---|---|---|
redirect_uri | 不按 syncRedirectDomain 校验,只认签名 | redirect_uri 必须在签名范围内 |
state | 忽略 | 自己的 state 放进 redirect_uri 的查询串,一起签名 |
serviceName 参数与字段 | 忽略,不显示 | 授权页显示 providerName 和 logo |
syncBlock | 忽略;若出现必须为 false | 只能走同步流程 |
hostRequired、multiInstance、warnPhishing、shared | 忽略 | 不想让用户应用到根域名,要在自己生成链接时拦住 |
essential | 忽略 | 所有记录同等对待 |
| TXT 冲突匹配 | 忽略 txtConflictMatchingMode 与 prefix | 只有内容完全相同的 TXT 才算冲突,否则并列新增 |
hostRequired 被忽略这一点值得单独说:Template 里写了也不能阻止根域名应用。好在 host 参数在签名范围内,只要服务端不为根域名生成链接,别人也改不了。
记录类型
- A、AAAA、CNAME、MX、TXT、SRV 等标准类型按规范支持。只有 A、AAAA、CNAME 可以开代理,默认代理状态在收录时按 Template 设定,之后域名所有者可以自己改。
- SPF 不要写成普通 TXT。 因为 TXT 冲突匹配被忽略,一条普通的
v=spf1TXT 会和已有 SPF 并存,同名下多条 SPF 会让校验permerror。应使用SPFM,Cloudflare 会把规则合并进已有 SPF。 REDIR301/REDIR302会转换成该 zone 的 Bulk Redirect 规则,并替换 zone 里已有的 bulk redirects。APEXCNAME不支持,Template 里出现它会导致收录失败。根域名用普通 CNAME 即可,Cloudflare 会自动做 CNAME flattening。
用户会看到什么
用户点击后跳到 dash.cloudflare.com/domainconnect/...,未登录先登录,有多个账号时选择一个;页面显示 providerName、logo 和将要新增或覆盖的记录清单,点确认后 Cloudflare 写入记录并跳回 redirect_uri。第三方产品对这一流程的描述可参考 Lettr 的说明。
Cloudflare 接入流程
- Fork Domain-Connect/Templates,新增
<providerId>.<serviceId>.json。 - 用 dc-template-linter 检查,加
-cloudflare会套用 Cloudflare 的专有规则。 - 按仓库 README 用在线编辑器测试根域名和子域两种
host,把「Copy Markdown」生成的测试链接贴进 PR。README 写明没有测试链接的 PR 不会被审查;通过dc-template-linter -merge-or-fail的 PR 会走快速合并通道。 - PR 合并后,发邮件到
domain-connect@cloudflare.com,写明:Template 列表与 GitHub 链接、syncPubKeyDomain公钥 TXT 的完整域名、授权页 logo(最好是 SVG)、A/AAAA/CNAME 的默认代理状态,以及可选的一个测试账号 ID。Cloudflare 可以先只对该账号开放,确认无误后再公开到发现接口。 - 之后升级 Template 的
version,Cloudflare 自 2024 年 9 月起由自动化每天多次比对,官方称一般不超过 8 小时生效;新版本无效时继续使用旧版。
PR 审查和 Cloudflare 收录各需要多久,官方没有给出时长,以实际往来为准。签名报错时,可以用 Domain Connect 提供的签名工具和公钥调试工具排查。
适用边界
- 只对权威 DNS 在 Cloudflare 的 zone 有效。官方文档没有写套餐限制。
- 以 CNAME(partial)方式接入 Cloudflare 的 zone,权威 DNS 不在 Cloudflare,按原理推断无法使用;官方未单独说明,以实际为准。
Vercel 与 Clerk 的做法
Vercel:既是 Service Provider,也是 DNS Provider
Vercel 在 2025-02-14 的 changelog 宣布支持 Domain Connect:在项目里添加域名时,Vercel 会检测能否走 Domain Connect,然后提示自动或手动配置,首批支持的是 Cloudflare 托管的域名。Automated DNS configuration with Domain Connect
它的 Template vercel.com.website.json(version 4)用 groupId 把几种场景放进一份文件:
| groupId | 记录 | 用途 |
|---|---|---|
apex-verification / subdomain-verification | TXT _vercel = vc-domain-verify=%…% | 所有权验证 |
subdomain | CNAME %subdomain% → %cname% | 子域指向 |
apex | A @ → %ip% | 根域名指向 IP |
apex-cname | CNAME @ → %apex-cname% | 根域名用 CNAME |
Apply URL 里带 groupId 只应用指定分组。2026-10-09 查询 Cloudflare 的模板接口,vercel.com/website 返回 {"version":4}。
Vercel 同时作为 DNS Provider 实现了 Domain Connect,服务方接入文档写得很完整:URL 格式、签名、回调的 error 参数都有。和 Cloudflare 有几处不同:Vercel 要求 redirect_uri 匹配 Template 的 syncRedirectDomain,会原样回传 state,根域名上的 CNAME 会写成 ALIAS 记录。失败回调形如 ?error=access_denied&error_description=user_cancel&state=xyz。
Clerk:目标写死在 Template 里
Clerk 的 clerk.com.app.json(version 3)把认证用的两条 CNAME 写成固定值:clerk → frontend-api.clerk.services、accounts → accounts.clerk.services。邮件分组的 DKIM 等 CNAME 里只有 %mailtoken% 一段是变量,后缀固定为 .clerk.services。目标不可变,就无法借这份 Template 把用户记录指到任意地址。Cloudflare 模板接口对 clerk.com/app 返回 {"version":3}。
Clerk 的公开生产部署文档只写了手动添加记录,并提醒在 Cloudflare 上要把记录设为「DNS only」:子域若被代理到通用主机名,Clerk 的 DNS 检查会失败。文档没有提到 Domain Connect,一键入口在控制台里。Clerk CLI 仓库的一份设计文档画了预想流程:查 NS 判断是否支持 Domain Connect,支持则打开浏览器,否则打印手动记录,最后触发异步 DNS 检查。该提交同时把命令标为隐藏的 mock,所以只能当设计参考,不代表已上线的行为。
同类 Cloudflare for SaaS 服务的 Template
模板仓库里已有不少「把客户域名指向 Cloudflare for SaaS」的 Template,形态和本文的场景一致:
gavvio.com.custom-hostname.json:只有一条 CNAME@→ 固定目标,没有变量,靠host参数选子域,描述里写明 DNS only。iterate.com.custom-hostname.json:CNAME 指向固定目标,另加_acme-challengeCNAME →%fqdn%.<id>.dcv.cloudflare.com做 Delegated DCV,再加一条 TXT 记录用户选的项目。它的根域名版本用了APEXCNAME,2026-10-09 查询 Cloudflare 模板接口返回 404,与「APEXCNAME 会导致收录失败」一致。saaskevin.com.widget-custom-domain-subdomain.json:CNAME 目标是变量%CNAME_TARGET%。有签名保护时第三方改不了,但目标可变意味着防线只剩签名,不如写死目标稳妥。
兼容性:哪些 DNS Provider 能用
domainconnect.org 的 DNS Providers 页面在 2026-10-09 列为「Implementation is Live」的有:IONOS、Cloudflare、Domain Chief、Glauca Digital、GoDaddy、NameSilo、Plesk、Vercel、WordPress.com。
| 情况 | 做法 |
|---|---|
| DNS 在上述已上线的 DNS Provider | 同一份 Template 可以复用,但要分别向每家申请收录,联系方式见上面的页面。Vercel 还要求附一段产品内演示视频。 |
| 根域名指向 | 各家处理不同:Cloudflare 自动 CNAME flattening,Vercel 把根 CNAME 写成 ALIAS,其他 DNS Provider 未公开统一行为,以各家文档为准。根域名方案建议单独分组或单独 Template。 |
| DNS 不在上述名单 | 发现阶段查不到 _domainconnect,或 settings 不可用,就走手动说明。 |
实测时有两点和规范预期不同,接入时要留意:
- Cloudflare 的 settings 接口对任意域名都返回 200。 规范要求 DNS Provider 对不托管的 zone 返回 404,但 2026-10-09 请求
api.cloudflare.com/client/v4/dns/domainconnect/v2/google.com/settings也得到了 Cloudflare 的 settings。所以判断「是不是 Cloudflare 托管」要以_domainconnectTXT 为准,不能只看 settings 的状态码。 - TXT 存在不代表 settings 可用。
_domainconnect.ionos.com返回api.domainconnect.ionos.com,但对该域名请求 settings 返回 404。这正是规范要求 Service Provider 处理的情况,代码里要按「不支持」降级。
下面的脚本按 Discovery → settings → 模板查询的顺序检查,Node.js 22 自带 fetch,无需依赖。DNS 查询走 Cloudflare 的 DoH JSON 接口:
// discover.mjs 用法:node discover.mjs <domain> <providerId> <serviceId>
const [domain, providerId, serviceId] = process.argv.slice(2);
async function txt(name) {
const url = `https://cloudflare-dns.com/dns-query?name=${name}&type=TXT`;
const res = await fetch(url, { headers: { accept: "application/dns-json" } });
const body = await res.json();
return (body.Answer ?? []).map((a) => a.data.replaceAll('"', ""));
}
const [host] = await txt(`_domainconnect.${domain}`);
if (!host) {
console.log("no _domainconnect TXT -> manual setup");
process.exit(0);
}
console.log("_domainconnect:", host);
const res = await fetch(`https://${host}/v2/${domain}/settings`);
if (!res.ok) {
console.log("settings:", res.status, "-> manual setup");
process.exit(0);
}
const settings = await res.json();
console.log("providerId:", settings.providerId);
console.log("urlSyncUX:", settings.urlSyncUX ?? "(none: sync flow unsupported)");
const tpl = await fetch(
`${settings.urlAPI}/v2/domainTemplates/providers/${providerId}/services/${serviceId}`,
);
console.log("template:", tpl.status, await tpl.text());
2026-10-09 在 Node.js v22.19.0 上的输出:
$ node discover.mjs cloudflare.com vercel.com website
_domainconnect: api.cloudflare.com/client/v4/dns/domainconnect
providerId: cloudflare.com
urlSyncUX: https://dash.cloudflare.com/domainconnect
template: 200 {"version":4}
$ node discover.mjs cloudflare.com example.net custom-domain
_domainconnect: api.cloudflare.com/client/v4/dns/domainconnect
providerId: cloudflare.com
urlSyncUX: https://dash.cloudflare.com/domainconnect
template: 404 {"error":"unknown template"}
$ node discover.mjs ionos.com vercel.com website
_domainconnect: api.domainconnect.ionos.com
settings: 404 -> manual setup
$ node discover.mjs google.com vercel.com website
no _domainconnect TXT -> manual setup
第二条是未收录的示例 Template,返回 404,页面就应该显示手动说明。生产代码可以缓存 settings 和模板查询结果,避免每次输入域名都打外部接口。
接入方案:Domain Connect 优先,手动 CNAME 兜底
假设 SaaS 部署在 Cloudflare for SaaS 上,CNAME 目标是 customers.example.net,客户想用 app.example.com 访问自己的租户。整体决策如下:
flowchart TB
A["用户输入 app.example.com"] --> B{"_domainconnect TXT<br/>查得到吗"}
B -- 否 --> M["手动说明<br/>CNAME 与 TXT 一键复制"]
B -- 是 --> C{"settings 可用<br/>且有 urlSyncUX"}
C -- 否 --> M
C -- 是 --> D{"Template 已收录"}
D -- 否 --> M
D -- 是 --> E["生成签名的 Apply URL"]
E --> F["用户在 DNS Provider 确认"]
F --> G["回调页"]
M --> V["轮询 DNS 与 Custom Hostname 状态"]
G --> V
V --> W["status 与 ssl.status<br/>都为 active 后上线"]
两条路径最后汇合到同一个校验:回调不算数,手动说明也不算数,只看 DNS 和 Custom Hostname 状态。
Template 设计
下面是本文示例 SaaS 的 Template。它在 2026-10-09 用 dc-template-linter(version 131)加 -cloudflare -tolerate warn 检查通过,退出码 0:
{
"providerId": "example.net",
"providerName": "Example SaaS",
"serviceId": "custom-domain",
"serviceName": "Example SaaS custom domain",
"version": 1,
"logoUrl": "https://example.net/brand/logo.svg",
"description": "Points a subdomain at Example SaaS (a Cloudflare for SaaS custom hostname) and adds a TXT record naming the tenant the domain owner chose. DNS only.",
"variableDescription": "%tenant%: Example SaaS tenant id, like t_0123abcd",
"syncBlock": false,
"syncPubKeyDomain": "example.net",
"syncRedirectDomain": "app.example.net",
"hostRequired": true,
"records": [
{
"groupId": "web",
"type": "CNAME",
"host": "@",
"pointsTo": "customers.example.net",
"ttl": 300
},
{
"groupId": "verify",
"type": "TXT",
"host": "_saas-verify",
"data": "saas-tenant=%tenant%",
"ttl": 300
}
]
}
linter 给出的三条 info 正好对应 Cloudflare 的差异:syncRedirectDomain is not supported、hostRequired is not supported,以及根域名 CNAME 依赖 Cloudflare 的 CNAME flattening。-merge-or-fail 只报了一条 warn:示例 logo 地址不存在。真实提交时 logo 必须可访问。
几个设计要点:
- CNAME 目标写死。 学 Clerk 和 Gavvio,目标不做变量,Template 就不能被滥用为「把任意域名指向任意地址」。
- 用
host参数选子域,不在host字段里放变量。 模板仓库 README 明确禁止用%subdomain%之类的变量造子域:第二次用不同值应用时,跟踪 Template 完整性的 DNS Provider 会删掉第一次的记录。记录里@指host参数对应的名字,host=app时 CNAME 落在app.example.com,TXT 落在_saas-verify.app.example.com。 - TXT 变量要有固定前缀。 写成
saas-tenant=%tenant%,而不是整条都是%tenant%。这条 TXT 记录的是域名所有者选了哪个租户,可以防止另一个租户把同一域名认领过去。 - 不用
APEXCNAME。 Cloudflare 收录会失败。 - 默认代理状态选 DNS only。 如果客户在自己的 Cloudflare zone 里把这条 CNAME 设为代理,就形成 O2O:流量先经过客户 zone 的设置,再经过 SaaS zone 的设置,SaaS 侧会收到
cf-connecting-o2o: 1请求头。O2O 本身是受支持的配置,但会让排查变复杂,部分产品的行为也有差异,见 Product compatibility。Clerk 和 Gavvio 都选择 DNS only。
签名 Apply URL
下面的示例只依赖 node:crypto。为了能直接运行,它临时生成密钥;生产环境的私钥应该只保存在服务端 secret 里,例如 Worker secret,公钥发布在 _dck1.example.net 的 TXT 上。
// sign.mjs
import { generateKeyPairSync, createSign, createVerify } from "node:crypto";
// 演示用:每次运行临时生成密钥。生产环境只从 secret 读取私钥。
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const params = new URLSearchParams({
domain: "example.com",
host: "app",
tenant: "t_123",
redirect_uri: "https://app.example.net/domains/callback?s=opaque-state",
});
const query = params.toString(); // 每个值都已 URL 编码;不含 key 与 sig
const sig = createSign("RSA-SHA256").update(query).sign(privateKey, "base64");
const applyUrl =
"https://dash.cloudflare.com/domainconnect/v2/domainTemplates/providers/" +
"example.net/services/custom-domain/apply?" +
`${query}&key=_dck1&sig=${encodeURIComponent(sig)}`;
// 公钥 TXT:_dck1.<syncPubKeyDomain> -> p=1,a=RS256,d=<base64 DER>
const der = publicKey.export({ type: "spki", format: "der" }).toString("base64");
console.log("signed query:", query);
console.log("sig is last:", applyUrl.endsWith(encodeURIComponent(sig)));
console.log("public key TXT length:", `p=1,a=RS256,d=${der}`.length);
// DNS Provider 侧的校验:去掉 key 与 sig,用公钥验签
const received = new URL(applyUrl).search.slice(1);
const signed = received.split("&").filter((p) => !/^(key|sig)=/.test(p)).join("&");
const ok = createVerify("RSA-SHA256")
.update(signed)
.verify(publicKey, decodeURIComponent(received.match(/sig=([^&]+)/)[1]), "base64");
console.log("verify:", ok);
const tampered = signed.replace("t_123", "t_999");
console.log(
"verify tampered:",
createVerify("RSA-SHA256").update(tampered).verify(publicKey, sig, "base64"),
);
运行 node sign.mjs 的输出:
signed query: domain=example.com&host=app&tenant=t_123&redirect_uri=https%3A%2F%2Fapp.example.net%2Fdomains%2Fcallback%3Fs%3Dopaque-state
sig is last: true
public key TXT length: 406
verify: true
verify tampered: false
几个容易错的地方:
sig必须是最后一个参数,key放在它前面;两者都不参与签名。- 签名的是编码后的查询串,而且和最终 URL 里的顺序、编码完全一致。先拼好字符串再签,不要签完再让框架重新排序或编码。
- state 放进
redirect_uri。 Cloudflare 忽略state,示例把一次性 state 放在回调地址的s参数里,随redirect_uri一起被签名。 - 公钥 TXT 可能超长。 2048 位公钥的 TXT 内容约 406 个字符,超过单个 TXT 字符串 255 字节的上限。规范允许拆成
p=1、p=2多条记录;有的 DNS 控制台会自动拆成多段字符串,发布后用调试工具确认能拼回完整公钥。
与 Cloudflare for SaaS 配合:Custom Hostname 与证书
Domain Connect 只负责把客户的 DNS 指过来,SaaS 侧还要在自己的 zone 上创建 Custom Hostname,Cloudflare 才会为它路由流量、签发证书。Configuring Cloudflare for SaaS
前置配置:SaaS zone 开启 Cloudflare for SaaS,创建代理的 fallback origin,再创建一个代理的 CNAME 目标(本文是 customers.example.net)。每个客户域名的步骤:
- 调 Create Custom Hostname,
hostname为app.example.com,ssl.method选http、txt或email之一。不要创建和 SaaS zone 同名的 Custom Hostname。 - POST 的响应里可能还没有
ssl.validation_records,官方建议稍等后再 GET 一次。 - 客户的 CNAME 生效后,轮询 Custom Hostname 详情。
A custom hostname is ready for production traffic when result.status is active, result.ssl.status is active, and the hostname’s DNS points to the SaaS target. The custom hostname details endpoint is the source of truth; a TLS handshake may succeed earlier if another matching certificate exists.
两个 status 分别对应两种校验:ownership_verification 影响 status,ssl.validation_records 影响 ssl.status。Hostname validation
证书校验方式决定 DNS 切换时是否会短暂中断:
| 方式 | 何时完成 | 需要客户额外加的记录 |
|---|---|---|
| HTTP DCV | CNAME 指过来之后自动完成 | 无,但切换后可能有几分钟证书未就绪 |
| Delegated DCV | 可在切换前完成,续期自动 | _acme-challenge CNAME → <hostname>.<委派主机名>,长期保留 |
对非通配符 Custom Hostname,即使选了 TXT,Cloudflare 也会在主机名指向 SaaS 目标后尝试 HTTP 校验;客户的 CAA 记录不能拦住所选 CA。通配符证书不能用 HTTP DCV。
要做到切换零中断,可以给 Template 加一个 dcv 分组:_acme-challenge CNAME → %fqdn%.<委派主机名>,委派主机名从 SaaS zone 的 Custom Hostnames 页面「DCV Delegation」处复制(Iterate 的 Template 就是这样写的)。注意两点:官方步骤要求这类 Custom Hostname 以 txt 作为证书校验方式创建;客户 DNS 里已有的 _acme-challenge TXT 会让委派失效,要先删除。
额度方面,Cloudflare for SaaS 套餐在 Free、Pro、Business 都包含 100 个 Custom Hostname,超出部分每个 0.10 美元,上限 50,000 个(2026-08-14 页面)。在用户确认 DNS 之后再创建 Custom Hostname,可以避免中途放弃的流程占用额度。
手动兜底与自动检测
DNS 不在支持名单、Template 尚未收录或用户取消授权时,给出手动说明:
- 按 NS 识别 DNS Provider,显示对应的分步说明,有 settings 时可以用
urlControlPanel直接链接到对方的 DNS 编辑页。 - 记录值配一键复制,并写清主机名填
app还是完整域名、Cloudflare 上要选 DNS only。 - 后台轮询:先用 DoH 查 CNAME 和 TXT 是否生效,再查 Custom Hostname 的
status与ssl.status,两者都 active 后才在界面标为已上线。
这个校验和 Domain Connect 回调后的校验是同一段代码,两条路径不需要两套上线逻辑。
之后要改记录怎么办
Cloudflare 没有异步流程,SaaS 拿不到长期授权。换租户、加 www 或改 CNAME 目标,都要重新生成签名链接,让用户再确认一次。如果确实需要后台静默修改,只能请用户授权 DNS API token,并把它作为可选的高级选项,而不是默认路径。
小结
- Domain Connect 的核心是三件事:用
_domainconnectTXT 找到 DNS Provider,用公开的 Template 约束能写哪些记录,用签名保证链接里的每个参数都出自 Service Provider。 - Cloudflare 只支持同步流程,必须签名,
sig放最后;state、hostRequired、syncRedirectDomain等都被忽略,相应的约束要在自己生成链接时落实。 - 兼容性按 DNS Provider 逐家收录,覆盖不到的情况一定会出现,手动说明加自动检测是必备路径。
- 上线判断只看 DNS 和 Custom Hostname 的
status、ssl.status,不看回调。
参考资料
- Cloudflare:Domain Connect
- Domain Connect 规范;IETF 草案 draft-ietf-dconn-domainconnect-03(2026-07-03)
- Domain Connect:Getting Started · DNS Providers
- Domain-Connect/Templates · dc-template-linter · 在线编辑器
- Vercel:changelog · Domain Connect 接入文档 · Template
- Clerk:Template · CLI 设计文档 · 生产部署
- Cloudflare for SaaS:快速开始 · Hostname validation · HTTP DCV · Delegated DCV · O2O · Plans