ユーザ用ツール

サイト用ツール


freebsd:let_s_encrypt_の_ssl_証明書を使う_apache・sendmail

FreeBSD で Let's Encrypt の SSL 証明書を使う(Apache・Sendmail)

2024 年 2 月頃から Gmail のメールサーバがいろいろうるさくなるらしいので、それを機に Let's Encrypt の SSL 証明書を導入してみることにした。

HTTPS 用の証明書を取得する(http-01 challenge 編)

Sendmail は証明書に関してあまり厳格ではない?という話もあるので、まずは Apache で HTTPS 接続を生かしてみることに。
何はともあれ、証明書取得用の client を install する。いろいろ批判もあるようだが、お手軽に certbot を。

# pkg install py39-certbot
# pkg install py39-certbot-apache

ところで、うちは内部ネットワークと DMZ の間に 2 つの GW がある。

  1. NS(公開 primary)・MX 1・web サーバ(実態は内部の web サーバを外へ見せるための reverse proxy)・SOCKS5 proxy
  2. NS(公開 secondary)・MX 2・NAT 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.confVirtualHost 定義の中で RewriteEngine On の次の行(= 書き換えルールの直前)に以下の記述を行なうことで落ち着いた。

RewriteCond  %{REQUEST_URI}%  ~^(/\.well-known/)

あとはお作法に従って証明書を取得するだけ。

certbot certonly --webroot -w /usr/local/www/apache24/data -d www.agt.ne.jp

質問に答え、しばし待つと……取得できた! :-)

ところが、ほかのドメインも HTTPS 化しようとして、すぐに行き詰まった。やっぱりワイルドカード証明書がないときつい…… (_ _;

HTTPS 用の証明書を取得する(dns-01 challenge 編)

ワイルドカード証明書を取得するには、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 回試行している間には成功するだろう、きっと (__;

Apache への設定

証明書を取得できたら、あとはそれを使って設定するだけ。
まずは <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 を使用する場合、そのセクション内に SSLCertificateFileSSLCertificateKeyFile を書くのが必須である一方、httpd-ssl.conf の当該設定は有効にする必要がない(しても意味がない)ことには注意が必要。
……これで無事に各ドメインのページへ HTTPS でアクセスできるようになった :-)

Sendmail への設定

さて、いよいよ本題の 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」になっているのが気持ち悪い。で、いろいろ調べた結果、以下のことを追加で実施。

  1. ca_root_nss パッケージをインストール(または最新化)する(別に ports を使ってもいいけど……)。
  2. 上記設定の「cacert.pem」が /usr/local/share/certs/ca-root-nss.crt を指すように symlink を張る。
  3. 以下のコマンドを実行して、「/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 で取得した証明書を使用した。

もう一台の MX でも証明書取得

MX1 の設定が完了したところで、もう一台の MX2 でも同様に設定を行なっていく。ただし、こちらでは外部から見えるホスト名が MX1 とは異なるドメインであることや、ポート 80 でのアクセスを遮断していることもあって、dns-01 challenge を使って証明書を取得する。また、その関係で、取得するのは当該ドメインのワイルドカード証明書となった。
ここで一つ問題(?)が。必要なパッケージをインストールした後、うっかり上記のパッチを当てずに証明書の取得を行なってしまったのだが……こちらはあっさり成功してしまった (@_@;。MX1 では全然うまくいかなかったのに、一体なぜ……。 ……よくわからないのだが、まあ、良しとする (_"_;
ともあれ、さっくりと設定完了。ちなみにこちらは Core i7-8850H(2.6GHz)のマシンなのだが、なんと dh.param の生成がきっかり 3 分で終わってしまった (@_@;;;。技術の進歩ってば、ほんとに恐ろしい (_"_;;;。考えてみたら、うちにある中で 3 番目に高速な PC なんだよな……。

1)
後でわかったことだが、certbotdns-rfc2136 プラグインは「事前に zone ファイルに TXT レコードが存在すると、認証に失敗する」という問題があるため、別の目的で既に TXT レコードを使用している zone ファイルを使うことはそもそもできなかった。
freebsd/let_s_encrypt_の_ssl_証明書を使う_apache・sendmail.txt ? 最終更新: 2023/12/31 22:36 by a-gota