HN 日本語サマリー

← 一覧へ戻る
プログラミング

Sanstun: Rust製のsans-IOで並列処理に強いWireGuard実装

Sanstun: A sans-IO, parallel-friendly WireGuard implementation in Rust (firezone.dev)

17 pointsby wh33zle1 コメント

要約

Firezoneは、Rustで書かれたWireGuardの実装である「sanstun」を発表しました。sanstunは、IO処理とビジネスロジックを分離するsans-IOアーキテクチャを採用し、並列処理とテストの容易さを重視しています。これにより、既存のboringtun実装の課題を解決し、より効率的で柔軟なWireGuard環境の構築を目指しています。

全文翻訳

Sanstunの紹介: Rust製のsans-IOで並列処理に強い、ユーザー空間WireGuard実装

本日、sanstunを発表します。これは、WireGuard®ステートマシンをsans-IOで実装したRust製のライブラリで、使いやすさ、セキュリティ、パフォーマンスを最適化するために、並列処理に強く、idiomaticなAPIを備えています。

sans-IOとは何ですか?

sans-IOとは、システムを、IOおよび副作用が状態と「ビジネスロジック」からきれいに分離されるように設計するという考え方です。sans-IOにおけるIOとは、ファイルやネットワークIOだけを意味するわけではありません。時間やランダム性なども含まれます!

副作用がないため、sans-IOコンポーネントは非常にうまく組み合わさり、テストも比較的容易です。この設計は、関数カラーリングのような問題も回避し、Rustの所有権ルールともうまく連携します。

この投稿のコード例や参照はRustですが、sans-IOはRust固有のものではありません。そのパターンやアイデアは、どの言語にも適用できます。より詳細な説明については、sans-IOの投稿をご覧ください。

歴史

Firezoneを最初に構築したとき、私たちはWireGuard実装としてboringtunを採用しました。boringtunは私たちの望むことをほぼ実現していましたが、時間の経過とともに、バグ修正やAPI追加のためにフォークしたコードを何度もパッチすることになりました。目標は、これらのパッチをアップストリームにマージできるようにすることだったため、既存のAPIは常にそのままの形で機能するように維持していました。

しかし、製品要件が進化するにつれて、APIの互換性を100%維持することがますます困難であることがわかりました。boringtunに対して行った最初の大きな変更の1つは、時間処理に関して完全にsans-IOにすることでした(firezone/boringtun#5)。これは、_atサフィックスを持つすべてのものに新しいAPIを追加し、Instantを受け取ることを意味しました。sans-IOの時間処理は、システム全体を正確にシミュレートしたい場合に非常に重要です。その方法については、このブログ記事をご覧ください。

また、内部バッファリングをフィーチャーフラグで管理しました。最初は、boringtunがセッションを持っていない(またはなくなった)場合に、パケットを送信しようとすると自動的にセッションを確立してくれるのは便利に思えます。しかし、内部バッファリングにはいくつかの問題が伴います。

  1. ある時点で、バッファをドレインする必要があります。boringtunは、ゼロ長のデータグラムでdecapsulateを呼び出すことを要求することでこれを解決します。これは直感的ではありません。
  2. 追加のメモリ割り当てが発生します(少なくともboringtunでの実装方法では)。
  3. バッファサイズは設定可能/調整可能であるべきです。

これらの問題はすべて同じ解決策を指し示しています。ライブラリ内でバッファリングしないことです。これにより、両側のインターフェースと実装が簡素化されます。

パッチと修正の完全なリストはかなり長いです。依存関係の更新を除いても、70件以上のPRがあります。そのうちのいくつかは、私たちが発見したエッジケースの修正です。他のものは新しいAPIを追加したり、特定の部分を設定可能にしたりします。

2年以上boringtunを使用し、パッチを適用した後、ユーザー空間WireGuard実装のAPI全体がどのようになるべきかについて、私たちはかなり良いアイデアを持っていました。それが今、sanstunとして存在します。

より良いsans-IO API

sans-IO APIのコアビルディングブロックは次のとおりです。

  • 受信パケットの処理:
    • Tunnel::handle_handshake_initiation
    • Tunnel::handle_handshake_response
    • Tunnel::handle_cookie_reply
    • Tunnel::begin_decapsulate & Tunnel::commit_decapsulate
  • 送信パケットの処理: Tunnel::begin_encapsulate
  • タイマー更新の処理: Tunnel::handle_timeout & Tunnel::poll_timeout
  • イベントの処理: Tunnel::poll_event

内部バッファリングがないため、特定のAPIをループで呼び出すことを要求するAPI契約はなく、これは全体的なsans-IOモデルによりよく適合します。パケットを送信するには、Tunnel::begin_encapsulateを呼び出します。セッションがまだない場合、パケットをバッファリングするのはあなたの責任です。新しいセッションの作成は、ハンドシェイクの送信を意味します。いつでもTunnel::initiate_handshakeで新しいハンドシェイクを開始できます。

これらのAPIの設計は非常に意図的であり、WireGuardプロトコルを簡単に復習するとより理解しやすくなります。

WireGuardプロトコル

WireGuardのメッセージフォーマットは、ハンドシェイク初期化、ハンドシェイク応答、Cookie応答、暗号化データの4種類のメッセージをサポートしています。

すべてのWireGuardセッションは、比較的コストのかかるDiffie-Hellman鍵交換から始まります。WireGuardの設計により、エンドポイントを公開してもDoSの機会が開かれることはありません。

エンドポイントの公開鍵を知っている人は誰でも、偽のハンドシェイク初期化を作成し、受信UDPパケットの送信元IPを偽装できます。受信側のWireGuard実装は、提供されたハンドシェイクデータが偽であることを伝える前に、DH操作の半分を実行する必要があります。しかし、その時点ですでにCPUリソースは消費されています。十分なハンドシェイク初期化を送信すると、受信側は無関係なハンドシェイクの処理にすべてのCPUサイクルを費やすことになります。

これを防ぐために、WireGuardはCookieメカニズムを指定しており、リモートエンドに主張された送信元IPの所有権を証明させます。これにより、送信元IPごとのハンドシェイクのレート制限が可能になります。

これらのレート制限は、単一のトンネルではなく、WireGuardインターフェース全体に適用されます。

ハンドシェイクが完了すると、両方のピアは対称鍵暗号化されたデータパケットを交換できます。ハンドシェイクの一部として、ピアはローカルで選択されたセッションインデックスも交換します。これらのインデックスにより、トンネルのデマルチプレキシングや、トンネル内の複数のセッションが可能になります。

プロトコルの不変条件をAPI設計にマッピングする

Rustのような言語で作業している場合、型を使用することを楽しむ可能性が高いです。そして、型をうまく使用することは、コンパイル時に不変条件を強制することを意味し、特定のバグを出荷することさえできなくなります。たとえば、メッセージにレートリミッターを適用し忘れると、通常の操作では現れないため、かなり悪い結果になります。同様に、受信パケットを間違ったトンネルにルーティングすることは、本番環境でのみ現れるものです。

良いAPI設計は、壊れたものを作成することを不可能にし、プロトコルがどのように機能するかを理解するのに役立ちます。

1つのインターフェース、複数のトンネル

WireGuardインターフェースは単一の鍵で識別され、トンネルは2つのインターフェース間の接続です。したがって、パケットを送信するたびに、どのトンネルに送信する必要があるかを指定する必要があり、パケットを受信したときは、どのトンネルに属するかを特定する必要があります。

sanstunでは、これらの概念はInterfaceとTunnelの型に直接マッピングされます。すべての受信パケットはInterface::receiveを通過します。これは、生のデータグラムを解析する唯一の公開APIです。パケットを解析し、レート制限を適用し、インターフェースが負荷下にある場合は送信して戻すCookie応答とともにReceivedMessageを返します。これらは、Tunnelの対応する関数に直接マッピングされます: Tunnel::handle_handshake_initiationなど。ハンドシェイク初期化は、イニシエーターの公開鍵によってルーティングされます。その他のすべてのメッセージは、TunnelIdで識別される、どのTunnelにディスパッチされるべきかを示します。

1つのインターフェース、複数のトンネル すべてのUDPデータグラムはInterface::receiveから入ります。メッセージは、すべてのチェックに合格した場合にのみトンネルに転送されます。データグラム Cookie応答 負荷下 Interface::receive ヘッダーの解析 MACの検証 Cookieを検証 ソースのレート制限 ハンドシェイク初期化 Aの公開鍵 ハンドシェイク応答 TunnelId 5 Cookie応答 TunnelId 5 トランスポートデータ TunnelId 7 Tunnel 3 peer A handle_handshake_initiation Tunnel 5 peer B handle_handshake_response handle_cookie_reply Tunnel 7 peer C begin_decapsulate

すべての受信データグラムはInterface::receiveを通過する必要があります。

内部的には、sanstunはここで多くのことを自動で行います。トンネルインデックスを解析して上位24ビットを抽出し、レート制限を適用し、Cookie応答を生成し、ハンドシェイクメッセージを検証します。

特に重要なのは、HandshakeInitiationなどの型のコンストラクタはすべてクレートプライベートであることです。Tunnelは公開のhandle_handshake_initiation関数を公開していますが、それを呼び出す唯一の方法は、まずInterface::receiveを介してそのような型を取得することです。そしてInterface::receiveは、レートリミッターを適用することでアプリケーションを保護します。これらはすべて、あなたがそれについて考える必要はありません。

並列暗号化

sanstunの最も優れた機能の1つは、パケットの暗号化および復号化のための並列処理に強いAPIです。

プロトコルレベルでは、WireGuardはトンネル間で状態を共有しません。したがって、WireGuard実装をピア間で並列化することは非常に簡単です。

しかし、単一のトンネル内での作業を並列化することについてはどうでしょうか? WireGuardはレイヤー3で動作するため、別のピアとの単一セッションは、多数の個々のネットワークフローのトラフィックを運びます。たとえ異なるピアへのトンネルを異なるスレッドで処理する場合でも、同じホストまたはサブネットへのすべてのネットワークフロー(つまり、一意の5タプル)は順次処理され、ChaCha20-Poly1305(データパケットのWireGuardの暗号化方式)を処理するCPUの速度によって実質的に制限されます。悪意のあるオンパスオブザーバーによるリプレイ攻撃から保護するために、WireGuardデータパケットには単調増加するパケットカウンターがあります。このカウンターは単一セッションにスコープされているため、複数のスレッドから同時にこの状態にアクセスする場合、同期が必要になります。

これを回避するために、sanstunはブックキーピングと実際の暗号化および復号化を分離します。Tunnel::begin_encapsulateを呼び出しても、実際には何も暗号化されません。現在のセッションをチェックし、パケットカウンターを予約し、タイマー状態を進め、次にPendingEncapsulateを返します。

impl PendingEncapsulate {
    pub fn encrypt<'a>(self, plaintext: &[u8], dst: &'a mut [u8]) -> Result<DataMessage<'a>, Error> {
        // ...
    }
}

PendingEncapsulateはSendを実装しているため、作業の大部分を実行するために別のスレッドに転送できます。sanstunリポジトリでcargo bench data_pathを実行すると、この分割の効果が示されます。

data_path/64B/begin_encapsulate
                        time:   [20.785 ns 20.828 ns 20.869 ns]
data_path/64B/encrypt   time:   [226.15 ns 226.22 ns 226.29 ns]
data_path/64B/begin_decapsulate
                        time:   [37.600 ns 37.675 ns 37.744 ns]
data_path/64B/decrypt   time:   [205.78 ns 205.83 ns 205.87 ns]
data_path/64B/commit_decapsulate
                        time:   [17.486 ns 17.529 ns 17.575 ns]

data_path/1280B/begin_encapsulate
                        time:   [20.785 ns 20.828 ns 20.869 ns]
data_path/1280B/encrypt time:   [933.49 ns 933.54 ns 933.59 ns]
data_path/1280B/begin_decapsulate
                        time:   [37.606 ns 37.681 ns 37.751 ns]
data_path/1280B/decrypt time:   [914.81 ns 914.89 ns 914.97 ns]
data_path/1280B/commit_decapsulate
                        time:   [17.657 ns 17.707 ns 17.764 ns]

予想通り、ブックキーピング部分(begin_encapsulate、begin_decapsulate、commit_decapsulate)はパケットサイズに比例しません。パケットサイズに比例するのはencryptとdecrypt関数です。パケットサイズが1280バイトの場合、新しいパケットを暗号化するブックキーピング部分はわずか2%です。実際の暗号化を別のスレッドにオフロードすると、これは45倍以上の速度向上の可能性を示唆しています!

PendingEncapsulateを別のスレッドに渡すコストや、その他のオーバーヘッドを考慮しないため、実際には45倍の効果的な速度向上を達成するのは困難です。それにもかかわらず、暗号化をワーカー・スレッドにオフロードできることで、スループットが大幅に向上します。

暗号化をワーカー・スレッドにオフロードする 3つのスレッドのシーケンス図: インターフェイスとトンネルの状態を保持するオーナー・スレッド、およびその両側にある2つのワーカー・スレッド。
ワーカー1 オーナースレッド インターフェイス、トンネル ワーカー2 送信 受信 PendingEncapsulate n encrypt 934 ns DataMessage n PendingEncapsulate n+1 encrypt 934 ns DataMessage n+1 begin_encapsulate ×221 ns 各 PendingDecapsulate m decrypt 915 ns Decrypted m PendingDecapsulate m+1 decrypt 915 ns Decrypted m+1 begin_decapsulate ×238 ns 各 commit_decapsulate ×2 リプレイチェック、18 ns 各

オーナー・スレッドはブックキーピングのみを行い、暗号化と復号化はワーカー・スレッドで実行されます。

sans-IOライブラリであるため、sanstunは暗号化をどのように並列化するかについては関知しません。シンプルに保ちたい場合は、encryptとdecryptをインラインで呼び出し、並列化を気にしないこともできます。

sanstunのAPI設計で達成したかった主なことは、Interface周辺のすべてをシングルスレッドに保ちながら、並列暗号化を可能にすることでした。これにより、内部レートリミッターはロックやアトミックカウンターなしで機能し、Rustの借用チェッカーを活用しながら、複数のトンネルからの状態を組み合わせることができます。

終わりに

sanstunの登場により、私たちのfirezone/boringtunフォークは廃止されました。VPN、メッシュネットワーク、またはその他のものを構築している場合でも、sanstunがあなたの生活を少し楽にすることを願っています。crates.ioで利用可能です:crates.io。

sanstunはApache 2.0ライセンスの下でオープンソースです。GitHubでチェックしてください:GitHubで、イシューとプルリクエストを歓迎します!