cloudflare还能这么玩?零成本搭建简单、安全、可控的 DDNS 中继
文章末尾附有代码,如果要本方案更详细的代码内容,关注公众号并发送 ddns代码
搞 HomeLab 或者玩 NAS 的朋友,肯定绕不开动态公网 IP 的解析问题(DDNS)。
传统的做法大家都很熟悉:在软路由、NAS 甚至一直开机的电脑上跑个脚本,或者装个 DDNS 插件,把云厂商(比如 Cloudflare)的 API Token 填进去,定时把最新的公网 IP 汇报给云端。
这些方案安全风险太高,API Token 权限其实很大。你把它明文写在本地脚本里,很容易泄漏。
我也使用过 花生壳、pubyun(原3322)、dynv6等,后面他们的服务要么就是门槛提高,全免费的动态域名服务,不用自己折腾 Token,但无奈免费的服务就是不稳定,时不时就给你来个抽风解析失败,导致家里主机失联。
被这些痛点折磨后,我决定自己做一个完全自主可控、稳定、且安全的 DDNS 解析方案。思路很简单:利用 Cloudflare 全球第一梯队的稳定节点,同时把安全风险彻底解决。于是就有了这个轻量级方案:ddns-relay
它是怎么运作的?
以前是家里设备直接和 Cloudflare API 交互。现在,我在中间加了一层 CF Worker 作为“中继”。
高权限的 CF API Token 被加密存放在 Worker 的环境变量里,永远不会下发到本地。你家里的设备只需要保存一个你自己定义的“访问密码(Secret)”,然后向这个 Worker 发送一个 HTTP 请求。
家 ─(原生curl + 密码)─> CF Worker ─(API Token)─> DNS 解析
│
├─ 校验白名单
└─ 自动反查 Zone ID这样做的好处显而易见:
首先是绝对的安全。哪怕你的本地密码不小心泄露了,Worker 端我也写了一层域名白名单兜底。攻击者最多只能修改你白名单里的那几个指定子域名,没法越界动你其他的业务记录。
其次是客户端极简。不需要装任何 Python 环境、复杂的 Shell 脚本或是第三方 Docker 镜像。只要你的系统里有 curl,一行命令直接搞定。
怎么部署?
⚠️ 提前说个大前提:要用这套方案,你的域名必须托管在 Cloudflare。如果你是在阿里云、腾讯云买的域名,免费把域名的 DNS 服务器(Name Servers)改到 CF 即可,这是常规操作。
整个部署过程大概只要三五分钟,而且完全白嫖(CF Worker 免费版每天 10 万次请求,做 DDNS 简直是降维打击)。
第一步:去 Cloudflare 拿一个 Token
登录 CF 控制台,在“我的个人资料 -> API 令牌”里建个新令牌。用 "DNS & Zones" 模板。权限给两个:DNS 选 Edit,Zone 选 Read。生成出来的 Token 妥善保存。
第二步:把 Worker 跑起来
如果你不想用命令行折腾,最简单的方法就是直接在 CF 控制台新建一个 Worker。把我开源在项目里的 worker.js 代码原封不动全选、粘贴进去。
然后在 Worker 的 Settings -> Variables and Secrets 里加上三个环境变量:
• CF_API_TOKEN:(类型选 Secret)填刚才申请的 Token。• SHARED_SECRET:(类型选 Secret)你自己定一个密码,比如mypassword123,客户端调用时用。• ALLOWED_DOMAINS:(类型选 Plaintext)填一个 JSON 数组,比如["home.example.com", "nas.example.com"],只有这几个域名允许被更新。注意必须是一行 JSON,不要换行。
⚠️ 重点避坑提示:Cloudflare 默认分配的
*.workers.dev域名在国内由于 DNS 污染等原因,通常是无法正常访问的。为了保证家里软路由能稳定调通,强烈建议在 CF Dashboard 的 Worker 设置里,给它绑定一个你托管在 CF 上的自定义域名 (Custom Domain)。
第三步:家里设备直接跑 curl
Worker 部署完之后,服务端的工作就彻底结束了。代码里写了自动反查逻辑,你连 A 记录都不用去 CF 后台建,首次调用它会自动帮你建好。
回到你家里的 Linux 或软路由(比如 OpenWrt)上,直接用系统自带的 crontab 加个定时任务,每 5 分钟跑一次:
# 如果你想更新 IPv4(A 记录)
*/5 * * * * curl -4 -X POST "https://你绑定的自定义域名?name=home.example.com" -H "X-DDNS-Secret: 你的密码" >/dev/null 2>&1
# 如果你想更新 IPv6(AAAA 记录,适合没有公网 v4 的朋友)
*/5 * * * * curl -6 -X POST "https://你绑定的自定义域名?name=home.example.com" -H "X-DDNS-Secret: 你的密码" >/dev/null 2>&1把 URL 里的域名和 Header 里的密码换成你自己的。代码里写了自适应逻辑,Worker 会根据你请求的 IP 格式(curl -4 还是 curl -6)自动决定是创建 A 记录还是 AAAA 记录。如果是 Windows,直接用任务计划程序跑一行 PowerShell 的 Invoke-RestMethod 也是一样的。
写在最后
这套方案把原本在本地处理的复杂逻辑(查 Zone ID、判断记录存不存在、更新还是创建)全抛给了云端节点,本地只保留了最原始的触发机制。
顺带一提,这个玩法不仅适合家庭宽带,如果你经常买 VPS 服务器折腾,每次开新机器跑一下这行 curl,瞬间就能把 IP 绑定到你的专属域名上,连控制台都不用登录,非常丝滑。
代码
//// 环境变量:// CF_API_TOKEN (Secret) - Cloudflare API Token,需有目标 zone 的 DNS Edit 权限// SHARED_SECRET (Secret) - 客户端与 Worker 之间的共享 secret// ALLOWED_DOMAINS (array) - 信任的域名列表,必须是当前账号名下 ["home.example.com","nas.example.com"]//// 调用方式:// POST / Header: X-DDNS-Secret: <secret>// 可选 query: ?name=xxx.example.com ?ip=1.2.3.4// 进程内 zone_id 缓存(zone_id 几乎不变,缓存到 isolate 重启即可)const zoneIdCache = new Map();export default {async fetch(request, env) {// 只接受 POST 和 GETif (request.method !== 'POST' && request.method !== 'GET') {return json({ ok: false, error: 'method not allowed' }, 405);}const url = new URL(request.url);// ① 校验共享 secret(Header 优先,query 兜底)const secret =request.headers.get('X-DDNS-Secret') ||url.searchParams.get('secret');if (!secret || secret !== env.SHARED_SECRET) {return json({ ok: false, error: 'unauthorized' }, 401);}// ② 解析允许列表(JSON 数组)let allowedDomains;try {allowedDomains = JSON.parse(env.ALLOWED_DOMAINS);if (!Array.isArray(allowedDomains)) throw new Error('not array');} catch (e) {return json({ ok: false, error: 'server misconfigured: ALLOWED_DOMAINS must be JSON array' },500);}// ③ 确定目标域名const recordName =url.searchParams.get('name') ||allowedDomains[0];if (!recordName) {return json({ ok: false, error: 'no record name and ALLOWED_DOMAINS empty' }, 400);}// ④ 白名单校验(精确匹配)if (!allowedDomains.includes(recordName)) {return json({ok: false,error: `domain not allowed: ${recordName}`,hint: 'add it to ALLOWED_DOMAINS env var',},403);}// ⑤ 确定 IP:?ip= 优先,否则用 CF-Connecting-IPconst explicitIP = url.searchParams.get('ip');const clientIP = explicitIP || request.headers.get('CF-Connecting-IP');if (!clientIP) {return json({ ok: false, error: 'cannot determine IP' }, 400);}// ⑥ 判断记录类型const recordType = detectRecordType(clientIP);if (!recordType) {return json({ ok: false, error: 'invalid IP: ' + clientIP }, 400);}// ⑥ 自动解析 zone_id(带缓存)const apiHeaders = {Authorization: `Bearer ${env.CF_API_TOKEN}`,'Content-Type': 'application/json',};let zoneId;try {zoneId = await resolveZoneId(recordName, apiHeaders);} catch (e) {return json({ ok: false, error: 'resolve zone failed: ' + e.message },502);}// ⑦ 调用 CF DNS APIconst apiBase = `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`;const listResp = await fetch(`${apiBase}?type=${recordType}&name=${encodeURIComponent(recordName)}`,{ headers: apiHeaders });const listData = await listResp.json();if (!listData.success) {return json({ ok: false, error: 'list failed', detail: listData.errors }, 502);}const record = listData.result[0];const body = {type: recordType,name: recordName,content: clientIP,ttl: 60,proxied: false,};// ⑧ 决策if (!record) {const createResp = await fetch(apiBase, {method: 'POST',headers: apiHeaders,body: JSON.stringify(body),});const createData = await createResp.json();if (!createData.success) {return json({ ok: false, error: 'create failed', detail: createData.errors }, 502);}return json({ ok: true, action: 'created', name: recordName, type: recordType, ip: clientIP, zone_id: zoneId });}if (record.content === clientIP) {return json({ ok: true, action: 'unchanged', name: recordName, type: recordType, ip: clientIP });}const updateResp = await fetch(`${apiBase}/${record.id}`, {method: 'PUT',headers: apiHeaders,body: JSON.stringify(body),});const updateData = await updateResp.json();if (!updateData.success) {return json({ ok: false, error: 'update failed', detail: updateData.errors }, 502);}return json({ok: true,action: 'updated',name: recordName,type: recordType,ip: clientIP,previous_ip: record.content,});},};// 从完整域名逐级猜测根域名,反查 zone_idasync function resolveZoneId(fqdn, apiHeaders) {// 先查缓存for (const [zoneName, id] of zoneIdCache.entries()) {if (fqdn === zoneName || fqdn.endsWith('.' + zoneName)) {return id;}}// 逐级剥离子域名尝试匹配// a1.home.example.com → ['a1.home.example.com', 'home.example.com', 'example.com', 'com']const parts = fqdn.split('.');for (let i = 0; i < parts.length - 1; i++) {const candidate = parts.slice(i).join('.');const resp = await fetch(`https://api.cloudflare.com/client/v4/zones?name=${encodeURIComponent(candidate)}`,{ headers: apiHeaders });const data = await resp.json();if (!data.success) {throw new Error(JSON.stringify(data.errors));}if (data.result && data.result.length > 0) {const zoneId = data.result[0].id;const zoneName = data.result[0].name;zoneIdCache.set(zoneName, zoneId); // 缓存return zoneId;}}throw new Error(`no zone found for ${fqdn}`);}function detectRecordType(ip) {if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {const parts = ip.split('.').map(Number);return parts.every(p => p >= 0 && p <= 255) ? 'A' : null;}if (ip.includes(':') && /^[0-9a-fA-F:]+$/.test(ip)) return 'AAAA';return null;}function json(obj, status = 200) {return new Response(JSON.stringify(obj, null, 2), {status,headers: { 'Content-Type': 'application/json' },});}