DDNS-利用免费的CloudflareWorker操作DNS更新

Kevin Tsang Lv2

本文将利用 Cloudflare Worker 的无服务器架构和 Linux/NAS 的脚本能力,提供了一个低成本、灵活的 DDNS 解决方案。

Cloudflare Worker配置

Step1

创建一个cloudflare的worker.js,填入以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
async function getZoneId(domain, headers) {
  const url = `https://api.cloudflare.com/client/v4/zones?name=${domain}`;
  const res = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(5000) });
  const data = await res.json();
  if (!data.success || data.result.length === 0) {
    throw new Error(`Failed to get zone ID for domain: ${domain}`);
  }
  return data.result[0].id;
}

async function getDnsRecordId(apiUrl, recordName, headers) {
  const url = `${apiUrl}?name=${recordName}`;
  const res = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(5000) });
  const data = await res.json();
  if (!data.success) {
    throw new Error('Failed to get DNS record ID');
  }
  return data.result.length > 0 ? data.result[0].id : null;
}

async function createDnsRecord(apiUrl, body, headers) {
  const res = await fetch(apiUrl, {
    method: 'POST',
    headers,
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(5000)
  });
  const data = await res.json();
  if (!data.success) {
    throw new Error('Failed to create DNS record: ' + JSON.stringify(data.errors));
  }
}

async function updateDnsRecord(apiUrl, recordId, body, headers) {
  const res = await fetch(`${apiUrl}/${recordId}`, {
    method: 'PUT',
    headers,
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(5000)
  });

  const data = await res.json();

  if (!data.success) {
    throw new Error('Failed to update DNS record: ' + JSON.stringify(data.errors));
  }

}

function isIPv6(ip) {
  return ip && ip.includes(':');
}

function isIPv4(ip) {
  return ip && ip.split('.').length === 4;
}

/**
 * 解析 record 参数,拆分成 domain 和子域名 name
 * 例如:
 * - "example.com" => domain="example.com", name="@"
 * - "sub.example.com" => domain="example.com", name="sub"
 * - "a.b.example.com" => domain="example.com", name="a.b"
 */

function parseRecord(record) {
  const parts = record.split('.');
  if (parts.length < 2) throw new Error('Invalid record format');
  if (parts.length === 2) {
    return { domain: record, name: '@' };
  } else {
    const domain = parts.slice(-2).join('.');
    const name = parts.slice(0, parts.length - 2).join('.');
    return { domain, name };
  }
}

export default {
  async fetch(request, env, ctx) {
    try {
      const url = new URL(request.url);
      const params = url.searchParams;
      // 先判断是不是旧版传参(传入email和api_key)
      const emailParam = params.get('email');
      const apiKeyParam = params.get('api_key');
      // 新版参数
      const passParam = params.get('pass');
      const envPass = env.PASS;
      let email, apiKey;
      if (emailParam && apiKeyParam) {
        // 旧版,直接用传入参数
        email = emailParam;
        apiKey = apiKeyParam;
      } else {
        // 新版先校验 pass
        if (!passParam || !envPass || passParam !== envPass) {
          return new Response('Unauthorized: invalid pass parameter', { status: 401 });
        }
        // 新版用环境变量
        email = env.EMAIL;
        apiKey = env.API_KEY;
        if (!email || !apiKey) {
          return new Response('Server not configured with EMAIL or API_KEY', { status: 500 });
        }
      }
      // 读取 record,必须
      const record = params.get('record');
      if (!record) {
        return new Response('Missing required parameter: record', { status: 400 });
      }
      // TTL,缺省 1(自动)
      const ttlStr = params.get('ttl');
      const ttl = ttlStr ? parseInt(ttlStr) : 1;
      // proxied,默认false
      const proxied = params.get('proxied') === 'true';
      // IP 获取策略:
      // 旧版:仅使用URL参数ip
      // 新版:优先请求头cf-connecting-ip(仅IPv6),否则用URL参数ip
      let ipToUse = null;
      const ipParam = params.get('ip');
      if (emailParam && apiKeyParam) {
        // 旧版
        if (!ipParam) {
          return new Response('Missing IP parameter in old version', { status: 400 });
        }
        ipToUse = ipParam;
      } else {
        // 新版
        const clientIp = request.headers.get('cf-connecting-ip');
        if (clientIp && isIPv6(clientIp)) {
          ipToUse = clientIp;
        } else if (ipParam && (isIPv4(ipParam) || isIPv6(ipParam))) {
          ipToUse = ipParam;
        } else {
          return new Response('No valid IPv6 address found in request headers or ip parameter', { status: 400 });
        }
      }
      // 解析 record
      let domain, name;
      try {
        ({ domain, name } = parseRecord(record));
      } catch (e) {
        return new Response('Invalid record format', { status: 400 });
      }
      // 组装请求头
      const headers = {
        'X-Auth-Email': email,
        'X-Auth-Key': apiKey,
        'Content-Type': 'application/json'
      };
      // 取Zone ID
      const zoneId = await getZoneId(domain, headers);
      const apiUrl = `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`;
      const fullRecordName = name === '@' ? domain : `${name}.${domain}`;
      const recordId = await getDnsRecordId(apiUrl, fullRecordName, headers);
      // 记录类型
      const recordType = isIPv6(ipToUse) ? 'AAAA' : 'A';
      const dnsBody = {
        type: recordType,
        name: fullRecordName,
        content: ipToUse,
        ttl: ttl,
        proxied: proxied
      };
      if (recordId) {
        await updateDnsRecord(apiUrl, recordId, dnsBody, headers);
      } else {
        await createDnsRecord(apiUrl, dnsBody, headers);
      }
      return new Response(`DNS record ${fullRecordName} updated to IP: ${ipToUse}`, { status: 200 });
    } catch (err) {
      console.error('Error in DDNS Worker:', err);
      return new Response('Internal Server Error', { status: 500 });
    }
  }
};

Step2 创建环境变量(可选)

环境变量有以下三个,如果不填也可以用,即[旧版传参方式]
由于API_KEY这里是你的cloudflare global api key
建议创建环境变量并用pass代替直接在请求中传apikey

Type Name Value
Secret API_KEY Value encrypted
Secret EMAIL Value encrypted
Plaintext PASS Your passcode

参数说明和用法

支持的请求参数(通过URL查询参数传递)

参数名 说明 是否必需 备注
record 需要更新的DNS记录,完整域名,如example.comsub.example.com 自动解析顶级域与子域名
ip IPv4或IPv6地址 旧版必需 新版优先使用请求头中的cf-connecting-ip的IPv6地址
ttl TTL值 默认为1(自动TTL)
proxied 是否启用Cloudflare代理 默认为false,传true启用代理
email Cloudflare账户邮箱 旧版认证 通过URL传入,旧版参数
api_key Cloudflare API Key 旧版认证 通过URL传入,旧版参数
pass 访问密码 新版认证 必须与环境变量PASS一致

旧版示例

GET https://your-worker-url/?email=you@example.com&api_key=your_api_key&record=sub.example.com&ip=1.2.3.4&ttl=120&proxied=true

  • 说明:更新sub.example.com的A记录为1.2.3.4,TTL为120秒,启用Cloudflare代理。

新版示例

GET https://your-worker-url/?pass=your_pass&record=example.com

  • cf-connecting-ip会自动获取请求发起者的IPv6地址(如有),或者URL中带ip参数(IPv4或IPv6)。
  • 使用环境变量EMAILAPI_KEY作鉴权。
  • 如果未传ttl,默认为1(自动)。
  • proxied参数可选,默认false。

在命令行中,执行wget或者curl + 链接都可以触发


Linux脚本示例 - 以QNAP NAS为例

事实上QNAP的DDNS配置里可以直接用这个请求,但是这里还是使用cron job运行脚本的方法以展现通用性:

一、创建一个脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78

#!/bin/sh

# 注意换行标记要选择`LF`而非`CRLF`

# --- 配置区 ---
ENABLE=true                    # 开关,true启用DDNS更新,false跳过所有操作
IP_CHANGE_ONLY=true            # true只在IP变化时更新,false无论IP是否变化都更新
DDNS_URL="https://example.com/?pass=%pass%&record=%record%"
LOG_DIR="/sbin/DDNS/log"


mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/ddns.log"
IP_FILE="$LOG_DIR/current_ip.txt"

log() {
  echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> "$LOG_FILE"
}
get_current_ip() {
  # 获取公网IPv6地址示例(根据需要可改)
  curl -6 -s https://ifconfig.co/
}

get_last_ip() {
  if [ -f "$IP_FILE" ]; then
    cat "$IP_FILE"
  else
    echo ""
  fi
}

save_current_ip() {
  echo "$1" > "$IP_FILE"
}

if [ "$ENABLE" != "true" ]; then
  log "DDNS update disabled via ENABLE flag."
  exit 0
fi

current_ip=$(get_current_ip)

if [ -z "$current_ip" ]; then
  log "Failed to get current IP, will try updating without IP..."
  # 试着不带ip参数访问DDNS_URL
  response=$(wget -qO- "$DDNS_URL")
  log "DDNS update response (no IP): $response"
  echo "$response" | grep -qi "updated"
  if [ $? -eq 0 ]; then
    log "Update without IP succeeded."
    exit 0
  else
    log "Update without IP failed. Exiting."
    exit 1
  fi
fi

last_ip=$(get_last_ip)

if [ "$IP_CHANGE_ONLY" = "true" ] && [ "$current_ip" = "$last_ip" ]; then
  log "IP unchanged ($current_ip), no update needed (IP_CHANGE_ONLY=true)."
  exit 0
fi

log "IP changed or forced update: last_ip=[$last_ip], current_ip=[$current_ip]. Updating DDNS..."
response=$(wget -qO- "$DDNS_URL&ip=$current_ip")
log "DDNS update response: $response"
echo "$response" | grep -qi "updated"

if [ $? -eq 0 ]; then
  save_current_ip "$current_ip"
  log "IP update recorded successfully."
else
  log "IP update failed or unexpected response."
fi

exit 0

二、给脚本授权执行权限

SSH登录NAS,执行:

bash复制

chmod +x /share/Public/DDNS/ddns.sh


三、创建日志目录(如果不存在)

bash复制

mkdir -p /share/Public/DDNS/log


四、设置威联通的定时任务

  1. 编辑 crontab 配置文件(威联通重启后该文件自带生效)

bash复制

vi /etc/config/crontab

  1. 在文件末尾添加(假设每天凌晨4点执行):

复制

0 4 * * * /share/Public/DDNS/ddns.sh

  1. 保存并退出。

  2. 重新加载crontab并重启服务:

bash复制

crontab /etc/config/crontab && /etc/init.d/crond.sh restart


五、验证和调试

  • 手动执行脚本测试:

bash复制

/share/Public/DDNS/ddns.sh

  • 查看日志:

bash复制

cat /share/Public/DDNS/log/ddns.log

vim 编辑 /etc/config/crontab 文件,添加定时任务行


1. 打开文件

在威联通的 SSH 终端执行:

1
vi /etc/config/crontab

vivim 通常是同一个编辑器)


2. 进入编辑模式添加行

打开文件后你处于“普通模式”,可以用以下步骤添加新行:

  • 按键盘上的Page Down键或用方向键 滚动到文件末尾(也可以用快捷键 G 直接跳到文件末尾,按大写 G

  • o(小写字母o),这会在当前行下面新开一行,并进入编辑模式(光标出现,能输入文字)

  • 输入你要添加的内容:

1
0 4 * * * /share/Public/DDNS/ddns.sh

建议放在/share/共享目录 下,如果放在/root或者/sbin这类地方,可能重启后就没了。


3. 保存并退出

  • 输入完毕后,按下键盘上的 Esc 键,退出编辑模式回到普通模式

  • 输入 :wq (注意冒号是英文状态下的冒号,w 是写入保存,q 是退出)

  • 按回车键执行命令,文件保存后退出 vim


4. 如果意外更改了内容,不想保存想退出怎么办?

  • Esc 键确保处于普通模式

  • 输入 :q! (冒号 + q + 感叹号),表示强制退出且不保存改动

  • 按回车即可退出 vim,所有改动都会丢弃,文件保持原样


5. 常用 Vim 小技巧总结

操作 命令/按键 说明
进入编辑模式 i(光标处插入),o(下一行插入) 可以输入文本
退出编辑模式 Esc 回到普通模式
保存文件并退出 :wq:x + 回车 写入文件并退出
不保存退出 :q! + 回车 强制退出,不保存改动
跳转到文件末尾 G 快速跳转
上下移动 方向键 ↑ ↓ 移动光标

6. 例子流程

1
vi /etc/config/crontab
  • 按大写 G 跳到底部
  • o 新建下一行
  • 输入:
1
0 4 * * * /share/Public/DDNS/ddns.sh
  • Esc
  • 输入 :wq
  • 按回车退出保存

这样你的定时任务就被添加到了 /etc/config/crontab,重启后不会丢失。

如果你对vim不熟练,也可以把文件内容备份出来,在本地电脑编辑好后再传回NAS替换,或者用 cat >> /etc/config/crontab 方式追加(需要小心)。


5. 调试(可选)

如果想在运行时看到脚本输出,可以临时修改脚本,在关键步骤加一些 echo 命令,或者直接执行脚本时启用shell的调试:

bash复制

sh -x /share/Public/DDNS/ddns.sh

这会打印每条执行的命令和参数,方便排查问题。

  • Title: DDNS-利用免费的CloudflareWorker操作DNS更新
  • Author: Kevin Tsang
  • Created at : 2025-06-14 00:00:00
  • Updated at : 2025-06-24 18:08:49
  • Link: https://blog.infrost.site/2025/06/14/CloudflareDDNSbyScript/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments