Let's encrypt のサーバー証明書を更新する

Let's Encrypt さんで取得したサーバー証明書を更新しようというおはなし。基本的に Let's Encrypt さんから発行される証明書の有効期限は 3か月なので、細かいサイクルで更新していく必要があります。

前回は初回だったので nginx とか普通に停止して実行しましたが、今回は既に稼働している https なり smtps, imaps なりが対象なので、ダウンタイムを極力短くしたいです。

また、そもそも 443 で稼働している https 用の証明書と、通常は 443 なんぞ開けていない smtps や imaps 用の証明書を一括で renew するのはちょっとムリがある1)な……と。

standalone と webroot

https 用の証明書を更新する時は、稼働中の nginx に 443 を掴ませたまま、nginx を停止せずに更新できるように、--webroot オプションを使用します。

他方、443 を掴んでいない smtps と imaps 用の証明書を更新する時は、--standalone オプションで certbot に 443 を掴んで貰えばうまくいきそうです。

こんなコマンドを投げてあげればうまくいきそうです。

certbot certonly --non-interactive --force-renewal --webroot --webroot-path /path/to/htdocs -d manimani.cc -d www.manimani.cc

--standalone を指定するとドメイン所有者確認2)用に certbot が 443 を掴みにくるため、既存の nginx なりは停止しておく必要がありますが、--webroot を指定することで certbot ではなく既存の nginx なりを使用してドメイン所有者確認を実行できるようです。

--webroot-path は、ドメイン所有者確認のためのファイル一式を展開する場所を指定します。let's encrypt 側から https://manimani.cc/.well-known/acme-challenge/ 配下のファイルにアクセスが来るため、対応するパスを設定しておきます。要はドキュメントルートですね。

--non-interactive は、一切の確認を表示せずに全自動で流れるようにします。そうすると、全ての選択肢が “より安全” な方向に振れてしまうので、“証明書の更新が必要になるまでは常に既存の証明書を保つ”……要はある程度有効期限が迫ってこないと、更新してくれなくなってしまいます。どの程度をもって “必要” と判断してくれるのかは調べてないのですが、これだとスケジュール処理で余裕をもって更新したい場合に困ります。なので、ここは --force-renewal オプションを付けて強制更新することで回避します。

試しにやってみるには --dry-run オプションを付ければ良いです。

Saving debug log to /var/log/letsencrypt/letsencrypt.log
Starting new HTTPS connection (1): acme-staging.api.letsencrypt.org
Cert not due for renewal, but simulating renewal for dry run
Renewing an existing certificate
Performing the following challenges:
http-01 challenge for manimani.cc
http-01 challenge for www.manimani.cc
Using the webroot path /path/to/htdocs for all unmatched domains.
Waiting for verification...
Cleaning up challenges
Generating key (2048 bits): /etc/letsencrypt/keys/0006_key-certbot.pem
Creating CSR: /etc/letsencrypt/csr/0006_csr-certbot.pem

IMPORTANT NOTES:
 - The dry run was successful.

こんな感じですね……なんですが、どうもこの --dry-run オプションって、--dry-run を指定しなかった時と結構挙動が異なるようです。テスト実行でコケても、本番の時にはすんなり通るとかあるので、あまり --dry-run で成功させることに固執しないほうが良さそうですね。

「メールサーバーに紐付けている ip アドレスの port:443 は使っていないから certbot の standalone モードで掴めるだろう」とか思っていた時期も(ry

たぶん certbot は 0.0.0.0:443 を掴みにくるんだと思います。なので、たとえ ip が違っていてもそのサーバー上で 1つでも 443 ポートにバインドしているサーバーがあると、certbot は異常終了します。

Saving debug log to /var/log/letsencrypt/letsencrypt.log
Starting new HTTPS connection (1): acme-v01.api.letsencrypt.org
Renewing an existing certificate
Performing the following challenges:
tls-sni-01 challenge for mail.manimani.cc
Cleaning up challenges
Could not bind TCP port 443 because it is already in use by another process on this system (such as a web server). Please stop the program in question and then try again.

nginx で 443 を listen する

できるだけダウンタイムを短くしたいので、稼働している nginx を落とすよりは、稼働している nginx に一時的にメールサーバーの ip で 443 を listen してもらった方がよさそうです。

というわけで、こんな shell スクリプトを作って systemd の timer に登録しました。

certrenew.sh
#!/bin/bash
#
# Update certificates.
#
. /path/to/tools/utils.sh
 
CERTBOT_CMD='/usr/bin/certbot'
 
SYSTEMCTL_CMD='/usr/bin/systemctl'
 
CERTRENEW_DIR='/path/to/tools/certrenew.d'
 
NGINX_CONF_DIR='/etc/nginx/conf.d'
 
OPTS_LIST='certrenew.list'
 
NGINX_CONF='certrenew.conf'
 
 
 
if [ ! -f "${CERTBOT_CMD}" ] || [ ! -x "${CERTBOT_CMD}" ]; then
        error "Command not found or not executable: ${CERTBOT_CMD}"
        exit 1
fi
 
if [ ! -f "${SYSTEMCTL_CMD}" ] || [ ! -x "${SYSTEMCTL_CMD}" ]; then
        error "Command not found or not executable: ${SYSTEMCTL_CMD}"
        exit 1
fi
 
if [ ! -f "${CERTRENEW_DIR}/${OPTS_LIST}" ] || [ ! -r "${CERTRENEW_DIR}/${OPTS_LIST}" ]; then
        error "List not found or not readable: ${CERTRENEW_DIR}/${OPTS_LIST}"
        exit 2
fi
 
if [ ! -f "${CERTRENEW_DIR}/${NGINX_CONF}" ] || [ ! -r "${CERTRENEW_DIR}/${NGINX_CONF}" ]; then
        error "File not found or not readable: ${CERTRENEW_DIR}/${NGINX_CONF}"
        exit 2
fi
 
if [ ! -d "${NGINX_CONF_DIR}" ] || [ ! -w "${NGINX_CONF_DIR}" ]; then
        error "Directory not found or not writable: ${NGINX_CONF_DIR}"
        exit 2
fi
 
# copy special nginx conf.
/bin/cp ${CERTRENEW_DIR}/${NGINX_CONF} ${NGINX_CONF_DIR}/${NGINX_CONF}
 
if [ $? -ne 0 ]; then
        error "Can't copy nginx config file."
        exit 3
fi
 
# restart nginx
${SYSTEMCTL_CMD} restart nginx
 
if [ $? -ne 0 ]; then
        error "Failed to restart nginx: $!"
        exit 4
fi
 
# remove special nginx conf.
/bin/rm -f ${NGINX_CONF_DIR}/${NGINX_CONF}
 
if [ $? -ne 0 ]; then
        warn "Failed to remove nginx config: $!"
fi
 
info "Start update certificates."
 
while read OPTS; do
 
        # skip blank line.
        if [[ "${OPTS}" =~ ^\s*$ ]]; then
                continue
        fi
 
        # skip comment line.
        if [[ "${OPTS}" =~ ^\s*# ]]; then
                continue
        fi
 
        # run certbot
        ${CERTBOT_CMD} certonly ${OPTS}
 
        if [ $? -ne 0 ]; then
                error "Failed to update certificate: ${OPTS}: $!"
                exit 3
        fi
 
        info "Succeed to update certificate: ${OPTS}"
 
done < ${CERTRENEW_DIR}/${OPTS_LIST}
 
${SYSTEMCTL_CMD} restart nginx
 
if [ $? -ne 0 ]; then
        error "Failed to restart nginx: $!"
        exit 4
fi
 
info "Succeed to update all certificates."
 
exit 0

この shell スクリプトが読み込む certrenew.list はこんな感じに、certbot のコマンドラインオプションを列記してあります。

--non-interactive --force-renewal --webroot --webroot-path /path/to/htdocs -d manimani.cc -d www.manimani.cc
--non-interactive --force-renewal --webroot --webroot-path /path/to/htdocs -d tekkadon.manimani.cc
--non-interactive --force-renewal --webroot --webroot-path /path/to/htdocs -d mail.manimani.cc

いまんところ証明書を 3つ持っているので、こんな感じですね。

そして nginx の conf.d に放り込まれる certrenew.conf には、一時的に 443 を開けるバーチャルサーバーの設定が書いてあります。

server {
        listen                          203.143.127.236:443;
        server_name                     mail.manimani.cc;

        root                            /path/to/htdocs;

        ssl on;
        ssl_protocols                   TLSv1 TLSv1.1 TLSv1.2;
        ssl_certificate
                /path/to/mail.manimani.cc/fullchain.pem;
        ssl_certificate_key
                /path/to/ssl/mail.manimani.cc/privkey.pem;
        ssl_dhparam                     /path/to/dhparam.pem;
        ssl_session_timeout             2h;
        ssl_session_cache               shared:SSL443:50m;
        ssl_session_tickets             on;
        ssl_prefer_server_ciphers       on;

        keepalive_timeout               70;
        sendfile                        on;
        client_max_body_size            0;

        access_log /path/to/access_log main;
        error_log /path/to/error_log info;
}

こんな感じで、2か月に1回ペースで叩いておけば良いかなと。

1)
manimani.cc の web サーバーと mail サーバーは ip アドレスは別ですが、ssl 終端は全て reverse proxy している nginx が担当しているので。
2)
acme-challenge
https://manimani.cc/lib/plugins/linkback/exe/trackback.php/wiki:manimani.cc:update_server_certificate