From undeadly.

(5 月 5〜10 日、東京で遂行された Network Hackathon に関する Damien Miller (djm@) のレポート。)

n2k8 は素敵なイベントでした。運営も会場も良くて、全員すばらしい時を過ごしました。 で、ぼく (djm@) がやったことは……

tcpbench

前半では TCP パフォーマンスを調査して Markus Friedl (markus@) のパッチで遊んでました。 このパッチは TCP の送信バッファを自動調節して、 帯域×遅延の積が 大きい接続をもっと上手に扱うためのものです。 まずは 1 秒ごとに統計情報を出すようアレンジした netcat [nc(1)] でちょっとテストしてみたのですが、即刻、もっと良いツールが必要だということになりました。 ベンチマークの優れたツールは結構いろいろあるのですが、ぼくの場合は カーネルの TCP 実装が転送中どう振る舞っているかがもっとよく分かるもの、そしてカーネルを printf() だらけにするよりもっとエレガントなものが必要でした。

そういうわけで生まれたのが tcpbench(1) です。このツールは可能な限り素早く TCP ストリームでデータを送りつつ 帯域の統計を表示する、シンプルなクライアント/サーバです。 tcpbench には、このシンプルな基本機能に加えて kvm(3) 経由で TCP セッションに関わるカーネル変数 so_send, so_recv, inpcb, tcpcb のほとんどを採取・表示する能力もあります。これによって、 データの送受信時に TCP スタックが何をしているのかがとても見えやすくなりました。 おそらく、スタックのさらなる改良に役立つことでしょう。

MaxSessions

OpenSSH は何年にもわたって、ひとつの SSH 接続で複数の シェル/ログイン/ファイル転送セッションを持てるようにしてきました。 その間ずっと、最大セッション数は 10 に固定されていました。n2k8 では、これを sshd_config(5) の新しいノブ MaxSessions でランタイム制御できるようにしました。 おかげで、この制限をすこし緩めたい管理者の助けになっています。 また "MaxSessions 0" と指定することで、 ポートフォワードには影響のないままログインとシェル実行を完全に無効化することもできます。 別の利用法としては、接続多重化を無効化する "MaxSessions 1" があります。

MaxSessions が小さな数に固定されていた理由は、セッションひとつでも びっくりするほどたくさんのファイルデスクリプタを使うので、 数を大きくすると簡単に sshd のぶんを使い切ってしまうというのがあります。 しかも sshd は、デスクリプタが足りなくなるとうまく対処できませんでした。 ほとんどの場合は fatal error したり、空きを作ろうとしてリークしたりするのです。 そこでぼくは MaxSessions を変動可能にする一環として、 この点を改善すべく sshd を監査し、見つかる限りのダメ動作を修正しました。 とは言っても不正な動作がまだウヨウヨしてるかもしれませんので、MaxSessions は ファイルデスクリプタを枯渇させない値にするよう、強くおすすめします。 いちばん良いテスト方法は、MaxSessions の数だけセッションを上げた sshd で fstat(1) を実行し、sshd 起動時の ulimits と比較することです。

この fd 監査で、いくつかの fatal() エラーコールを単なる通知に変えました。 しかし残念ながら ssh クライアントは、シェル/ログインセッションが拒否された と言われることなど予想しておらず、そういう時はハングしてしまっていました。 なので、開始したセッションすべてに確認を求めるよう、ssh にも手を加えました。 サーバから確認の応答が返ってきたらチェックしたり、 何かがうまくいかなければエラーメッセージのかたちでフィードバックしたりします。

Hackers!
OpenSSH 書き込みエラー

金曜の夜おそく Markus とぼくで、OpenSSH の bugzilla データベース最古のバグを ひとつつぶしました。SSH プロトコル 2 の上でリモート実行されたコマンドが ローカル出力のファイルデスクリプタを閉じられたとき、閉じたというシグナルが リモートコマンドに届いていなかったのです。なぜこれが重大なのかというと、この例を見てください。

cat /dev/zero | true

ふつうのシェルでのリダイレクトなら "true" はさっさと終了して stdin デスクリプタを閉じます。 "cat" はすぐそれに気づいて、自分からすれば stdout が閉じられたわけなので、即刻停止します。 ssh で同様にすると

ssh remotehost "cat /dev/zero" | true

ですが、ssh は stdout 閉鎖に気づいているものの、"cat" しているリモート sshd には報告されないため、"cat" は知らずに気楽にデータを送りつづけるのです。

SSH プロトコルで、あらゆるシェル、ログイン、フォワードの動きは "channel" (チャンネル) というプロトコル概念の中で起こっています。これによって各活動が共存し SSH 転送 (TCP 上) の接続を共有することができているのです。 さて、今回の問題を直すにはちょっとしたヒネリが必要でした。というのも、 SSH 2 のチャンネルプロトコルには完全にチャンネルを閉じるシグナルがあって、 ローカル側がもうデータを送らないようにすることはできるのですが、 リモート側のデータを止めさせるシグナルの標準規格はないのです。 ただ面白いことに、SSH プロトコル 1 ではこういうシグナルをサポートしていて、 今回のバグは出現していませんでした。

解決策 (Markus により実装) は新たなチャンネルプロトコル要求 "eow@openssh.com" でした。 SSH プロトコルにはベンダが新しく要求を定義できる cool な拡張機構があり、 そのベンダのドメイン名で決まる名前空間を使うので後方互換性があります。 サポートしていない SSH 実装では無視されるだけなのです。OpenSSH-CVS は今後、出力ファイルデスクリプタが閉じるとピアにメッセージを送るようになります。 ピアは (こちらも OpenSSH-CVS だとすれば) このメッセージを チャンネルの出力側が閉じてしまったしるしとして受け取り、 ローカルの入力ファイルデスクリプタを閉じることになるわけです。おかげで OpenSSH はこれまでよりも少し透過型で「シェルっぽい」動作になります。

ただ、これだけではバグ修正に至りませんでした。OpenSSH は双方向の socketpair(2) で子プロセスと通信していて、入力にも出力にも同じデスクリプタを使っているので、 これを閉じると両方とも閉じてしまうのです。まず Markus が shutdown() で半閉じしようとしましたが、欲しかったシグナル解釈にはなりませんでした (この件はまだ調査の必要あり)。でも嬉しいことに、解決策がありました。 socketpair の代わりに pipe を使うというものです。OpenSSH はかつて pipe を使っていましたが、socketpair のほうが ログイン/シェルセッションごとにいくつかファイルデスクリプタを節約できるので、 数年前に切り換えたのです (socketpair はいつでも双方向だが、pipe にその保証はない)。 Markus は pipe 版のコードを蘇生させ、デフォルトに戻しました。こうして ぼくらの欲しかった「閉じ」解釈が手に入ったというわけです。

この時点でもうパッチをコミットする準備がほとんどできていたのですが、 ひとつ問題に気づきました。ssh が、終了しようとするまさにその時 fatal error になることがあるのです。これは出たり出なかったりしていましたが、とあるテストケースに 絞りこむことができました。

ssh "od /sbin/isakmpd; echo ok 1>&2" | true

リモートコマンドの最初の部分 ("od /sbin/isakmpd") は、 出力チャンネルが閉じたときに残りを途切れさせる出力というだけです。 次の部分 ("echo ok 1>&2") は拡張 (stderr) チャンネルを開いたままにするためのものです。いろいろデバグして分かったのは、 チャンネルの閉鎖とチャンネルを使うログイン/シェルセッションの閉鎖との ややこしい相互依存を取り扱っているコードに、競合状態があるということです。 このせいでリモート側は、もう閉じてあるファイルデスクリプタで select() してしまい、エラーになっていたのです。Markus はチャンネルの stderr 部分を閉じる条件をきつくして、クラッシュバグを直しました。

この修正には深夜のものすごく長い時間がかかってしまいましたが、 まあ簡単なものだったら何年も前にやってあったでしょうからね……。

At the Onsen
OpenSSH TCP 接続

もうひとつのバグは、これもほぼ同じくらい長生きなのですが、 ホスト名で指定された先に OpenSSH が接続をフォワードしようとするとき、DNS から返ってきた 最初のアドレスしか試さないというものです。その接続が拒否されると、 残りのアドレスは無視されてしまっていました。

OpenSSH はノンブロッキングな connect() を使うことで、遅いポートフォワード接続が他の接続を止めてしまうことのないようにしています。なので、 単に順番どおり試行していくだけでは修正できないのですが、まあかなり直球勝負でいけました。 getaddrinfo() から返ってきたアドレスの連結リストをコピーしておいて、前のが失敗したら次を試すというようにしたのです。

ここで始まった作業

n2k8 の序盤、Markus とぼくは TCP パフォーマンスの改善について話し合いました。 ぼくらの TCP 実装は送受信バッファのサイズについてとても保守的で 自動調節は一切ありませんので、帯域×遅延の積が大きいリンク (たとえばオーストラリア・カナダ間) ではあまり性能が出ないのです。 しかし間もなく、TCP コードや調節方法をぼくがじゅうぶん分かっていない、 ということが明らかになったので、今回の hackathon では少しのあいだ、 スタック (煙突?) や Markus のパッチを素人いじりしたり、 自動調節技術の論文を読んだりして過ごしました。 できれば通常の OpenBSD hackathon でコードを書くのに間に合うよう、 もっと利口になりたいです、マル。

他には、ツリーにふたつ残っている簡素な 線形合同法 ID 生成コード (両方 IPv6) を、交換または改善できないものかと眺めたりしていました。 どちらもまだ、最近 bind リゾルバや IP の ID 生成に報告された予測攻撃に対して脆弱だとは 言われていませんが、ぼくたちとしては変更が必要になるまで攻撃を待っていたいとは思いません。 片方はいとも簡単に直せますが、もう片方はちょっと面倒っぽいです。"

Damien