certbot
の dns-rfc2136
プラグインは「事前に zone ファイルに TXT
レコードが存在すると、認証に失敗する」という問題があるため、別の目的で既に TXT
レコードを使用している zone ファイルを使うことはそもそもできなかった。2024 年 2 月頃から Gmail のメールサーバがいろいろうるさくなるらしいので、それを機に Let's Encrypt の SSL 証明書を導入してみることにした。
Sendmail は証明書に関してあまり厳格ではない?という話もあるので、まずは Apache で HTTPS 接続を生かしてみることに。
何はともあれ、証明書取得用の client を install する。いろいろ批判もあるようだが、お手軽に certbot
を。
# pkg install py39-certbot # pkg install py39-certbot-apache
ところで、うちは内部ネットワークと DMZ の間に 2 つの GW がある。
MX1 と MX2 は、諸般の事情で一部異なるドメインのメールを受け取っている(外部への送信は常に MX1 から)。ちなみに、web サーバの reverse proxy には Apache の mod_rewrite
モジュールを使用している。
で、外部向けに HTTPS をサポートするためには上記の 1. の GW 上で certbot
を実行することになるが、そのままだと .well-known/
へのアクセスも内部サーバへ飛ばされてしまい、証明書を取得するための http-01 challenge が失敗してしまう。
そこで、/.well-known/
宛のアクセスを reverse proxy の対象から外すべく試行錯誤した結果、httpd-vhosts.conf
の VirtualHost
定義の中で RewriteEngine On
の次の行(= 書き換えルールの直前)に以下の記述を行なうことで落ち着いた。
RewriteCond %{REQUEST_URI}% ~^(/\.well-known/)
あとはお作法に従って証明書を取得するだけ。
certbot certonly --webroot -w /usr/local/www/apache24/data -d www.agt.ne.jp
質問に答え、しばし待つと……取得できた!
ところが、ほかのドメインも HTTPS 化しようとして、すぐに行き詰まった。やっぱりワイルドカード証明書がないときつい…… (_ _;
。
ワイルドカード証明書を取得するには、dns-01 challenge を使用する必要があるという。若干手間がかかるようなのだが、そうは言っていても仕方がないので、そちらを試してみることに。
まずは手っ取り早く、TXT
レコードを手動で記載する。
# certbot certonly --manual --preferred-challenges dns-01 -d '*.tetsudou.jp'
とりあえずはうまくいったが、当然ながらこの方法では証明書の自動更新ができない。FreeBSD では periodic.conf
に weekly な設定を 1 行追加するだけで証明書の自動更新をしてくれるというお手軽な仕組みが折角あるのに、これが使えずにその都度ちまちま手作業で zone ファイルを編集して更新するのは、なんか負けた気がするし、何よりめんどくさい (-_-;
。
そこで、しぶしぶ(?)Dynamic DNS を有効にすることにした。とは言っても、自動更新された zone ファイルからはコメントが消えてしまったりするという話もあったりするし、TXT
レコードをいじることから SPF の設定などに影響が及ぶ恐れもあるので1)、証明書自動更新用の zone ファイルは分離する。
最初に、標準手順に従って certbot
が Dynamic DNS へのアクセスができるようにするキー情報ファイルを作成。これを named.conf
の外部公開用 view の冒頭で読み込ませる。
include "/usr/local/etc/namedb/certbot-key.key";
そして、通常の zone ファイル群の設定のあとに、dns-01 challenge 用の Dynamic DNS での更新が可能な zone ファイルの設定を行なう。
zone "_acme-challenge.agt.ne.jp" { type master; file "/usr/local/etc/namedb/dynamic/acme-challenge_agt.zone"; check-names ignore; update-policy { grant certbot-key name _acme-challenge.agt.ne.jp. TXT; }; };
更新可能な client は認証キーが一致したもののみで、かつ TXT
レコード以外は更新できないようにしておく。
ちなみに、certbot
が「_
」を含む識別子を使用するので、「check-names ignore
」の指定は必須(書いておかないと、「これを書け」と怒られる)。
Zone ファイルは、割とフツーに書く。SOA レコードなどはほかの書き方もあるが、Dynamic DNS の機能で更新される際に勝手に書き換わってしまう模様。
$ORIGIN . $TTL 3600 ; 1 hour _acme-challenge.agt.ne.jp IN SOA ns1.agt.ne.jp. root.ns1.agt.ne.jp. ( 2023122414 ; serial 3600 ; refresh (1 hour) 900 ; retry (15 minutes) 2592000 ; expire (4 weeks 2 days) 3600 ; minimum (1 hour) ) NS ns1.agt.ne.jp.
さらに、/usr/local/etc/namedb/certbot-key.key
に保存した認証情報を使って、/usr/local/etc/letsencrypt/dns-rfc2136.ini
を作成する。そして、忘れちゃいけない、この方法で dns-01 challenge を実行するためのプラグインをインストールする。
# pkg install py39-certbot-dns-rfc2136
……これで準備は完了。
あとは certbot
を実行するだけ……
certbot certonly --dns-rfc2136 --dns-rfc2136-credentials /usr/local/etc/letsencrypt/dns-rfc2136.ini -d '*.agt.ne.jp'
……と思ったのだが、そうは問屋が卸さなかった。何回やっても認証が通らない。観察していると、.jnl
ファイルは作成(更新)されているのだが zone ファイルは更新されておらず、DNS の情報を読み出すサイトからアクセスしてみても、TXT
レコードが追加されていないように見える。試しているうちに、あっという間に「1 日あたりの失敗は 5 回まで」の上限に引っかかってしまった。涙で枕を濡らしながら(という気分で (^^;
)就寝。
翌日。.jnl
ファイルが更新され、「DNS の伝播を待つために 60 秒待つ……」と言って certbot
が寝てる間に rndc sync _acme-challenge.agt.ne.jp
を打ってやると、TXT
レコードの追加が反映されて見えるようになった。これでようやく成功。certbot -v --dry-run renew
で更新のシミュレーションをしてみても、やはり rndc sync
を打たないと全く成功せず、打ってやると成功した。
色々調べてみたら、公式のコミュニティーでも「TXT
レコードの追加が反映されずに dns-01 challenge が失敗する」という問題は何度も報告されていて、その中には「certbot
が 60 秒待っている間に rndc sync
を実行すると成功する」というそのものずばりな報告もあったのだが、この件が報告されるたびに開発者が「俺の環境ではちゃんと動いている。だから、俺の実装は悪くない」と言って close してしまっていた。おいおい…… (-.-;
仕方がないので、TXT
レコードを追加・削除した後に rndc sync
を実行するよう、スクリプトを改修する。
なんでも BIND 9.11 から追加されたという isc
モジュールを使うこともできるようなのだが、調べてみるとこのモジュール、確かに 9.11 からソースと一緒に配布されるようになっていたが、pkg
で BIND をインストールした場合はその中に含まれておらず、さらに 9.18 ではソースの中からも消えてしまっていた(ちなみに ChangeLog には記載なし……)。
というわけで、安直に rndc
コマンドを実行してしまうことにした。
--- /usr/local/lib/python3.9/site-packages/certbot_dns_rfc2136/_internal/dns_rfc2136.py-dist 2023-05-10 04:44:36.000000000 +0900 +++ /usr/local/lib/python3.9/site-packages/certbot_dns_rfc2136/_internal/dns_rfc2136.py 2023-12-23 16:42:46.767325000 +0900 @@ -15,6 +15,9 @@ import dns.tsigkeyring import dns.update +#import rndc +import subprocess + from certbot import errors from certbot.plugins import dns_common from certbot.plugins.dns_common import CredentialsConfiguration @@ -113,6 +116,7 @@ key_name: key_secret }) self.algorithm = key_algorithm + #self.key_secret = key_secret self.sign_query = sign_query self._default_timeout = timeout @@ -151,6 +155,8 @@ raise errors.PluginError('Received response from server: {0}' .format(dns.rcode.to_text(rcode))) + self._sync_txt_record(domain) + def del_txt_record(self, record_name: str, record_content: str) -> None: """ Delete a TXT record using the supplied information. @@ -186,6 +192,8 @@ raise errors.PluginError('Received response from server: {0}' .format(dns.rcode.to_text(rcode))) + self._sync_txt_record(domain) + def _find_domain(self, record_name: str) -> str: """ Find the closest domain with an SOA record for a given domain name. @@ -246,3 +254,19 @@ except Exception as e: raise errors.PluginError('Encountered error when making query: {0}' .format(e)) + + def _sync_txt_record(self, domain: str) -> None: + """ + Synchronize result of addition or deletion a TXT record to zone file using rndc command. + + :param str domain: The target domain name. + :raises certbot.errors.PluginError: if an error occurs executing rndc command. + """ + + rndc = subprocess.Popen(('rndc', 'sync', domain), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + out, err = rndc.communicate() + if rndc.returncode != 0: + raise errors.PluginError('Error at writing zone for {0}: {1}' + .format(domain, err))
……雑な patch なので、仮に動かなくても保証はできませんが :-p。あと、試行錯誤していた形跡がちょっと残ってるけど、気にしてはいけない。
ともあれ、これで更新も自動でできるようになった。……はずだったんだが、certbot -v --dry-run renew
を何回か実行してみると、どういうわけか時々失敗する。様子を追っていると、ちゃんと .jnl
が更新された直後に zone ファイルへもそれが反映されているのだが……原因不明。まあ、逆に言えば時々は成功するので、期限切れが近くなってから 4〜5 回試行している間には成功するだろう、きっと (__;
。
証明書を取得できたら、あとはそれを使って設定するだけ。
まずは <VirtualHost *:80>
の設定をコピって <VirtualHost *:443>
の設定をする……のだが、HTTP は全 virtual domain で共通の設定が 1 つあればよかったところ、HTTPS は証明書の関係で各 domain 毎に別々のエントリを作らなければならない。一方、reverse proxy 用の書き換えルールは従来と変わらないので、そのままだと HTTP と同じ設定を個々の domain の HTTPS 用設定に繰り返し書くことになってしまう。これは大変わかりづらいし、メンテナンス性もよくない。
そこで、rewrite ルールを httpd-vhost-rewriterules.conf
という新規ファイルに転記し、これを include することにした。
ここから先は、それぞれに取得したワイルドカード証明書を使ってゴリゴリと各 domain 用の <VirtualHost *:443>
なエントリを作っていく。
要点は以下の 3 つ。
SSLEngine On
を書く。SSLCertificateFile
に、そのドメイン用に取得した fullchain.pem
を指定する。SSLCertificateKeyFile
に、そのドメイン用に取得した privkey.pem
を指定する。
それ以外は HTTP 用の設定と変わらない。ちなみに、VirtualHost
を使用する場合、そのセクション内に SSLCertificateFile
と SSLCertificateKeyFile
を書くのが必須である一方、httpd-ssl.conf
の当該設定は有効にする必要がない(しても意味がない)ことには注意が必要。
……これで無事に各ドメインのページへ HTTPS でアクセスできるようになった 。
さて、いよいよ本題の Sendmail の件。実は以前からオレオレ証明書で STARTTLS は動くようにしてあったのだが、これを今回取得した証明書で置き換える。
現在使用している sendmail.cf
は /etc/mail/freebsd.mc
をソースとして生成されたものだが、そこには以下の記述がある。
dnl Enable STARTTLS for receiving email. define(`CERT_DIR', `/etc/mail/certs')dnl define(`confSERVER_CERT', `CERT_DIR/host.cert')dnl define(`confSERVER_KEY', `CERT_DIR/host.key')dnl define(`confCLIENT_CERT', `CERT_DIR/host.cert')dnl define(`confCLIENT_KEY', `CERT_DIR/host.key')dnl define(`confCACERT', `CERT_DIR/cacert.pem')dnl define(`confCACERT_PATH', `CERT_DIR')dnl define(`confDH_PARAMETERS', `CERT_DIR/dh.param')dnl
このうち、「host.cert
」が取得した証明書のうちの cert.pem
を、「host.key
」が同じく privkey.pem
を、それぞれ指すように symlink を張る。また、/etc/mail/certs/
で以下のコマンドを実行して、dh.param
ファイルを生成しておく。
# openssl dhparam -out dh.param 4096
これには数時間かかったというような記事も見かけるのだが…… Core i7 8559U(2.7GHz)のマシンでは、それほどかからなかった(13 分程度)。時代の進歩って恐ろしい (-.-;
試しに Gmail とメールのやり取りをしてみると……ちゃんと TLS 1.3 で暗号化はされているようだが、「verify=FAIL
」になっているのが気持ち悪い。で、いろいろ調べた結果、以下のことを追加で実施。
ca_root_nss
パッケージをインストール(または最新化)する(別に ports を使ってもいいけど……)。cacert.pem
」が /usr/local/share/certs/ca-root-nss.crt
を指すように symlink を張る。/usr/local/share/certs/ca-root-nss.crt
のハッシュ値+.0
」という symlink で cacert.pem
にアクセスできるようにする。# ln -s cacert.pem `openssl x509 -noout -hash < cacert.pem`.0
一応念のために sendmail を再起動し、改めて Gmail からのメールを受信してみると……無事に「verify=OK
」になった。スッキリ 。
ちなみに、この MX では当該ホストの FQDN に対して http-01 challenge で取得した証明書を使用した。
MX1 の設定が完了したところで、もう一台の MX2 でも同様に設定を行なっていく。ただし、こちらでは外部から見えるホスト名が MX1 とは異なるドメインであることや、ポート 80 でのアクセスを遮断していることもあって、dns-01 challenge を使って証明書を取得する。また、その関係で、取得するのは当該ドメインのワイルドカード証明書となった。
ここで一つ問題(?)が。必要なパッケージをインストールした後、うっかり上記のパッチを当てずに証明書の取得を行なってしまったのだが……こちらはあっさり成功してしまった (@_@;
。MX1 では全然うまくいかなかったのに、一体なぜ……。
……よくわからないのだが、まあ、良しとする (_"_;
。
ともあれ、さっくりと設定完了。ちなみにこちらは Core i7-8850H(2.6GHz)のマシンなのだが、なんと dh.param
の生成がきっかり 3 分で終わってしまった (@_@;;;
。技術の進歩ってば、ほんとに恐ろしい (_"_;;;
。考えてみたら、うちにある中で 3 番目に高速な PC なんだよな……。
certbot
の dns-rfc2136
プラグインは「事前に zone ファイルに TXT
レコードが存在すると、認証に失敗する」という問題があるため、別の目的で既に TXT
レコードを使用している zone ファイルを使うことはそもそもできなかった。