
过去几年很多开发者把个人主页或项目文档放到了 GitHub Pages、Gitee Pages 上以为“Pages”只是托个静态网页。但如果把目光放到 Cloudflare Pages你会发现它和 Workers 共用同一套边缘运行环境还能跑真正的服务端逻辑。一个很典型的玩法就是用 Workers 或 Pages Functions 自建一个加密 DNS 解析网关。先说一个明确判断这篇文章要搭的“加密 DNS 服务器”并不是传统意义上的权威 DNS 服务器而是一个带隐私和可控策略的DoH 网关。它接收客户端发来的 HTTPS 加密 DNS 请求再转发给上游递归解析服务最后把结果返回给客户端。通过这一层你可以拥有一个自定义域名的加密 DNS 入口还能统一加缓存、鉴权和解析策略。读完这篇文章你能跑通两件事一是用 Cloudflare Worker 实现一个支持 JSON 格式的 DoH 解析 API二是用 Cloudflare Pages 实现“静态页面 DNS 转发函数”的一体化部署。文章里还会讲 wire format 方式的实现思路、常见坑、鉴权和缓存策略方便你在生产环境中落地。1. 这篇文章真正要解决的问题不少人会有这样的经历打开电脑连上 WiFi 后DNS 查询默认走路由器下发的地址在某些网络环境下DNS 响应被篡改、被加广告、长时间无响应问题排查起来非常被动。普通 DNS 请求是明文传输的中间设备确实有可能看到你在查询哪些域名。所以现在越来越多人开始关注加密 DNS也就是 DoHDNS over HTTPS、DoTDNS over TLS这类方案。但直接使用公共 DoH 服务也有不舒服的地方公共地址大家都在用不方便统计自己的查询量想在里面加一层“域名过滤”或“本地缓存”也比较麻烦。于是就有了“自建加密 DNS 网关”的需求。云服务商里Cloudflare Workers 和 Pages 是很适合做这件事的载体。它们不需要你买服务器不需要自己管证书和守护进程写完代码直接发布到边缘节点几十行代码就能把 DNS 查询包一层 HTTPS。这篇文章真正适合的读者有三类想搞懂 DoH 原理并希望用 Serverless 方式落地一个真实服务的开发者家里有多台设备、多个网络环境想统一一个自定义 DNS 入口的工程师Page 托管用户想知道 Cloudflare Pages 除了托管静态页面还能做什么的人。也要提前说清楚边界Cloudflare Workers 上跑的是“DNS 代理/转发器”不是权威 DNS。你不能在这里面管理自己的域名记录也不适合把它当作大规模内部 DNS 基础设施。它的定位是“加密 DNS 入口 策略控制层”。2. 加密 DNS 基础DoH、DoT、DoQ 概念与区别在进入代码之前先把几个概念理清楚。很多文章把 DoH、DoT、DoQ 混着说初学者很容易被绕晕。2.1 明文 DNS 的问题普通 DNS 一般是客户端向 53 端口发起 UDP 查询。这个请求从你的电脑到路由器再到运营商递归 DNS中间经过的链路如果被监听对方可以看到你查询的域名。更麻烦的是如果响应被篡改客户端可能被引导到错误的 IP 地址。2.2 三种主流加密 DNS 方式协议全称传输方式默认端口优点弱点DoHDNS over HTTPSHTTPSHTTP/2 或 HTTP/3443和普通网页流量混在一起难以被单独识别浏览器支持度高实现相对复杂要求服务端支持 HTTPSDoTDNS over TLSTLS 专属连接853实现简单连接生命周期稳定专用端口容易被网络设备识别和封锁DoQDNS over QUICQUIC基于 UDP853低延迟多路复用正在被主流客户端逐步支持生态系统还不够成熟客户端支持较少这里最容易混淆的是 DoH 和 DoT。简单记DoH 的流量是“HTTPS 请求”和你看网页用的是同一个模型DoT 是“为 DNS 单独开设一条 TLS 通道”。两者都做了加密但在网络中的“可见性”不同。2.3 DoH 的两种数据格式文章后面会频繁提到两种格式application/dns-jsonJSON 格式适合开发调试、脚本调用也适合在 Worker 里直接处理 JSON 对象。浏览器地址栏直接访问也能看到可读结果。application/dns-message二进制 DNS 消息格式和传统 DNS 报文结构一致只是外面包了一层 HTTPS。系统级的原生 DoH 功能比如 Windows 上的自定义 DoH 模板通常使用这种格式。做网关时最简单的是先做 JSON 格式因为代码好写、验证方便。但如果想让手机系统或浏览器原生 DoH 直接用就需要支持 wire format。2.4 加密 DNS 网关的请求链路用 Cloudflare Worker 实现的自建加密 DNS 网关请求链路是这样的客户端设备/浏览器 ↓ HTTPSDoH 请求 Cloudflare Worker自定义域名 ↓ 上游 DoH 请求 Cloudflare 公共 DNS 或其他递归 DNS ↓ DNS 明文解析 互联网权威 DNS 服务器关键点在于客户端和 Worker 之间是加密的Worker 和上游之间也走 DoH那么从网关出去的查询也是加密的。中间只多了一层自己的策略逻辑。3. Workers 还是 Pages方案选型与适用场景很多读者会问标题里又说 Workers 又说 Pages到底该用哪个其实 Cloudflare Pages Functions 底层就是 Workers 运行时写法也很接近。区别主要在于“你还需要不需要托管静态资源”。维度Cloudflare WorkersCloudflare Pages部署目标纯函数 / API静态资源 Functions项目类型wrangler deploy发布 Worker构建静态站点后发布Functions 放在functions/目录适用场景只想快速发布一个 API、网关、代理已有静态站点希望站点接口也跑在同一域名下自定义域名通过 Worker 路由绑定Pages 自定义域名绑定免费额度有每日请求量限制超出会停止服务同样有免费额度适合中小流量项目本地开发wrangler devwrangler pages dev选型建议很直接如果你只是想要一个 DNS API 或一个隐藏服务直接选 Worker项目结构最简洁。如果你想做一个“带管理后台的加密 DNS 控制台”或者已经有一个静态站点希望把接口合到同一个域名下用 Pages 更舒服。不过要注意二者都跑在 Cloudflare 边缘节点上。默认域名是*.workers.dev或*.pages.dev这两个域名虽然免费但如果要做生产环境 DNS 入口建议绑定自己的域名。一来行为更可控二来在客户端配置 DoH 地址时也更像正式服务。4. 环境准备与前置条件开始写代码前先把环境准备好。这里的核心依赖是 Cloudflare 的 wrangler 命令行工具。4.1 需要准备的东西Cloudflare 账号如果没有先去官网注册一个。免费版足够完成本文章的所有实验。Node.js 环境建议使用当前 LTS 版本。wrangler 是 npm 包需要 Node 环境来安装和运行。一个域名可选但推荐如果域名已经托管在 Cloudflare后面绑定自定义域名会非常方便。本地开发工具VS Code 或任意编辑器都可以。4.2 安装 wrangler安装命令npm install -g wrangler安装完成后确认版本wrangler --version如果网络环境不允许全局安装也可以把 wrangler 装到项目本地npm init -y npm install -D wrangler然后通过npx wrangler使用本地命令。4.3 登录 Cloudflare在项目目录执行wrangler login命令会打开浏览器让你授权 wrangler 访问你的账号。授权完成后wrangler 会把凭证保存到本地后续发布和部署都可以直接用。如果是在 CI/CD 环境中使用一般会用CLOUDFLARE_API_TOKEN环境变量代替交互登录。本文章的家庭/个人部署场景直接wrangler login就够了。4.4 创建项目目录以最常用的 Worker 方案为例mkdir my-doh-gateway cd my-doh-gateway npm init -y后续代码都放在这个目录里。5. 方案一用 Worker 实现 DoH JSON 解析 API这是最简单、也最容易跑通的方案。整个 Worker 就是一段 JavaScript处理 GET 请求从 URL 参数里读取域名和记录类型再请求上游 DoH 服务把结果转成 JSON 返回给客户端。5.1 创建 wrangler.toml在项目根目录创建wrangler.tomlname my-doh-gateway main src/index.js compatibility_date 2024-01-01name是 Worker 名称会出现在部署后的默认域名里。main指定入口文件。compatibility_date是 Cloudflare Workers 的兼容性日期按 wrangler 提示填写即可不需要过度纠结。5.2 Worker 核心代码创建src/index.jsconst UPSTREAM_DOH https://cloudflare-dns.com/dns-query; const VALID_TYPES [ A, AAAA, CNAME, MX, NS, TXT, SOA, SRV, CAA, HTTPS ]; function isValidDomain(name) { return /^(?.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)[a-zA-Z]{2,63}$/.test(name); } export default { async fetch(request, env, ctx) { const url new URL(request.url); if (request.method ! GET) { return new Response(Method Not Allowed, { status: 405 }); } const name url.searchParams.get(name); const type (url.searchParams.get(type) || A).toUpperCase(); if (!name || !isValidDomain(name)) { return new Response(Invalid name param, { status: 400 }); } if (!VALID_TYPES.includes(type)) { return new Response(Unsupported type: ${type}, { status: 400 }); } const upstreamUrl ${UPSTREAM_DOH}?name${encodeURIComponent(name)}type${type}; try { const upstreamResponse await fetch(upstreamUrl, { headers: { accept: application/dns-json, user-agent: cloudflare-worker-doh/1.0, }, }); if (!upstreamResponse.ok) { return new Response(Upstream error: ${upstreamResponse.status}, { status: 502, }); } const payload await upstreamResponse.json(); const body JSON.stringify(payload, null, 2); return new Response(body, { headers: { content-type: application/dns-json; charsetutf-8, cache-control: max-age60, access-control-allow-origin: *, }, }); } catch (error) { return new Response(Upstream request failed: ${error.message}, { status: 502, }); } }, };这段代码的核心逻辑有四个部分参数校验只接受 GET 请求并且校验name和type。域名不合法或记录类型不支持时直接返回 400避免把无效请求转发到上游。上游转发通过fetch请求cloudflare-dns.com/dns-query并说明自己要application/dns-json格式。异常处理上游连接失败或返回非 2xx 状态时返回 502并保留原始错误状态码。响应头设置了 CORS 头方便浏览器侧的脚本直接跨域调用同时设置了cache-control给客户端一点缓存空间。5.3 本地调试在项目目录执行wrangler dev默认会在本地开一个服务一般是http://localhost:8787。打开浏览器访问http://localhost:8787/dns-query?nameexample.comtypeA如果一切正常你会看到一个 JSON 响应里面包含 DNS 查询的Status、Answer等字段。5.4 发布到 Cloudflare执行wrangler deploy发布成功后控制台会输出一个*.workers.dev地址。比如https://my-doh-gateway.xxxxxxxx.workers.dev/dns-query这个地址就是你的 DoH JSON API 入口。5.5 绑定自定义域名推荐如果你的域名已经接入 Cloudflare在 Cloudflare 控制台进入 Worker 详情页找到“设置”里的“域名和路由”添加一条路由dns.example.com/*然后选择这个 Worker。等 DNS 生效后就可以通过https://dns.example.com/dns-query?nameexample.comtypeA访问自己的加密 DNS 网关。6. 方案二用 Pages Functions 实现 DNS 转发与静态页面一体化如果你除了 DNS API还想在同一个站点下放一个简单的查询页面Pages 是更好的选择。Pages 项目把静态资源放在根目录把函数放在functions/目录文件名就是路由路径。比如functions/dns-query.js对应的路由就是/dns-query。6.1 项目结构my-pages-doh/ ├── public/ │ └── index.html └── functions/ └── dns-query.jspublic/目录是静态资源根目录functions/目录中的 JS 文件会被当作服务端函数编译运行。6.2 编写 DNS 转发函数创建functions/dns-query.jsconst UPSTREAM_DOH https://cloudflare-dns.com/dns-query; const VALID_TYPES [ A, AAAA, CNAME, MX, NS, TXT, SOA, SRV, CAA, HTTPS ]; function isValidDomain(name) { return /^(?.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)[a-zA-Z]{2,63}$/.test(name); } export async function onRequestGet(context) { const { request } context; const url new URL(request.url); const name url.searchParams.get(name); const type (url.searchParams.get(type) || A).toUpperCase(); if (!name || !isValidDomain(name)) { return new Response(Invalid name param, { status: 400 }); } if (!VALID_TYPES.includes(type)) { return new Response(Unsupported type: ${type}, { status: 400 }); } const upstreamUrl ${UPSTREAM_DOH}?name${encodeURIComponent(name)}type${type}; try { const upstreamResponse await fetch(upstreamUrl, { headers: { accept: application/dns-json, user-agent: cloudflare-pages-function-doh/1.0, }, }); if (!upstreamResponse.ok) { return new Response(Upstream error: ${upstreamResponse.status}, { status: 502, }); } const payload await upstreamResponse.json(); const body JSON.stringify(payload, null, 2); return new Response(body, { headers: { content-type: application/dns-json; charsetutf-8, cache-control: max-age60, access-control-allow-origin: *, }, }); } catch (error) { return new Response(Upstream request failed: ${error.message}, { status: 502, }); } }Pages Functions 的入口约定是onRequestGet、onRequestPost这样的命名导出。context对象里包含了request、env、params等字段和 Worker 的fetch回调参数不完全一样但核心逻辑可以复用。6.3 可选的静态查询页面在public/index.html放一个最简单的页面方便在浏览器里测试!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleMy DoH Gateway/title /head body h1My DoH Gateway/h1 p这是一个示例页面。DNS API 入口为 /dns-query。/p /body /html6.4 本地运行 Pages在项目根目录执行wrangler pages dev public它会同时启动静态资源和 Functions。访问http://localhost:8788/dns-query?nameexample.comtypeA就能看到 JSON 响应。6.5 发布 Pages 项目先构建静态资源如果只是上面的纯静态页面不需要打包然后发布wrangler pages deploy public命令执行后wrangler 会提示你创建 Pages 项目并返回一个*.pages.dev地址。之后在 Cloudflare Pages 控制台可以绑定自定义域名。7. 进阶支持 Wire Format让系统原生 DoH 也能用JSON 格式方便调试但一个关键问题是Windows、Android 等系统自带的 DoH 客户端通常不会发 JSON 请求而是发标准的二进制 DNS 消息。为了让系统原生 DoH 也能享受自建网关网关需要处理application/dns-message。7.1 Wire Format 工作原理系统发起 DoH GET 请求时一般会带上一个dns查询参数参数值是 DNS 二进制消息的 base64url 编码。网关接收到这个参数后需要把 base64url 字符串解码成二进制 DNS 消息把二进制 DNS 消息作为 bodyPOST 到上游 DoH 服务拿到上游返回的二进制 DNS 响应把它原样返回给客户端响应类型为application/dns-message。7.2 支持 Wire Format 的 Worker 代码const UPSTREAM_DOH https://cloudflare-dns.com/dns-query; function b64UrlToArrayBuffer(input) { const b64 input.replace(/-/g, ).replace(/_/g, /); const padding b64.length % 4 0 ? : .repeat(4 - (b64.length % 4)); const base64 b64 padding; const binary atob(base64); const bytes new Uint8Array(binary.length); for (let i 0; i binary.length; i) { bytes[i] binary.charCodeAt(i); } return bytes.buffer; } export default { async fetch(request, env, ctx) { const url new URL(request.url); const b64DnsMessage url.searchParams.get(dns); if (!b64DnsMessage) { return new Response(Missing dns parameter, { status: 400 }); } let dnsMessage; try { dnsMessage b64UrlToArrayBuffer(b64DnsMessage); } catch (error) { return new Response(Invalid dns message encoding, { status: 400 }); } try { const upstreamResponse await fetch(UPSTREAM_DOH, { method: POST, headers: { content-type: application/dns-message, accept: application/dns-message, }, body: dnsMessage, }); if (!upstreamResponse.ok) { return new Response(Upstream error: ${upstreamResponse.status}, { status: 502, }); } const responseBuffer await upstreamResponse.arrayBuffer(); return new Response(responseBuffer, { status: 200, headers: { content-type: application/dns-message, cache-control: max-age60, }, }); } catch (error) { return new Response(Upstream request failed: ${error.message}, { status: 502, }); } }, };这段代码的细节在于 base64url 解码URL 安全的 base64 会把换成-把/换成_并且可能不带 padding。这里先补回标准字符再补上 padding最后用atob解码成二进制字节。7.3 在系统里使用如果你的系统支持自定义 DoH 服务器 URL可以填https://dns.example.com/dns-query系统会自动使用标准 DNS-over-HTTPS 格式请求。注意不同系统对“自定义 DoH”的支持程度不一样。有些浏览器只允许填公共 DoH 提供商的下拉选项有些则允许输入模板 URL。如果系统不支持自定义模板也可以通过第三方客户端工具来二次转发。8. 运行验证与效果检查代码写完之后不要急着收工。逐个验证 JSON、wire format、自定义域名和日志。8.1 验证 JSON 格式 API假设 Worker 地址是https://my-doh-gateway.xxxxxxxx.workers.dev执行curl -s https://my-doh-gateway.xxxxxxxx.workers.dev/dns-query?nameexample.comtypeA预期输出类似{ Status: 0, TC: false, RD: true, RA: true, AD: true, CD: false, Question: [ { name: example.com, type: 1 } ], Answer: [ { name: example.com, type: 1, TTL: 3600, data: 93.184.216.34 } ] }Status: 0表示解析成功。如果Answer字段为空说明该域名没有对应记录需要检查记录类型是否正确。8.2 验证非法参数curl -i https://my-doh-gateway.xxxxxxxx.workers.dev/dns-query?namenot-a-domaintypeA预期返回400 Invalid name param。这一步很关键能确认参数校验逻辑生效。8.3 查看 Worker 日志部署到 Cloudflare 后可以用以下命令查看实时日志wrangler tail my-doh-gateway日志里能看到每次请求的 URL、是否命中异常分支、上游响应状态等。如果用户访问报错先从这里找线索。8.4 验证 Pages 版本Pages 部署完成后同样执行curl -s https://your-project.pages.dev/dns-query?namecloudflare.comtypeAAAA验证返回结果。如果整个页面是静态站点 函数模式需要同时确认/路径能打开页面/dns-query能返回 API JSON。8.5 失败时的第一排查步骤一个常见错误是访问 502。此时按顺序做三步看 URL 参数是否包含非法字符或空格看 Worker 日志里有没有上游 DNS 返回错误码在本地用同样 URL 请求 Cloudflare 公共 DNS确认上游本身可以访问。如果上游正常、本地也有权限那大概率是代码里的参数拼接或响应解析出了问题。9. 常见问题与排查方法问题现象可能原因排查方式解决方案返回 400 Invalid name域名格式不对或带了协议头先用浏览器访问确认参数去掉https://只保留主机名返回 502 Upstream error上游 DoH 服务暂时不可用或请求头不被接受查看 Worker 日志记录上游返回的状态码切换上游地址比如改用https://dns.google/resolve返回 405 Method Not Allowed客户端使用了 POST/PUT 等方法确认 curl 或系统客户端发的是 GET代码中只开放 GET或按实际需求增加 POST 处理自定义域名访问不了Worker 路由未生效或 DNS 记录解析到了其他地方Cloudflare 控制台检查 Worker 路由和 DNS 记录添加dns.example.com/*路由等待 DNS 生效Pages 函数的/dns-query返回 404functions/文件名或路径不对查看项目结构和文件名确保文件路径是functions/dns-query.js对应路由/dns-query系统原生 DoH 无法使用系统需要application/dns-message但你的 Worker 只支持 JSON用 curl 模拟dns参数请求测试部署支持 wire format 的版本请求量大了后 Worker 被暂停免费版额度耗尽Cloudflare 控制台查看用量增加鉴权、加缓存或考虑升级付费版大多数问题都出在三处参数校验、上游地址、响应格式。排查时先用浏览器和 curl 走一遍通道路径再进入代码逻辑。10. 最佳实践与工程建议自建 DoH 网关虽然代码量不大但从“能跑”到“能稳定服务”中间还差很多工程细节。10.1 加鉴权避免被陌生人当公共 DNS如果 Worker 地址公开且没有任何鉴权任何人都能拿它当公共 DoH 用免费额度很快会被耗尽。最轻量的做法是加一个请求头 Tokenconst AUTH_TOKEN your-secret-token; export default { async fetch(request, env, ctx) { const token request.headers.get(x-auth-token); if (token ! AUTH_TOKEN) { return new Response(Unauthorized, { status: 401 }); } // 继续处理请求 } };Token 不要放在 URL 参数里避免出现在访问日志中。更规范的做法是使用 Cloudflare 的 Access 服务做身份认证但那种方案更适合内部系统。10.2 合理使用缓存DoH 请求天然适合做缓存。同一个域名可能会在短时间内被多次查询可以用 Cloudflare KV 或 Cache API 缓存解析结果减少上游请求。需要注意DNS 解析结果有 TTL缓存时间不宜超过上游返回的 TTL否则会拿到过期的解析结果。10.3 日志与隐私策略自建网关意味着你会看到所有通过它查询的域名。对个人用户来说这是“掌握自己的数据”如果开放给别人使用就要承担隐私责任。建议只记录统计信息比如查询数量和类型分布不要完整记录用户查询的域名。如果公司内部使用要先明确合规要求。10.4 上游故障切换不要只依赖一个上游。可以在 Worker 里配置多个上游地址当主上游返回 5xx 或超时时自动切到备用上游。简单的做法是const UPSTREAM_LIST [ https://cloudflare-dns.com/dns-query, https://dns.google/resolve ];循环尝试列表中的上游成功就返回失败就换下一个。10.5 控制请求速率即使加了 Token也要在逻辑上限制单个 IP 的调用频率。Cloudflare 免费版对单 Worker 的请求量有额度限制你可以在代码中用 KV 做简单的滑动窗口计数。注意 KV 的写入是最终一致的不适合做高并发精确限流。更严格的限流方案需要结合 Cloudflare WAF Rate Limiting。10.6 面向生产环境域名、证书与稳定性建议不要长期依赖*.workers.dev或*.pages.dev作为生产入口。虽然它们免费但对外提供服务时绑定自己的域名能统一证书策略和访问控制也能避免默认域名被人猜测后滥用。域名接入 Cloudflare 后证书自动由 Cloudflare 管理不需要自己处理 HTTPS 配置。11. 总结与后续学习方向到这里你已经用 Cloudflare Workers 和 Pages 分别实现了加密 DNS 网关。方案一适合快速跑通 API方案二适合静态站点和 API 一起部署进阶版本则可以让系统原生 DoH 客户端直接接入。回头看几个关键收获Cloudflare Pages 并不只是“静态托管平台”它和 Workers 共用一套运行时DoH 网关的核心是参数校验、上游转发和响应格式转换自建网关真正带来的价值是“自定义入口 策略控制层”。下一步可以继续深入的方向有三个第一把 DNS 解析结果写到 Cloudflare KV 里做持久化缓存第二在 Worker 里加入域名黑名单/白名单逻辑做成一个简短的家庭 DNS 过滤规则第三加上一个可视化管理页面让你在浏览器里查看查询统计和配置上游地址。最后提醒一点把网关开放给外部用户时要先想清楚鉴权、日志和额度问题。先用本地wrangler dev把完整逻辑跑通再部署到线上遇到问题优先查看wrangler tail的日志输出。这套思路适合轻量级自用和个人项目如果要用到企业生产环境还需要结合更完整的监控、限流和权限体系。