#!/usr/bin/env bash
# 测试可用于彩虹聚合dns V2.11 (Build 1042) AI生成
set -euo pipefail
# 需修改的配置
API_BASE="https://dns.example.com" # API地址(末尾不要斜杠)
USER_ID="用户ID"
API_KEY="API密钥"
DDNS_FQDN="ddns.example.com" # 要DDNS的域名
TTL=600 # TTL(会自动不低于平台最小TTL)
LINE_ID="default" # 线路ID
# 开关:是否处理 IPv4(A) / IPv6(AAAA)(true/false)
ENABLE_IPV4=true
ENABLE_IPV6=true
# 日志相关(同目录自动生成配置文件,可在配置里改)
DEFAULT_LOG_MAX_LINES=1000
########################################
# 内部变量(无需修改)
########################################
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_FILE="${DIR}/dnsmgr-ddns.log"
CONF_FILE="${DIR}/dnsmgr-ddns.log.conf"
LOCK_DIR="${DIR}/.dnsmgr-ddns.lock"
# 运行锁,防并发
if ! mkdir "${LOCK_DIR}" 2>/dev/null; then
echo "检测到已有实例在运行,已退出。" >&2
exit 0
fi
cleanup() { rmdir "${LOCK_DIR}" >/dev/null 2>&1 || true; }
trap cleanup EXIT
# 生成/加载日志配置
if [[ ! -f "${CONF_FILE}" ]]; then
{
echo "# Auto-generated log config for dnsmgr-ddns.sh"
echo "LOG_MAX_LINES=${DEFAULT_LOG_MAX_LINES}"
} > "${CONF_FILE}"
fi
# shellcheck disable=SC1090
source "${CONF_FILE}"
: "${LOG_MAX_LINES:=${DEFAULT_LOG_MAX_LINES}}"
log() {
local ts; ts="$(date '+%F %T')"
printf '[%s] %s\n' "${ts}" "$*" >> "${LOG_FILE}"
# 控制日志行数
local lines
lines=$(wc -l < "${LOG_FILE}" 2>/dev/null || echo 0)
if [[ "${lines}" -gt "${LOG_MAX_LINES}" ]]; then
tail -n "${LOG_MAX_LINES}" "${LOG_FILE}" > "${LOG_FILE}.tmp" && mv "${LOG_FILE}.tmp" "${LOG_FILE}"
fi
}
# 依赖检测(仅用 jq 解析 JSON;另需 curl 与 md5sum)
need_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "缺少依赖:$1" >&2; exit 1; }; }
need_cmd curl
need_cmd jq
need_cmd md5sum
# 签名:md5(user_id+timestamp+key) 小写
make_sign() {
local ts s raw
ts="$(date +%s)"
s="${USER_ID}${ts}${API_KEY}"
raw="$(printf '%s' "${s}" | md5sum)"; raw="${raw%% *}"
printf '%s|%s' "${ts}" "${raw}"
}
api_post() {
# 用法:api_post "/path" "k=v" "k=v" ...
local path="$1"; shift
local ts sign
IFS="|" read -r ts sign < <(make_sign)
local url="${API_BASE}${path}"
local args=( -sS -X POST "${url}"
--data-urlencode "uid=${USER_ID}"
--data-urlencode "timestamp=${ts}"
--data-urlencode "sign=${sign}" )
for kv in "$@"; do
args+=( --data-urlencode "${kv}" )
done
curl "${args[@]}"
}
# 获取公网IP
get_ipv4() { curl -4 -fsS ip.sb 2>/dev/null || true; }
get_ipv6() { curl -6 -fsS ip.sb 2>/dev/null || true; }
is_ipv4() { [[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; }
is_ipv6() { [[ "$1" == *:* ]]; }
# -------- FQDN 规范化:去尾部点、小写、去首尾空白 --------
trim() { local s="$1"; s="${s#"${s%%[![:space:]]*}"}"; s="${s%"${s##*[![:space:]]}"}"; printf '%s' "$s"; }
SANITIZED_FQDN="$(trim "${DDNS_FQDN}")"
SANITIZED_FQDN="${SANITIZED_FQDN%.}" # 去掉尾部点
SANITIZED_FQDN="${SANITIZED_FQDN,,}" # 小写
# 在域名列表里找与 FQDN 匹配的主域(最长后缀匹配)
# 仅用 jq 将 rows 输出为 "id<TAB>name";Bash 里做后缀匹配,避免依赖 jq 的 test/endswith 等函数
find_domain() {
local fqdn="$1" resp
resp="$(api_post "/api/domain" "offset=0" "limit=100")" || true
# 安全地取出 id 和 name;如果 rows 缺失或不是数组,得到空输出
local lines
lines="$(jq -r '
if (type=="object" and has("rows") and (.rows|type)=="array") then
.rows[] | "\(.id)\t\(.name)"
else
empty
end
' <<< "${resp}" 2>/dev/null || true)"
if [[ -z "${lines}" ]]; then
log "find_domain(): 接口响应异常(未解析到 rows):${resp}; fqdn=${fqdn}"
printf '%s' ""
return 0
fi
local best_id="" best_name="" best_len=0
local id name lc_name
while IFS=$'\t' read -r id name; do
[[ -z "$id" || -z "$name" ]] && continue
# 规范化 name:小写、去尾点、去首尾空白
lc_name="$(trim "${name}")"
lc_name="${lc_name%.}"
lc_name="${lc_name,,}"
# 精确匹配或后缀匹配(fqdn == name 或 fqdn 以 ".name" 结尾)
if [[ "${fqdn}" == "${lc_name}" || "${fqdn}" == *".${lc_name}" ]]; then
local nlen=${#lc_name}
if (( nlen > best_len )); then
best_len=$nlen
best_id="$id"
best_name="$lc_name"
fi
fi
done <<< "${lines}"
if [[ -n "${best_id}" ]]; then
printf '{"id":%s,"name":"%s"}' "${best_id}" "${best_name}"
else
log "find_domain(): 未找到后缀匹配;fqdn=${fqdn}; rows=${lines//$'\n'/, }"
printf '%s' ""
fi
}
# 查询域名详情,拿 minTTL
get_domain_min_ttl() {
local domain_id="$1" resp
resp="$(api_post "/api/domain/${domain_id}" "loginurl=0")" || true
jq -r 'try .data.minTTL catch empty' <<< "${resp}"
}
# 查询已有记录(按子域 + 类型过滤)
get_record() {
local domain_id="$1" sub="$2" rtype="$3" resp
resp="$(api_post "/api/record/data/${domain_id}" "limit=100" "subdomain=${sub}" "type=${rtype}")" || true
jq -c --arg t "$rtype" '
try ( ( .rows // [] ) | map(select(.Type==$t)) | ( .[0] // empty ) ) catch empty
' <<< "${resp}"
}
# 新增记录
add_record() {
local domain_id="$1" sub="$2" rtype="$3" value="$4" line="$5" ttl="$6"
api_post "/api/record/add/${domain_id}" "name=${sub}" "type=${rtype}" "value=${value}" "line=${line}" "ttl=${ttl}"
}
# 修改记录
update_record() {
local domain_id="$1" rid="$2" sub="$3" rtype="$4" value="$5" line="$6" ttl="$7"
api_post "/api/record/update/${domain_id}" "recordid=${rid}" "name=${sub}" "type=${rtype}" "value=${value}" "line=${line}" "ttl=${ttl}"
}
########################################
# 主流程
########################################
log "===== dnsmgr-ddns 启动:${SANITIZED_FQDN} ====="
log "使用的FQDN(规范化)=${SANITIZED_FQDN}"
# 0) 如果 IPv4 与 IPv6 都关闭则直接退出
if [[ "${ENABLE_IPV4}" != "true" && "${ENABLE_IPV6}" != "true" ]]; then
echo "IPv4 与 IPv6 均被禁用,未进行任何更新。"
log "IPv4 与 IPv6 均被禁用,流程结束"
exit 0
fi
# 1) 确定主域 & 子域
domain_json="$(find_domain "${SANITIZED_FQDN}")"
if [[ -z "${domain_json}" || "${domain_json}" == "null" ]]; then
log "错误:在可管理域名中未找到 ${SANITIZED_FQDN} 对应的主域"
echo "未在可管理域名中找到 ${SANITIZED_FQDN} 对应主域,退出(详见日志)。" >&2
exit 1
fi
DOMAIN_ID="$(jq -r '.id' <<< "${domain_json}")"
DOMAIN_NAME="$(jq -r '.name' <<< "${domain_json}")"
if [[ -z "${DOMAIN_ID}" || -z "${DOMAIN_NAME}" || "${DOMAIN_NAME}" == "null" ]]; then
log "错误:domain_json 解析失败:${domain_json}"
echo "主域解析失败(详见日志)。" >&2
exit 1
fi
if [[ "${SANITIZED_FQDN}" == "${DOMAIN_NAME}" ]]; then
SUBDOMAIN="@"
else
SUBDOMAIN="${SANITIZED_FQDN%."${DOMAIN_NAME}"}"
fi
log "已解析主域:id=${DOMAIN_ID}, name=${DOMAIN_NAME}, sub=${SUBDOMAIN}"
# 2) 确保 TTL 不低于平台最小值
min_ttl_str="$(get_domain_min_ttl "${DOMAIN_ID}")" || true
if [[ -n "${min_ttl_str}" && "${min_ttl_str}" =~ ^[0-9]+$ ]]; then
if (( TTL < min_ttl_str )); then
log "将 TTL 从 ${TTL} 调整为平台最小值 ${min_ttl_str}"
TTL="${min_ttl_str}"
fi
fi
# 3) 获取公网 IP(按开关)
IPV4=""; IPV6=""
if [[ "${ENABLE_IPV4}" == "true" ]]; then
IPV4="$(get_ipv4)"
if is_ipv4 "${IPV4}"; then log "检测到 IPv4:${IPV4}"; else IPV4=""; log "未检测到 IPv4"; fi
else
log "已禁用 IPv4 处理,跳过获取"
fi
if [[ "${ENABLE_IPV6}" == "true" ]]; then
IPV6="$(get_ipv6)"
if is_ipv6 "${IPV6}"; then log "检测到 IPv6:${IPV6}"; else IPV6=""; log "未检测到 IPv6"; fi
else
log "已禁用 IPv6 处理,跳过获取"
fi
# 4) 处理 A/AAAA
do_one_type() {
local rtype="$1" ip="$2"
if [[ -z "${ip}" ]]; then
log "跳过 ${rtype}:IP 为空"
return 0
fi
local rec_json rid cur_val resp code msg
rec_json="$(get_record "${DOMAIN_ID}" "${SUBDOMAIN}" "${rtype}")"
if [[ -n "${rec_json}" && "${rec_json}" != "null" ]]; then
rid="$(jq -r 'try .RecordId catch empty' <<< "${rec_json}")"
cur_val="$(jq -r 'try .Value catch empty' <<< "${rec_json}")"
if [[ -n "${rid}" && -n "${cur_val}" ]]; then
if [[ "${cur_val}" == "${ip}" ]]; then
log "记录未变化(${rtype} ${SANITIZED_FQDN}=${ip})"
else
log "更新 ${rtype} ${SANITIZED_FQDN}:${cur_val} -> ${ip}"
resp="$(update_record "${DOMAIN_ID}" "${rid}" "${SUBDOMAIN}" "${rtype}" "${ip}" "${LINE_ID}" "${TTL}")"
code="$(jq -r 'try .code catch 0' <<< "${resp}")"
msg="$(jq -r 'try .msg catch empty' <<< "${resp}")"
if [[ "${code}" = "0" ]]; then
log "更新成功(${rtype})"
else
log "更新失败(${rtype}):code=${code} msg=${msg} resp=${resp}"
echo "更新 ${rtype} 记录失败:${msg:-unknown}" >&2
return 1
fi
fi
else
# 解析异常:当作不存在处理
log "记录解析异常(${rtype}),按不存在处理并尝试新增"
resp="$(add_record "${DOMAIN_ID}" "${SUBDOMAIN}" "${rtype}" "${ip}" "${LINE_ID}" "${TTL}")"
code="$(jq -r 'try .code catch 0' <<< "${resp}")"
msg="$(jq -r 'try .msg catch empty' <<< "${resp}")"
if [[ "${code}" = "0" ]]; then
log "新增成功(${rtype})"
else
log "新增失败(${rtype}):code=${code} msg=${msg} resp=${resp}"
echo "新增 ${rtype} 记录失败:${msg:-unknown}" >&2
return 1
fi
fi
else
# 记录不存在,新增
log "新增 ${rtype} ${SANITIZED_FQDN}=${ip}"
resp="$(add_record "${DOMAIN_ID}" "${SUBDOMAIN}" "${rtype}" "${ip}" "${LINE_ID}" "${TTL}")"
code="$(jq -r 'try .code catch 0' <<< "${resp}")"
msg="$(jq -r 'try .msg catch empty' <<< "${resp}")"
if [[ "${code}" = "0" ]]; then
log "新增成功(${rtype})"
else
log "新增失败(${rtype}):code=${code} msg=${msg} resp=${resp}"
echo "新增 ${rtype} 记录失败:${msg:-unknown}" >&2
return 1
fi
fi
}
# 分别处理 A/AAAA(按开关)
if [[ "${ENABLE_IPV4}" == "true" ]]; then
do_one_type "A" "${IPV4}" || true
else
log "未启用 IPv4(A) 更新,跳过"
fi
if [[ "${ENABLE_IPV6}" == "true" ]]; then
do_one_type "AAAA" "${IPV6}" || true
else
log "未启用 IPv6(AAAA) 更新,跳过"
fi
log "===== dnsmgr-ddns 完成:${SANITIZED_FQDN} ====="
echo "DDNS 更新完成(详见 ${LOG_FILE})。"
我是放在/root/下,保存为 dnsmgr-ddns.sh
需要安装jq:apt install jq -y (debian)
给脚本运行权限:chmod +x dnsmgr-ddns.sh
设置定时任务 :crontab -e
每 5 分钟跑一次:*/5 * * * * /root/dnsmgr-ddns.sh >/dev/null 2>&1
日志默认写到同目录的 dnsmgr-ddns.log