無料プランのあるLuaDNSとDocker上のCertbotでLet's Encryptのワイルドカード証明書を取得・自動更新する

# 概要

[Certbotがプラグインで対応しているDNSサービス](https://certbot.eff.org/docs/using.html#dns-plugins)のうち、無料プランを持つサービスの一つである [LuaDNS](https://luadns.com/) を使用し、Docker上のCertbotからの[DNS-01チャレンジ](https://letsencrypt.org/ja/docs/challenge-types/#dns-01-%E3%83%81%E3%83%A3%E3%83%AC%E3%83%B3%E3%82%B8)によりLet's Encryptのワイルドカード証明書の取得、およびcronによる自動更新を行う。
使い方にもよるが扱うドメインが3個までであれば完全に無料でワイルドカード証明書を利用できる。

※本稿はRHEL8互換環境を前提とする

# 1. LuaDNS

その名の通り「Luaスクリプトを使用してgitリポジトリ経由でレコードを編集できる」という特徴的な機能を持つDNSサービスで、3ゾーン30レコードまで使える無料プランがある。
無料プランでもほぼフル機能が使用でき、クエリ数は無制限、TTLは最短5分に設定できる。

https://luadns.com/

なお、本稿ではWebUIから手動で設定する。

## 1-1. サインアップ

[サインアップ画面](https://api.luadns.com/signup)でメールアドレスとパスワードを入力して「Sign Up」ボタンを押し、入力したメールアドレスに届いた確認メールからアカウント作成承認画面を開いて完了する。

## 1-2. レコードの編集

ヘッダーメニューの「Zones」→「Add Zone」ボタン→「Name」入力欄にドメイン名を入力して「Add Zone」ボタンで編集画面を開く。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63934/d2712ef2-f13a-15fb-cba8-af9fcb3bc2d3.png)

- 記法はbind互換
    - 保存後にヘッダーメニューのZones→Zone名と移動するとほぼbindのゾーンファイルそのままの内容が表示される、確認に便利
- SOAレコードとNSレコードは自動で設定される
    - ドメインを管理しているレジストラ等で対象ドメインのネームサーバーをここで表示されるLuaDNSのものに設定する（末尾の `.` は不要）
- Certbotは動作時に `--domains` オプションで指定したドメインの数だけ一時的にDNS認証用のTXTレコードを追加する
    - 無料プランの場合は「手動設定のレコード数+Certbotが一時的に追加するレコード数」の合計を30件以下に収める必要がある点に注意
        - DKIMレコードなども使っていると無料枠ではドメイン2個が限界になる場合もある

## 1-3. APIアクセスの有効化とCertbot用認証情報ファイルの作成

- LuaDNSのヘッダーメニューから「Account」に入る
    - 「Show Token」のボタンを押して表示されたトークンをメモ
    - 「Enable API Access」のチェックを入れる
    - 「Update Settings」ボタンを押す
- [Certbot用のLuaDNSの認証情報ファイル](https://certbot-dns-luadns.readthedocs.io/en/stable/)を作成する
    - ファイル名は例に倣って `luadns.ini` とする
    - LuaDNSに登録したメールアドレスと先の手順でメモしたトークンを記入する

```ini:luadns.ini
# LuaDNS API credentials used by Certbot
dns_luadns_email = webmaster@example.com
dns_luadns_token = 1234567890abcdef1234567890abcdef
```

# 2. Certbot

[Certbot公式のdockerイメージ](https://hub.docker.com/u/certbot)のうち、[LuaDNS用プラグインの入ったもの](https://hub.docker.com/r/certbot/dns-luadns)を使用する。Certbotの詳細は公式ドキュメントを参照のこと。

https://certbot.eff.org/docs/

なお、本稿では手順1-3で作成した `luadns.ini` を除き、Certbotが入出力するファイルの置き場所はDocker上とホスト上で一致させる。

## 2-2. 証明書取得

### Composeファイル

証明書の取得は通常1回のみの操作になるため、Composeファイルのファイル名をドメインに一致させ、コマンドラインオプションで明示的に指定して実行することとする。

```yaml:DOMAIN.yml
services:
  certbot:
    image: certbot/dns-luadns:latest
    command: >-
      certonly
      --dns-luadns
      --dns-luadns-credentials /root/.secrets/certbot/luadns.ini
      --dns-luadns-propagation-seconds 30
      --non-interactive
      --agree-tos
      --email <EMAIL>
      --domains <DOMAIN>,*.<DOMAIN>
    volumes:
      - /etc/letsencrypt:/etc/letsencrypt
      - /var/lib/letsencrypt:/var/lib/letsencrypt
      # LuaDNS認証情報は適当な場所に置いてマウントする
      - ./luadns.ini:/root/.secrets/certbot/luadns.ini:ro
      # ログを取りたい場合コメントを外す
      #- /var/log/letsencrypt:/var/log/letsencrypt
```

- `--dns-luadns-propagation-seconds 30` はTXTレコード追加の実行からDNS認証の実行までの待機秒数
    - DNS認証用TXTレコードは使用後に削除されるため、LuaDNSの管理画面でレコードの変化を確認したい場合は長めにしておく
    - 本番時の適切な設定は不明だが、何度か試した限りではLuaDNSへのTXTレコード追加の反映はほぼ即時だった
- `--email <EMAIL>` で設定するメールアドレスは有事の際に唯一の連絡手段になるため使用可能なものを設定しておくのが無難
- `--domains <DOMAIN>,*.<DOMAIN>` でベースドメイン（サブドメインなし）とワイルドカード（すべてのサブドメイン）の両方を対象としている
- 実行せずにテストしたい場合はcommandに `--dry-run` オプションを足す
- 取得時の設定がホストの `/etc/letsencrypt/renewal/<DOMAIN>.conf` 等に保存され、更新時に使用される
- 証明書はホストの `/etc/letsencrypt/archive/<DOMAIN>` に保存され、最新のものが `/etc/letsencrypt/live/<DOMAIN>` にシンボリックリンクで配置される
    - 通常はliveに配置された最新の証明書を使用する

### 実行

```bash:shell
# 初回実行時のみ実施
$ docker compose -f <DOMAIN>.yml pull

# 取得実行
$ docker compose -f <DOMAIN>.yml up
```

一時的にDNS認証用のTXTレコードが追加されるとLuaDNSのレコード一覧は以下のようになる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63934/5f40ea53-3a94-237d-3cd2-0e24e4510a40.png)

## 2-3. 証明書更新

### Composeファイル

```yaml:docker-compose.yml
services:
  certbot:
    image: certbot/dns-luadns:latest
    command: >-
      renew
      --deploy-hook="echo DEPLOY_HOOK_CALLED"
    volumes:
      - /etc/letsencrypt:/etc/letsencrypt
      - /var/lib/letsencrypt:/var/lib/letsencrypt
      # LuaDNS認証情報
      - ./luadns.ini:/root/.secrets/certbot/luadns.ini:ro
      # ログを取りたい場合コメントを外す
      #- /var/log/letsencrypt:/var/log/letsencrypt
```

- ホストの `/etc/letsencrypt/renewal` に保存されている設定が読み込まれるためオプション等の指定は不要
- すべての設定に対して更新を試みるため、証明書を複数保持している場合でも1回実行すればよい
- 更新が行われた場合のみ `--deploy-hook` でログに `DEPLOY_HOOK_CALLED` の文字列を出力する
    - この文字列を検出したら証明書更新時に必要な処理を実行する（後述）
- 実行せずにテストしたい場合はcommandに `--dry-run` オプションを足す

### 自動実行

定期的に前節のComposeファイルを実行すればよい。なおCertbotの仕事が終わるとコンテナは停止状態（ `docker compose stop` と同等）になる。

```bash:shell
$ docker compose up
```

例えば `/etc/cron.weekly` に以下のようなシェルスクリプトを置き、 `chmod +x /etc/cron.weekly/certbot-renew.sh` で実行権限を付与すれば1週間毎に実行される。

```shell:certbot-renew.sh
#!/bin/bash

# cronのシェルはPATHが通っていないので通す
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin

# 更新用の docker-compose.yml が /root/docker/certbot に置いてある場合
cd /root/docker/certbot || exit 1

res=$(docker compose up)

# DEPLOY_HOOK_CALLED の文字列がログにあれば更新されたと見なす
if echo "${res}" | grep -q "DEPLOY_HOOK_CALLED"; then
  # 更新実行時の処理

  # 例: Nginxのプロセス順次再起動
  docker exec nginx nginx -s reload
  # 例: Apacheのプロセス順次再起動
  docker exec httpd apachectl graceful
  # 例: php-fpmの再起動
  docker restart php74
  # 例: SMTPサーバの再起動
  docker restart smtp
fi
```

- 証明書が更新された場合、証明書を使用するすべてのサービスやアプリケーションに読み込ませる必要がある
- ホストにインストールされているnginxなら実行後の処理は `systemctl reload nginx` 等になる
- ここでログを出力してもよい

## 2-3. Certbotの更新

[Releases](https://github.com/certbot/certbot/releases)等を参考にして必要があれば更新する。

```bash:shell
# コンテナを削除
$ docker compose down

# その時点のlatestをpull
$ docker compose pull

# 更新後の動作に問題がなければ古いイメージを削除
$ docker images

REPOSITORY                    TAG              IMAGE ID       CREATED         SIZE
certbot/dns-luadns            latest           a271df674f35   3 weeks ago     138MB
certbot/dns-luadns            <none>           0023be3c29c4   7 months ago    133MB

$ docker image rm 0023be3c29c4
```

# 参考

## 更新スキップ時のログ

```text
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Processing /etc/letsencrypt/renewal/<DOMAIN>.conf
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Certificate not yet due for renewal

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
The following certificates are not due for renewal yet:
  /etc/letsencrypt/live/<DOMAIN>/fullchain.pem expires on 2022-09-06 (skipped)
No renewals were attempted.
No hooks were run.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
```

## 更新実行時のログ

2-3で設定したフックにより `DEPLOY_HOOK_CALLED` の文字列が出力されている。

```text
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Processing /etc/letsencrypt/renewal/<DOMAIN>.conf
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Renewing an existing certificate for <DOMAIN> and *.<DOMAIN>
Waiting 30 seconds for DNS changes to propagate
Hook 'deploy-hook' ran with output:
 DEPLOY_HOOK_CALLED

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Congratulations, all renewals succeeded: 
  /etc/letsencrypt/live/<DOMAIN>/fullchain.pem (success)
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
```