Skip to content

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
CloudflareCloudflare(API v4)curl、jq
阿里云 DNS阿里云 DNS官方 aliyun CLI、jq
接新服务商的模板只需实现 add_record / remove_record 两个函数—

先确认是否需要 DNS hook

CertiMan 有两种不写脚本的验证方式:

  • dns-proxy:为验证记录名配置一条指向平台的 CNAME,后续续期无需修改 DNS
  • dns-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
    ;;
esac

Cloudflare ​

在 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。

准备:

  1. 安装阿里云 CLI:https://help.aliyun.com/document_detail/121541.html
  2. 运行 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

相关文档 ​