TL;DR
DNSにAPIがなくても、_acme-challenge サブドメインだけをCNAMEで別のDNSに委任すればDNS-01を自動化できます。委任先は無料でAPIのある deSEC。既存ゾーンに足すのはCNAME 1本だけで、以後は触りません。
課題
ワイルドカード証明書や、80/443が外から届かないホストの証明書はDNS-01でしか取得できません。DNS-01は更新のたびにTXTを立てて消すためDNSにAPIが必要ですが、レジストラ付属のDNSにはAPIがないことが多いです。
一例としてスタードメインの旧契約の場合は、付属サーバーを使うためには専用ネームサーバーを使わねばならず、それではAPIが利用できません。
DNSの提供元そのものを変えなくても使えるのが、この方法の強みです。
deSECとは
日本ではほとんど知られていませんが、DNS-01の委任先としては定番の選択肢です。
- 運営: ドイツの非営利団体 deSEC e.V.。DNSのセキュリティ向上を目的とする組織で、広告も有料プランもありません。
- 無料: 寄付ベースで運営されており、クレジットカードの登録も不要です。
- DNSSEC: 全ゾーンでデフォルト有効。鍵の管理は不要です。
- REST API: 全操作をAPIから叩けます。トークンに権限スコープも設定できます。
-
dedyn.ioのサブドメイン: 無料でもらえるので、委任先用のドメインを別途用意せずに済みます。
無料でSLAもないため、本番ゾーンを丸ごと預けるかは各自の判断になります。ただし本記事の使い方なら預けるのは _acme-challenge の1レコードだけです。deSECが落ちても影響するのは証明書の更新だけで、Webサイトの名前解決は元のDNSのまま動き続けます。リスクの取り方として現実的な範囲でしょう。
レートリミットや商用利用の扱いは利用規約とドキュメントで確認してください。
構成
example.com のゾーン(API非対応)
A @ 203.0.113.10
CNAME _acme-challenge → _acme-challenge.myname.dedyn.io ← 足すのはこの1本だけ
│
myname.dedyn.io のゾーン(deSEC) ▼
TXT _acme-challenge ←── certbot の auth hook が API で書き換え
以下は example.com + *.example.com の証明書、Ubuntu + certbotを前提とします。
なぜCNAMEで通るのか
DNS-01の検証でLet's Encryptがやっているのは、_acme-challenge.example.com のTXTを引いて値が期待したトークンと一致するか見ることだけです。そしてDNSの名前解決はCNAMEを透過的に辿ります。
Let's Encrypt → TXT _acme-challenge.example.com ください
DNS → それはCNAMEです。_acme-challenge.myname.dedyn.io を見てください
DNS → そのTXTは "abc123..." です
Let's Encrypt → 期待値と一致。検証OK
つまりそのTXTがどのゾーンに置かれているかは問われません。辿った先が別事業者の管理下でも構いません。ACMEの仕様上も想定された動作で、acme-dnsのようなツールも同じ原理で成り立っています。
結果として、自分のゾーンに必要なのは一度書いたら変わらない静的なCNAME 1本だけになり、更新のたびに書き換わるTXTはAPI対応DNS側に丸ごと押し出せます。これがAPIのないDNSでも自動化できる理由です。
Step 1. アカウント開設
https://desec.io/ の "Create Account" から登録します。Domain name 欄で myname.dedyn.io のようなdynDNSドメインを同時に作れるので、ここで作るのが楽です。認証メールのリンクを踏むとアカウントが有効化されます。
Step 2. ACME専用トークンを発行する
Step 1のトークンはアカウント全体を操作できるため、サーバーに置くには強すぎます。Tokens 画面の「+」で専用トークンを作り、Show advanced settings をONにします。
| 項目 | 推奨 |
|---|---|
| Can create / delete domains, Can manage tokens | すべてOFF(既定のまま) |
| Client subnets | 既定は 0.0.0.0/0, ::/0 = どこからでも。サーバーの送信元IPに絞るのが一番効きます |
| Maximum age | 空。期限を切るとある日突然更新が止まります |
| Maximum unused period | 空。設定するなら更新間隔(約60日)より十分長く |
Secretは作成時のみ表示されるので、この場で保存してください。
疎通確認は次の1発で足ります。TXT値はDNSのプレゼンテーション形式のまま渡すため、二重クォートが必要です。TTLの下限は既定3600(.../domains/${DESEC_DOMAIN}/ の minimum_ttl)。
curl -sS -X PUT "https://desec.io/api/v1/domains/myname.dedyn.io/rrsets/_acme-challenge/TXT/" \
-H "Authorization: Token ${DESEC_TOKEN}" -H "Content-Type: application/json" \
-d '{"ttl": 3600, "records": ["\"hello\""]}' | jq .
RRset単位まで絞るなら(API限定)
「_acme-challenge のTXTだけ書き込み可」というポリシーはUIに項目がなく、APIからしか設定できません。UIで「Can manage tokens」だけONにした一時トークン($ADMIN)を作り、設定後に削除します。
curl -sS https://desec.io/api/v1/auth/tokens/ \
-H "Authorization: Token $ADMIN" | jq '.[] | {id, name}'
POL="https://desec.io/api/v1/auth/tokens/${TOKEN_ID}/policies/rrsets/"
# 先にデフォルトポリシー(全拒否)。これが無いと個別ポリシーを追加できない
curl -sS -X POST "$POL" -H "Authorization: Token $ADMIN" \
-H "Content-Type: application/json" \
-d '{"domain": null, "subname": null, "type": null, "perm_write": false}'
curl -sS -X POST "$POL" -H "Authorization: Token $ADMIN" \
-H "Content-Type: application/json" \
-d '{"domain": "myname.dedyn.io", "subname": "_acme-challenge", "type": "TXT", "perm_write": true}'
エンドポイントとフィールド名は公式ドキュメントで確認してください。
Step 3. 既存DNSにCNAMEを1本追加
既存の管理画面で手作業で1回だけ追加します。
| ホスト | タイプ | 値 |
|---|---|---|
_acme-challenge |
CNAME | _acme-challenge.myname.dedyn.io. |
-
example.comと*.example.comは同じ_acme-challenge.example.comを使うため、ワイルドカード用の別レコードは不要です。 - 末尾ドットの扱いは管理画面次第で、付けないと
...dedyn.io.example.comに化ける実装があります。 -
既存の
_acme-challengeTXTが残っていたら削除します。 同名にCNAMEと他タイプは共存できません。
dig +short CNAME _acme-challenge.example.com で意図した先が返れば完了です。CAAを設定している場合は issue / issuewild に letsencrypt.org があるか確認してください。CAAは委任先ではなく元ドメイン側が参照されます。
Step 4. フックスクリプト
プラグインもありますが、バージョン差分に振り回されないようcurlだけの自作フックにします。
sudo install -d -m 700 /etc/desec
sudo tee /etc/desec/env > /dev/null <<'EOF'
DESEC_TOKEN=Step2のトークン
DESEC_DOMAIN=myname.dedyn.io
EOF
sudo chmod 600 /etc/desec/env
sudo tee /usr/local/bin/desec-auth-hook.sh > /dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
source /etc/desec/env
: "${DESEC_TOKEN:?}" "${DESEC_DOMAIN:?}"
STATE_DIR=/run/desec-acme
mkdir -p "$STATE_DIR"; chmod 700 "$STATE_DIR"
STATE="${STATE_DIR}/${CERTBOT_DOMAIN}.txt"
# PUTは上書きなので過去分も保持する
echo "$CERTBOT_VALIDATION" >> "$STATE"
RECORDS=$(sort -u "$STATE" | sed 's/.*/"\\"&\\""/' | paste -sd, -)
curl -sSf -X PUT \
"https://desec.io/api/v1/domains/${DESEC_DOMAIN}/rrsets/_acme-challenge/TXT/" \
-H "Authorization: Token ${DESEC_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"ttl\":3600,\"records\":[${RECORDS}]}" > /dev/null
sleep 60
EOF
sudo chmod 700 /usr/local/bin/desec-auth-hook.sh
値を蓄積している理由。 example.com と *.example.com を1枚に入れると、Let's Encryptは同じ名前に2つの異なる検証値を要求します。manual hookはドメインごとに順に呼ばれるため、素朴にPUTで上書きすると1つ目が消えて失敗します。TXTは複数値を持てるので、両方を並べてPUTします。ここを踏まないとワイルドカードで確実に転びます。
sudo tee /usr/local/bin/desec-cleanup-hook.sh > /dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
source /etc/desec/env
rm -f "/run/desec-acme/${CERTBOT_DOMAIN}.txt"
curl -sSf -X DELETE \
"https://desec.io/api/v1/domains/${DESEC_DOMAIN}/rrsets/_acme-challenge/TXT/" \
-H "Authorization: Token ${DESEC_TOKEN}" > /dev/null || true
EOF
sudo chmod 700 /usr/local/bin/desec-cleanup-hook.sh
Step 5. 発行
sudo certbot certonly \
--manual --preferred-challenges dns \
--manual-auth-hook /usr/local/bin/desec-auth-hook.sh \
--manual-cleanup-hook /usr/local/bin/desec-cleanup-hook.sh \
-d example.com -d '*.example.com' \
--agree-tos -m you@example.com --no-eff-email \
--dry-run
The dry run was successful. が出たら --dry-run を外します。
Step 6. 自動更新
certbotはフックのパスまで /etc/letsencrypt/renewal/example.com.conf に保存します。フック内で /etc/desec/env をsourceしているため、以後は certbot renew だけで動き、環境変数の受け渡しも不要です。
sudo certbot renew --dry-run
sudo certbot renew --deploy-hook 'systemctl reload nginx' # 一度指定すればconfに記録される
CNAME委任というパターン自体は他のAPI対応DNSでもacme-dnsでも使えるので、覚えておくと応用が効きます。deSECは非営利運営なので、使い続けるなら寄付しましょう。
参考
- deSEC: https://desec.io/ / API docs: https://desec.readthedocs.io/
- Challenge Types: https://letsencrypt.org/docs/challenge-types/

