Gilles Chehade(gilles@), Eric Faurot(eric@), および Charles Longeau(chl@) の三人が最近 OpenBSD に commit した OpenSMTPD の更新について、Gilles が書いている。(訳注:翻訳中に主語を省いてしまったので、 I (Gilles) なのか We なのか気に なる人は原文にあたってほしい。ゴメン。あと、これは Undeadly の記事 を訳したものけど、もともとは poolp.org のブログを切り貼りしているみたいなので、時系列は不定な気がす る。同じこと何回も言ってるし。気になったらコードを読もう。)
最近、aliases と ~/.forward で以前よりも読みやすい展開書式を可能にする新しいロジックを追加した。かつては %a や %u と いった一文字の書式をサポートしていたが、これは誤解しやすいうえ、新しく (しかしパッと見ただけで意味の通る) 書式を追加しに くいという問題があった。そこで今後は、もっと明解な書式をサポートすることにする。 %{rcpt.domain} や %{user.directory} と いった様々な書式が smtpd.conf(5) で文書化されており、そのどれもが、始点や終点を正か負のオフセット値で指定し部分展開でき るようになっている。たとえば gilles というユーザなら、 %{user.username[1:5]} は ille というように展開される。
その後、コードは簡素化され、文字列がきちんと展開できなかった原因の off-by-one バグも修正されている。ついでに %{sender} を %{sender.user}@%{sender.domain} と同じ意味で使えるようにした。%{rcpt} と %{dest} も同様。
無駄なコードを減らそうとしているとき、バーチャルドメインのコードに出くわして思い出したのだが、以前 OpenBSD ハッカーの だれかが、とてもシンプルな機能を欲しがっていた。バーチャルドメインを別のドメインにマップして、ユーザを共有できるようにし たいというのだ。
どうにかしてランタイムに見るテーブルを増やさず実現する方法はないかと考え、気づいた。書式をすこし変更すれば、プライマ リドメインに使うコードを共有することにより、バーチャルドメイン対応を簡素化できる。そうすれば、この機能を非常に単純な方法 で実現したうえで、ホスト名のワイルドカードという (プライマリにはあるのに) バーチャルに欠けていた機能まで可能になる。なん と、コードを削って機能を追加してしまったのだよフフフ。
それまで、 example.org にバーチャルドメインを提供するには以下のような smtpd.conf を作る必要があった。 (訳注:table name [type:]file ではなく map name source type file だったと思うけどまあいいか)
table domains "/etc/mail/vdomains.txt" accept for virtual <domains> deliver to maildir
そして /etc/mail/vdomains.txt は
example.org enabled # 必須 gilles@example.org gilles @example.org gilles # 全部転送
のようになっていた。ここで、キーを舐めずにドメインを見つけるための、特別な行が必要だったことに注目。これは問題なく動 作してきたし、実際に poolp.org の各種ドメインで使っているものだ。しかし今後はちょっと言葉を変えて、
table domains "/etc/mail/domains" table users "/etc/mail/users" accept for domain <domains> virtual <users> deliver to maildir
という書式が有効になる。この場合 /etc/mail/domains はドメイン名だけが一行ごとに書いてあるリストで、/etc/mail/users は 以前と同様のマッピングが書いてある。ただし、
# example.org enabled -> これはもう要らないので消して構わない gilles@example.org gilles @example.org gilles # 全部転送
となる。たいして改善されたように見えないだろうが、一歩下がって見てみてほしい。まず、これまでの for virtual の代わりに for domain を使うようになっており、そのパラメータは静的リストでも、K_DOMAIN サービスを提供するテーブルでも構わない。これ はつまり、
table domains { poolp.org, "*.poolp.org", opensmtpd.org } accept for domain <domains> virtual [...]
や
table domains "/etc/mail/domains" # 一行ずつ accept for domain <domains> virtual [...]
または
accept for domain { poolp.org, opensmtpd.org } virtual { root => gilles } [...]
といった書き方が可能ということだ。バーチャルドメインがいくつ欲しくなってもそれは同じだし、プライマリとバーチャルで同 じサービスを使っているため、プライマリドメインだけの機能だったワイルドカード表記がバーチャルドメインにもいつの間にか使え るようになっている。
良いことはまだある。ドメイン名とユーザ名を別々のテーブルにしたので、ドメインをマップするようにして、同じユーザ名マッ ピングを複数のドメインに適用することができるようになったのだ。
table domains { poolp.org, opensmtpd.org } table users { staff => gilles,eric,chl } accept for domain <domains> virtual <users>
こう設定すると、どちらのドメインでも staff@ へのメールは gilles, eric, chl に届く。
というわけで、テーブルは accept にインラインで書いても、静的に宣言しても、<括弧> 記法を使っても (データに問題が なければ) どれでも同じく動作するということを覚えておこう。
parse.y が、正しい設定できちんと動作するとはいえ、とてもおかしな設定でも正しいと解釈してしまうので、時間をかけて文法 の簡素化に取り組んだ。
ある曖昧なケースに対処しているとき、その解釈が使っている複雑なロジックは、むかし実装しようと思っていた機能 (しかしルー ルが曖昧になりかねないのでやめた機能) に由来していることを発見した。そこで、実装する価値のない部分はすべて消し去ることに した。結果、不要になっていたリストや構造体を削除し、デーモン間でまちまち(あるときはポインタ、またあるときは ID で参照) だ ったテーブルの使い方を一貫させた。
最終的には、だいぶ文法のメタボを解消することができた。より正しく、より明解に。
もちろん既存の設定ファイルが使えなくなるわけだが、10 行程度までなら修正はあっという間のはずだ。(aliases, プライマリド メイン、バックアップ MX, バーチャルドメイン、リレー、等々で) 複雑にならざるを得ない poolp.org の smtpd.confの変換に 2 分 もかからなかったのだから。(訳注:おもに変換が必要なのは前出の map に関わるものだろう)
整頓された parse.y を土台にすることで、これまで不可能だった新機能を作り上げることができた。バーチャルドメインも宛先に "for local" や "for any" を使えるようにしたのだ。実を言えば "accept for any" や "accept for local" は使えたのだが、そう するとOpenSMTPD はプライマリドメインのことだと思いこんで、ユーザ名部分でシステムユーザを見にいっていた。これぞ parse.y の (ANY, LOCAL, DOMAIN, VIRTUAL が同レベルにあり、VIRTUAL が特殊ケースとみなされるという) 曖昧さの副作用だった。
今はバーチャルドメインも普通のドメイン (に、バーチャルマッピングを定義してあるもの) でしかない。ANY だろうと LOCAL だ ろうと同じロジックが適用されるのだから、 "accept for any" も"accept for any virtual [...]" も可能で、先週 (訳注: poolp.org のブログ記事のことか?) のaccept for domain "*" virtual [...] という文法とは違い、ちゃんと期待どおりに動作する。 domain "*" のほうも有効ではあるが、内部の扱いは違っている。
あるユーザがチケットを出して、「メールの宛先に関係なく受け取り、ひとつのアカウントに配送させるようにできるか」つまり OpenSMTPD をゴミ溜めにすることができるか、と質問していた。これは "accept for any virtual [...]" の改善のおかげで、以前よ り簡単にできるようになった。必要なのは、ドメインに関係のないグローバルな「何でも accept」を作ることだけだからだ。
今や virtual は、 "@" というグローバル何でも屋をサポートしている。複数のドメインに対する何でも屋を静的にマッピングす ることが可能だ。
accept for any virtual { "@" => gilles } deliver to maildir
上のようにすれば、どんなドメインのどんなユーザ名だろうと、ローカルの gilles に配送される。
MFA は SMTP セッションにおける各ステージのフィルタリングを担っているプロセスだが、これも格段に簡素化した。たとえば SMTP ステートについて MFA はもう関知しない。そこに関わるのは API の階層を踏みこえた行為だった。こうして以前と同じサービ スを提供しつつ大量のコードを削除してある。ダイエット効果は 100 行を超え、MFA はフィルタ評価のエントリポイントとして働く ことだけを唯一の目的としたごく小さなコードになった。
これは、フィルタ関連で予定している作業の地ならしだった。予定の時点ですでに複雑なので、着手前に不要な複雑さを加えたく なかったのだ。
MTA にも大きな変更がある、というより、ほぼ完全に書き直してある。今や MTA は経路への MX, PTR, ログイン情報を共有する方 法を知っているので、一回の DNS リクエストで、複数のドメインに宛てた複数のメッセージを扱うことができる。
MX に問題があるときの対応も大幅に改善している。特定の MX との接続があまりに何度も失敗するときには、その MX に故障マー クを付けておいて、新規セッションで使わないようにするのだ。
優先度の等しい複数 MX への接続を取りまわす能力もある。MTA はメッセージをたくさん送るときに、優先度の数値がいちばん小 さい複数の MX に対して (既定の接続数制限の範囲内で) セッションを張る。
ある優先度の MX がすべてエラーになれば、バックアップ MX に到達できるよう次のレベルへ移る。その経路の MX がすべて到達 できなければ一時的エラー (訳注:4xx ってことか) を発行する。
特筆すべきは、かつて恒久的エラー (訳注:5xx?) を出していたエラーのいくつかが、今では単にその MX に故障マークを付ける だけになったため、別の MX を経由してメールが到達できるようになっていることだ。
ログも改良されており、特に SSL 関連のエラーで顕著だ。MX の問題がすべて ("smtp-out" として) ログに残るようになっている ので、管理者がリレーの問題を診断する助けになるはずだ。
フィルタに手をつける前に必要な作業として、SMTP エンジンにも手を入れてある。これが今 poolp.org で動いて OpenSMTPD メー リングリストを回している。
考えかたとしては、コード全般を読んだり拡張したりしやすくすることを念頭に置いていた。
まず、 SMTP 特有の構造体や定義はほとんど smtp_session.c に分離した。これで smtpd.h のカオスが少し晴れた。そして、あの ひどい submit_status 構造体をついに葬り去る機会にもなった。
ユーザコマンドの処理はそれより頭を使わずに整理できた。imsg 処理のコードはまったく独自の構造体を使うようになっている。
こうした書き直しは面倒な作業だった。というのは、おもに以前のコードの見通しが悪かったからだが、加えて、リグレッション テスト作りをあまりにサボっていたからだ。テストスイートは今回の件をとおして改善された。
さらに、最近の MFA の変更で MFA とのやりとりが複雑になっていたので、大まかに言えば、受けとったフィルタ済みデータから、 MFA に渡すメッセージデータを剥ぎとるといったようなことが必要になった。
まとめると、この新バージョンは新機能を実装する基盤としてずいぶん良くなっているはず。SMTP サイドで適切にフィルタしたり パイプラインしたり、といった機能だ。
OpenSMTPD にはもう 1 年以上もフィルタが存在しているのだが、API が確定しておらず、他にもっと重要なこともあって、無効に されている。
OpenSMTPD のフィルタは、特製ライブラリ (デーモン化とイベントベースなコールバック機構を備える) にリンクした、スタンド アローンのプログラムだ。
フィルタは超・超簡単。実際に動く例でも、こんなにシンプルだ。
#define SMTPD_FILTER #include "smtpd-api.h" void mail_cb(uint64_t id, struct filter_mail *p, void *arg) { /* お馬鹿さんをブロック */ if (! strcmp(p->domain, "0pointer.net")) { filter_api_reject(id, 530, "You're not welcome, go away !"); return; } filter_api_accept(id); } int main(int argc, char *argv[]) { /* ライブラリを初期化し、デーモンおよびフレームワークを起動 */ filter_api_init(); /* コールバックを登録 */ filter_api_register_mail_callback(mail_cb, NULL); /* イベントループ */ filter_api_loop(); /* 来ないけど */ return 0; }
今は、つなぎ目のコードをちょっと変えて、imsgproc という新 API を活用させようとしている。これは imsg / fork / exec と いった、全フィルタで使うことになる操作を一般化するもので、もしかすると、ほかのプラグイン式バックエンドにも使えるかもしれ ない。すでに動くようになっており、手元では 10 以上のフィルタがコンパイルに成功している。色々なフック (単独および各種組み 合わせ) で動くフィルタだ。
この API は現時点ではまだ有効になっていないが、将来のリリースで必ず利用できるようにするつもりだ。
新しい問題にも取りくんでみた。エラー処理にバグがないことを保証するにはどうすればいいのか、特に、そのエラーがなかなか 再現できないレアケースのときにどう確認すればいいのか、という問題だ。そうしたエラーの典型例は getpwnam() がデスクリプタ不 足や EIO で失敗するというものだ。このような場合、OpenSMTPD には正しく一時的エラーとして扱ってほしい。恒久的エラーではじ いてしまうと、迷子メールになってしまう。
そこで、Netflix の Chaos Monkey という、勝手にランダムなエラーを出して高可用性を確かめるツールのことを思い出し、 MONKEY_* というマクロを要所に置いてそいつに混沌を吐かせるようにしてみた。小さなサルたちが、imsg 処理に遅延をはさんだり、 各所でランダムな一時的エラーを出したりするのだ。
テストを始めて 10 分もしないうちに、おサルさんたちはふたつの非常にこまかいバグをあぶり出してくれた。人手では何年もし ないと遭遇しなかったであろうバグだ。こうして、このコードにはバグがなくなったのだから、今度はもっとサルを繁殖・拡散させる 必要がある。
テストは、 github にミラーして本家と同期された別ブランチで実施されている。自分で試すには、ただ monkey ブランチ (サル の枝だなんて超ウケるよね? [undeadly編:べつに……]) をチェックアウトし、CFLAGS=-DUSE_MONKEY 環境変数をセットしてビルドす ればいい。そうすれば、自分にメールを送ると、こんな感じになるはずだ。
$ echo test | mail -s 'test' gilles $ echo test | mail -s 'test' gilles send-mail: command failed: 421 Temporary failure $ echo test | mail -s 'test' gilles send-mail: command failed: 421 Temporary failure $ echo test | mail -s 'test' gilles $
通常 5xx エラーにならないところでは、おサルさんモードが 5xx を返すということは一切ないのが理想だ。
Eric が DNS API を整理・改良してくれた。 dns_query_*() 関数群の引数は以前よりも論理的な順序になっている。構造体 dns は葬り去られ、 imsg 専用の小さなふたつの構造体を使うようになっている。dns_query_mx_preference() が導入され、あるドメイン の特定の MX の優先度を受信できるようになった。そうして、すべての MX アドレスが (順番にではなく) いっぺんに並行して解決さ れるようになっている。
Eric は MTA の内部も改良してくれて、リレーのドメイン、ホスト、起点、経路が上手に抽象化されている。ひとつの MTA セッショ ンは、指定されたひとつの経路でのみ処理をして、その経路上のエラーだけを報告するようになっている。
リレーは種々の制限の範囲内でできるだけ多くの経路を開き、最大限に活用してメールをさばく。あ、もう K_SOURCEADDR 解決サー ビスを使えるようにはなっているが、実際に使うのは来週のマイルストーンあたりになるだろう。
map API は、table という素直な新 API に交代した。 smtpd.conf の観点で言うと、table は map よりも宣言がシンプルだし、 型があるので不適切な文脈での使用をパース時点で検出できるし、解決サービスに分かれているので、バックエンドが一部の解決しか サポートしていなくても、不足しているかどうかは smtpd がこれまたパース時に発見できる。この新しい table という API は物事 を大いに簡素化してくれて、バックエンドは簡単に書けるようになり、最終結果の信頼性は格段に高くなった。
そして table API はバックエンドハンドルを開いたままにするよう改良され、解決のたびにバックエンドを開け閉めせずにすむよ うになっている。これが db と静的データのバックエンドに与える影響はさほど大きくないが、ネットワークバックエンド (はじめは ldap)を書き始める前にやっておく必要があったのだ。
table API の登場によって、 user_backend API をなくして table の解決サービスを使うようにすることができた。それで、シス テムユーザ名の解決を getpwnam に頼るのではなく、 table バックエンドで書くことができる……のだが、現時点で OpenSMTPD には内 部に <getpwnam> というテーブルがハードコードされているので、実際にはまだやるべき作業が残っている。
解決バックエンドとして file はサポートしなくなった。 smtpd.conf が何らかの解決にファイルを参照するとき、内部では静的 テーブルに変換することになる。これで重複コードを削除しつつ、まったく同じ結果を得ることができる。ユーザの目には見えない変 更点ということだ。
table API は、バックエンドが設定ファイルを (自分でパースせず) サポートするための簡素な仕組みを提供している。そのよう なバックエンドは smtpd.conf でこうやって宣言し、
table foobar mybackend:/etc/mail/mybackend.conf
以下のようにすれば、
static int table_mybackend_config(struct table *t, const char *configfile) { void *cf; cf = table_config_create(); if (! table_config_parse(cf, configfile, T_HASH)) { table_config_destroy(cf); return 0; } table_set_configuration(t, cfg); return 1; }
これで /etc/mail/mybackend.conf というファイルをパースして key/value 型のテーブルに入れるようになる。そうすれば他のあ らゆるハンドラから、こうして値を取り出すことができる。
static void * table_mybackend_open(struct table *t) { void *cf = table_get_configuration(t); if (table_config_get(cf, "key") == NULL) { log_warnx("table_mybackend: open: missing key from config file"); return NULL; } return t; }
実例が必要だったので、 SQLite サポートを table バックエンドとして追加し、「全種類の」解決に SQLite を使えるようにして みた。まだ map API を解決に使っていた頃にも、実験としてやったことはあったのだが、今回はホンモノだ。
SQLite サポートには Postfix のものと同じアプローチを使い、文法をこちらから強要するのではなくユーザがクエリを自分で用 意することで、最大限の柔軟性を可能にしている。
どう設定することができるか、サンプル smtpd.conf でお見せしよう。
# smtpd.conf table mytbl sqlite:/etc/mail/sqlite.conf # 同時にまた別のファイルを設定することもできるし table mytbl2 sqlite:/etc/mail/sqlite-other.conf # ひとつの設定を複数種の解決に使うこともできるぜ accept for domain <mytbl> alias <mytbl> deliver to mbox
そしてこれがサンプル sqlite.conf だ。
# データベースの場所 # dbpath /tmp/sqlite.db # Alias lookup query # # rows >= 0 # fields == 1 (user varchar) # query_alias select value from aliases where key=?; # Domain lookup query # # rows == 1 # fields == 1 (domain varchar) # query_domain select value from domains where key=?;
もちろん smtpd は sqlite バックエンドで複数のテーブルを使うこともできるし、複数の設定ファイルを使うこともできるし、ふ たつのドメインに別々の alias データベースを使うこともできれば、解決の全種類、全情報をひとつのデータベースにまとめること もできる。これ以上ない柔軟性だ。解決サービスのうち「すべて」を SQLite バックエンドでサポートしているので、OpenSMTPD で扱 うものは何でも入れておける。
OpenSMTPD は解決すべてに table API を使うと言ったが、最後までそれ以外の API を使っていたのが、ユーザ情報だ。
table API は解決が非同期に実行されることを想定しているが、OpenSMTPD にはユーザ解決を同期的にやるコードがあったのだ。 (配送の直前であるとか、 ~/.forward をチェックするときのホームディレクトリとか)
そこで、ユーザ名から uid, gid, ホームディレクトリなどを解決する新しい解決サービス K_USERINFO を導入した。そして ~/.forward チェックと配送コードを変更し、同期的な user_lookup() API ではなく K_USERINFO サービスを通して、非同期にユーザ 情報を解決するようにした。
K_USERINFO を実装する唯一のバックエンドは table_getpwnam といい、基本的には以前と同じことを非同期にやるだけだが、これ が実装された時点で user_lookup() は灰塵に帰することになった。
OpenSMTPD はテーブルから起点 IP アドレスを取り出す方法を教わっているものの、まだ実際に使うようになってはいない。これ を使えば、たとえば自分のアドレスを spamhaus のサルどもがブラックリストに入れたとき、起点アドレスを上書きすることができる ようになるだろう。(訳注:後述)
K_SOURCEADDR サービスは、テーブルにあるアドレスをひとつずつ順番に返す循環解決をおこなうので、複数のアドレスを保持する テーブルは、そのアドレスを順繰りに使うことになる。
長らく要望されていて、しかし実装するのが非常に困難だった機能、それはバーチャルユーザ対応だ。
OpenSMTPD はエンドユーザが (getpwnam() を使って解決できる) 実際のシステムユーザであることを要求していた。K_USERINFO 解決サービスはそれを少し変えて、「 table_lookup() で解決できるユーザ」を要求させるようにした。
そして解決サービスはどのバックエンドを使って書くこともできるので、K_USERINFO ハンドラを table_static, table_db, table_sqlite で書いてみた。そして smtpd.conf に新しいキーワードを追加し、ルール中でユーザテーブルを指定できるようにし た。
table bleh1 { vuser => vuser:10:100:/tmp/vuser } table bleh2 { vuser => vuser:20:200:/tmp2/vuser } accept for domain poolp.org users <bleh1> deliver to maildir accept for domain opensmtpd.org users <bleh2> deliver to maildir accept for domain pool.ps deliver to maildir
このようにすれば、OpenSMTPD は poolp.org ドメイン宛てのメールを受け取るが、 bleh1 テーブルにあるユーザしか探さない。 opensmtpd.org には別のユーザデータベースがある。この例ではユーザ名が同じだが uid, gid, homedir は違っている。また今回の 例では静的テーブルを使ったが、実際には sqlite でも db でも、なーんでも使えるぞ。
pool.ps ドメインにはユーザテーブルがないので、 (ほとんどのユーザが期待する) 既定のとおりシステムデータベースを使う。
数か月前から smtpd.conf は、経由したいリレーを relay URL 記法で定義できるようになっている。
table creds { mail.poolp.org => gilles:mypasswd } accept for any relay via tls+auth://mail.poolp.org:31337 auth <creds>
上のようにすると、メールを送るときは creds テーブルでリレーのドメイン名に合致するエントリを探して、そのログイン情報を 使う。ただ気に入らないのは、これではログイン情報を複数のリレーで共有できないし、同じ名前のもとで働くふたつのリレーに別々 のログイン情報を割り当てることや、まあ他にも色々と不可能だったということだ。
それに、外向きの認証には K_CREDENTIALS を使っているのに、内向きには table API ではなく auth_backend API を使っていた ことにもムカついていた。
そこで Eric を説得して、 relay URL に新しい仕組みを取り入れたら素敵やん、という話に持っていった。たとえば tls+auth://label@mail.poolp.org:31337 のようにラベルを URL 中に含めておいて、ログイン情報解決のキーに使えるようにするの だ。
そうすれば複数のリレーが同じラベルを参照したり、同じホスト名で別のリレーが別々のラベルを参照したりできる。さらに、次 のふたつの素敵ワザも可能になる。まず、ラベルは別のサービスでまた解決されるので、中身のログイン情報を変えればその場で MTA が気づいてくれるということ。また、内向きのほうの認証はユーザ名がラベルになっているということにすれば、 K_CREDENTIALS を そのまま、内外どちらのセッションにも使える、そして auth_backend API を抹殺できることだ。
と、いうことで……、書かせてもらって、こういうことが可能になっている。
table in_auth { gilles => gilles:encryptedpasswd } table out_auth { bleh => gilles:cleartextpasswd } listen on all tls auth <in_auth> accept for domain poolp.org deliver to maildir accept for any relay via tls+auth://bleh@mail.poolp.org auth <out_auth>
そしてこれは、ユーザがローカルであることを想定していた最終案件に幕を下ろす。今後 OpenSMTPD は、ユーザがローカルであっ たり実在したりということを、いかなる機能・用途においても前提としない。
OpenSMTPD の LDAP 対応作業は、ずいぶん昔に始めていたのだが、何かの理由で完成せずに手元で腐りかけていた。
数か月前、その部品を git ブランチに戻した。そうすれば数日に一度は見かけて、「怠けちゃイカン」と思えるだろうから。とは いえ自身あまり LDAP の大ファンでもなく、言ってみればユーザでもないので、だれかが拾って育ててくれないかという希望のもと、 ブランチはパブリックにしておいた。
それを poolp ユーザのひとりが現状に追従させ、 alias 解決に使えるようにし始めてくれた。そこから再開し、コードを簡素化 したり、ほとんどの解決サービスに対応させ、たいていの運用例では OpenSMTPD がバックエンドに LDAP を使えるようになった。
以下はローカルユーザ認証、ドメイン解決、 alias 解決を LDAP に対して実行する設定ファイルだ。
# /etc/mail/smtpd.conf # table myldaptable ldap:/etc/mail/ldapd.conf listen on egress tls auth <myldaptable> accept for domain <myldaptable> alias <myldaptable> deliver to maildir accept for any relay
そして以下はそのテーブル設定だ。
# /etc/mail/ldapd.conf # url ldap://127.0.0.1 username cn=admin,dc=opensmtpd,dc=org password thisbemypasswd basedn dc=opensmtpd,dc=org # aliases lookup # alias_filter (&(objectClass=courierMailAlias)(uid=%s)) alias_attributes maildrop # credentials lookup # credentials_filter (&(objectClass=posixAccount)(uid=%s)) credentials_attributes uid,userPassword # domains lookup # domain_filter (&(objectClass=rFC822localPart)(dc=%s)) domain_attributes dc
動くところまで対応は進んでいるが、現時点でふたつの問題がある。まず、接続が切れてもバックエンドが LDAP サーバに再接続 しないこと。次に、クエリが同期的なので、時間のかかるクエリは解決プロセスを足止めしてしまうこと。
なお、テストには OpenBSD の ldapd(8) だけを使った。こいつは呆れるほど単純なので、必要以上の面倒を抱えたくない人にはピッタリだ。実際このおかげで、最初に考えて いたよりもずっと楽しく実験できたよ……。
これからはもっと LDAP に詳しくなろうと思っている。というのも、 LDAP 対応を要求された回数からすると、今後 LDAP に関す る質問をしょっちゅう受けることになると思われるからだ。まずは自分で使ってみるかな、とか言ったりして。
Eric は K_SOURCE 解決サービスを relay につっこんでくれた。これは MTA プロセスがリレーに外向きで接続をするときに使う自 分の起点アドレスをOpenSMTPD が解決できるようにするサービスだ。
それまで OpenSMTPD は、コードへの (poolp ブランチに入っている) ハックなしでは、外向き通信に使う IP アドレスを指定でき なかった。ブランチ独自ハックにとどめていたことには理由がある。ほかの部分がきちんと書き直されてパズルがピタッと合うように なるまで遅らせていたのだ。
というわけで、今では source キーワードを使ってアドレスを指定することができる。
table myaddrs { 88.190.237.114, 91.121.164.52 } accept for any relay source <myaddrs>
アドレスが複数あるときは順繰りに使われる。MTA は、使えなくなっているアドレスを検出してくれる。
昔は存在していたが掃除のときに消えてしまった機能のひとつが、途中経過報告 (intermediate bounces) だ。
メール配送に失敗すると、 OpenSMTPD はメッセージが届かなかったことを差出人に通知しなければいけない。即座に失敗とわかり 通知できる場合もあるが、時には失敗が一時的なもので、メッセージをデーモンが保管し、折にふれて配送を試行しつづけることもあ る (まあ実際のロジックはもうちょい複雑なんだけど、イメージはつかめるでしょ)。そういうとき、通知を送るのは OpenSMTPD が既 定で 4 日のあいだ配送の努力をして、ついに諦めたあとになる。
もうとっくに相手のメールボックスにあるものだと思っていたメールが実際にはどこにも届いていなかった、という通知が 4 日も 経ってから来るのは、当然とても気分の悪いものだ。途中経過報告は、配送が何度か一時的エラーになった時点で差出人に通知し、も うしばらくデーモンががんばってみるつもりだということを知らせてくれる。
議論の結果、 Eric は OpenSMTPD に途中経過報告を実装しなおしてくれた。ただし、他のデーモンとは少し違った方法で。既定で は、メールが配送されないまま 4 時間以上キューにとどまっていたときに途中経過報告が送られる。……しかしその後さらに複数回の 経過報告を送るよう、遅れを smtpd.conf に指定することができるのだ。たとえば、途中経過報告を 4 時間後、1日後、2 日後のタイ ミングで欲しいなら、こうするだけでいい。
bounce-warn 4h, 1d, 2d
キーワードは変わるかもしれないが、考え方とコードは現在こうなっており、こう動作している。
セッションのタグ付けは大昔から実装してあって、「たぶん」 OpenSMTPD がまだ OpenSMTPD ではなく poolp プロジェクトのひと つだったときにも存在していた、と思う。
その機能は隠されており、マニュアルにも書いていなかった。使えるとは言ってもまだ、ユーザが好き勝手な状況で使い始めて、 その面倒をあとで見なければならないというのは困ると思っていた。基本的には、リスナを通る時点でセッションにタグを付け、特定 のタグの付いたセッションにだけルールが適用されるようにするというだけの機能だ。
しかし Eric は、ある運用例に打ってつけだと気づいた。 DKIM 署名だ。
DKIM 署名が欲しいと言っても、そういうフィルタを書きたいということにはならない。もう世の中には署名ツールがある。だから 必要なのは、メッセージを受け取って DKIM 署名ツールに渡し、ツールが返してくれたものを本来の宛先に送るという、それだけのこ とだ。
ツールのひとつは DKIMproxy だ。詳細は省くが、概念としては、 DKIMproxy へ転送してほしいセッションがどれなのか、そして DKIMproxy から戻ってきて最終目的地にリレーするべきセッションがどれなのかを識別するためにタグを使う。
# 既定経路にアタッチされたインタフェースすべてで listen # listen on egress # ループバックの 10029 番ポートで listen し、DKIM タグを付ける # listen on lo0 port 10029 tag DKIM # DKIM タグの付いたセッションだけリレーを許可 # accept tagged DKIM for any relay # タグが付いていないセッションがここに到達する # ので、DKIMproxy に渡す # accept for any relay via smtp://127.0.0.1:10028
というわけで、もし自分の gmail アカウントにメールを送るとすると、まずデーモンに接続するが、そのセッションはタグが付い ていないので最後のルールに合致し、メッセージは DKIMproxy に送られる。次に DKIMproxy は、そのメールをループバックインタ フェースの 10029 番ポートに返送し、このセッションには DKIM タグが付くので最初のルールに合致する。これが 4 行。頭おかし い。
OpenSMTPD にはセキュア通信を扱うコードが昔からあるが、証明書チェーン (証明パス) の検証はサーバモードでもクライアント モードでも実施していなかった。サーバモードではクライアント証明書を求めず、クライアントモードではサーバから渡された証明書 が正しいか確認していなかったのだ。
サポートを追加し始めたところ、最初の問題にぶつかった。 chroot 内から CA バンドルへのアクセスだ。reyk@ と話し合った結 果、彼の言うとおり最善の策はデザインを改善することだと思った。証明書や鍵は、ネットワークに接するプロセスから別プロセスに 分離して、必要に応じて扱うのが良い。
毎度のことながら OpenSSL を相手にするのは楽しいもので、まるで Richard Stallman と美食をともにするような気分だった。だが、やがて 光が差しこみ、なんとか期待どおりの動きをさせることができた。良い点を言えば、クライアントとサーバの両モードで処理が対称に なっているので、コードの大部分が共通ということだ。
こうして秘密を扱う部分は解決プロセスに分離された。 SMTP と MTA プロセスは imsg フレームワークを使って要求を出し、 チェーンをたどって検証してもらうのだ。コードは全部で数行におさまった。扱う OpenSSL コードが少なければ少ないほど良いのだ から、これは嬉しい。
今のところ X509 属性の完全な検証まではしていないので、 OpenSMTPD は Received 行で嘘をつき、検証をしていないフリをして いる。今後、検証が 100% 正確だという自信が持てたら Received 行に本当のことを書くよう修正するつもりだ。
さて、話題を変える前に、これに関連して考えていることをふたつほど。
もし K_CERT という table サービスを提供するとすれば、証明書やチェーンを好きなバックエンド (ldap, sql, 等々) から取り 出せるようになるだろう。こんなのは作業量にして 1 時間ほどだ。そうしていない理由は単に、現時点で必要ないというだけだ。
また、証明書を検証して MTA や SMTP に返す結果は、成功か失敗かという単純なステータスだ。つまり証明書ベースの認証は今や 朝飯前、おそらくこれも 1 時間の作業であろうと思われる。じゃあどうして実装していないのかというと……あとはわかるな?
SSL コードの作業をしている最中に、手元のプライマリとセカンダリ MX でリレーするときのおかしな動作を見つけた。
テストの結果、どこかで "backup" フラグが消えていることがわかった。Eric と軽くチャットした結果、 backup:// スキーマを 導入しエンベロープのフラグを廃止すべきだということを納得してもらった。これなら、 relay URL として
backup://mx2.poolp.org
を指定することで、 mx2.poolp.org がバックアップ MX だということをハッキリ示せる。
その後、 TLS を要求していないのに使おうとしたり、要求したのに平文にフォールバックしたりするという奇妙な問題を見つけ た。relay URL スキーマ文法にはどこかあやしげな所があるというのがハッキリしたので、もっと明確に定義する必要が出てきた。
あらゆる可能性を調査した結果、以下のように定義した。
smtp+tls:// -> TLS を試して、だめなら平文、これが既定 smtp:// -> 平文のみ、暗号化なし tls:// -> STARTTLS のみ、暗号化経路を保証 smtps:// -> SMTPS のみ、暗号化経路を保証 ssl:// -> STARTTLS がだめなら SMTPS, 暗号化経路を保証
これで、 smtp+tls:// が指定されない限り、暗号化を要求して平文にフォールバックすることはないので、安全にリレーされてい るという期待を裏切ることはなくなる。
ちかごろは、たっぷりストレステストをしている。
受信経路は、何百万というメールを何百ものセッションから、ぐちゃぐちゃな順番、データ内容で受けるというテストをしてあ り、もう岩のように頑強だという自信がある。もうすぐ 10 億通の最終テストを実施する予定だ。
送信経路もテストしてきた。はじめは限定的な環境でテストし、そのときはバグが見つからなかった。次に実環境でテストし、い くつか小さなバグが見つかって、その都度修正されていった。まだひとつ "memory usage" 関連の問題があるが、起こるのは非常に特 殊な高負荷環境であり、みんながふつうに経験するようなものではないし、パッチはある (commit できる状態にするまで、いくらか 作業が必要だが)。
こうしたストレステストのあいだに、いくつかの仮説に決着をつける大量の情報を集めることができた。今では、ほかの MTA たち と比べて自分がどこに立っているのかを知っているし、進展のある領域を精密に理解できるようにするツールも作ってある。自分たち が恥じる理由など、まったくないことは明白になっている。
OpenSMTPD は、とても効率の良いキューが書き出されるように設計されている。その根拠としては、すでに複数のバックエンドを 書いているので、API で費される時間、バックエンドで費される時間、ディスクベースのバックエンドではディスク IO の正確なコス トも知っているから、と言える。
ではとても速いキューなのだろう、と思うかもしれないが、既定のバックエンドは設計上、最適とは言えない。これは俺の中の管 理者の部分が、速度を犠牲にしてでも欲しいと思う機能があるからだ。
そうした機能のひとつは、エンベロープごとにファイルを用意し、かつエンベロープとメッセージ本文を分離することで、SMTP ト ランザクションをバックアップして別マシンで容易にレストアできる、とかそういうものだ。
こうやってユーザに親切にすると、ディスクを叩きすぎないようにするワザが使えず、エンベロープの数だけ open()-fsync()-close() するし、メッセージの数だけ mkdir()/rename() するし、そのうえキューの不可分性を扱うためにも、ファ イルシステム関連のコールをさらに必要とすることがある。このうち多くの部分は本来 OpenSMTPD に必要なく、もっとずっと効率の 良い方法で扱えるものだ……が、人間が監視・操作しやすいようにというだけの理由でこうなっている。
エンベロープをまとめてひとつのバイナリファイルに書き込めるキュー (fsync() が一回で済む) にすれば、速度は劇的にアップ する。将来どこかの時点でそれを可能にするのは確実だが、しかし既定のキューはいつでも、たとえ受信が遅くなろうとも、ユーザフ レンドリーであり続けるだろう。
とは言っても、やはりキューは速く実行してもらいたいし、設計上の問題は最小限にしたい。理想的には、競合製品の最適化され たキューと比べて 10% 以上は遅くならないようにしたい。そこで……、
しばらくキューのコードを追跡してみて、システムコールの発生パターンが思っていたのと違うことを突き止めた。OpenSMTPD の キューはロジックがとても単純で比例のパターンを持つので、いくつかのシステムコールは呼び出し回数がピッタリ一致するはずだっ た。そこで、kdump(1) の出力する各システムコール回数が理論上の最適値になるまで追いかけて修正した。キュー操作ひとつひとつをプロファイリングする のに役立つ関数も 2、3 追加し、それを Eric がもっと良いインタフェースにしてくれたので、キューのバックエンド開発に役立つプ ライスレスなツールが手に入った。
Eric はデータをもっと効率的にエンコード/デコードできるようにする API を作って、メモリ使用量の改善やプロセス間 IO の軽 量化も図ってくれた。それまでは構造体をそのまま渡していたが、それだと、ほんの数バイトしか使っていない巨大なバッファが入っ ているかもしれない。新しい API は、必要なデータを渡すだけでなく、そのタイプチェック機能も提供して、間違ったデータを渡さ ないよう確認できる。おまけに、この API なら型サイズを使ってデータの平均サイズを計算し、それ以上必要になったときにだけ realloc するということができる。
これが完成した時点で、送信経路については上々の出来、受信経路についてはほかの MTA に引けをとらない出来となった。改善の 余地はまだまだ大いにあるが、自分たちに課している制約を考えれば、まわりで一番遅い MTA の二倍以下の遅さにとどまっているこ とを嬉しく思う。本当に。
既定のキューは、「変わった」状況を甘く見ない設計だった。
予期せぬ事態になればいつでもデーモンが fatal で死ぬようになっていた。予期せぬ事態というのは起きないはずの事態なのだか ら、死んでも問題ないではないか?
いやいや。大アリだ。ふつうの環境では起こらないと言っても、人間は悪気なくファイルやディレクトリを chmod したりすること があり、そして……、 OpenSMTPD はただならぬ事態を察知して自殺を図ってしまうのだ。
正直なところ、キューの fatal なんて何ヶ月も報告がなかったし、 poolp.org でも一度も起きていないと思う。……ストレステス トまでは。
ストレスをかけると、fts(3) API の間違った使い方に起因する fatal() をいくつか経験した。それを修正して、決めた。キューのコードにある fatal() を片っ端 から一時的エラーに変換しよう、そうやって、障害が発生してもデーモンが広い心で対処してくれるようにしよう、と。
思っていたよりもずっと簡単だったので、こうして fs-queue は今や、キューにイタズラをする管理者にも対処できるようになっ ている。もちろん管理者がキューに手を加えるべきではないのだが、chmod(1) や mv(1) した程度で fatal() しないようにはするのは必要かな、と思って。
smtpd.conf ファイルには、挨拶のときに表示するホスト名を指定する "hostname" というディレクティブがあった。
これは廃止され、かわりに、リスナごとにホスト名を指定することができるようになっている。
listen on lo0 listen on 192.168.1.1 hostname mx1.int.poolp.org listen on 192.168.2.1 hostname mx2.int.poolp.org
指定しなければマシンのホスト名を使う。
アドレスのテーブルを使って、リレーのときの起点アドレスを smtpd.conf で上書き指定することができる。
table myaddrs { 192.168.1.2, 192.168.1.3 } accept for all relay source <myaddrs>
上の例ではアドレスのひとつを起点アドレスにバインドしてリレーする。しかし SMTP セッションの HELO/EHLO の段階で、 MTA は自分のホスト名を知らせなければいけない。そのホスト名がチェックされて、使っている起点アドレスと一致しないと拒否されるこ とがあるのだ。
それで、使っている IP アドレスに合った HELO/EHLO パラメータをリモートホストに提供する方法が必要になった。一時は MTA から DNS 解決をしてゴニョゴニョ、というアイディアもあったが、NAT のせいでうまくいかなかった。
新しく K_ADDRNAME という解決サービスを使って IP アドレスをホスト名にマップできるようにしてはどうかと提案した。
table myaddrs { 192.168.1.2, 192.168.1.3 } table myhelo { 192.168.1.2 => mx1.poolp.org, 192.168.1.3 => mx2.poolp.org } accept for all relay source <myaddrs> helo <myhelo>
こうすれば MTA は、まず myaddrs テーブルから起点アドレスを取り、 HELO/EHLO の時点では myhelo テーブルから (接続に使っ たアドレスに合致する) ホスト名を取るようになる。
しょっちゅう要求される機能がもうひとつある。差出人のメールアドレスをルールセットの適用条件に使えるようにすることだ。
最近まで、ルールの適否を見るときにクライアントの IP アドレスと宛先ドメインは見ていたが、「差出人が gilles@poolp.org のメールはすべて accept」とか「差出人が @redhat.com のメールはすべて reject」というようなことは表現できなかった。
そこで、 accept と reject 双方でメールアドレス全体かドメイン部分に適用される "sender" というフィルタリングを導入し た。動作は次のとおり。
# 差出人のドメインが @openbsd.org なら、どこからでも可 accept from any sender "@openbsd.org" for any relay # 差出人のドメインが @poolp.org なら localhost から可 accept sender "@poolp.org" for any relay # 差出人が gilles@poolp.org のときだけ、どこからでも可 (訳注:なら from any が足りないのでは?) accept sender gilles@poolp.org for any relay
relay と deliver のルールに適用でき、テーブルを使って、ネットワーク別にドメインやユーザ別のリレールールを適用できる。
table hackers { "@opensmtpd.org", "@poolp.org" } table slackers { richard@foot-cheese.org, lennart@thepig.org } accept from 192.168.1.0/24 sender <hackers> relay accept from 192.168.2.0/24 sender <slackers> relay via smtp://example.org
ある OpenBSD ユーザが ldapd の ssl.c にある問題を報告してくれた。DH パラメータに使っている素数が 512 ビットと (近年の 標準からすれば) 短すぎるので、 OpenLDAP クライアントから拒否されたのだ。OpenSMTPD の ssl.c はずっと前に素数を 1024 ビッ トに上げてあるのだが、 ldapd の ssl.c は実は 2 年前の OpenSMTPD のコピーだった。
OpenBSD デーモン間にある ssl.c の同期ズレは長らく頭痛のタネだが、今回のズレを直せば、将来 reyk@ が OpenIKED や relayd を進めるにつれてもっと乖離するかも、と思ったので、彼と少し話をして、非悪魔的バージョンの (つまりデーモンのことを知らない ) ssl.c を作ることにした。
この分野にはまだ仕事が残っているが、今のところ OpenSMTPD に同梱されているのは、 relayd と同じ ssl_privsep.c と、smtpd 固有の構造体をまったく知らないので他のデーモンと共有できる ssl.c, そして smtpd 固有の部分を入れた ssl_smtpd.c である。
ssl.c に新しいコードはなく、各種デーモンと共有できるようにインタフェースを作り直しただけということに注意。
監視コマンドが smtpctl ユーティリティに追加されている。これは実行中の OpenSMTPD インスタンスを管理者が容易に監視でき るようにするもので、状態を一秒ごとに表示して、やっていることをリアルタイムで監視できる。
自分自身ずっと欲しかった機能を追加した。実行中のデーモンを再起動することなく追跡を有効にするのだ。
あるリモートホストに対する接続が失敗したのを突然見つけたとしよう。こう打てばいい。
smtpctl trace transfer
すると、リモートホストにセッションを張るごとにリアルタイム表示される。
trace にはたくさんのサブシステムがある。内向き接続、外向き接続、プロセス間のメッセージ交換、などなど……、まあ man を読 もう。
ついでに。ボトルネックを検証する必要があるなら OpenSMTPD には imsg やキューをリアルタイムでプロファイリングする機能も ある。
smtpctl profile imsg
smtpctl profile queue
追跡もプロファイルも、その場でオンオフ可能だ。
Eric がコードを整理してくれて、 evpid で済むときはエンベロープを渡さないようになった。これでプロセス間のバッファに データを詰めこまずに済む (evpid は 64 ビットで、エンベロープ構造体は数キロバイト)。
本当にエンベロープを渡す必要のあるところでは圧縮してはどうかと Eric が言うので、ASCII のエンベロープ圧縮を提案した。 もうディスクベースのエンベロープに使って動作が実証されているからだ。この ASCII エンベロープは、一部しか使っていない巨大 フィールドを持つデータ構造をそのまま渡すかわりに、100 バイトほどにまで小さくなる ASCII 表現を渡すことで、エンベロープを 大いに圧縮することができる。
これで過酷な負荷状況にも前より対応できるはず。
わっかんねーだろーなー。
こまごまとしたバグを直してきたが、実機テストではまずお目にかからない非常に特殊な場合に発現するものだ。
こまかい修正・改良・書き直しもある。たとえば "relay backup" の MX パラメータは自機ホスト名から引いてくるので省略可能 にしたり、キュー削除の API はエンベロープではなく evpid を取るようにしたり、userinfo を使わない構造体から削除したり、 キューのバケットを作るとき msgid の末尾ではなく冒頭 2 バイトを使うようにしたり、特定の設定ファイルで起こる segfault を修 正したり、認証を一時的失敗にできるようにしたり、などなど。
デスクリプタのリークに由来すると思われるクラッシュの報告があった。Linux の lsof は、メッセージファイルが削除されても MTA プロセスがまだデスクリプタを開いたままだという表示だった。追跡の結果、宛先が拒否されたセッションを別のメッセージ送信 に再利用するとき、MTA プロセスの条件分岐漏れでメッセージファイルが閉じられなくて起きたのだと分かった。ツーライナー。玄関 あけたら二行で修正だ。
ログ書式も改良されており、人間に理解しやすく機械にも解釈および grep しやすい精密な書式を使って情報を最大限に提供する ために、各種の言いかえや変更をしてある。
RAM queue_backend の追加は、現時点ではおもにデバグにしか使えないが、デーモンを落としたときメールがなくなっても気にし ない人なら便利じゃないかな。
新しい dict_* API は、 tree_* API の親戚だがキーが char* なので、今後テーブルの扱いや管理に関する大量のコードを簡素化 させてくれるはず。
数百箇所にも及ぶ KNF 美化活動、昔の define の削除、無駄に遠回りをしていただけの構造体をなくすリファクタリング、同じ コードのまま簡潔に表現、なども……。
いろいろなバグも修正したが、その中にはエンベロープをスキップさせ起動時にクラッシュを引き起こすものもあった。
スケジューラ API は、実装が面倒な Qwalk API をやめて新しい Q_LEARN というキュー操作にすることで、改良された。
mailq は、「オンライン」モードに対応して、オフラインよりも多くの情報を提供できるようになった。オンラインモードとは、 スケジューラに問い合わせて確実なリアルタイム情報を取得するもの。オフラインモードは以前の動作と同じものだ。
整理整頓にも多くの時間をかけている。先週すでに 3 時間かけて KNF 修正したばかりだったが、今回の整理はコードと構造体を 標的にした。必要ない imsg 交換を数箇所で削ったり、あらゆる場面で受け渡していた巨大な構造体を用途ごとに分割したり。キュー と MFA をシンプルに書き直した結果、コードが読みやすく imsg 交換が追いやすくなった、良いことずくめだ。
コードベース全般の整理にも多くの時間をかけている。なかでも、 global struct smtpd から、いくつかのフィールドを特定の (そのフィールドを使っている) ファイルに static で分離した。これは、 API の層を踏み越えないようにするうえで役に立つ。
次に、モンスター構造体 submit_status を狩ることにかなりの時間を費した。あらゆる種類のプロセス間通信にこの構造体が使わ れていたが、必要な情報だけを持ち特定の受け渡し状況に特化した軽い構造体を複数用意することにした。これには各種プロセスで 少々手直しが必要になったが、結果としては混乱を減らし、コードはメンテも楽だし新入りにも読みやすくなった。
SMTP シナリオをスクリプト記述できるテスト環境の第一弾もある。将来は、新機能やバグ修正で退行 (regression) させていない か確認できる様々なシナリオを書くことになるだろう。
いつもながら、高品質を保つためにはテストが必須だ。最新の stable コードは OpenSMTPD のサイトからダウンロードできる。OpenBSD 版の tarball は -current (もしくは -current の libc/asr ディレクトリを持って きた -stable) でビルドできるはずだ。 portable 版は sqlite3, Berkeley DB, libevent のライブラリやヘッダ依存性が満たされてい れば Linux/FreeBSD/NetBSD/DragonflyBSD で問題なくコンパイルできるはず。