RustとDNSの1年

(注:2016/09/28、いただいたフィードバックを元に翻訳を修正いたしました。)

この記事は、RustやDNSの使い方を皆さんにお教えするためのものではありません。むしろ、私がDNSクライアント/サーバをRustで開発した時に面白いなと思った点について書く日記のようなものです。

約1年半前のことですが、私は史上最高とも言えるプログラミング言語と出会いました。それは私がGo言語を学んでいる最中のことでした。Goは学習していて楽しい言語で、Java出身の私は特にひとつの点を素晴らしいと評価しました。それは、シングルバイナリをコンパイルできるし、それをデプロイしたり実行するのも早くて簡単だという点です。正直言って、Goでプログラムを書いて初めて、C言語のスタティックバイナリをどれほど気に入っていたか気付いたのです。クラスパスはないし、デフォルトのメモリ設定をいじることもなく、デフォルトのガベージコレクタを変更する必要もありません。Goはとてもいい言語ですが、私にとってはいくつか問題がありました。ジェネリクスがなく、例外タイプや検査例外もなく、「ガベッジコレクタがついたC言語」を書いている気分になってしまいました(でも、Cよりは簡単ですし、メモリセーフという大きなボーナスもあります)。

それから、私はHacker NewsでRustと呼ばれる新しい言語が話題になっているのを見かけ始めました。当時は1.0のリリースに向けていた時期でしたが、私が初めて試してみたのは0.8だったと思います。私はRust by Exampleに助けられながら、その言語を学び始めました。私は何かを書くたびに、古くて濁ったプログラミング知識の膜が自分の目からキレイに拭き取られていくのを感じました。その光を見てしまったら、この約束の地に降り立ってしまったら、もう引き返すことはできません。同僚たちは全員、私がするRustの話に飽き飽きしているようでした。ときどき自分が熱心なRust教徒の牧師になった気分になりました。しかし、この時点では私がRustで書いたもので、何か形になっていたものはまだありませんでした。

そして、コンピュータの神様は「DNSで書き直しなさい」と言った

CVE-2015-5477がBIND9で発生しましたが、聞くところによるとかなりひどいもののようでした。少し調べてみると、私はすぐに納得しましたが、BINDの一番大きな問題は「Cで書かれていること」というのが明らかとなりました。バッファオーバーフローや、配列の範囲外へのアクセス、競合状態などです。時間があれば、全部のリストを見てみてください。このうちの大体50%ほどが、(安全な)Rustを使うことで避けられる問題だと私は思っています。BIND9は唯一のDNSサーバなのでしょうか?答えはノーです。しかし、最も広く利用されているのがBIND9なのです。

今までの話を聞いて「私がCを嫌っている」とお思いかもしれませんが、Cは私がおそらく一番気に入っている言語でもあるのです。純粋で、むきだしの力があります。しかし、デバッグに関しては苦労させられますし、次のような疑問がいつもついて回ります。「NULL終端されていないバッファでstrstr()を呼び出したら、どうなるのだろうか?」と(そう、この疑問を追求するのはとても面白いはずですよ、Kevin G)。

ということで、私は取りかかりました。最初にガッカリするポイントは、仕様がないこと。あるのは1987年にまでさかのぼるRFCです。具体的には、RFC 1034RFC 1035です。幸運にも、IETFがRFCの状況を記していますし(RFCのページの冒頭のカラーコーディング)、「このRFCを更新するRFC」「このRFCにより廃止されるRFC」といった前後への参照もあります。私の初めの仕事は、実装をしようと思っていたすべてのRFCを整理することでした(このリストはこの先、常に変わっていきます)。まずはRFC 1035から始めました。

そして、私がこの作業を始めたのは、本業の傍らでのことでした。ちなみに、私はまだ幼い子供が2人いて、仕事もフルタイムでやっています。私にとって「傍らで」何かすることは決して簡単なことではありません。1週間で6時間も作業ができればいいほうです。

DNSクライアントとサーバの誕生

README.mdは以下のとおりです。

# trust-dns
A Rust based DNS server

コミットは以下です。

commit a3496cebf37c5e88bfbd4d7c5f036afe1d61cf6d
Author: Benjamin Fry <benjaminfry@me.com>
Date:   Fri Aug 7 19:47:12 2015 -0700

    Initial commit

RFC 1035の基本的なところをほぼ終わらせるのに数週間かかりました。私が実際にRustに最初に感謝したのは、うまく定義された固定サイズの整数型、u8u16u32u64でした。バイナリストリームを再度パースするのは本当に面白い作業でした。ここ10年ほどはJavaを使い続けていたのですが、Javaではビットシフトがこんなに直感的であると思いもしませんでした(Javaには符号付き整数型しかなかったので)。これはCに戻っているような感覚でした。DNSヘッダのパースの一例が以下です(この記事の書いている時点でのもの)。

fn read(decoder: &mut BinDecoder) -> DecodeResult<Self> {
  let id = try!(decoder.read_u16());

  let q_opcd_a_t_r = try!(decoder.pop());
  // if the first bit is set
  let message_type = if (0x80 & q_opcd_a_t_r) == 0x80 { MessageType::Response } else { MessageType::Query };
  // the 4bit opcode, masked and then shifted right 3bits for the u8...
  let op_code: OpCode = ((0x78 & q_opcd_a_t_r) >> 3).into();
  let authoritative = (0x4 & q_opcd_a_t_r) == 0x4;
  let truncation = (0x2 & q_opcd_a_t_r) == 0x2;
  let recursion_desired = (0x1 & q_opcd_a_t_r) == 0x1;

  let r_z_ad_cd_rcod = try!(decoder.pop()); // fail fast...
  let recursion_available = (0b1000_0000 & r_z_ad_cd_rcod) == 0b1000_0000;
  let authentic_data = (0b0010_0000 & r_z_ad_cd_rcod) == 0b0010_0000;
  let checking_disabled = (0b0001_0000 & r_z_ad_cd_rcod) == 0b0001_0000;
  let response_code: u8 = 0x0F & r_z_ad_cd_rcod;

  let query_count = try!(decoder.read_u16());
  let answer_count = try!(decoder.read_u16());
  let name_server_count = try!(decoder.read_u16());
  let additional_count = try!(decoder.read_u16());

  Ok(Header { id: id, message_type: message_type, op_code: op_code, authoritative: authoritative,
    truncation: truncation, recursion_desired: recursion_desired,
    recursion_available: recursion_available,
    authentic_data: authentic_data, checking_disabled: checking_disabled,
    response_code: response_code,
    query_count: query_count, answer_count: answer_count,
    name_server_count: name_server_count, additional_count: additional_count })
  }

これらの各動作で、それぞれの変数の各ビットに何が格納されているかを私は正確に把握しています。いくつか、まだ見直して変更を加え切れてないものもありますが、ビットをチェックするのに、この整数型のバイナリフォーマットを使うことに決めました。そのほうがより明確だと考えたからです(上記のコードに残っている16進数とは対照的です。そのうち整理しなきゃとは思うのですが、「壊れていないものを直すな」ってやつです)。

  let recursion_available = (0b1000_0000 & r_z_ad_cd_rcod) == 0b1000_0000;

Rustにまだ馴染みのない人向けに言うと、==演算の結果はブーリアンなので、recursion_availableの型が推論されます。また、私の命名が一見変なのは分かっていますが、それぞれの文字がビットを表しています。読み込んでいるビットフィールドを視覚的に理解するのに役に立っています。

このビットシフトの例が以下です。

let op_code: OpCode = ((0x78 & q_opcd_a_t_r) >> 3).into();

この演算をJavaの32ビット限度でするとしたら、必要なのが>>>>>のどちらなのかを思い出すために、私は自分の頭をかいていたでしょう(実際、JUnitテストのほうが簡単でしょう)。私はその答えを知っていますが、みなさんは分かりますか?Rustでは、こんな疑問とは無縁です。上記のものを以下のように変更しました。こっちのほうが断然、分かりやすいと私は思います。

  let op_code: OpCode = ((0b0_1111_0_0_0 & q_opcd_a_t_r) >> 3).into();

バイナリリテラルはJavaもちょうど1.7でサポートしたところですが、これも含めてRustの人間工学には素晴らしいものがあると言わざるを得ません。リテラルを_で区切って見やすくすることができるのも素晴らしいです。多くの人は、let million = 1_000_000のように、単に,の代わりに使うことが多いでしょう。上記のコードをもう少し詳細に分析すると、into()Fromトレイトの実装からの機能なのです。

impl From<u8> for OpCode {
  fn from(value: u8) -> Self {
    match value {
      0 => OpCode::Query,
      2 => OpCode::Status,
      4 => OpCode::Notify,
      5 => OpCode::Update,
      _ => panic!("unimplemented code: {}", value),
    }
  }
}

上記のコードは、整数型でのマッチングやDNSのOpCode列挙型への変換の基本的な使い方を示しています。注意したいのはpanic!です。そうです、これは論理的なバグです。これはちょうどIssueとしてまとめたところでした。基本的には、このpanic!は、誰かが正しくないOpCodeを送ってきた時に、サーバをクラッシュさせてしまいます。これには重要なポイントがはらんでいます。

Rustはロジックのバグを防げない

Rustはメモリリーク1と、並行性に関するバグの一部、その他のバグを回避します。特効薬というわけではなさそうですが、メモリアクセス問題に煩わされる必要はなくなるでしょうか?ヌルポインタのデリファレンスは?メモリリーク1は?Javaを使い始めた頃には、そうするだけの理由がありました。しかし今ではシステムレベルプログラミングに、しかもJavaより安全が保障される言語で戻ってこられるのです。

panic!は私がRustにおけるエラーに対して不安を感じていたころの名残りです。Rustで起こる例外処理の変更はそれ自体を単純化するものがあります。エラーチェーンは、とても簡素化されたエラー型の定義とです。私はコードパスで遭遇するだろう領域から全てのpanic!を使ったケースを取り除けたと思いました。決して完璧とは言いませんでしたが!(この記事を書くことがバグを解明する助けとなったので、たとえ、この記事を読んでくれる人が誰もいなくても、意義はあったのです)。

エラーを処理すべし

Rustではエラーを無視することができません。Javaの検査例外と同じようなことです(ところで、私は「全ての例外はチェックされるべき」派です)。Rustはこの点においてJavaに似たプラクティスを用います。もしエラーの処理方法が分からないのであれば、再度スローします(Javaにおけるベストプラクティス)。私は上記のFromを近いうちに書き換えるつもりですが、最も単純な形での例外処理を見てみましょう。

let query_count = try!(decoder.read_u16());

上記のコードは、デコーダのバイトストリームから次のu16を読み込みます。もちろん、これが失敗することもありえます。原因としては、「u16を読み込むのに十分なバイトが残されていない」「TcpStreamによって返されて接続が失敗する」といったものが考えられます。しかし、このコンテキストでは、このようなエラーに対して何ができるでしょうか。実は何もできません。リカバーする方法はないので、再度スローします。これがtry!の働きです。しかしRustではJavaほど単純ではありません。Rustは静的型付き言語です。この記事では例外処理に伴う弱点以外まで突っ込んでいく気はありません。例外処理についてはここで詳しく学ぶことができます。

Rustは「Result型を通してエラーを伝播させるか、あるいは処理するように強制する」という賢い決断をしました。Rustは強く型付けされているため、エラーを返すような関数呼び出しはどれも、全ての内部の関数呼び出しを補償する必要があります。

上記の例では、必要なのはread_u16()から返されたDecodeErrorを処理することだけです。しかし、Client::query()では、クライアントエラーを返します。これはただの異なる型の共用体です。

links {
  super::decode_error::Error, super::decode_error::ErrorKind, Decode;
  super::encode_error::Error, super::encode_error::ErrorKind, Encode;
}

Rustのenumは、むしろC言語の共用体に等しいので、型それ自体が大きくなることはありません。定義はそれぞれの付け加えられたエラーとともに拡張されます。Clientは、明らかにメッセージの送受信という2つのオペレーションを実行するので、DecodeErrorまたはEncodeErrorの可能性がどうしても残ります。この問題を処理するオーバーヘッドは最初やっかいでしたが、問題の理解が進むにつれて、それほど大したものではなくなりました。そして、またerror_chainによってさらに単純になったのです。

RFC1035の実装は意外に難しかった

サーバコードと権限に関する何度かのイテレーションの後、ついに2015年の9月、完全に機能するDNSサーバとクライアントができました。しかし、それは私が目指すものではありませんでした。私が目指しているのは、個人的に楽しむためにDNSCryptを付け加えて、DNSSecを実装することでした。さらに、自慢できるようなダイナミックライブラリも目指していました。

この時、私はふと立ち止まって考えました。つまり、DNSSecはどのくらい難しいかと考えたのです。RFCについて色々と述べたことを覚えていますか?DNSSecは幾つかの改訂を経てきました。過去のRFCの残骸の痕跡は次のようにたどることができます。RFC2065(1997)はRFC2535(1999)へと改訂され、さらにRFC4033、RFC4034、RFC4035(2005)へと改訂されました。これらのRFCに対し、RFC6840(2013)において重要な明確化が行われました。私はその時は見逃しましたが最近修正をしたので、issue #27を見てください。これは、以下の素晴らしい一節に起因するものです。

上記の段落のガイダンスは以前の版の内容と異なりますが、最新の一般的なプラクティスから構成されています。[RFC4034]のセクション6.2の3項では、これらのリソースレコードタイプの名前はどちらも小文字に変換されるべきであると述べています。以前の[RFC3755]では、小文字に変換するべきではないと述べています。最新のプラクティスは、どちらのドキュメントにも完全には従っていません。

どういうことか分かりますか?誰もこの仕様を適切に実装しなかったので、今や「実装こそが仕様である」ということになってしまっているのです。これは本末転倒ではないでしょうか。

いずれにせよ、意外に難しいということにちょっと戻りましょう。多分、図にすると分かりやすいでしょう。

Trust-DNS work history
注釈:
無我夢中の頂上(blindmans peak)
絶望の穴(pit of dispair)
破滅の谷(valley of doom)
救いの頂上(saviors peak)

無我夢中の頂上で、私は絶好調でした。Rustに夢中な私を止められるものは何もありませんでした。私は非同期IOライブラリmioといったものへのサポートの追加を楽しんでいました(時間があればfuturesへの移行も検討しています)。その後私は「DNSSecのサポートを追加する時だ」と決心しました。前述したRFCやその他のものを、何度も読み直しました。私は実装を始めましたが、「パースし、その後適切に認証する、という手順のためにRRSIGを導入する」ということができるように何度も試みるうち、やがて絶望の穴に落ち込みました。分かったことと言えば、RustのOpenSSL ポートにおいてさらなるメソッドが必要だということぐらいでした。それは、OpenSSLについて十分過ぎるほど学んだ後のことでした(本当に驚いたのですが、私はそこで見たものを忘れられません。Rustではなく、Cだったのです)。

絶望の穴に落ち込んでいる間、私は次のような疑問を抱き、何度もあきらめようと思いました。「要点は何なのか?」、「世の中にDNSサーバはいくらでもある。Daniel Julius BernsteinのDNSサーバは堅実だ。私は本当に何か新しいことができるのだろうか?」、「これを使おうという人がいるのだろうか?」。私にはこれらの疑問に対するしっかりとした答えがありました。それは、最終的にこの実装を独自のものとするような答えであり、さらに、私がDNSにかねてから抱いていた課題に対する回答となるような機能を提供できるものでした。私は地道に取り組み、着々と前進しました。その後、私の署名ロジックはどれも適切に機能しないということが分かりました。このことで、私は破滅の谷に突き落とされました。自分に問いかけていた疑問は、どんどん大きくなるばかりでした。「時間のムダだからやめてしまえ」。しかし、どうしてやめられるでしょうか。ここまでたどり着いたのですから、最後までやり遂げなければなりません。前に戻ってDNSSecのRFCについて全て読み返す必要がありました。そして、どこで大失敗したのかを理解したのです。

その後、救いの頂上へと登っていきましたが、これは大変な道のりでした。ベイエリアのサイクリストにとっては、イーストベイのスリーベアーズを走ることに似ています。スリーベアーズにはベイビーベアーとママベアーという、きつい坂があります。更にパパベアーには偽ピークがあるため、登り切ったと思った後に、もう一度、最後の険しい道を登らなければ頂上にたどり着けません。それでも、下り坂を時速70キロ以上で走るスリルを楽しめると考えれば、頂上まで登る価値はあります。

テストをする人生

私が驚かされ、しかも全ての言語が本来備えているべきだと思った機能は、単純な#[test]アノテーションでの埋め込みテストでした。これにより、テストバイナリをcargo testコマンドで生成できます。ほとんどの言語は、通常はprintln!("hello world")というコードを含む、単純なmain()を書くところから始めますが、Rustではテストから始めるのが比較的容易です。私はおよそ84%のカバレッジを達成しており、さらに上げることもできます。しかし、レポートによると、検出されたバグはほとんど含まれているものの、統合テストでは、私が最近Travisで実行していないcargo test -- --ignoredが含まれていませんでした。無料CIサービスのTCPサーバでは問題がありそうです。私は85~90%を大きく上回り過ぎるカバレッジをいいと思ったことはありません。収穫逓減が予想されるからです。Dijkstraは次のように述べています。

テストは、バグがあることを示す非常に効果的な方法だが、残念なことにバグがないことを的確に示すことはできない。

私が気に入っているのはサーバコード用のスレッドテストを書く機能です。Rustでは、私がJavaで行っていた方法に似たテストをもっと容易に行うことができます。

#[test]
fn test_server_www_udp() {
  let addr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(127,0,0,1), 0));
  let udp_socket = UdpSocket::bound(&addr).unwrap();

  let ipaddr = udp_socket.local_addr().unwrap();
  println!("udp_socket on port: {}", ipaddr);

  thread::Builder::new().name("test_server:udp:server".to_string()).spawn(move || server_thread_udp(udp_socket)).unwrap();

  let client_conn = UdpClientConnection::new(ipaddr).unwrap();
  let client_thread = thread::Builder::new().name("test_server:udp:client".to_string()).spawn(move || client_thread_www(client_conn)).unwrap();

  let client_result = client_thread.join();

  assert!(client_result.is_ok(), "client failed: {:?}", client_result);
}

この部分を詳しく見てみると、2つのスレッドを生成しています。1つはクライアント用で、1つはサーバ用です。両方とも、使用中のソケットアドレスへのバインドに関する問題が発生しないよう、ランダムなローカルポートを使います。それから、サーバを起動します。次にサーバのランダムに割り当てられたポートを新しいクライアントに渡し、クライアントのスレッドを生成し始めます。テストではunwrap()panic!が、ほぼ完璧といえるほど機能していることが分かります。サーバスレッドは極めて単純です。

fn server_thread_udp(udp_socket: UdpSocket) {
  let catalog = new_catalog();

  let mut server = Server::new(catalog);
  server.register_socket(udp_socket);

  server.listen().unwrap();
}

数年前、分散システムのために似たようなテストをJavaで書いてみました。しかし、私がJavaで好きに使っていた初期のテストよりも、こっちの方がずっと簡単です。以下がこのテストの要であるクライアントスレッドです。

fn client_thread_www<C: ClientConnection>(conn: C) {
  let name = Name::with_labels(vec!["www".to_string(), "example".to_string(), "com".to_string()]);
  println!("about to query server: {:?}", conn);
  let client = Client::new(conn);

  let response = client.query(&name, DNSClass::IN, RecordType::A).expect("error querying");

  assert!(response.get_response_code() == ResponseCode::NoError, "got an error: {:?}", response.get_response_code());

  let record = &response.get_answers()[0];
  assert_eq!(record.get_name(), &name);
  assert_eq!(record.get_rr_type(), RecordType::A);
  assert_eq!(record.get_dns_class(), DNSClass::IN);

  if let &RData::A(ref address) = record.get_rdata() {
    assert_eq!(address, &Ipv4Addr::new(93,184,216,34))
  } else {
    assert!(false);
  }

  let mut ns: Vec<_> = response.get_name_servers().to_vec();
  ns.sort();

  assert_eq!(ns.len(), 2);
  assert_eq!(ns.first().unwrap().get_rr_type(), RecordType::NS);
  assert_eq!(ns.first().unwrap().get_rdata(), &RData::NS(Name::parse("a.iana-servers.net.", None).unwrap()) );
  assert_eq!(ns.last().unwrap().get_rr_type(), RecordType::NS);
  assert_eq!(ns.last().unwrap().get_rdata(), &RData::NS(Name::parse("b.iana-servers.net.", None).unwrap()) );
}

お気づきかと思いますが、関数がClientConnection型のジェネリクスとして宣言されており、TCPとUDPの両方のクライアントで機能するモノモーフィックな関数を呼び出すことができます。つまり、1つのテストでTCPとUDPのサーバとクライアントの両方をテストできるということです。一例としてここにコードをご紹介しますが、残りはserver.rsのソースでご確認ください。

Name::with_labels()呼び出しが少し使いにくいように見える、というのは自分で分かっています。それをすっきりさせたいのですが、私が試したいStringのinternに関する幾つかの考えをまだ決めかねているのです。実際、Nameにおけるラベルの実装は、「Rustにガベージコレクションがあれば」と思った極めて数少ない箇所の1つです。この願望は、internを実行する良い方法をが得られれば解消するかもしれません。

それでは、Trust-DNSは今どこに?

Trust-DNSは(私の知る限り)現在実用されていません。現在起こっていることの正当性の検証に多くの労力を注いでおり、支援を受け続けています。さらなる支援は、いつでも歓迎します。実際にTrust-DNSに対して繰り返し攻撃を生成するために、実行型のDNSファザーを手に入れたいと思っています。そして、ベンチマークを取得して、他のサーバとの比較テストもしたいと考えています。

私が誇りに思うことは、DNSSecのサポート、クライアントサイドのバリデーション、ローカルキーでのゾーン署名です。サーバもクライアントも、SIG0のバリデーションと認証を有する動的DNSをサポートしています。サーバではSQLiteでジャーナリングをサポートしています。また、512バイトを超えるUDPのパケット(デフォルトでは1500バイト)に対してEDNSをサポートしています。

私は、今、DNSCrypt2に取り組んでいるところです。そして、私はもう少し面白そうなアイデアに向かっています。この探究がこんなに長くなるとは、全く想像もしませんでした。しかし、たった今始まったばかりです。そして、とても素晴らしい言語を学ぶのに加えて、それは非常に価値のあることです。Rustを完成させることに多くの時間を費やしてくれた皆さんに感謝します。私にとってのプログラミングの楽しみに新な活気を吹き込んでくれたのは、Rustのエコシステムなのです。

(進捗状況は定期的に投稿するつもりです)


  1. ) 「Rustはメモリリークを防いでくれない」というフィードバックをたくさん受け取りました。私の経験上、「『変数が解放されない』といった状況には、あえてそうしようとしない限りはならない」といった意味で、少なくともJava以上には良いものだと思っています。これはセーフコードで発生します。例えば、大きくなり続けるVectorや、Rustにメモリの解放やクリーンアップをコールしないように伝えるstd::mem::forgetの好ましくない使用法です。いつこうしたいと考えますか? FFIメソッドを通してCへオブジェクトを渡す時に使いますし、他の場合に使うこともあります。もっとお読みになりたい場合は、こちらのサイトhttps://github.com/rust-lang/rfcs/pull/1066の投稿をチェックしてください。皆さんがこのことを思い出せることを趣旨として書き残しました。投稿を読んで、考えてみてください。実際に、メモリリークを防ぐ言語は無いと理解してください。とはいえRustはメモリセーフです…。 

  2. ) オフラインでの議論と、このIssue「Feature request: RFC 7858, DNS over TLS #38」を受けて、私はDNSCryptの作業を先送りにして、その代りDNS over TLSに着目しようかと強く思っています。