こんにちは。クラウド基盤本部の野島です。
今年のインターンシップでは、プラットフォーム(自社基盤)コースとして2名の方を受け入れ、それぞれ異なる課題をやってもらいました。 そのうちの一つは Pingora に関する課題で、覚道さんに取り組んでいただきました。(もう一つの課題は nginx のキャッシュの性能に関するもので、これについては昨日の記事をご参照ください)
Pingora は Cloudflare が開発したロードバランサのためのフレームワークであり、Rust を使って好きなロジックを組み込んだロードバランサを書くことができます。 今回のインターンでは Pingora を使って TLS のクライアント証明書を使った認証プロキシを作ってもらいました。 そこで、この開発の中で得られた Pingora や OpenSSL に関する知見を共有しようと思います。
この記事は覚道さんのインターンにおける成果をサイボウズの社員である野島がまとめたものです。 この記事は Rust の基本的な知識を前提としています。
背景
私たちは nginx をメインのロードバランサとして利用しています。 プロダクション環境におけるロードバランサには多くの機能が求められます。 動的ディスパッチ、同時リクエスト数制限、認証・認可、特殊なルーティング、TLSセッション同期など、我々は多くの機能を nginx の設定ファイルの上に実装してきました。 長年受け継がれてきた nginx の設定ファイルはもはやプログラムというべき複雑さですが、設定ファイルであるがゆえに、カプセル化や型チェック、単体テストといった現代のプログラミング言語の恩恵に預かることができません。 ここまでくると、いっそのことロードバランサを全部プログラムとして書いてしまえばよいのではないか、と思えてきます。
そこで Pingora です。
Pingora は Cloudflare が OSS として公開しているロードバランサです。 より正確にいうと、Pingora はロードバランサのための フレームワーク であり、Rust を使って好きなロジックを組み込んだロードバランサを書くことができます。 Rust で書くということは、型安全性、メモリ安全性、並行性、パフォーマンスといった Rust の特徴をロードバランサにも持ち込むことができるということです。
我々は我々の複雑な nginx をよりメンテナンスしやすい形に移行できないか考えています。 そこで、今回はサイボウズ社内で "Skylab" と呼ばれている機能の実装を Pingora で行ってみました。 "Skylab" は、TLS のクライアント証明書を動的にロードした CA を用いて行う機能で、サイボウズの nginx の中でも最も複雑なものの一つです。
Pingora 入門
Pingora がどのようなものか、簡単な例を通じて見ていきましょう。 なお、この記事の例は Pingora 0.3 で動作確認しています。
Hello, World
Pingora がどんな感じなのか、まずは触ってみましょう。
以下に Hello, world!
を返す Web サーバーを Pingora で書いた例を示します。
固定のレスポンスを返すだけだと面白くないので、アクセスカウンターも付けています。
//! ```cargo //! [dependencies] //! async-trait = "0.1" //! env_logger = "0.11" //! http = "1" //! log = "0.4" //! pingora = { version = "0.3", features = [ "lb" ] } //! ``` use std::sync::atomic::{AtomicU64, Ordering}; use async_trait::async_trait; use http::header::{CONTENT_LENGTH, CONTENT_TYPE}; use http::{Response, StatusCode}; use pingora::apps::http_app::ServeHttp; use pingora::protocols::http::ServerSession; use pingora::server::Server; use pingora::services::listening::Service; struct HelloApp { counter: AtomicU64, // アクセスカウンター } #[async_trait] impl ServeHttp for HelloApp { async fn response(&self, _server_session: &mut ServerSession) -> Response<Vec<u8>> { let n = self.counter.fetch_add(1, Ordering::SeqCst); let message = format!("Hello, world!\r\nあなたは {n} 人目の訪問者です!\r\n").into_bytes(); Response::builder() .status(StatusCode::OK) .header(CONTENT_TYPE, "text/plain") .header(CONTENT_LENGTH, message.len()) .body(message) .unwrap() } } fn main() -> pingora::Result<()> { env_logger::init(); let mut server = Server::new(None)?; server.bootstrap(); let hello_app = HelloApp { counter: AtomicU64::new(1), }; let mut hello_service = Service::new("hello app".to_owned(), hello_app); hello_service.add_tcp("[::]:3000"); server.add_service(hello_service); server.run_forever(); }
それでは実行してみましょう。デバッグログを出したほうが面白いので RUST_LOG=debug
を付けて実行します。
$ RUST_LOG=debug cargo run
別の端末から http://localhost:3000/
にアクセスすると、Hello, world!
とアクセスカウンターが表示されるはずです。アクセスするたびにカウントが増えていくことを確認してください。
$ curl -i localhost:3000 HTTP/1.1 200 OK Content-Type: text/plain Content-Length: 59 Date: Thu, 19 Sep 2024 11:55:43 GMT Connection: keep-alive Hello, world! あなたは 1 人目の訪問者です!
このように、Pingora では次のような手順で HTTP リクエストを処理するサーバーを書くことができます。
本筋とは少し外れますが、アクセスカウンターの値を保持する変数 counter
は AtomicU64
として宣言されています。
これを普通の u64
として宣言すると、コンパイルエラーになります。
AtomicU64
はスレッド間で共有して同時に読み書きしても安全なのに対して、u64
は共有と書き込みを両立できないためです。
このように Rust はマルチスレッドのよくあるバグをコンパイル時に検出することができる、安全性の高い言語であると言うことができると思います。
リバースプロキシ
Hello, World の例は全くロードバランサ感がありませんでした。 そこで次はリバースプロキシを実装してみましょう。 以下の例は 8000 番ポートにリバースプロキシを立て、リクエストを 3000 番ポートに転送するものです。
//! ```cargo //! [dependencies] //! async-trait = "0.1" //! env_logger = "0.11" //! http = "1" //! log = "0.4" //! pingora = { version = "0.3", features = [ "lb" ] } //! ``` use async_trait::async_trait; use pingora::prelude::*; struct LB; #[async_trait] impl ProxyHttp for LB { type CTX = (); fn new_ctx(&self) -> Self::CTX { () } async fn upstream_peer(&self, _session: &mut Session, _ctx: &mut ()) -> Result<Box<HttpPeer>> { let peer = HttpPeer::new("127.0.0.1:3000", false, "".to_string()); Ok(Box::new(peer)) } } fn main() -> pingora::Result<()> { env_logger::init(); let mut my_server = Server::new(None)?; my_server.bootstrap(); let mut lb = http_proxy_service(&my_server.configuration, LB); lb.add_tcp("[::]:8000"); my_server.add_service(lb); my_server.run_forever(); }
先程の Hello, World の例を起動したまま、このリバースプロキシを起動してみましょう。
そして、http://localhost:8000/
にアクセスしてみてください。
Hello, World のレスポンスがリバースプロキシから返ってくるはずです。
前回の例との違いは ServeHttp
ではなく ProxyHttp を実装していることです。
ProxyHttp
はリバースプロキシのためのトレイトで、upstream_peer
メソッドでリクエストを転送する先を指定することができます。
この例では upstream_peer
で固定のバックエンドを返していますが、もちろん動的にバックエンドを変えることも可能です。
例えば、別のサーバーにバックエンドを問い合わせ、その結果に応じてバックエンドを動的に変えるといったロジックも書くことができます。
Pingora には upstream_peer
を実装するうえで便利な struct や trait が用意されています。
- バックエンドの選択ロジックを提供する LoadBalancer
これを使うとヘルスチェックやラウンドロビンなどを簡単に組み込めるようになっています。 - バックエンドを取得するためのトレイトである ServiceDiscovery
これを実装することで、好きなロジックでバックエンドのリストを取得することができます。
LoadBalancer や ServiceDiscovery を使った例は少し長くなってしまったので GitHub へのリンクを貼っておきます。
https://github.com/nojima/hello-pingora/blob/main/examples/04-dynamic-improved.rs
"Skylab" について
Pingora の雰囲気を掴んだところで、この記事のゴールである Skylab について説明します。 そのためにはまず mTLS について説明する必要があります。
通常、TLS を使った通信ではクライアントがサーバーの証明書を検証します。 すなわち、クライアントはサーバーが提示した証明書と署名を検証することで、サーバーが本当にそのドメインの所有者によって運用されていることを確かめるのです。
一方、TLS では逆にサーバーがクライアントに対して証明書を要求することもできます。 これにより、クライアントが本当に意図したクライアントであることをサーバーは検証することができます。 このときクライアントがサーバーに対して提出する証明書を クライアント証明書 と呼びます。
このようにクライアントとサーバーがお互いにお互いを認証しあうことは mTLS (mutual TLS) と呼ばれます。 Skylab は mTLS を cybozu.com の追加の認証として利用する機能です。
クライアント証明書による認証は以下のようなメリットがあります。
- パスワードに対する攻撃に対して安全
全ての従業員のパスワードを適切に管理するのは多くの会社にとって困難です。一部の従業員が弱すぎるパスワードや流出したパスワードを使用してしまうリスクが常にあります。クライアント証明書を利用することで、パスワードが破られてしまっている場合でも攻撃者によるログインを防ぐことができます。 - フィッシング攻撃に対して安全
攻撃者が cybozu.com に酷似したサイトを作り、エンドユーザーがそのサイトにパスワードなどを入力してログインしてしまったとします。そのような場合でも TLS の秘密鍵は決して通信相手に送信されないため、盗むことができません1。よって攻撃者は cybozu.com にログインできません。
クライアント証明書を使った認証は以下のような手順で行われます。
- プライベートCAを構築する
- 新規の証明書を発行し、このプライベートCAで署名する
- この証明書をクライアントに配布する
- クライアントは cybozu.com にアクセスする際にこの証明書を提出する
- サーバーはプライベートCAの証明書を使って、クライアントから提出された証明書を検証する
重要なのは5番で、サーバーがクライアント証明書を認証するためには、その証明書に署名したCAの証明書を持っている必要があるということです。 ではサーバーはどうやってCA証明書を手に入れればよいのでしょうか?
プライベートCAが一個しかない場合や複数あるとしても少量で変化しない場合、話は簡単です。 単にサーバーにファイルとして同梱しておけばよいのです。
しかし Skylab の場合、話は複雑です。CA が大量にあり、しかも動的に増えたり減ったりするからです。 cybozu.com において、顧客が Skylab を契約するとその顧客専用のプライベートCAが作成され、そしてそのサービスを解約するとそのCAは削除される仕組みになっています。 したがって、Skylab を提供するためには検証に使う CA の動的な追加・削除が求められます。 このような機能を nginx は提供していないため、我々は非常に苦労してこの機能を実装しました2。
インターンの目標は、Skylab を Pingora の上でクリーンに実装することです。
Step1. クライアント証明書を要求する Pingora サーバーを作る
最初の目標は、とりあえずクライアント証明書を要求するような Pingora サーバーを作ることです。 動的な CA のロードはまた後で考えることにします。
ここで衝撃の事実は判明するのですが、少なくともバージョン0.3の時点では Pingora はクライアント証明書を要求する機能を持っていませんでした (mTLS のクライアント側になる機能はあるのですが、サーバー側になる機能はなさそうでした)。
いきなり詰んだかと思ったのですが、実は大丈夫です。 Pingora は OpenSSL の構造体を直接触るためのインターフェイスがあるため、自力でクライアント証明書の要求を有効化してしまえばよいのです。
実装したコード(の抜粋)は以下のようになります。 コード内のコメントは説明のために追加したものです。
// App は固定のレスポンスを返すだけのサービス // Step1 ではあまり重要ではないので、詳細は省略する struct App; #[async_trait] impl ServeHttp for App { async fn response(&self, _http_session: &mut ServerSession) -> Response<Vec<u8>> { ... } } // DynamicCert は TLS ハンドシェイク時に呼ばれるコールバック // Step1 ではあまり重要ではないので、詳細は省略する struct DynamicCert { ... } #[async_trait] impl pingora::listeners::TlsAccept for DynamicCert { async fn certificate_callback(&self, ssl: &mut pingora::tls::ssl::SslRef) { ... } } fn main() -> Result<()> { let mut server = Server::new(None)?; server.bootstrap(); let mut app = Service::new("Saying hello service".to_string(), App); let dynamic_cert = DynamicCert::new("_wildcard.example.com.pem", "_wildcard.example.com-key.pem")?; // ★ クライアント証明書を要求するように設定 let mut tls_settings = pingora::listeners::TlsSettings::with_callbacks(dynamic_cert)?; tls_settings.set_verify(SslVerifyMode::PEER | SslVerifyMode::FAIL_IF_NO_PEER_CERT); tls_settings.set_ca_file("client-ca.pem").unwrap(); tls_settings.set_client_ca_list(X509Name::load_client_ca_file("client-ca.pem").unwrap()); app.add_tls_with_settings("0.0.0.0:8080", None, tls_settings); server.add_service(app); server.run_forever(); Ok(()) }
重要なのは★を付けた部分です。ここでクライアント証明書を要求するように設定しています。 TlsSettings はその名の通り TLS の設定を行うための構造体ですが、これが OpenSSL の関数を薄くラップしたインターフェイスを提供しており、これを通じてクライアント証明書を要求するように設定することができます。
例えば TlsSettings::set_verify
は SSL_CTX_set_verify をラップしたメソッドで、引数に渡したビットフラグによって証明書の検証方法を設定します。
今回は PEER (クライアント証明書を要求する) と FAIL_IF_NO_PEER_CERT (クライアント証明書が提出されなかった場合にエラーにする) を指定しています。
また、set_ca_file
と set_client_ca_list
もそれぞれ SSL_CTX_load_verify_locations と SSL_CTX_set_client_CA_list に対応しています。
前者がクライアント証明書を検証するための CA を設定するもので、後者がクライアントへ送る CA リストを設定するものです。
これで接続時にクライアント証明書を要求するような Pingora サーバーができました。
Step2. 顧客ごとに異なるCAで検証する
次に、顧客ごとに異なる CA を使ってクライアント証明書を検証するようにします。
これの実装には上の例でもちらっと出てきたハンドシェイク時に呼ばれるコールバックを使います。 しかし、このコールバックには罠があります。 呼ばれるタイミングが ClientHello と ServerHello の間なのです。
一方、クライアント証明書は ServerHello より後に送られてきます。 つまりコールバック関数が呼ばれたタイミングでクライアント証明書を読むことは不可能なのです。
Skylab ではクライアント証明書の書かれている顧客IDをもとに CA をロードしていました。 Pingora では同様の実装が不可能です。
しばらくこの問題で困っていましたが、よく考えると ClientHello には SNI が含まれており、それを使って顧客を特定すればよいことに気付き、なんとか実装することができました。
SNI というのは、TLS のハンドシェイクの際にクライアントがサーバーに対して提示するホスト名のことです。
例えば、クライアントが https://example.com/
にアクセスする場合、SNI には example.com
が入ります。
cybozu.com では顧客ごとに異なるサブドメインでサービスを提供しているため、SNI を見るだけでどの CA を使ってクライアント証明書を検証すればよいかがわかるわけです。
以下のコードはその実装です。少し長いのでコールバック関数の部分のみ抜き出しています。
#[async_trait] impl pingora::listeners::TlsAccept for DynamicCert { async fn certificate_callback(&self, ssl: &mut pingora::tls::ssl::SslRef) { use pingora::tls::ext; // サーバー証明書を設定 // ※ unwrap を使っているが、後でまともなエラーハンドリングを追加する ext::ssl_use_certificate(ssl, &self.cert).unwrap(); ext::ssl_use_private_key(ssl, &self.key).unwrap(); // SNIに応じて異なる証明書を使う // ここでは hoge.example.com と fuga.example.com のみをサポートする if let Some(sni) = ssl.servername(NameType::HOST_NAME) { match sni { "hoge.example.com" => load_certificate(ssl, "hoge-client-ca.pem").unwrap(), "fuga.example.com" => load_certificate(ssl, "fuga-client-ca.pem").unwrap(), other => println!("SNI {} is unknown", other), } } } } // 指定されたファイルから証明書をロードして検証用の CA として設定する // ※ エラーハンドリングのコードは ... で省略している fn load_certificate(ssl: &mut SslRef, filename: &str) -> Result<()> { // ファイルから X509 証明書をロード let certificate = fs::read(filename).map_err(|e| ...)?; let certificate = X509::from_pem(&certificate).map_err(|e| ...)?; let mut ca_stack = Stack::new().map_err(|e| ...)?; let ca_name = certificate.subject_name().to_owned().map_err(...)?; ca_stack.push(ca_name).map_err(...)?; // クライアントに渡す CA リストを設定 ssl.set_client_ca_list(ca_stack); let mut builder = X509StoreBuilder::new().map_err(...)?; builder.add_cert(certificate).map_err(...)?; let certificate_store = builder.build(); // クライアント証明書の検証に使う CA を設定 ssl.set_verify_cert_store(certificate_store).map_err(...)?; Ok(()) }
このコールバックによってクライアント証明書の検証に利用する CA が設定されます。 ハンドシェイクが進んでクライアント証明書が送られてきたら、ここで指定された CA によって検証が行われるようになりました。
Step3. 外部のサーバーから CA をフェッチする
Step2 のコードではローカルファイルシステムから証明書をロードしていましたが、動的に増減する証明書をロードバランサのファイルシステムに同期するのは実際の運用では困難です。 そこで、他のサーバーから HTTP 経由で証明書を取得することにします。
これは簡単に実装できます。
単に、fs::read
していたところを reqwest::get
に置き換えるだけです。
Step3 は Pingora においては些細なステップですが、実は nginx においては実現が難しい課題でした。 nginx では OpenSSL のコールバックの中で非同期な関数を呼ぶことができないため、他のサーバーと通信することができません。 したがって、サイボウズの現在のロードバランサはローカルファイルシステムに全てのCA証明書を持っておくことを余儀なくされています。 つまり、ロードバランサがステートフルになっています。
現在我々はインフラを Kubernetes の上に再構築しようとしています。 ロードバランサもいつか Kubernetes の上に持っていかなければなりません。 しかしステートフルなサーバーは Kubernetes の上で動かすのがとても大変です。 よってロードバランサはステートレスにして、証明書のような可変なデータは既存の(頑張って運用されている)データストアから取得するようにしたかったわけです。 そのような意味で、Step3 は今回の PoC のキーとなるマイルストーンなのです。
Step4. ユーザーフレンドリーなエラーを返す
Step3 までの実装ではエラーハンドリングに問題がありました。
- クライアント証明書が提出されなかった場合や、提出された証明書が検証に失敗した場合、サーバーはレスポンスを返さずに即座に接続を切断してしまいます。 このときブラウザは真っ白な画面を表示するだけで、ユーザーには何が起こったのかわかりません。
- SSLハンドシェイクのコールバック関数の中でエラーが発生した場合、unwrap でパニックしてしまいます。 この場合もユーザーにわかりやすいエラーメッセージを表示することができません。
サービスとして提供するには、エラーが発生した場合にユーザーにわかりやすいエラーメッセージを表示することが重要です。 よって、Step4 ではエラーハンドリングを改善し、ユーザーに何が起こったのかをエラーメッセージとして返せるようにします。
まず、TLS の verify の設定を変更します。
// 今まで行っていた // tls_settings.set_verify(SslVerifyMode::PEER | SslVerifyMode::FAIL_IF_NO_PEER_CERT); // の代わりに以下のように設定する。 tls_settings.set_verify_callback(SslVerifyMode::PEER, |_, _| true);
set_verify_callback
は set_verify
と似たメソッドですが、モードの設定に加えて証明書検証時のコールバック関数を指定することができます。
これは OpenSSL がクライアント証明書を検証した直後に呼ばれるコールバックで、クライアント証明書の検証結果をふまえた上で接続を受け入れるのか拒否するのかを決定することができます。
我々は HTTP のレイヤーでエラーメッセージを返したいと思っているので、このコールバック関数は常に true
を返すようにしておきます。
この設定により、証明書の検証に失敗した場合や、証明書がクライアントから提示されなかった場合に、サーバーは TCP コネクションを切断せず、HTTPハンドラまで処理が到達するようになりました。
次にすることは、HTTPハンドラの中で証明書エラーやその他のエラーをキャッチし、ユーザーにエラーメッセージを返すことです。
ところで、certificate_callback
は戻り値を持ちませんでした。いったいどうやってエラーを HTTP ハンドラに渡せばよいのでしょうか?
実は OpenSSL には任意のデータを Ssl 構造体に格納するための機能があります。
これを使うことで certificate_callback
で発生したエラーをハンドラまで渡すことができます。
まずは Ssl::new_ex_index
を呼んでエラーを格納するためのインデックスを作成しておきます。
// サーバー起動時に new_ex_index を呼んでエラーを格納するためのインデックスを作成しておく let error_index = Ssl::new_ex_index()?;
そして、certificate_callback
でエラーが発生した場合は、そのエラーを Ssl 構造体に格納します。
async fn certificate_callback(&self, ssl: &mut SslRef) { ... // コールバック関数内でエラーが発生した場合は、エラーを ssl に格納する if let Err(e) = load_certificate(ssl, &pem) { ssl.set_ex_data(self.error_index, *e); return; } ... }
最後に、HTTP ハンドラの中でエラーを取り出して、ユーザーにエラーメッセージを返します。
また、クライアント証明書が提示されていたかどうかは ssl.peer_certificate()
で、検証に失敗したかどうかは ssl.verify_result()
でそれぞれ判定できます。
#[async_trait] impl ServeHttp for App { async fn response(&self, http_session: &mut ServerSession) -> Response<Vec<u8>> { let Some(stream) = http_session.stream() else { return ... }; let Some(ssl) = stream.get_ssl() else { return ... }; // クライアント証明書がクライアントから提示されていない場合、FORBIDDEN を返す if ssl.peer_certificate().is_none() { return build_text_response(StatusCode::FORBIDDEN, "Client certificate not found"); } // ハンドシェイク内で発生したエラーを ssl から取得し、もしエラーがあれば FORBIDDEN を返す if let Some(e) = ssl.ex_data(self.error_index) { return build_text_response(StatusCode::FORBIDDEN, &e.to_string()); } // クライアント証明書の検証に失敗した場合、FORBIDDEN を返す if ssl.verify_result() != X509VerifyResult::OK { return build_text_response( StatusCode::FORBIDDEN, "Failed to verify a client certificate", ); } // クライアント証明書の検証に成功したので、正常なレスポンスを返す ... } }
以上で、クライアント証明書の検証に失敗した場合、クライアント証明書が提示されていなかった場合、そしてコールバック関数内で内部エラーが発生した場合に、ユーザーにエラーメッセージを返すことができるようになりました。
このコードにはひとつ微妙な点があります。
それは ServerSession
から Ssl
を取り出すことができるのは HTTP/1.1 の場合のみで、HTTP/2 の場合は取り出せないということです。
幸い Skylab はそもそも HTTP/2 での利用を想定していないため、この問題は今回の PoC においては問題になりませんでしたが、ここらへんは Pingora の将来に期待したいところです。
完成
以上より、Pingora を使って Skylab 機能を実装することができました。 説明は省きましたが、この PoC はリバースプロキシの機能を持っており、Skylab の認証を行った上で任意のバックエンドサーバーにリクエストを転送することができます。 つまり、任意のバックエンドに Skylab の認証を追加できるようなミドルウェアになっています。
コード行数は 300 行程度であり、クライアント証明書以外の部分を Pingora にまかせているため、簡潔でわかりやすいコードになったと思います。
おわりに
インターンシップが始まったタイミングでは、メンターも Pingora に関しては Pingora 入門 の節に書いた程度の知識しかなく、Skylab の機能が本当に実装できるのか分からない状態でした。 このほぼゼロとも言える状態で覚道さんに実装を進めていただき、最終的には PoC の実装を完了することができました。 Step1~Step4 で紹介したコードは覚道さんの書いたコードをほぼそのまま引用したものです(ただしコメントはこの記事のために私が追加しました)。 今回の課題は Rust と OpenSSL の知識を要求する難易度の高いものだったと思いますが、大きく詰まることなくスムーズに完成まで進み、メンターとしても驚いています。 ありがとうございました。
- TLS では、 (1) クライアントは秘密鍵を使ってメッセージに署名し、公開鍵とその署名をサーバーに送信する (2) サーバーは送られてきた公開鍵を使って署名が正しいか検証する という手順でクライアントが正しいことを確かめています。実は「サーバーにクライアント証明書を送信する」と言ったときの「クライアント証明書」の中身はこの公開鍵(といくつかのメタデータ)なのです。秘密鍵をサーバーに送信しなくてもサーバーは署名が正しいか検証できるというのがポイントです。↩
- nginx における Skylab の実装はこの発表の43~47ページを参照してください: https://www.slideshare.net/slideshow/devsummit2015/44905601↩