人間とウェブの未来

「ウェブの歴史は人類の歴史の繰り返し」という観点から色々勉強しています。

Pmilter: Programmable Mail Filter Serverを作った

Pmilterというサーバソフトウェアを作りました。

github.com

PmilterはProgrammable Mail Filterの略で、SMTPサーバ(送信や受信)とmilterプロトコルで通信し、SMTPサーバの送受信の振る舞いをRubyでコントロールできるサーバソフトウェアです。

これまでにも、milter managerやRubyのgemを使ってmilterサーバを作るといった素晴らしいソフトウェアがありました。ですが、今回僕がフルスクラッチで作りたかった理由としては、

  • とにかくインストールや設定がシンプルで運用しやすいサーバソフトウェアにしたい
  • ミドルウェアとして振る舞いを設定する感覚でRubyで制御する事に専念したい
  • 依存ライブラリを減らしワンバイナリでサーバに配置できるようにしたい
  • 設定変更に再起動することなくRubyを変更するだけで振る舞いを変えられるようにしたい
  • インフラエンジニアも楽しく簡単にコードを書きながらメールサーバを運用したい
  • spamメールや踏み台などに対して独自のアルゴリズムを簡単に実装してメールキューに入る前にrejectしたい

などがあったのと、後はWebばっかりじゃなくてメールに関するソフトウェアもちゃんと作っておきたいなと思ったからです。

実際作ってみるとSMTPやmilterプロトコルを勉強することにもなって、すごく楽しかったです。

ということで、以下にpmilterの使い方を簡単に紹介します。

pmilterの使い方

pmilterはmilterプロトコルでSMTPサーバと通信して制御できるソフトウェアです。milterプロトコルの詳細は省略しますが、簡単にいうと、SMTPサーバでクライアント(MUAだったりMTAだったり)から接続を受けた時に、各種コマンドでやり取りを行います。その際の、各種コマンドやイベント時に、milterサーバに対して通信を行って接続情報を渡し、受けたmilterサーバはその情報を元にフィルター処理や接続の可否などの処理を行って、結果をSMTPサーバに返します。sendmailやpostfixはmilterプロトコルに対応しているため、導入も設定に1行かけばよく平易です。

つまりイメージとしてはWebサーバと似ていて、リクエストからレスポンスを返すまでに各種イベントフェーズがあって、そのイベント毎にpmilterに処理をフックして、pmilterで処理を行い結果を返して、SMTPサーバ側の処理を拡張する、というような動きです。

pmilterはサーバとしてUnix Domain Socket、あるいは、TCPでListenして起動後、SMTPサーバから各種イベントで処理の依頼がきます。そのタイミングで、Rubyのスクリプトをフックしておくことができます。Rubyのスクリプト内では Pmilter クラスによってSMTPサーバへの各種接続情報を取りだすことができ、そのデータを元にRuby側で処理を行い(例えば、クライントIPアドレスやenvelopeの情報、ヘッダやボディ等をDBに保存して解析してDoSっぽいメールは一時的に弾くなど)結果をSMTPサーバに伝える事ができます。

Rubyスクリプトを登録できるフックは以下の pmilter.conf にあるように、様々なフェーズを選択することができます。

  • pmilter.conf
[server]
# hoge.sock or ipaddree:port
listen = "/var/spool/postfix/pmilter/pmilter.sock"
timeout = 7210
log_level = "notice"
mruby_handler = true
listen_backlog = 128
debug = 0

[handler]
# connection info filter handler
mruby_connect_handler = "handler/connect.rb"

# SMTP HELO command filter handler
mruby_helo_handler = "handler/helo.rb"

# envelope sender filter handler
mruby_envfrom_handler = "handler/mail_from.rb"

# envelope recipient filter handler
mruby_envrcpt_handler = "handler/rcpt_to.rb"

## header filter handler
mruby_header_handler = "handler/header.rb"

# end of header handler
#mruby_eoh_handler = "/path/to/handler.rb"

# body block filter handler
mruby_body_handler = "handler/body.rb"

# end of message handler
mruby_eom_handler = "handler/eom.rb"

# message aborted handler
#mruby_abort_handler = "/path/to/handler.rb"

# connection cleanup handler
#mruby_close_handler = "/path/to/handler.rb"

# unknown SMTP commands handler
#mruby_unknown_handler = "/path/to/handler.rb"

## DATA command handler
#mruby_data_handler = "/path/to/handler.rb"

例えばここで登録しているRubyスクリプトは以下のような内容で書いていたとします。サンプルなので平易なコードにしています。

  • handler/connect.rb
puts "hello pmilter handler called from #{Pmilter.name}"
puts "client ipaddr #{Pmilter::Session.new.client_ipaddr}"
puts "client hostname #{Pmilter::Session.new.client_hostname}"
puts "client daemon #{Pmilter::Session.new.client_daemon}"
puts "handler phase name: #{Pmilter::Session.new.handler_phase_name}"

コネクトフェーズでは色々とコネクト情報が取得できます。

  • handler/helo.rb
puts "helo hostname: #{Pmilter::Session.new.helo_hostname}"
puts "tls client issuer: #{Pmilter::Session.new.cert_issuer}"
puts "tls client subject: #{Pmilter::Session.new.cert_subject}"
puts "tls session key size: #{Pmilter::Session.new.cipher_bits}"
puts "tls encrypt method: #{Pmilter::Session.new.cipher}"
puts "tls version: #{Pmilter::Session.new.tls_version}"
  • handler/mail_from.rb
puts "env from from args: #{Pmilter::Session.new.envelope_from}"
puts "env from from symval: #{Pmilter::Session.new.mail_addr}"
puts "SASL login name: #{Pmilter::Session.new.auth_authen}"
puts "SASL login sender: #{Pmilter::Session.new.auth_author}"
puts "SASL login type: #{Pmilter::Session.new.auth_type}"

if Pmilter::Session.new.envelope_from == "<spam-from@example.com>"
  Pmilter.status = Pmilter::SMFIS_REJECT
end

上記のようにREJECTのステータスをセットすると、SMTPもクライアント(MUAやMTA)からの接続をちゃんとREJECTしてくれますので、ここに何かデータを元にアクセス制御するなんてことも簡単にできるでしょう。mruby-geoipを使って、ある特定のFromや怪しいSubjectが大量にきていて、かつ、接続元のIPアドレスが怪しいカントリーコードだったら、10秒間弾く、なんてこともRubyで実装すればそれほど苦なく実装できるのではないかと思います。

  • handler/rcpt_to.rb
puts "env to from arg: #{Pmilter::Session.new.envelope_to}"
puts "env to from symval: #{Pmilter::Session.new.rcpt_addr}"
  • handler/eom.rb
puts "myhostname: #{Pmilter::Session.new.myhostname}"
puts "message_id: #{Pmilter::Session.new.message_id}"
puts "reveive_time: #{Time.at Pmilter::Session.new.receive_time}"
puts "add_header(X-Pmilter:True): #{Pmilter::Session::Headers.new['X-Pmilter'] = 'Enable'}"

まだあまり変更方面のメソッドはあえて実装していませんが、一応ヘッダを追加するメソッドだけは実装しています。このあたりは、色々と様子を見ながら実装していきたいなと思っています。

  • handler/header.rb
puts "header: #{Pmilter::Session::Headers.new.header}"
  • handler/body.rb
puts "body chunk; #{Pmilter::Session.new.body_chunk}"

# Skip over rest of same callbacks
# only once call body handler when return Pmilter::SMFIS_SKIP
Pmilter.status = Pmilter::SMFIS_SKIP

上記のheaderフェーズやbodyフェーズは、headerの行毎やbodyのchunk毎にコールバックされますが、必要な処理が途中で終わったら、SKIPステータスを返すことで同様のフェーズの後続のコールバックはしないようにするなども可能です。

また、これらのRubyコードは、pmilter起動中であっても再起動することなく変更可能です。

そして、postfixで以下のようにpmilterサーバを登録します。

  • postfix main.cf
# postfix chroot on /var/spool/postfix
# create pmilter.socket as /var/spool/postfix/pmilter/pmilter.sock
smtpd_milters = unix:/pmilter/pmilter.sock

その後、postfixを起動させて、postfixを介してメールを送ったり受信したりすると、以下のようにmiterと通信してmilterのRubyの処理が行われている事が分かると思います。

hello pmilter handler called from pmilter
client ipaddr 192.168.123.123
client hostname mx.example.net
client daemon milter-test-server
handler phase name: mruby_connect_handler
helo hostname: delian
tls client issuer: cert_issuer
tls client subject: cert_subject
tls session key size: 0
tls encrypt method: 0
tls version: 0
env from from args: <from@example.com>
env from from symval: mail_addr
SASL login name:
SASL login sender:
SASL login type:
env to from arg: <to@example.com>
env to from symval: <to@example.com>
header: {"From"=>"<from@example.com>"}
header: {"To"=>"<to@example.com>"}
header: {"Subject"=>"Hello"}
body chunk; Hello world!!
myhostname: mail.example.com
message_id: message-id
reveive_time: Wed Nov 02 21:02:15 2016
add_header(X-Pmilter:True): Enable

この例では、単にputsするだけのものですが、mrbgemなどで色々と拡張しつつ、独自のアクセス制御などが簡単に実装できると思います。例えば、http-dos-detectorというApacheやnginx用のDoS検知ソフトウェアを以前同じような方法で作りましたが、smtp-dos-detectorなども簡単に作ることができるでしょう。pmilterを使ったメールの拡張実装をOSSで公開するといのも良いでしょう。

hb.matsumoto-r.jp

その他、GitHubにベンチマークやその他の参考情報を載せていますが、いかんせん新作なので、是非手元で色々と試して頂ければよいかと思います。

まとめ

ということで、メールサーバをこれから作る人も、これまでのメールサーバを運用する人も、ワンバイナリで簡単にデプロイでき、何個でも起動させて、localでunix domain socketで通信してSMTPサーバをRubyで制御するもよし、TCPでリッスンして複数のSMTPサーバで単一のpmilterと通信して、全体へのアクセスを一元管理で制御したりデータを保存したりするもよし、複数のpmilterを立ててpostfixにpmilterを複数登録するもよし、などなどRubyで簡単にSMTPの挙動やアクセス制御といった実装を簡単にできるようになりました。

また、MXとしてのSMTPや送信サーバ用のSMTPなど、様々な場所でpmilterを扱えるので、spamの受信をどう制御するか、spamの踏み台やメール送信しすぎ問題をどう制御するか、なども、Rubyで色々と試行錯誤して実装することができるでしょう。

また、pmitlerはできるだけシンプルな作りにしているので、今までなかなか手が届かなかったメールサーバの複雑な処理を、ほとんどRubyの知識がなくても設定の感覚で簡単にかけるのではないか(上記の設定をみてもわかるとおり、複雑な実装はしていない)と思うので、まずは色々と試して頂ければ幸いです。そこからメールの楽しさに気付くかもしれません。

特に、mod_mrubyやngx_mrubyを触っている人は、ほとんど同じような感覚で実装できる上に、そのこで使った資産(KVSなど色々)を活用できると思うので、是非お試しください。