commit b4a02c3c086e4d46fbd16a037922bafa9f4af0aa Author: dsh Date: Sun Aug 16 13:46:46 2026 -0400 Cloudflare DDNS 更新脚本(脱敏版):多域名 A/AAAA 同步,过滤 ULA 内网 IPv6 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f525296 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Cloudflare DDNS 更新脚本 + +每 5 分钟自动检测本机公网 IPv4/IPv6,变化时同步更新 Cloudflare DNS 记录(A / AAAA),支持多域名、泛解析,可选 CDN 代理。 + +## 特点 + +- 一个 Token 管理多个域名,自动识别所属 Zone +- 公网 IP 只检测一次,多条记录复用 +- IP 无变化时跳过,节省 API 调用 +- 仅发布公网 IPv6(`2000::/3`),自动过滤 ULA 内网地址(`fc00::/7`) +- 获取不到 IPv6 时回退到外部检测接口 + +## 使用方法 + +1. 在 Cloudflare 后台创建受限 Token:My Profile → API Tokens,权限 `Zone → DNS → Edit` +2. 将 Token 填入脚本顶部 `API_TOKEN`(或改用 `API_EMAIL` + `API_KEY` 全局密钥方式) +3. 修改 `RECORDS` 列表,每条一行,格式 `域名:类型(:proxied)` + - 类型:`A` = IPv4,`AAAA` = IPv6 + - `proxied`:可选,`true` 走 CDN 代理,`false` 纯解析(默认) +4. 安装到 cron,每 5 分钟执行一次: + +```cron +*/5 * * * * root /usr/local/bin/cloudflare-ddns.sh >> /var/log/cloudflare-ddns.log 2>&1 +``` + +## 配置示例 + +```bash +API_TOKEN="你的_Cloudflare_API_Token" + +RECORDS=( + "example.com:A" + "example.com:AAAA" + "*.example.com:A:true" +) +``` + +> 注意:本仓库已脱敏,`API_TOKEN` / `API_EMAIL` / `API_KEY` 均为空占位,不含任何真实密钥。请勿将真实密钥提交到公开仓库。 diff --git a/cloudflare-ddns.sh b/cloudflare-ddns.sh new file mode 100644 index 0000000..e60ba93 --- /dev/null +++ b/cloudflare-ddns.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ========== 配置区 ========== +# 认证方式二选一(方式一优先): +# 方式一(推荐): My Profile -> API Tokens 创建的受限 Token, 权限 Zone->DNS->Edit +API_TOKEN="" +# 方式二: Global API Key (My Profile -> API Tokens -> API Keys 页面), 配合账号邮箱 +API_EMAIL="" +API_KEY="" +# 每条记录一行, 格式: 域名:类型(:proxied) +# 域名 根域 / 子域 / 泛解析均可, 所属 Zone 自动识别(同一账号下一个 Token 即可) +# 类型 A=IPv4, AAAA=IPv6 +# proxied 可选: true=开Cloudflare CDN代理, false=纯解析(默认) +# 以后新增域名只需在下面加一行 +RECORDS=( + "lmve.net:A" + "lmve.net:AAAA" + "*.lmve.net:A" + "*.lmve.net:AAAA" + "wnfed.com:A" + "wnfed.com:AAAA" + "*.wnfed.com:A" + "*.wnfed.com:AAAA" +) +# =========================== + +API="https://api.cloudflare.com/client/v4" +LOG() { echo "[$(date '+%F %T')] $*"; } + +# 认证头: 优先用受限 Token, 否则用 Global API Key + 邮箱 +AUTH=() +if [ -n "$API_TOKEN" ]; then + AUTH=(-H "Authorization: Bearer $API_TOKEN") +elif [ -n "$API_EMAIL" ] && [ -n "$API_KEY" ]; then + AUTH=(-H "X-Auth-Email: $API_EMAIL" -H "X-Auth-Key: $API_KEY") +else + LOG "!!! 未配置认证信息 (API_TOKEN 或 API_EMAIL+API_KEY)" + exit 1 +fi + +# IP 只抓取一次, 多条记录复用 +CACHE4="" CACHE6="" +declare -A ZONE_CACHE + +get_ipv4() { + if [ -z "$CACHE4" ]; then + CACHE4=$(curl -fsS --max-time 10 -4 https://api.ipify.org 2>/dev/null) || true + fi + echo "$CACHE4" +} + +get_ipv6() { + if [ -z "$CACHE6" ]; then + # 优先取本机网卡上的公网 IPv6 (2000::/3), 排除 ULA 内网地址 (fc00::/7); 取不到再走外部检测 + CACHE6=$(ip -6 addr show scope global 2>/dev/null | awk '/inet6/{print $2}' | grep -E '^[23][0-9a-fA-F]{3}:' | head -1 | cut -d/ -f1) || true + if [ -z "$CACHE6" ]; then + CACHE6=$(curl -fsS --max-time 10 -6 https://api64.ipify.org 2>/dev/null) || true + fi + fi + echo "$CACHE6" +} + +# 从记录名推断所属 Zone (逐级剥离左边标签, 匹配最长后缀), 结果缓存 +get_zone_id() { + local rec="$1" cand="$1" resp zid + while [ "$(echo "$cand" | tr -cd '.' | wc -c)" -ge 1 ]; do + if [ -n "${ZONE_CACHE[$cand]:-}" ]; then + echo "${ZONE_CACHE[$cand]}" + return 0 + fi + # 含通配符的候选名不可能是 Zone, 直接跳过 + if [[ "$cand" != *"*"* ]]; then + resp=$(curl -fsS -g --max-time 15 "${AUTH[@]}" \ + "$API/zones?name=$cand&status=active" 2>/dev/null) || true + zid=$(echo "$resp" | grep -o '"id":"[0-9a-f]\{32\}"' | head -1 | sed 's/.*:"//;s/"$//') + if [ -n "$zid" ]; then + ZONE_CACHE[$cand]="$zid" + echo "$zid" + return 0 + fi + fi + cand="${cand#*.}" + done + LOG "!!! 找不到 $rec 所属的 Zone" + return 1 +} + +update_record() { + local name="$1" type="$2" proxied="${3:-false}" zone_id ip="" + type=$(echo "$type" | tr '[:lower:]' '[:upper:]') + zone_id=$(get_zone_id "$name") || return 1 + if [ "$type" = "A" ]; then ip=$(get_ipv4); else ip=$(get_ipv6); fi + [ -z "$ip" ] && { LOG "跳过 $name:获取不到 $type 地址"; return 1; } + + local resp rec_id="" cur_ip="" + resp=$(curl -fsS -g --max-time 15 "${AUTH[@]}" \ + "$API/zones/$zone_id/dns_records?type=$type&name=$name" 2>/dev/null) || true + rec_id=$(echo "$resp" | grep -o '"id":"[0-9a-f]\{32\}"' | head -1 | sed 's/.*:"//;s/"$//') + cur_ip=$(echo "$resp" | grep -o '"content":"[^"]*"' | head -1 | sed 's/.*:"//;s/"$//') + + if [ -n "$rec_id" ] && [ "$cur_ip" = "$ip" ]; then + LOG "$type $name 未变化 ($ip),跳过" + return 0 + fi + + local method="POST" url="$API/zones/$zone_id/dns_records" + [ -n "$rec_id" ] && { method="PUT"; url="$url/$rec_id"; } + + local body="{\"type\":\"$type\",\"name\":\"$name\",\"content\":\"$ip\",\"ttl\":120,\"proxied\":$proxied}" + if curl -fsS -g --max-time 15 -X "$method" "${AUTH[@]}" \ + -H "Content-Type: application/json" -d "$body" "$url" 2>/dev/null \ + | grep -q '"success":true'; then + LOG "$type $name -> $ip 更新成功" + else + LOG "!!! $type $name 更新失败" + return 1 + fi +} + +main() { + for entry in "${RECORDS[@]}"; do + IFS=':' read -r name type proxied <<< "$entry" + update_record "$name" "${type:-A}" "${proxied:-false}" || true + done +} +main