Linux 服务器安装配置 acme.sh 自动申请证书(Cloudflare DNS 验证、最小化权限要求)

2020-01-26

acme.sh 是一组脚本辅助我们在 Linux 系统上申请有 acme api 的证书提供商的证书。Let's Encrypt 通配符域名需要使用 dns 记录验证,而 acme.sh 提供的 cloudflare 更新脚本获取了过多不需要的权限,在本文中我们通过替换相应脚本实现了最小化权限要求。

!! 通过 DNS 方式验证域名申请证书存在着一定的安全风险,在服务器被攻破的情况下,攻击者可以获取到用于 dns 更新的凭据,进而劫持 dns ,修改域名邮箱相关记录,指向恶意服务器,导致对域名等的控制权完全丧失。使用本文的方法不可以规避这种攻击。该攻击可通过用 CNAME 指向一个特定的用于验证的域名来缓解,参见 DNS alias mode - acme.sh ,或者对重要账户不使用域名邮箱。

Cloudflare 提供有 多账户功能 ,可以实现下述功能,但是由于细化权限多账户功能是 Enterprise 专有的,而我是一个穷人,所以...

至于为什么这么复杂,LE 社区上有一篇相关的帖子 Why has DNS-01 to be such complicated? - Let's Encrypted Community ,欢迎对现状不满的人上去反馈(微笑)。LE 因为安全问题(确保每次申请都是申请人想要的)不支持静态 acme challenge,故每次申请都需要重新设置相关的 txt 记录。 正如你在上文看到的,LE 支持 CNAME 方式将 acme challenge 委托给另外一个域,假设有一个域名提供商,可以提供一个很方便的 API 更新记录,将 acme challenge 委托给另外一个域,和提供静态解析相比,除了提高了很多不必要的复杂度以外,并不能提高安全性。

想要一个更简单的证书颁发机制,参阅 一种可能更好的加密互联网的方式——DANE

安装

install.sh (root)

#!/bin/bash

pushd /tmp
curl -L -o acme.sh-master.tar.gz "https://github.com/Neilpang/acme.sh/archive/master.tar.gz"
tar xzf acme.sh-master.tar.gz
pushd acme.sh-master
rm -rf /opt/acme.sh
mkdir /opt/acme.sh
./acme.sh --install --home /opt/acme.sh --noprofile
popd
popd

# 修改 Cloudflare DNS 更新脚本,最小化权限要求(对特定域名的 edit 权限)
cat << 'EOF' > /opt/acme.sh/dnsapi/dns_cf.sh
#!/usr/bin/env sh

# Config Envs
CF_Amount=100
# CF_1_Zone_Name="domain.com"
# CF_1_Zone_ID="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
# CF_1_Token="xxxx"
# CF_2_Zone_Name="domain2.com"
# CF_2_Zone_ID="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
# CF_2_Token="yyyy"
# ...


CF_API="https://api.cloudflare.com/client/v4"

_cf_rest() {
  m=$1
  ep="$2"
  data="$3"
  _debug "$ep"

  export _H1="Content-Type: application/json"
  export _H2="Authorization: Bearer $_token"

  if [ "$m" != "GET" ]; then
    _debug data "$data"
    response="$(_post "$data" "$CF_API/$ep" "" "$m")"
  else
    response="$(_get "$CF_API/$ep")"
  fi

  if [ "$?" != "0" ]; then
    _err "error $ep"
    return 1
  fi
  _debug2 response "$response"
  return 0
}

# Usage:
#   _get_root _acme-challenge.www.domain.com 
#     -> _record_name=_acme-challenge.www _zone_name=domain.com _zone_id=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa _token=xxxx
# domain -> _record_name _zone_name _zone_id _token
_get_root() {
  domain=$1
  i=1
  while [ "$i" -le "$CF_Amount" ]; do
    eval "_zone_name=\"\${CF_${i}_Zone_Name:-\$(_readaccountconf_mutable CF_${i}_Zone_Name)}\""
    eval "_zone_id=\"\${CF_${i}_Zone_ID:-\$(_readaccountconf_mutable CF_${i}_Zone_ID)}\""
    eval "_token=\"\${CF_${i}_Token:-\$(_readaccountconf_mutable CF_${i}_Token)}\""

    if [ -z _zone_name ]; then i=$(_math "$i" + 1) && continue; fi

    # no possibility to see record_name=@
    _record_name=$(basename $domain $_zone_name)
    if [ "$_record_name$_zone_name" != "$domain" ]; then i=$(_math "$i" + 1) && continue; fi

    eval "_saveaccountconf_mutable CF_${i}_Zone_Name \"\$_zone_name\""
    eval "_saveaccountconf_mutable CF_${i}_Zone_ID \"\$_zone_id\""
    eval "_saveaccountconf_mutable CF_${i}_Token \"\$_token\""

    return 0
  done
  return 1
}



# Usage: dns_cf_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
# fulldomain txtvalue
dns_cf_add() {
  fulldomain=$1
  txtvalue=$2

  _debug "First detect the root zone"
  if ! _get_root "$fulldomain"; then
    _err "invalid domain"
    return 1
  fi
  _debug _zone_id "$_zone_id"
  _debug _record_name "$_record_name"
  _debug _zone_name "$_zone_name"

  _debug "Getting txt records"
  _cf_rest GET "zones/${_zone_id}/dns_records?type=TXT&name=$fulldomain"

  if ! printf "%s" "$response" | grep \"success\":true >/dev/null; then
    _err "Error"
    return 1
  fi

  # For wildcard cert, the main root domain and the wildcard domain have the same txt subdomain name, so
  # we can not use updating anymore.
  #  count=$(printf "%s\n" "$response" | _egrep_o "\"count\":[^,]*" | cut -d : -f 2)
  #  _debug count "$count"
  #  if [ "$count" = "0" ]; then
  _info "Adding record"
  if _cf_rest POST "zones/$_zone_id/dns_records" "{\"type\":\"TXT\",\"name\":\"$fulldomain\",\"content\":\"$txtvalue\",\"ttl\":120}"; then
    if _contains "$response" "$txtvalue"; then
      _info "Added, OK"
      return 0
    elif _contains "$response" "The record already exists"; then
      _info "Already exists, OK"
      return 0
    else
      _err "Add txt record error."
      return 1
    fi
  fi
  _err "Add txt record error."
  return 1
}

# fulldomain txtvalue
dns_cf_rm() {
  fulldomain=$1
  txtvalue=$2

  _debug "First detect the root zone"
  if ! _get_root "$fulldomain"; then
    _err "invalid domain"
    return 1
  fi
  _debug _zone_id "$_zone_id"
  _debug _record_name "$_record_name"
  _debug _zone_name "$_zone_name"

  _debug "Getting txt records"
  _cf_rest GET "zones/${_zone_id}/dns_records?type=TXT&name=$fulldomain&content=$txtvalue"

  if ! printf "%s" "$response" | grep \"success\":true >/dev/null; then
    _err "Error"
    return 1
  fi

  count=$(printf "%s\n" "$response" | _egrep_o "\"count\":[^,]*" | cut -d : -f 2)
  _debug count "$count"
  if [ "$count" = "0" ]; then
    _info "Don't need to remove."
  else
    _record_id=$(printf "%s\n" "$response" | _egrep_o "\"id\":\"[^\"]*\"" | cut -d : -f 2 | tr -d \" | head -n 1)
    _debug "_record_id" "$_record_id"
    if [ -z "$_record_id" ]; then
      _err "Can not get record id to remove."
      return 1
    fi
    if ! _cf_rest DELETE "zones/$_zone_id/dns_records/$_record_id"; then
      _err "Delete record error."
      return 1
    fi
    _contains "$response" '"success":true'
  fi
}
EOF

卸载

uninstall.sh (root)

#!/bin/bash

/opt/acme.sh/acme.sh --home /opt/acme.sh --uninstall
rm -rf /opt/acme.sh

创建 Token

Cloudflare Dash Table > My Profile > API Tokens > Create Token

Start with a template > Edit zone DNS (edit permission to specific zone)

Zone Resources > Select your domain (zone)

Continue to summary > Create Token

Copy the Token

复制 Zone 信息

进入到相应的域名(Zone)页面,复制 Zone ID,

申请证书

命令格式

(root)

# 每添加一个域名 CF_{num}_... 中的 num 需要 +1 ,最多 100 个,已经添加的可以去 /opt/acme.sh/account.conf 中查看
# Zone Name 一般是域名(一般不带 www www.net.cn 这种除外)
CF_1_Zone_Name="example.com" CF_1_Zone_ID="fffffffffffffffffffffffffffffffff" CF_1_Token="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" \
/opt/acme.sh/acme.sh --home /opt/acme.sh --issue --dns dns_cf -d example.com -d '*.example.com'

安装证书

(nginx)
(root)
/opt/acme.sh/acme.sh --home /opt/acme.sh --install-cert -d example.com --key-file /etc/ssl/private/example.com.key --fullchain-file /etc/ssl/certs/example.com.chained.cer --reloadcmd "service nginx force-reload"

上述命令会将文件复制到相应位置,并且在每次更新证书后重新启动服务。

但具体的配置仍然需要手动进行。

Ref

维护网站需要一定的开销,如果您认可这篇文章,烦请关闭广告屏蔽器浏览一下广告,谢谢!
加载中...

(。・∀・)ノ゙嗨,欢迎来到 lookas 的小站!

这里是 lookas 记录一些事情的地方,可能不时会有 lookas 的一些神奇的脑洞或是一些不靠谱的想法。

总之多来看看啦。