Let's EncryptのSSL証明書更新時にサービスを再起動する

Let's EncryptでSSLサーバー証明書を更新する際にはcronなどを使ってcertbot-auto renewを定期的に実行していれば証明書の残り有効期限が30日を切ると自動的に新しい証明書を取得してくれるのでとても便利ですが、一般的にSSLサーバー証明書が更新された場合には、その証明書を使っているサービスなどの再起動がも必要となるのですが、このサイト(Qiita)でも古い記事だと以下の様に--post-hookオプションでサービスの再起動をするように推奨している記事が多く見られますが、実は--post-hookだと証明書の更新の有無に関わらずパラメーターで指定した処理が行われてしまうため、場合によっては色々と不都合が起こります。

```shell:
# ./certbot-auto renew -q --no-self-upgrade --post-hook "service httpd restart"
```
証明書の更新に成功した場合にだけサービスの再起動を行いたいなら`--post-hook`ではなく`--deploy-hook`を使うべきでしょう。
`--deploy-hook`ならcertbotの実行が完了して、証明書が更新された後に一度だけ実行されるので、無駄なサービスの再起動などが発生しません。

```shell:
# ./certbot-auto renew -q --no-self-upgrade --deploy-hook "service httpd restart"
```

さらには最近のLet's Encrypt(certbot)では証明書更新時の追加動作に関する仕組みが拡張され、`/etc/letsencrypt/renewal-hooks/`以下のディレクトリ`(pre, post, deploy)`以下に実行ファイルを置くことで、証明書更新の処理を行う際にこれを実行してくれる様になりました。

```dir:
# ls /etc/letsencrypt/renewal-hooks/
deploy  post  pre
```

|ディレクトリ|処理のトリガー|
|:-------|:--------------------------------------------------------------------------|
|pre|certbotが**全部の**証明書更新の処理を始める前に**証明書の更新の有無に関わらず**必ず実行されます|
|post |certbotが**全部の**証明書更新の処理を終わった後で**証明書の更新の有無に関わらず**必ず実行されます|
|deploy|certbotが**個々の**証明書更新の処理を終わった後で**証明書の更新された場合にだけ**実行されます|

ここで注意が必要なのは、`pre`と`post`ディレクトリ以下の実行ファイルはcertbotコマンドの1回の実行につき1回づつだけ実行されることです。
Let's Encryptが管理するモモンネーム(ドメイン)が幾つあろうとも(これは`/etc/letsencrypt/renewal/`以下の設定ファイルで確認できます)1回だけです。

`pre`と`post`の場合とは異なり、`deploy`ディレクトリ以下の実行ファイルは更新する証明書毎(`/etc/letsencrypt/renewal/`以下のディレクトリの数に同じ)に実行されます。
また、この`/etc/letsencrypt/renewal-hooks/deploy`ディレクトリ以下に置かれた実行ファイルが呼び出される際には`RENEWED_LINEAGE`、`RENEWED_DOMAINS` の環境変数それぞれに「新しく発行された証明書や秘密鍵へのパス」と「処理が実行されたコモンネーム(ドメイン名)の一覧」が引き渡されるので、実行ファイル中ではこの値を利用して、更新された証明書が処理対象となるのかどうかの判定を行なうことができます。
この環境変数は、certbot-autoのコマンドラインオプションで--deploy-hookを指定してただ１つの実行ファイルを起動した場合にも利用可能です。

以下は、かなり大雑把な書き方をしていますが、Let's Encrypt(certbot)でpostfixの設定ファイル中のSSL証明書(`smtpd_tls_cert_file=`で指定)が更新された場合にpostfixを再起動するためのシェルスクリプト の例です。

```bash:/etc/letsencrypt/renewal-hooks/deploy/postfix
#!/bin/bash

postfix_domain=$(basename $(dirname `grep -h "^smtpd_tls_cert_file\s*=\s*" /etc/postfix/main.cf | sed -e 's/smtpd_tls_cert_file\s*=\s*//g'`))

for domain in $RENEWED_DOMAINS; do
  for postfix_domain in $postfix_domains; do
    if [ "$domain" = "$postfix_domain" ]; then
      echo "SSL certificate for SMTP (postfix) was modified."
      # Display remaining days of certificate.
      cert_file=$RENEWED_LINEAGE/$domain/cert.pem
      expire_date=`openssl x509 -text -in ${cert_file} -noout -dates|grep "Not After"|sed -e 's/.*Not After : //'`
      expire=`date -d "$expire_date" '+%s'`
      echo The remaining days are $expire days.
      # Restart postfix
      service postfix restart
      exit
    fi
  done
done
```

上記の例では環境変数RENEWED_LINEAGEを使った例を示すのに無理やり証明書の残り期限を表示するという無駄な処理が入れてありますが、例えばpostfixなどと組み合わせて使うことの多いdovecot用の再起動スクリプトなら以下の様な感じで書くことができます。

```bash:/etc/letsencrypt/renewal-hooks/deploy/devecot
#!/bin/bash

dovecot_domain=$(basename $(dirname `grep -h "^ssl_cert\s*=\s*" /etc/dovecot/conf.d/*.conf | sed -e 's/ssl_cert\s*=\s*<//g'`))
echo dovecot_domain: $dovecot_domain

for domain in $RENEWED_DOMAINS; do
  for dovecot_domain in $dovecot_domains; do
    if [ "$domain" = "$dovecot_domain" ]; then
      echo "SSL certificate for IMAP4 (dovecot) was modified."
      service dovecot restart
      exit
    fi
  done
done
```

Apacheなどもこんな感じで…
※このサンプルは、あくまでも自分の環境での設定ファイルの配置に基づいて書かれているので、実際には皆さんの環境に合わせて書き直す必要もあるでしょう。
設定ファイルの内容解析やコモンネーム(ドメイン名)の解析と抜き出しもかなり適当なので、その辺りは調整を(^-^;)

```bash:/etc/letsencrypt/renewal-hooks/deploy/apahe
#!/bin/bash

ssl_domains_config=/etc/httpd/conf.d/ssl.conf
https_domains=
for ssl_domain_config in $ssl_domains_config; do
  if [ -f "$ssl_domain_config" ]; then
    config_domains=`cat $ssl_domain_config | grep -v "\s*#" | grep -e "ServerName" -e "ServerAlias" | sed -e 's/.*ServerName\|.*ServerAlias//g' | sed -e 's/\(.*\):.*/\1/g' | xargs`
    if [ -z "$config_domains" ]; then
      config_domains=`hostname`
    fi
    https_domains="$https_domains $config_domains"
  fi
done

for domain in $RENEWED_DOMAINS; do
  for ssl_domain in $https_domains; do
    if [ "$domain" = "$ssl_domain" ]; then
      echo "SSL certificate for httpd was modified."
      service httpd restart
      exit
    fi
  done
done
```

TIPS
`/etc/letsencrypt/renewal-hooks/`以下に置いたスクリプトなどの実行ファイルのテストをするのにcertbotに--dry-runオプションを付けて実行しても`pre`、`post`のファイルは実行されますが、deploy以下のファイルは実行されないので注意が必要です。

`/etc/letsencrypt/renewal-hooks/deploy/`の実行ファイルに関してはダミーで環境変数をセットして手動で単体実行してテストするなどの工夫が必要です。

--dry-run renewの実行結果の例(コモンネームhostname1.hogehoge.hostとhostname2.hogehoge.hostの２つの証明書があり、hostname2.hogehoge.hostにはalias1.hogehoge.host〜alias3.hogehoge.hostの３つの別名があることを想定)

```
# ./certbot-auto --dry-run renew
Saving debug log to /var/log/letsencrypt/letsencrypt.log

-------------------------------------------------------------------------------
Processing /etc/letsencrypt/renewal/hostname1.hogehoge.host.conf
-------------------------------------------------------------------------------
Cert not due for renewal, but simulating renewal for dry run
Plugins selected: Authenticator webroot, Installer None
Running pre-hook command: /etc/letsencrypt/renewal-hooks/pre/pre_hook_sample
Output from pre_hook_sample:
Pre script
Renewed domains
Certs and Keys file path

Renewing an existing certificate
/opt/eff.org/certbot/venv/lib/python2.6/site-packages/acme/jose/jwa.py:110: DeprecationWarning: signer and verifier have been deprecated. Please use sign and verify instead.
  signer = key.signer(self.padding, self.hash)
Performing the following challenges:
http-01 challenge for hostname1.hogehoge.host
Waiting for verification...
Cleaning up challenges
Dry run: skipping deploy hook command: /etc/letsencrypt/renewal-hooks/deploy/deploy_hook_sample

-------------------------------------------------------------------------------
new certificate deployed without reload, fullchain is
/etc/letsencrypt/live/hostname1.hogehoge.host/fullchain.pem
-------------------------------------------------------------------------------

-------------------------------------------------------------------------------
Processing /etc/letsencrypt/renewal/hostname2.hogehoge.host.conf
-------------------------------------------------------------------------------
Cert not due for renewal, but simulating renewal for dry run
Plugins selected: Authenticator webroot, Installer None
Pre-hook command already run, skipping: /etc/letsencrypt/renewal-hooks/pre/pre_hook_sample
Renewing an existing certificate
Performing the following challenges:
http-01 challenge for hostname2.hogehoge.host
http-01 challenge for alias1.hogehoge.host
http-01 challenge for alias2.hogehoge.host
http-01 challenge for alias3.hogehoge.host
Waiting for verification...
Cleaning up challenges
Dry run: skipping deploy hook command: /etc/letsencrypt/renewal-hooks/deploy/deploy_hook_sample

-------------------------------------------------------------------------------
new certificate deployed without reload, fullchain is
/etc/letsencrypt/live/hostname2.hogehoge.host/fullchain.pem
-------------------------------------------------------------------------------

-------------------------------------------------------------------------------
** DRY RUN: simulating 'certbot renew' close to cert expiry
**          (The test certificates below have not been saved.)

Congratulations, all renewals succeeded. The following certs have been renewed:
  /etc/letsencrypt/live/hostname1.hogehoge.host/fullchain.pem (success)
  /etc/letsencrypt/live/hostname2.hogehoge.host/fullchain.pem (success)
** DRY RUN: simulating 'certbot renew' close to cert expiry
**          (The test certificates above have not been saved.)
-------------------------------------------------------------------------------
Running post-hook command: /etc/letsencrypt/renewal-hooks/post/post_hook_sample
Output from post_hook_sample:
Post script
Renewed domains
Certs and Keys file path
```
- /etc/letsencrypt/renewal-hooks/pre/pre_hook_sampleは最初に1回だけ実行される
- /etc/letsencrypt/renewal-hooks/deploy/deploy_hook_sampleは実行がスキップされる
- /etc/letsencrypt/renewal-hooks/post/post_hook_sampleは最後に1回だけ実行される
- pre_hook_sampleとpost_hook_sampleには環境変数RENEWED_LINEAGE、RENEWED_DOMAINSは渡されない