Appearance
DNS 验证示例
在 DNS 中添加和删除验证记录的 present / cleanup hook 示例。用法与通用注意事项见 完整示例 · 概览。
示例脚本仅供参考,使用后果由使用者自行承担
这些脚本是参考模板,不是 CertiMan 产品的一部分。请根据自身业务、系统、安全策略充分调整并用 certiman hook test 试运行;使用导致的任何损失(含误删 DNS 记录)由使用者承担,TrustAsia 不承担相关责任。完整免责与凭证建议见 概览。
present 与 cleanup 共用一个脚本文件,按 CERTIMAN_HOOK_TYPE 区分执行分支。配置中的两个字段指向同一个脚本:
yaml
dcv:
method: dns
present: /home/<用户名>/.certiman/hooks/tencentcloud.sh
cleanup: /home/<用户名>/.certiman/hooks/tencentcloud.sh| 脚本 | 服务商 | 依赖 |
|---|---|---|
| 腾讯云 DNSPod | 腾讯云 DNSPod(云 API 3.0) | curl、openssl、jq |
| Cloudflare | Cloudflare(API v4) | curl、jq |
| 阿里云 DNS | 阿里云 DNS | 官方 aliyun CLI、jq |
| 接新服务商的模板 | 只需实现 add_record / remove_record 两个函数 | — |
先确认是否需要 DNS hook
CertiMan 有两种不写脚本的验证方式:
dns-proxy:为验证记录名配置一条指向平台的 CNAME,后续续期无需修改 DNSdns-persist:持久 TXT 授权,布置一次长期有效
两种方式都只需配置一次,后续续期无需重新布置验证记录。只有在需要全自动、且不在 DNS 中保留持久记录时,才需要由 present/cleanup hook 在每轮添加和删除记录。
三项常见错误:
- 等待 DNS 传播由 hook 自行负责:CertiMan 布置完全部域名后只做一次本地预检、不重试;记录尚未生效时报验证未通过。应在
CERTIMAN_REMAINING = 0的分支中执行sleep,使多域名证书只等待一次 - 不要自行拼接记录名或截取主域:
CERTIMAN_DCV_FQDN/MAIN_DOMAIN/HOST均由 CertiMan 计算。平台有时直接返回完整名称,再次拼接主域会产生多余后缀;在多级后缀(如.com.cn)下,自行截取主域容易出错 - 清理要按「记录名 + 值」精确定位:同一记录名下可能存在多条 TXT 记录(并发申请或上一轮残留),按名称批量清空会删除其他证书正在使用的记录。cleanup 在验证成功与失败时都会执行,记录已不存在时应视为清理成功
腾讯云 DNSPod
本脚本调用 dnspod.tencentcloudapi.com(云 API 3.0),使用 CAM 子账号凭证,权限可限定为解析记录的读写。脚本已实现 TC3-HMAC-SHA256 签名。
sh
#!/bin/sh
# certiman DCV hook:腾讯云 DNSPod(云 API 3.0,TC3-HMAC-SHA256 签名)。
#
# present 与 cleanup 共用本文件,靠 CERTIMAN_HOOK_TYPE 分流:
# cert.yaml:
# dcv:
# method: dns
# present: /home/<用户名>/.certiman/hooks/tencentcloud.sh
# cleanup: /home/<用户名>/.certiman/hooks/tencentcloud.sh
#
# 依赖:curl、openssl、jq
# 试运行:certiman hook test -d <域名> --step present
set -eu
# ── 改这里 ────────────────────────────────────────────────────────────────
# 访问管理(CAM)建子账号,授权策略 QcloudDNSPodFullAccess,取它的 SecretId/SecretKey。
# 密钥以明文存于文件,须对本脚本执行 chmod 600。
SECRET_ID="AKIDxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
SECRET_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# 记录布置完到全网可查之间的等待秒数
WAIT_SECONDS=20
# ──────────────────────────────────────────────────────────────────────────
HOST="dnspod.tencentcloudapi.com"
SERVICE="dnspod"
VERSION="2021-03-23"
REGION="" # DNSPod 是全局服务,不需要地域
sha256_hex() {
printf '%s' "$1" | openssl dgst -sha256 -hex | sed 's/^.*= *//'
}
# hmac_hex 用十六进制密钥算 HMAC-SHA256,输出十六进制。
# 签名链的中间结果是二进制,在 shell 里只能靠 hex 往下传
hmac_hex() {
printf '%s' "$2" | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$1" -hex |
sed 's/^.*= *//'
}
hmac_hex_strkey() {
printf '%s' "$2" | openssl dgst -sha256 -mac HMAC -macopt "key:$1" -hex |
sed 's/^.*= *//'
}
# GNU date 用 -d @ts,BSD/macOS 用 -r ts,两种都试
utc_date() {
date -u -d "@$1" +%Y-%m-%d 2>/dev/null || date -u -r "$1" +%Y-%m-%d
}
# tc_api 调一个云 API 接口,输出 Response 段。
#
# 签名步骤照 TC3-HMAC-SHA256 规范:规范请求串 → 待签字符串 → 逐级派生密钥 → 签名。
# 任何一环差一个字节都只会得到"鉴权失败",错误信息指不出是哪一步,所以别改动这段。
tc_api() {
action="$1"
payload="$2"
timestamp=$(date -u +%s)
datestamp=$(utc_date "$timestamp")
action_lower=$(printf '%s' "$action" | tr '[:upper:]' '[:lower:]')
content_type="application/json; charset=utf-8"
signed_headers="content-type;host;x-tc-action"
canonical_headers="content-type:${content_type}
host:${HOST}
x-tc-action:${action_lower}
"
canonical_request="POST
/
${canonical_headers}
${signed_headers}
$(sha256_hex "$payload")"
credential_scope="${datestamp}/${SERVICE}/tc3_request"
string_to_sign="TC3-HMAC-SHA256
${timestamp}
${credential_scope}
$(sha256_hex "$canonical_request")"
secret_date=$(hmac_hex_strkey "TC3${SECRET_KEY}" "$datestamp")
secret_service=$(hmac_hex "$secret_date" "$SERVICE")
secret_signing=$(hmac_hex "$secret_service" "tc3_request")
signature=$(hmac_hex "$secret_signing" "$string_to_sign")
authorization="TC3-HMAC-SHA256 Credential=${SECRET_ID}/${credential_scope}, SignedHeaders=${signed_headers}, Signature=${signature}"
resp=$(curl -fsS "https://${HOST}" \
-H "Authorization: ${authorization}" \
-H "Content-Type: ${content_type}" \
-H "Host: ${HOST}" \
-H "X-TC-Action: ${action}" \
-H "X-TC-Version: ${VERSION}" \
-H "X-TC-Timestamp: ${timestamp}" \
${REGION:+-H "X-TC-Region: ${REGION}"} \
-d "$payload")
# 云 API 的业务错误也走 HTTP 200,只在 Response.Error 里说明,curl -f 拦不住
err=$(printf '%s' "$resp" | jq -r '.Response.Error.Code // empty')
if [ -n "$err" ]; then
printf '%s 失败: %s - %s\n' "$action" "$err" \
"$(printf '%s' "$resp" | jq -r '.Response.Error.Message')" >&2
# 签名带 5 分钟时间窗,机器时钟偏了同样报鉴权失败,而错误信息只说签名不对
case "$err" in
AuthFailure*)
echo " 排查:密钥是否正确、子账号有没有 DNSPod 权限、本机时钟是否准(date -u)" >&2
;;
esac
return 1
fi
printf '%s' "$resp" | jq '.Response'
}
json_str() {
printf '%s' "$1" | jq -Rs .
}
case "${CERTIMAN_HOOK_TYPE:-}" in
present)
# 主域与主机记录均由 certiman 计算,不要自行拼接
payload=$(printf '{"Domain":%s,"SubDomain":%s,"RecordType":%s,"RecordLine":"默认","Value":%s,"TTL":600}' \
"$(json_str "$CERTIMAN_DCV_MAIN_DOMAIN")" \
"$(json_str "$CERTIMAN_DCV_HOST")" \
"$(json_str "$CERTIMAN_DCV_TYPE")" \
"$(json_str "$CERTIMAN_DCV_VALUE")")
tc_api CreateRecord "$payload" >/dev/null
echo "已添加 ${CERTIMAN_DCV_TYPE} ${CERTIMAN_DCV_FQDN}"
# 最后一个域名布置完才统一等传播
if [ "${CERTIMAN_REMAINING:-1}" = "0" ]; then
echo "等待 ${WAIT_SECONDS}s 让记录生效"
sleep "$WAIT_SECONDS"
fi
;;
cleanup)
# 按记录名 + 值精确定位:同一个名下可能有多条 TXT
payload=$(printf '{"Domain":%s,"Subdomain":%s,"RecordType":%s}' \
"$(json_str "$CERTIMAN_DCV_MAIN_DOMAIN")" \
"$(json_str "$CERTIMAN_DCV_HOST")" \
"$(json_str "$CERTIMAN_DCV_TYPE")")
# 一条都没有时接口报 ResourceNotFound.NoDataOfRecord,那正是"无需清理"
records=$(tc_api DescribeRecordList "$payload" 2>/dev/null) || {
echo "记录已不存在,无需清理"
exit 0
}
ids=$(printf '%s' "$records" |
jq -r --arg v "$CERTIMAN_DCV_VALUE" '.RecordList[]? | select(.Value == $v) | .RecordId')
if [ -z "$ids" ]; then
echo "记录已不存在,无需清理"
exit 0
fi
for id in $ids; do
del=$(printf '{"Domain":%s,"RecordId":%s}' \
"$(json_str "$CERTIMAN_DCV_MAIN_DOMAIN")" "$id")
tc_api DeleteRecord "$del" >/dev/null
echo "已删除记录 ${id}(${CERTIMAN_DCV_FQDN})"
done
;;
*)
echo "未知的 CERTIMAN_HOOK_TYPE: ${CERTIMAN_HOOK_TYPE:-<空>}" >&2
exit 2
;;
esacCloudflare
在 Cloudflare 控制台创建 API Token,权限设为 Zone → DNS → Edit。不要使用 Global API Key,该密钥拥有账号的全部权限。
sh
#!/bin/sh
# certiman DCV hook:Cloudflare(API v4)。
#
# 依赖:curl、jq
# 试运行:certiman hook test -d <域名> --step present
set -eu
# ── 改这里 ────────────────────────────────────────────────────────────────
API_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
WAIT_SECONDS=20
# ──────────────────────────────────────────────────────────────────────────
API="https://api.cloudflare.com/client/v4"
# cf_api 发一个请求并返回 result 段;success 为 false 时退非 0。
cf_api() {
method="$1"
path="$2"
shift 2
resp=$(curl -fsS -X "$method" "${API}${path}" \
-H "Authorization: Bearer ${API_TOKEN}" \
-H "Content-Type: application/json" "$@")
if [ "$(printf '%s' "$resp" | jq -r '.success')" != "true" ]; then
printf 'Cloudflare %s %s 失败: %s\n' "$method" "$path" \
"$(printf '%s' "$resp" | jq -c '.errors')" >&2
return 1
fi
printf '%s' "$resp" | jq '.result'
}
# CERTIMAN_DCV_MAIN_DOMAIN 由 certiman 算好,多级后缀(.com.cn)也已经处理过
zone_id() {
id=$(cf_api GET "/zones?name=${CERTIMAN_DCV_MAIN_DOMAIN}" | jq -r '.[0].id // empty')
if [ -z "$id" ]; then
echo "在这个 Cloudflare 账号下找不到 ${CERTIMAN_DCV_MAIN_DOMAIN}" >&2
return 1
fi
printf '%s' "$id"
}
case "${CERTIMAN_HOOK_TYPE:-}" in
present)
zid=$(zone_id)
# 用 jq -n 拼 JSON 而不是直接把变量插进字符串:记录值里一个引号就能破坏整个
# 请求体,而报回来的只是一句语法错误
payload=$(jq -n \
--arg type "$CERTIMAN_DCV_TYPE" \
--arg name "$CERTIMAN_DCV_FQDN" \
--arg content "$CERTIMAN_DCV_VALUE" \
'{type: $type, name: $name, content: $content, ttl: 60}')
cf_api POST "/zones/${zid}/dns_records" --data "$payload" >/dev/null
echo "已添加 ${CERTIMAN_DCV_TYPE} ${CERTIMAN_DCV_FQDN}"
if [ "${CERTIMAN_REMAINING:-1}" = "0" ]; then
echo "等待 ${WAIT_SECONDS}s 让记录生效"
sleep "$WAIT_SECONDS"
fi
;;
cleanup)
zid=$(zone_id)
query="type=${CERTIMAN_DCV_TYPE}&name=${CERTIMAN_DCV_FQDN}"
ids=$(cf_api GET "/zones/${zid}/dns_records?${query}" |
jq -r --arg v "$CERTIMAN_DCV_VALUE" '.[] | select(.content == $v) | .id')
if [ -z "$ids" ]; then
echo "记录已不存在,无需清理"
exit 0
fi
for id in $ids; do
cf_api DELETE "/zones/${zid}/dns_records/${id}" >/dev/null
echo "已删除记录 ${id}(${CERTIMAN_DCV_FQDN})"
done
;;
*)
echo "未知的 CERTIMAN_HOOK_TYPE: ${CERTIMAN_HOOK_TYPE:-<空>}" >&2
exit 2
;;
esac阿里云 DNS
本脚本通过阿里云官方 CLI 调用 API。
准备:
- 安装阿里云 CLI:https://help.aliyun.com/document_detail/121541.html
- 运行
aliyun configure --profile certiman配置 AccessKey,并为该 AccessKey 所属的 RAM 用户授予AliyunDNSFullAccess权限
sh
#!/bin/sh
# certiman DCV hook:阿里云 DNS(经官方 aliyun CLI)。
#
# 依赖:aliyun、jq
# 试运行:certiman hook test -d <域名> --step present
set -eu
# ── 改这里 ────────────────────────────────────────────────────────────────
# aliyun configure 时用的 profile 名;留空则用默认 profile。
PROFILE="certiman"
# 阿里云免费版 TTL 下限 600s,比 DNSPod 慢
WAIT_SECONDS=30
# ──────────────────────────────────────────────────────────────────────────
command -v aliyun >/dev/null 2>&1 || {
echo "找不到 aliyun CLI;见脚本头部的安装说明" >&2
exit 127
}
# --profile 只在 PROFILE 非空时才传,传空字符串会让 CLI 去找一个名字为空的 profile
alidns() {
if [ -n "$PROFILE" ]; then
aliyun alidns --profile "$PROFILE" "$@"
else
aliyun alidns "$@"
fi
}
case "${CERTIMAN_HOOK_TYPE:-}" in
present)
# RR 是主机记录(相对主域的那一截),DomainName 是主域。两者都由 certiman 算好
alidns AddDomainRecord \
--DomainName "$CERTIMAN_DCV_MAIN_DOMAIN" \
--RR "$CERTIMAN_DCV_HOST" \
--Type "$CERTIMAN_DCV_TYPE" \
--Value "$CERTIMAN_DCV_VALUE" \
--TTL 600 >/dev/null
echo "已添加 ${CERTIMAN_DCV_TYPE} ${CERTIMAN_DCV_FQDN}"
if [ "${CERTIMAN_REMAINING:-1}" = "0" ]; then
echo "等待 ${WAIT_SECONDS}s 让记录生效"
sleep "$WAIT_SECONDS"
fi
;;
cleanup)
# 按记录名 + 值精确定位
records=$(alidns DescribeSubDomainRecords \
--SubDomain "$CERTIMAN_DCV_FQDN" \
--Type "$CERTIMAN_DCV_TYPE" 2>/dev/null) || {
echo "查不到记录,无需清理"
exit 0
}
ids=$(printf '%s' "$records" |
jq -r --arg v "$CERTIMAN_DCV_VALUE" '.DomainRecords.Record[]? | select(.Value == $v) | .RecordId')
if [ -z "$ids" ]; then
echo "记录已不存在,无需清理"
exit 0
fi
for id in $ids; do
alidns DeleteDomainRecord --RecordId "$id" >/dev/null
echo "已删除记录 ${id}(${CERTIMAN_DCV_FQDN})"
done
;;
*)
echo "未知的 CERTIMAN_HOOK_TYPE: ${CERTIMAN_HOOK_TYPE:-<空>}" >&2
exit 2
;;
esac接新服务商的模板
只需实现 add_record 与 remove_record 两个函数。执行分支判断、DNS 传播等待与错误处理已在模板中实现。
sh
#!/bin/sh
# certiman DCV hook 模板:接一家新的 DNS 服务商时从这里抄。
#
# 只有两处要填:add_record 与 remove_record。其余(分流、等传播、错误处理)已经写好。
set -eu
# ── 改这里 ────────────────────────────────────────────────────────────────
API_TOKEN="填入实际令牌"
WAIT_SECONDS=30
# ──────────────────────────────────────────────────────────────────────────
# add_record 往 DNS 上加一条记录。失败请退非 0。
add_record() {
# TODO 换成实际的服务商 API 调用
echo "TODO: 添加 ${CERTIMAN_DCV_TYPE} ${CERTIMAN_DCV_FQDN} = ${CERTIMAN_DCV_VALUE}" >&2
return 1
}
# remove_record 删掉本轮加的那条记录。
#
# 务必按「记录名 + 值」精确定位,不要按名称批量清空:同一名称下可能有多条 TXT
# (并发申请、上一轮的残留),清空会把别人正在用的记录一起删掉。
# 记录已经不在了要当成功——重复清理是常态,cleanup 会在验证成败两种情形下都执行。
remove_record() {
# TODO 换成实际的服务商 API 调用
echo "TODO: 删除 ${CERTIMAN_DCV_TYPE} ${CERTIMAN_DCV_FQDN} = ${CERTIMAN_DCV_VALUE}" >&2
return 1
}
case "${CERTIMAN_HOOK_TYPE:-}" in
present)
add_record
echo "已添加 ${CERTIMAN_DCV_TYPE} ${CERTIMAN_DCV_FQDN}"
# 放在 REMAINING=0 这一支里,多域名证书只等一次而不是每个域名都等
if [ "${CERTIMAN_REMAINING:-1}" = "0" ]; then
echo "等待 ${WAIT_SECONDS}s 让记录生效"
sleep "$WAIT_SECONDS"
fi
;;
cleanup)
# cleanup 失败在 certiman 里只警告、不让整次执行失败:清理没做干净顶多留下
# 一条无用的 TXT,而把它当失败会让一次已经成功签发的执行退出码非 0
remove_record || echo "清理失败,可能需要手工删除 ${CERTIMAN_DCV_FQDN}" >&2
;;
*)
echo "未知的 CERTIMAN_HOOK_TYPE: ${CERTIMAN_HOOK_TYPE:-<空>}" >&2
exit 2
;;
esac