May 17th, 2016

letsencrypt.sh for Let's Encrypt DNS challenge

無料でSSL証明書を発行してくれるサービスのletsencryptには、DNSでドメイン認証を行う方法がありサーバ側は既に対応済みだが、公式のクライアントはまだ問題があるらしく対応していない。公式の代わりにLukas Schauer氏のletsenrypt.shがDNS認証に対応してあるので、それを使ってLetsEncrypt証明書の管理をしている。その備忘録。

hookスクリプト

letsencrypt.shのDNS認証部分については、大まかに下記のような流れになっている。

  • letsencryptのサーバにCommonNameを申請
  • letsencryptのサーバからDNSのテキストレコードに登録する値を取得
  • 取得した値を_acme-challenge.COMMON-NAME.という名前でテキストレコードとしてDNSに登録
  • 登録されたレコードをletsencryptが確認
  • 確認ができれば証明書を発行

この処理について別途追加する必要があり、それぞれのDNS事情にあった認証を行えるようになっている。有名どころなDNSプロバイダについては、有志によってスクリプトが作成されているが、そこからリンクされているroute53のrubyスクリプトはサブドメインを渡した場合に不具合があるので、そのままでは動かなかった。
利用してるものは、もう少し手を加えているが、動くように修正したものが下記。

#!/usr/bin/env ruby

require 'aws-sdk'
require 'domain_name'

# ------------------------------------------------------------------------------
#   Credentials
# ------------------------------------------------------------------------------
# pick up AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY by default from
# environment
Aws.config.update({
  region: 'ap-northeast-1',
})
# ------------------------------------------------------------------------------

def setup_dns(common_name, domain, txt_challenge)
  route53 = Aws::Route53::Client.new()
  hosted_zone = route53.list_hosted_zones_by_name(
      {dns_name: "#{domain}."}).hosted_zones[0]
  changes = []
  changes << {
    action: "UPSERT",
    resource_record_set: {
      name: "_acme-challenge.#{common_name}.",
      type: "TXT",
      ttl: 60,
      resource_records: [
        value: "\"#{txt_challenge}\"",
      ],
    },
  }
  resp = route53.change_resource_record_sets({
    hosted_zone_id: hosted_zone.id,
    change_batch: {
      changes: changes,
    },
  })
  sleep 20
end

def delete_dns(common_name, domain, txt_challenge)
  route53 = Aws::Route53::Client.new()
  hosted_zone = route53.list_hosted_zones_by_name(
      {dns_name: "#{domain}."}).hosted_zones[0]
  changes = []
  changes << {
    action: "DELETE",
    resource_record_set: {
      name: "_acme-challenge.#{common_name}.",
      type: "TXT",
      ttl: 60,
      resource_records: [
        value: "\"#{txt_challenge}\"",
      ],
    },
  }
  resp = route53.change_resource_record_sets({
    hosted_zone_id: hosted_zone.id,
    change_batch: {
      changes: changes,
    },
  })
  sleep 10
end

if __FILE__ == $0
  hook_stage = ARGV[0]
  common_name = ARGV[1]
  txt_challenge = ARGV[3]

  domain = DomainName(common_name).domain

  puts "   hook_stage: #{hook_stage}"
  puts "  common_name: #{common_name}"
  puts "       domain: #{domain}"
  puts "txt_challenge: #{txt_challenge}"

  if hook_stage == "deploy_challenge"
    setup_dns(common_name, domain, txt_challenge)
  elsif hook_stage == "clean_challenge"
    delete_dns(common_name, domain, txt_challenge)
  end

end

rubygemのaws-sdk最新版とdomain_nameが必要。

このスクリプトではhook_stagedeploy_challengeclean_challengeの場合の処理しか無いが、証明書作成ができた時点にもhook_stagedeploy_certが指定され、txt_challengeに作成された証明書のpathが指定されて、このhookスクリプトが呼ばれる。

letsencrypt.sh 利用方法

letsencrypt.shのインストールおよび利用方法について、簡単にまとめておく。 /etc/letsencrypt.shを基準とするようにしてある。

cd /etc
git clone https://github.com/lukas2511/letsencrypt.sh
cd letsencrypt.sh

同じディレクトリ上にconfig.shがあれば、スクリプト実行時にデフォルト値として動作するようになっているので、作成しておく。 CHALLENGETYPEをdns-01、RENEW-DAYSは45日、秘密鍵は毎回再作成、hookスクリプトの指定を行っている。

CA="https://acme-v01.api.letsencrypt.org/directory"
LICENSE="https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf"
CHALLENGETYPE="dns-01"
HOOK="/etc/letsencrypt.sh/hook.rb"
HOOK_CHAIN="no"
RENEW_DAYS="45"
ACCOUNT_KEY="/etc/letsencrypt.sh/private_key.pem"
ACCOUNT_KEY_JSON="/etc/letsencrypt.sh/private_key.json"
KEYSIZE="4096"
WELLKNOWN="/etc/letsencrypt.sh/.acme-challenges"
PRIVATE_KEY_RENEW="yes"
OPENSSL_CNF="/usr/lib/ssl/openssl.cnf"
CONTACT_EMAIL=""
LOCKFILE="/etc/letsencrypt.sh/lock"

同様にdomains.txtが同じディレクトリにあれば、記載してあるコモンネームを利用するようになっていて、1行に1証明書で作成される。1行に複数のコモンネームを指定可能で、複数のコモンネームを1枚の証明書で作成できるようになっている。以下は実際に作ったサンプル。

d6rkaiz.com mta.d6rkaiz.com blog.d6rkaiz.com
deny.jp mta.deny.jp

実際の運用には下記のようなスクリプトを作成し、cronで毎月1回実行している。

#!/bin/bash

PATH=/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
LEDIR=/etc/letsencrypt.sh

set -e
tagname="letsencrypt_renew"

${LEDIR}/letsencrypt.sh -c -x | logger -t ${tagname}
${LEDIR}/letsencrypt.sh -gc | logger -t ${tagname}

svcs="nginx postfix dovecot"
for svc in ${svcs}
do
  service $svc restart | logger -t ${tagname}
done