Hateburo: kazeburo hatenablog

Operations Engineer / 運用系小姑 / Perl Monger

dehydrated とさくらのクラウドのDNS機能をつかってワイルドカード・マルチドメイン証明書取得

dehydrated はshell scriptでできた letsencrypt/acme のクライアントです。certbotlegoなど様々なツールがある中で、わりと長いこと使ってますが、安定してワイルドカード・マルチドメイン対応の証明書の取得・更新ができています。

github.com

dehydratedとさくらのクラウドDNS機能と先週末つくったクライアント sacloudns を使うことで、ほぼ自動的に証明書の取得と更新が行えるので、その紹介です。

github.com

こちらのツールは、リリースページからのダウンロードもしくはMacなら homebrew でインストールが可能です。

% brew install kazeburo/tap/sacloudns

dehydrated の準備

dehydrated はGitHubからcloneしてきます

$ git clone https://github.com/dehydrated-io/dehydrated.git
$ cd dehydrated

そして設定ファイルを用意します。

まず、 config ファイルを作成

CERTDIR="${BASEDIR}/certs"
ACCOUNTDIR="${BASEDIR}/accounts"
HOOK="${BASEDIR}/hook.sh"
HOOK_CHAIN="no"
CHALLENGETYPE="dns-01"
KEY_ALGO="prime256v1"

configのサンプルは docs/example 以下にあります。KEY_ALGOにprime256v1を指定することで楕円曲線暗号であるECDSAを使った証明書を作成できます。デフォルトはRSAになります

つぎに証明書を作成するドメイン一覧を記した domains.txt を作ります

# スペース区切りで複数のドメインを記せます。ワイルドカードも使えますが行頭にはかけません
example.com *.example.com example.jp *.example.jp > certalias
chocon.me *.chocon.me

この記事で紹介する方法で証明書を作成する場合、こちらのドメインはすべてさくらのクラウドDNS機能で管理されている必要があります。試してはないですが、Let's Encryptではマルチドメイン証明書のSAN(Subject Alternative Name)に100個までFQDNを追加することができるようです。

そして最後に、dns-01 で利用するDNSレコードの作成などを行う hook.sh を用意します。

こちらはAmazon Route 53のクライアント、 cli53 を使う以下コードを参考にして作りました

https://github.com/whereisaaron/dehydrated-route53-hook-script/blob/master/hook.sh

#!/bin/bash
set -e
# This hook script is written based on dehydrated-route53-hook-script
# https://github.com/whereisaaron/dehydrated-route53-hook-script

deploy_challenge() {
    local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}"

    local ZONE=$(find_zone "${DOMAIN}")
    
    if [[ -n "$ZONE" ]]; then
        echo "Creating challenge record for ${DOMAIN} in zone ${ZONE}"
        sacloudns radd --wait --zone ${ZONE} --name _acme-challenge.${DOMAIN}. --ttl 60 --type TXT --data ${TOKEN_VALUE}
    else
        echo "Could not find zone for ${DOMAIN}"
        exit 1
    fi
}

clean_challenge() {
    local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}"

    local ZONE=$(find_zone "${DOMAIN}")
    
    if [[ -n "$ZONE" ]]; then
        echo "Deleting challenge record for ${DOMAIN} from zone ${ZONE}"
        sacloudns rdelete --zone ${ZONE} --name _acme-challenge.${DOMAIN}. --type TXT --data ${TOKEN_VALUE}
    else
        echo "Could not find zone for ${DOMAIN}"
        exit 1
    fi
}

deploy_cert() {
    local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}"
    # NOP
}

unchanged_cert() {
    local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}"
    # NOP
}

function invalid_challenge {
    local DOMAIN="${1}" RESPONSE="${2}"

    local HOSTNAME="$(hostname)"

    (>&2 echo "Failed to issue SSL cert for ${DOMAIN}: ${RESPONSE}")
}

function get_base_name() {
    local HOSTNAME="${1}"

    if [[ "$HOSTNAME" == *"."* ]]; then
      HOSTNAME="${HOSTNAME#*.}"
      echo "$HOSTNAME"
      return 0
    else
      echo ""
      return 1
    fi
}

function find_zone() {
  local DOMAIN="${1}"

  local ZONELIST=$(sacloudns list | jq -r '.DNS[].DNSZone'|xargs echo -n)

  local TESTDOMAIN="${DOMAIN}"

  while [[ -n "$TESTDOMAIN" ]]; do
    for zone in $ZONELIST; do
      if [[ "$zone" == "$TESTDOMAIN" ]]; then
        echo "$zone"
        return 0
      fi
    done
    TESTDOMAIN=$(get_base_name "$TESTDOMAIN")
  done

  return 1
}

#
# This hook is called at the end of a dehydrated command and can be used
# to do some final (cleanup or other) tasks.
#
exit_hook() {
  :
}

HANDLER="$1"; shift
if [[ "${HANDLER}" =~ ^(deploy_challenge|clean_challenge|deploy_cert|unchanged_cert|invalid_challenge|request_failure|exit_hook)$ ]]; then
  "$HANDLER" "$@"
fi

さくらのクラウドAPIキーを環境変数を設定

さくらのクラウドのコントロールパネルからAPIキーを作成し、SAKURACLOUD_ACCESS_TOKENSAKURACLOUD_ACCESS_TOKEN_SECRET環境変数に設定します。

export SAKURACLOUD_ACCESS_TOKEN=xxx
export SAKURACLOUD_ACCESS_TOKEN_SECRET=yyy

sacloudns は作業ディレクトリに、.env ファイルを作成し、

SAKURACLOUD_ACCESS_TOKEN=xxx
SAKURACLOUD_ACCESS_TOKEN_SECRET=yyy

のように記すと、コマンド実行時に自動で読み込むこともできます。

dehydrated の実行

準備ができたのでdehydratedを実行

# 初回はアカウントの作成が必要
$ ./dehydrated --register --accept-terms -f config 
$ ./dehydrated -c -f config 

これで、DNSレコードの作成と検証が行われたのち、証明書が certs ディレクトリ以下に作成されます。

% ls -l certs/chocon.me 
cert-1612763291.csr
cert-1612763291.pem
cert.csr -> cert-1612763291.csr
cert.pem -> cert-1612763291.pem
chain-1612763291.pem
chain.pem -> chain-1612763291.pem
fullchain-1612763291.pem
fullchain.pem -> fullchain-1612763291.
privkey-1612763291.pem
privkey.pem -> privkey-1612763291.pem

証明書は nginx や Apache などのWebサーバや各クラウドにアップロードして使うこともできます。

おまけ: さくらのクラウド エンハンスドロードバランサーへの証明書アップロード

取得した証明書をさくらのクラウドのエンハンスドロードバランサーに設定してみます。

エンハンスドロードバランサーとは大規模なHTTP/HTTPSサービスに最適な高性能・高機能なロードバランサアプライアンスです。詳しくはこちら

manual.sakura.ad.jp

こちらも参考になります。

qiita.com

エンハンスドロードバランサーの構築が済んでいるといるとして、以下のようにすると証明書が登録できます。

$ cat template.jq
{
  "ProxyLB": {
    "PrimaryCert": {
      "ServerCertificate": $ServerCertificate,
      "IntermediateCertificate": $IntermediateCertificate,
      "PrivateKey": $PrivateKey
    },
    "AdditionalCerts": []
  }
}
$ jq -n \
-f template.jq 
--rawfile ServerCertificate certs/chocon.me/cert.pem \
--rawfile IntermediateCertificate certs/chocon.me/chain.pem \
--rawfile PrivateKey certs/chocon.me/privkey.pem \
| \
curl -d @- -X PUT \
-H "Content-Type: application/json" \
--user $SAKURACLOUD_ACCESS_TOKEN:$SAKURACLOUD_ACCESS_TOKEN_SECRET \
https://secure.sakura.ad.jp/cloud/zone/is1a/api/cloud/1.1/commonserviceitem/${リソース番号}/proxylb/sslcertificate

jqのテンプレート機能と、rawfileオプションとても便利ですね!

なお、この方法で証明書を登録した場合は、let's encryptでの証明書取得・更新機能は停止しておく必要があります。

エンハンスドロードバランサーのlet's encryptを利用した証明書取得では、1つのロードバランサーあたり、1個のドメインの証明書しか作れませんが、この方法だとそれよりも多くの証明書を登録できるので、たくさんのドメインを利用される場合は便利かもしれません。

最後に、ここまでの方法を一つのシェルスクリプトにまとめるなど自動化しておくのがいいでしょう。

何かのお役に立てれば幸いです。