cloudflare还能这么玩?零成本搭建简单、安全、可控的 DDNS 中继

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 和 GET    if (request.method !== 'POST' && request.method !== 'GET') {      return json({ okfalse, 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({ okfalse, 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(        { okfalse, error'server misconfigured: ALLOWED_DOMAINS must be JSON array' },        500      );    }    // ③ 确定目标域名    const recordName =      url.searchParams.get('name') ||      allowedDomains[0];    if (!recordName) {      return json({ okfalse, error'no record name and ALLOWED_DOMAINS empty' }, 400);    }    // ④ 白名单校验(精确匹配)    if (!allowedDomains.includes(recordName)) {      return json(        {          okfalse,          error: `domain not allowed: ${recordName}`,          hint'add it to ALLOWED_DOMAINS env var',        },        403      );    }    // ⑤ 确定 IP:?ip= 优先,否则用 CF-Connecting-IP    const explicitIP = url.searchParams.get('ip');    const clientIP = explicitIP || request.headers.get('CF-Connecting-IP');    if (!clientIP) {      return json({ okfalse, error'cannot determine IP' }, 400);    }    // ⑥ 判断记录类型    const recordType detectRecordType(clientIP);    if (!recordType) {      return json({ okfalse, 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(        { okfalse, error'resolve zone failed: ' + e.message },        502      );    }    // ⑦ 调用 CF DNS API    const 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({ okfalse, 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({ okfalse, error'create failed', detail: createData.errors }, 502);      }      return json({ oktrue, action'created', name: recordName, type: recordType, ip: clientIP, zone_id: zoneId });    }    if (record.content === clientIP) {      return json({ oktrue, 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({ okfalse, error'update failed', detail: updateData.errors }, 502);    }    return json({      oktrue,      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, null2), {    status,    headers: { 'Content-Type''application/json' },  });}