HTTP/2 クライアント実装サンプル (TLS版)

(2018.7) 実装をやり直し。スッキリ。

HTTP/2 を理解するために, 簡単に実装してみました。実用性は乏しいです。

このページでは, TLSで接続し, 暗号化します。

ソースコードの全部, Makefile はこちら; network · master · netsphere / my-cpp-lib · GitLab

実装の方針

Linux と Windows (mingw32/64) でテストしています。

次の記事を出発点として利用しました; HTTP2最速実装をmain()関数だけで簡単に説明する(SSL編)

特に TLS版は, main() に全部詰め込もうとすると, 非常に冗長になるため, 適宜, クラス・関数に分けて, 処理を纏めています。

実行結果

以降で説明するコードを実行すると, 次のようになります。できました!!

今回は, 決め打ちで, https://www.yahoo.co.jp/ にアクセスするようにしました。

TLS版は, 非TLS版と異なり, HTTP/1.1 からの upgrade はありません (できません)。TLSで接続してから, リクエストを送信します。

Connected to www.yahoo.co.jp
main:275 S payload length:0 bytes, type:SETTINGS, flags:00, stream_id:0
main:287 R payload length:12 bytes, type:SETTINGS, flags:00, stream_id:0
main:298 S payload length:0 bytes, type:SETTINGS, flags:01, stream_id:0
send_HEADERS_frame:197 S payload length:65 bytes, type:HEADERS, flags:04, stream_id:1
:authority: www.yahoo.co.jp
:method: GET
:path: /
:scheme: https
recv_streams:338 R payload length:4 bytes, type:WINDOW_UPDATE, flags:00, stream_id:0
recv_streams:338 R payload length:0 bytes, type:SETTINGS, flags:01, stream_id:0
recv_streams:338 R payload length:746 bytes, type:HEADERS, flags:04, stream_id:1
:status: 200
date: Sun, 01 Jul 2018 09:21:08 GMT
p3p: policyref="http://privacy.yahoo.co.jp/w3c/p3p_jp.xml", CP="CAO DSP COR CUR ADM DEV TAI PSA PSD IVAi IVDi CONi TELo OTPi OUR DELi SAMi OTRi UNRi PUBi IND PHY ONL UNI PUR FIN COM NAV INT DEM CNT STA POL HEA PRE GOV"
x-content-type-options: nosniff
x-xss-protection: 1; mode=block
x-frame-options: SAMEORIGIN
vary: Accept-Encoding
expires: -1
pragma: no-cache
cache-control: private, no-cache, no-store, must-revalidate
x-xrds-location: https://open.login.yahooapis.jp/openid20/www.yahoo.co.jp/xrds
content-type: text/html; charset=UTF-8
age: 0
via: http/1.1 edge2253.img.umd.yahoo.co.jp (ApacheTrafficServer [c sSf ])
server: ATS
set-cookie: TLS=v=1.2&r=1; path=/; domain=.yahoo.co.jp; Secure
recv_streams:338 R payload length:8183 bytes, type:DATA, flags:00, stream_id:1
====DATA====
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd====/DATA====
recv_streams:338 R payload length:5625 bytes, type:DATA, flags:00, stream_id:1
====DATA====
dWppLz9jcGlkPXByX3N1bmRheSZtZW51PXRvcHBhZ2UmdGFyPXRvcCZjcj10b3A-">ヤフオク!くじで落札額分====/DATA====
main:352 S payload length:0 bytes, type:GOAWAY, flags:00, stream_id:0

実装: TLSでの送信, 受信

main() の解説を始める前に, TLS でのソケット通信の部分から。OpenSSL を使います。

標準C++の流儀のために, ソケット通信は, basic_streambuf を拡張したものを使います; streambuf を拡張し, ソケット対応 [C++]

SocketStreamBuf を拡張して, TLS で通信できるようにします。

SSL_write(), SSL_read() は, SSL_ERROR_WANT_WRITE, SSL_ERROR_WANT_READ の考慮が必要.

やや長くなりますが、全文を掲載します。

tls_socket_streambuf.h:

C++
[RAW]
  1. #include "socket_streambuf.h"
  2. #include <openssl/ssl.h>
  3. #include <openssl/err.h>
  4. #include <openssl/x509v3.h>
  5. // OpenSSL を利用
  6. // SocketStream::rdbuf() に与える.
  7. class TlsSocketStreamBuf: public SocketStreamBuf
  8. {
  9. // 参照.
  10. SSL_CTX* m_ctx;
  11. // 所有. connected() メソッド内で設定する.
  12. SSL* m_ssl;
  13. typedef SocketStreamBuf super;
  14. public:
  15. TlsSocketStreamBuf(SSL_CTX* ctx): m_ctx(ctx), m_ssl(nullptr) {
  16. assert(ctx);
  17. }
  18. virtual ~TlsSocketStreamBuf() {
  19. close();
  20. }
  21. /**
  22. * SocketStream::open() から呼び出される.
  23. * TLS の事前設定を行う。
  24. * HTTP/2 ではハンドシェイクの前に ALPN 設定が必要のため、ここで
  25. * SSL_connect() (ハンドシェイク) しない。呼び出し側が行うこと。
  26. */
  27. virtual void connected( SOCKET sock, const char* host ) {
  28. assert( sock != -1 );
  29. assert( host && *host );
  30. assert( m_ssl == nullptr ); // TODO: 例外を投げる?
  31. super::connected(sock, host);
  32. m_ssl = SSL_new(m_ctx);
  33. if ( !m_ssl ) {
  34. ERR_print_errors_fp(stderr);
  35. return; // TODO: 例外投げる
  36. }
  37. SSL_set_fd(m_ssl, sock);
  38. // ホスト名の検証を行う. デフォルトでは有効になっていない。必須.
  39. SSL_set_hostflags(m_ssl, X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS);
  40. if ( !SSL_set1_host(m_ssl, host) ) {
  41. ERR_print_errors_fp(stderr);
  42. return ; // TODO: 例外投げる
  43. }
  44. // For SNI
  45. // これで, Subject: C=US, ST=CA, L=San Francisco, O=Cloudflare, Inc., CN=uservoice.com
  46. // 呼び出さないときは Subject: CN=ssl764794.cloudflaressl.com
  47. // => X509v3 Subject Alternative Name (SAN) でカバーされているので,
  48. // ホスト名検証を有効にしても, 接続はできる.
  49. // 現代では必須.
  50. if ( !SSL_set_tlsext_host_name(m_ssl, host) ) {
  51. ERR_print_errors_fp(stderr);
  52. return ; // TODO: 例外投げる
  53. }
  54. }

connected() で TLS ハンドシェイクの事前設定を行います。OpenSSL はデフォルトでサーバ証明書内のホスト名の検証を行わないので, SSL_set1_host() が必須です。また、SNI もデフォルトで有効になっていないので、呼び出します。

C++
[RAW]
  1. SSL* ssl() const {
  2. return m_ssl;
  3. }
  4. // @return If failed, -1.
  5. virtual int close() {
  6. if (m_ssl) {
  7. // SSL/TLS接続をシャットダウンする。
  8. SSL_shutdown(m_ssl);
  9. SSL_free(m_ssl);
  10. m_ssl = nullptr;
  11. }
  12. return super::close();
  13. }
  14. protected:
  15. virtual ssize_t sysread(void* buffer, size_t length)
  16. {
  17. assert(m_ssl);
  18. while (true) {
  19. int r = SSL_read(m_ssl, buffer, length);
  20. if (r > 0)
  21. return r;
  22. int err = SSL_get_error(m_ssl, r);
  23. switch (err)
  24. {
  25. case SSL_ERROR_WANT_READ:
  26. continue;
  27. default:
  28. throw err; // TODO: SSL_ERROR_SYSCALL の場合, errno.
  29. }
  30. }
  31. }
  32. // Writes characters from the array pointed to by buffer into the
  33. // controlled output sequence.
  34. virtual std::streamsize xsputn( const char_type* buffer,
  35. std::streamsize length )
  36. {
  37. if (!is_open())
  38. return 0;
  39. assert( m_ssl );
  40. while (true) {
  41. int r = SSL_write(m_ssl, buffer, length);
  42. if (r > 0)
  43. return r;
  44. int err = SSL_get_error(m_ssl, r);
  45. switch (err)
  46. {
  47. case SSL_ERROR_WANT_WRITE:
  48. continue;
  49. default:
  50. throw err; // TODO: SSL_ERROR_SYSCALL の場合, errno.
  51. }
  52. }
  53. }
  54. };

HTTP/2実装

初期化 〜 接続

では, main() の最初から。

OpenSSL v1.1.0 から, プロトコルの無効化の書き方が変わっています。v1.0.2 では SSL_CTX_set_min_proto_version() がないので, 個別に無効化します。

ルートCA証明書は, 環境によって場所が違うので, 適宜調整してください。よくあるWebの解説では, 証明書の検証をすっ飛ばしていることがままありますが、ダメです。

C++
[RAW]
  1. int main(int argc, char* argv[])
  2. {
  3. //------------------------------------------------------------
  4. // 接続先ホスト名.
  5. // HTTP2に対応したホストを指定します.
  6. //------------------------------------------------------------
  7. //unique_ptr<URI> uri( URI::parse("https://nghttp2.org/", nullptr) );
  8. // ヘッダが, 無圧縮で, 見やすい
  9. unique_ptr<URI> uri( URI::parse("https://www.yahoo.co.jp/", nullptr) );
  10. // Not HTTP/2
  11. //unique_ptr<URI> uri( URI::parse("https://tools.ietf.org/html/rfc7230", nullptr) );
  12. //------------------------------------------------------------
  13. // TCPの準備.
  14. //------------------------------------------------------------
  15. #ifdef _WIN32
  16. WSADATA wsaData;
  17. if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
  18. return 1;
  19. #endif
  20. //------------------------------------------------------------
  21. // SSLの準備.
  22. //------------------------------------------------------------
  23. // SSLライブラリの初期化.
  24. SSL_library_init();
  25. // エラーを文字列化するための準備.
  26. SSL_load_error_strings();
  27. // v1.0.2n 0x100020efL
  28. #if OPENSSL_VERSION_NUMBER >= 0x10100000L
  29. // グローバルコンテキスト初期化.
  30. SSL_CTX* _ctx = SSL_CTX_new( TLS_client_method() );
  31. // HTTP/2 は, TLS 1.2以降が必須.
  32. // HTTP/1.1 への fallback のために, TLS 1.1 も有効にする.
  33. SSL_CTX_set_min_proto_version(_ctx, TLS1_1_VERSION);
  34. #else
  35. // v1.0.2: SSLv2 .. TLSv1.2
  36. SSL_CTX* _ctx = SSL_CTX_new( SSLv23_client_method() );
  37. SSL_CTX_set_options(_ctx,
  38. SSL_OP_NO_SSLv2 | SSL_OP_NO_SSLv3 | SSL_OP_NO_TLSv1);
  39. #endif
  40. // 必須. ルートCA証明書を読み込む.
  41. // Fedora: 'ca-certificates' package.
  42. int r = SSL_CTX_load_verify_locations(_ctx, CA_BUNDLE_FILE, nullptr);
  43. if (!r) {
  44. ERR_print_errors_fp(stderr);
  45. fprintf(stderr, "loading certificate failed.\n");
  46. return 1;
  47. }
  48. ////////////////////////////////////////////
  49. // 接続
  50. addrinfo* res = ngethostbyname(uri->getHost().c_str(), uri->getPort(),
  51. SOCK_STREAM);
  52. if (!res) {
  53. fprintf(stderr, "host not found: %s\n", uri->getHost().c_str());
  54. return 1;
  55. }
  56. int error = 0;
  57. SOCKET _socket = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
  58. if (_socket < 0) {
  59. fprintf(stderr, "Fail to create a socket.\n");
  60. freeaddrinfo(res);
  61. return 1;
  62. }
  63. // 必須. See https://http2.github.io/faq/
  64. // man 7 TCP
  65. int flag = 1;
  66. r = setsockopt(_socket, IPPROTO_TCP, TCP_NODELAY, (char*) &flag, sizeof(flag));
  67. if ( r < 0 ) {
  68. error = get_error();
  69. //::shutdown(_socket, SHUT_RDWR);
  70. //close_socket(_socket);
  71. return 1;
  72. }
  73. if (connect(_socket, res->ai_addr, res->ai_addrlen) < 0 ) {
  74. fprintf(stderr, "connect() error.\n");
  75. freeaddrinfo(res);
  76. return 1;
  77. }
  78. // もういらん.
  79. freeaddrinfo(res);
  80. res = nullptr;
  81. // sslセッションオブジェクトを作成する.
  82. SSL* _ssl = SSL_new(_ctx);
  83. // ソケットと関連づける.
  84. r = SSL_set_fd(_ssl, _socket);
  85. if (!r) {
  86. ERR_print_errors_fp(stderr);
  87. return 1;
  88. }
  89. TlsSocketStreamBuf conn;
  90. conn.attach(_socket, _ssl);
  91. Http2Reader reader;
  92. reader.attach(&conn);

ハンドシェイク

TLS には ALPN という仕組みがあり, クライアントから対応プロトコルの一覧を送信し, サーバがそのなかから選ぶことで, 最初から合意したプロトコルを確立できます。

"h2" が HTTP/2 over TLS です。

サーバから返信された対応リストに "h2" がない場合は, HTTP/1.1 になります。

Web上のサンプルでは忘れられがちですが, サーバ証明書の検証は必須です。X509_free() で解放を忘れずに。これを抜かすと, SSL_free() でメモリリークします。

C++
[RAW]
  1. // プロトコルのネゴシエーションにALPNという方法を使います。
  2. // 具体的にはTLSのClientHelloのALPN拡張領域ににこれから使うプロトコル名を記述します.
  3. //
  4. // protosには文字列ではなくバイナリで、「0x02, 'h','2'」と指定する。
  5. // 最初の0x02は「h2」の長さを表している.
  6. //------------------------------------------------------------
  7. SSL_set_alpn_protos(_ssl, protos, protos_len + 1);
  8. // ハンドシェイク.
  9. if (SSL_connect(_ssl) <= 0){
  10. error = get_error();
  11. conn.close();
  12. return 1;
  13. }
  14. // 証明書の検証結果を得る. 必須.
  15. // SSL_get_verify_result() は, SSL_get_peer_certificate() との併用時のみ
  16. // 使える.
  17. X509* x509 = SSL_get_peer_certificate(_ssl);
  18. if ( !x509 ) {
  19. fprintf(stderr, "certificate missing.\n");
  20. conn.close();
  21. return 1;
  22. }
  23. //X509_print_fp(stdout, x509); // DEBUG
  24. // [BUGS]
  25. // If no peer certificate was presented, the returned result code is
  26. // X509_V_OK.
  27. r = SSL_get_verify_result(_ssl);
  28. if (r != X509_V_OK) {
  29. fprintf(stderr, "verification failed.\n");
  30. conn.close();
  31. return 1;
  32. }
  33. printf("Connected to %s\n", uri->getHost().c_str());
  34. X509_free(x509); // 忘れやすい.
  35. // 採用された ALPN を確認する.
  36. const unsigned char* ret_alpn = nullptr;
  37. unsigned int alpn_len = 0;
  38. SSL_get0_alpn_selected(_ssl, &ret_alpn, &alpn_len);
  39. if (!ret_alpn || !alpn_len) {
  40. printf("Not HTTP/2\n");
  41. return 1;
  42. }
  43. if (alpn_len != protos_len ||
  44. memcmp(ret_alpn, protos + 1, alpn_len) != 0){
  45. error = get_error();
  46. conn.close();
  47. return 1;
  48. }

後は, ほぼほぼ, 非TLS版と同じです。最初の HTTP/1.1 リクエストがないので, 下の HEADERS フレームが最初のリクエストになります。

C++
[RAW]
  1. // 24オクテットのバイナリを送信します
  2. // PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n
  3. //------------------------------------------------------------
  4. string pri = CLIENT_CONNECTION_PREFACE;
  5. r = conn.sputn(pri.c_str(), pri.length() );
  6. if (r < 0) {
  7. error = get_error();
  8. conn.close();
  9. return 1;
  10. }
  11. //------------------------------------------------------------
  12. // HTTP/2 通信のフロー
  13. //
  14. // まず最初に SETTINGS フレームを必ず交換します.
  15. // SETTINGS フレームを交換したら、設定を適用したことを伝えるために必ずACKを送ります.
  16. //
  17. // Client -> Server SettingFrame
  18. // Client <- Server SettingFrame
  19. // Client -> Server ACK
  20. // Client <- Server ACK
  21. //
  22. // Client -> Server HEADERS_FRAME (GETなど)
  23. // Client <- Server HEADERS_FRAME (ステータスコードなど)
  24. // Client <- Server DATA_FRAME (Body)
  25. //
  26. // Client -> Server GOAWAY_FRAME (送信終了)
  27. //------------------------------------------------------------
  28. //------------------------------------------------------------
  29. // Settings フレームの送信.
  30. // フレームタイプは「0x04」
  31. // 全てデフォルト値を採用するためpayloadは空です。
  32. // SettingフレームのストリームIDは0です.
  33. const char settingframe[FRAME_HDR_LENGTH] = {
  34. 0x00, 0x00, 0x00,
  35. 0x04, // SETTINGS
  36. 0x00, 0x00, 0x00, 0x00, 0x00};
  37. dump_frame_header('S', settingframe);
  38. r = conn.sputn(settingframe, FRAME_HDR_LENGTH);
  39. if ( r < 0 ) {
  40. error = get_error();
  41. conn.close();
  42. return 1;
  43. }
  44. //------------------------------------------------------------
  45. // Settingsフレームの受信.
  46. //------------------------------------------------------------
  47. char* frame = reader.read_frame();
  48. dump_frame_header('R', frame);
  49. assert(*(frame + 3) == 0x4); // SETTINGS
  50. //------------------------------------------------------------
  51. // ACKの送信.
  52. // ACKはSettingsフレームを受け取った側が送る必要がある.
  53. // ACKはSettingsフレームのフラグに0x01を立てて payloadを空にしたもの.
  54. //
  55. // フレームタイプは「0x04」
  56. // 5バイト目にフラグ0x01を立てます。
  57. //------------------------------------------------------------
  58. dump_frame_header('S', settingframeAck);
  59. r = conn.sputn(settingframeAck, FRAME_HDR_LENGTH);
  60. if ( r < 0 ) {
  61. error = get_error();
  62. conn.close();
  63. return 1;
  64. }
  65. // サーバーからのACKの受信は下でやります..
  66. //------------------------------------------------------------
  67. // HEADERSフレームの送信.
  68. //
  69. // フレームタイプは「0x01」
  70. // このフレームに必要なヘッダがすべて含まれていてこれでストリームを終わらせることを示すために、
  71. // END_STREAM(0x1)とEND_HEADERS(0x4)を有効にします。
  72. // 具体的には5バイト目のフラグに「0x05」を立てます。
  73. // ストリームIDは「0x01」を使います.
  74. //
  75. // ●HTTP2でのセマンティクス
  76. // :method GET
  77. // :path /
  78. // :scheme https
  79. // :authority nghttp2.org
  80. //
  81. map<string, string> m;
  82. m.insert(make_pair(":method", "GET"));
  83. m.insert(make_pair(":path", uri->getPath()) );
  84. m.insert(make_pair(":scheme", "https"));
  85. m.insert(make_pair(":authority", uri->getHost()));
  86. r = send_HEADERS_frame(&conn, 1, m);
  87. if ( r < 0 ) {
  88. int error = get_error();
  89. conn.close();
  90. return 1;
  91. }
  92. // ストリームを受信する.
  93. recv_streams(reader);
  94. recv_streams(reader);

recv_streams() については, 共通ルーチンのページをご覧ください。2回呼び出しているのは, DATA フレームが一つのフレームに収まらず, 複数に分割されたフレームを読むためです。

後処理

あとは, 後処理です。

C++
[RAW]
  1. //------------------------------------------------------------
  2. // GOAWAYの送信.
  3. //
  4. // これ以上データを送受信しない場合はGOAWAYフレームを送信します.
  5. // フレームタイプは「0x07」
  6. // ストリームIDは「0x00」(コネクション全体に適用するため)
  7. //------------------------------------------------------------
  8. static const char goawayframe[17] = {
  9. 0x00, 0x00, 0x00,
  10. 0x07, // GOAWAY
  11. 0x00, 0x00, 0x00, 0x00, 0x00,
  12. 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00 };
  13. dump_frame_header('S', goawayframe);
  14. r = conn.sputn( goawayframe, 17 );
  15. if (r < 0 ) {
  16. error = get_error();
  17. conn.close();
  18. return 1;
  19. }
  20. //------------------------------------------------------------
  21. // 後始末.
  22. //------------------------------------------------------------
  23. conn.close();
  24. SSL_CTX_free(_ctx);
  25. ERR_free_strings();
  26. return 0;
  27. }