Rust+QuinnでQUICのechoサーバを作る

f:id:aptpod_tech-writer:20201201143059j:plain

aptpod Advent Calendar 2020の2日目を担当します、研究開発グループの大久保です。

弊社では、新しいプロトコルであるQUICの利用法を調査しています。そこで今回は、RustのQUIC実装の1つであるQuinnを用いて、受け取ったリクエストをそのままクライアントへ返送するechoサーバを実装してみます。RustのQUIC実装には、他にquicheというものもありますが、Quinnはtokioの上に実装されているため、Rustのasync機能を活用して楽に書くことができます。

構成

quinn-echo-serverquinn-echo-clientという2つのクレートを作り、それぞれのCargo.tomlに以下の依存関係を追記します。

[dependencies]
anyhow = "1"
clap = "3.0.0-beta.2"
futures = "0.3"
quinn = "0.6"
tokio = { version = "0.2", features = ["full"] }

QUICを使うのにquinn、async周りの機能のためにfuturestokioが必要です。また、エラー処理には、最近おなじみになったanyhowを、CLI引数のパースにはclapを入れておきます。

サーバ側

quinn-echo-serverは以下のようになります。

use anyhow::*;
use clap::Clap;
use futures::StreamExt;
use quinn::{
    Certificate, CertificateChain, Connecting, Endpoint, NewConnection, PrivateKey, ServerConfig,
    ServerConfigBuilder, TransportConfig,
};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::PathBuf;

#[derive(Clap, Debug)]
#[clap(version = "0.1.0")]
struct Opts {
    #[clap(short, long)]
    port: u16,
    #[clap(short, long)]
    ca: PathBuf,
    #[clap(long)]
    privkey: PathBuf,
}

#[tokio::main]
async fn main() -> Result<(), Error> {
    // コマンドライン引数のパース
    let opts: Opts = Opts::parse();

    // QUICの設定
    let mut transport_config = TransportConfig::default();
    transport_config.stream_window_uni(0xFF);
    let mut server_config = ServerConfig::default();
    server_config.transport = std::sync::Arc::new(transport_config);
    let mut server_config = ServerConfigBuilder::new(server_config);
    // 証明書の設定
    let cert = Certificate::from_der(&std::fs::read(opts.ca)?)?;
    server_config.certificate(
        CertificateChain::from_certs(vec![cert]),
        PrivateKey::from_der(&std::fs::read(opts.privkey)?)?,
    )?;
    // QUICを開く
    let mut endpoint = Endpoint::builder();
    endpoint.listen(server_config.build());
    let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), opts.port);
    let (endpoint, mut incoming) = endpoint.bind(&addr)?;
    println!("listeing on {}", endpoint.local_addr()?);

    // クライアントからの接続を扱う
    while let Some(conn) = incoming.next().await {
        tokio::spawn(async {
            // クライアントとの処理を行い、エラーが起きたら表示
            match handle_connection(conn).await {
                Ok(_) => (),
                Err(e) => {
                    eprintln!("{}", e);
                }
            }
        });
    }

    Ok(())
}

// echoの処理をする関数
async fn handle_connection(conn: Connecting) -> Result<(), Error> {
    let NewConnection {
        connection,
        mut uni_streams, ..
    } = conn.await?;

    println!("connected from {}", connection.remote_address());

    // 受信用のストリームを開く
    if let Some(uni_stream) = uni_streams.next().await {
        let uni_stream = uni_stream?;
        // ストリームを読み出す
        let data = uni_stream.read_to_end(0xFF).await?;
        println!("received \"{}\"", String::from_utf8_lossy(&data));
        // 送信用のストリームを開く
        let mut send_stream = connection.open_uni().await?;
        // 返信を書き込む
        send_stream.write(&data).await?;
        send_stream.finish().await?;
        connection.close(0u8.into(), &[]);
    } else {
        bail!("cannot open uni stream");
    }

    println!("closed");

    Ok(())
}

下準備と接続を開くまでmain関数で行っています。コマンドライン引数で渡されたポート番号と、証明書ファイルのパスを基にサーバを立ち上げ、クライアントとの接続が起きたらhandle_connection関数に渡します。tokioランタイムの上で動作するので、main関数には#[tokio::main]属性を追加しておきます。

handle_connection関数では、受信用に単方向ストリームを開き、内容を全て読み出します。その後、送信用の単方向ストリームを開き、受け取った内容をそのまま書き出したら接続を終了します。

クライアント側

quinn-echo-clientは以下のようになります。

use anyhow::*;
use clap::Clap;
use futures::StreamExt;
use quinn::{Certificate, ClientConfigBuilder, Endpoint, NewConnection};
use std::net::SocketAddr;
use std::path::PathBuf;

#[derive(Clap, Debug)]
#[clap(version = "0.1.0")]
struct Opts {
    #[clap(short, long)]
    ipaddr: SocketAddr,
    #[clap(short, long)]
    ca: PathBuf,
}

#[tokio::main]
async fn main() -> Result<(), Error> {
    // コマンドライン引数のパース
    let opts: Opts = Opts::parse();

    // QUICの設定
    let mut client_config = ClientConfigBuilder::default();
    client_config.add_certificate_authority(Certificate::from_der(&std::fs::read(&opts.ca)?)?)?;
    let mut endpoint_builder = Endpoint::builder();
    endpoint_builder.default_client_config(client_config.build());
    let (endpoint, _incoming) = endpoint_builder.bind(&"0.0.0.0:0".parse().unwrap())?;

    // サーバへ接続
    let NewConnection {
        connection,
        mut uni_streams,
        ..
    } = endpoint.connect(&opts.ipaddr, "localhost")?.await?;
    println!("connected: addr={}", connection.remote_address());

    // メッセージの書き込み
    let msg = "hello";

    let mut send_stream = connection.open_uni().await?;
    send_stream.write(msg.as_bytes()).await?;
    send_stream.finish().await?;
    println!("sent \"{}\"", msg);

    // 返信の読み込み
    if let Some(uni_stream) = uni_streams.next().await {
        let uni_stream = uni_stream?;
        let data = uni_stream.read_to_end(0xFF).await?;
        println!("received \"{}\"", String::from_utf8_lossy(&data));
    } else {
        bail!("cannot open uni stream");
    }

    // 終了
    endpoint.wait_idle().await;

    Ok(())
}

main関数で接続、書き込み、返信の読み込みまで行います。QUICの接続を確立したら、送信用の単方向ストリームを開き、文字列"hello"を書き込みます。その後、受信用の単方向ストリームを開き、文字列に変換して表示したら終了します。

実行

適当なDER形式の証明書ファイルcert.derと秘密鍵priv.derを用意した場合、サーバを次のように立ち上げます。

quinn-echo-server --port 33333 --ca cert.der --privkey priv.der

このサーバに接続するクライアントは、次のように実行します。

quinn-echo-client --ipaddr 127.0.0.1:33333 --ca cert.der

すると次のような出力が得られます。

サーバ側

listeing on 0.0.0.0:33333
connected from 127.0.0.1:32820
received "hello"
closed

クライアント側

connected: addr=127.0.0.1:33333
sent "hello"
received "hello"

これでechoが返ってくることを確認できました。

最後に

今回は、tokioをベースとして構築されたQuinnをつかって、簡単なQUICのechoサーバを構築してみました。別のQUIC実装であるquicheの方は、自分でイベントを扱うループを書く必要があったりしますが、Quinnの方はRustのasync/await機能の基本が分かっていれば、比較的簡単に使いこなすことができます。コネクションやストリームを開いたり、ストリームの読み書きも、とてもRustらしい書き方で行えます。今回実装したのはechoサーバですが、これでQUICの特徴の1つである ストリームの使い方がわかるので、ここから応用することもできるでしょう。

新しいプロトコルであるQUICも、Rustの知識がある程度あればQuinnで簡単に使えるので、ぜひお試し下さい。