Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 18644] ファイルの概要

このコミットは、Go言語の標準ライブラリである net/http パッケージにおける Transport の挙動を改善するものです。具体的には、TLSハンドシェイクのタイムアウト機能を追加し、デフォルトでそのタイムアウトを設定することで、TLS接続の確立における潜在的なハングアップを防ぎ、より堅牢なネットワーク通信を実現します。

コミット

commit fd4b4b56c4a1fd3426fc9ab4c36ec1b270089d29
Author: Brad Fitzpatrick <bradfitz@golang.org>
Date:   Tue Feb 25 08:08:15 2014 -0800

    net/http: add Transport.TLSHandshakeTimeout; set it by default
    
    Update #3362
    
    LGTM=agl
    R=agl
    CC=golang-codereviews
    https://golang.org/cl/68150045

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/fd4b4b56c4a1fd3426fc9ab4c36ec1b270089d29

元コミット内容

net/http: Transport.TLSHandshakeTimeout を追加し、デフォルトで設定する。

変更の背景

HTTPクライアントがHTTPSエンドポイントに接続する際、TCP接続の確立後にTLSハンドシェイクが行われます。このTLSハンドシェイクは、クライアントとサーバー間で暗号化された通信チャネルを確立するための重要なステップです。しかし、ネットワークの不安定性、サーバー側の問題、または悪意のある攻撃などにより、このハンドシェイクプロセスが予期せずハングアップする可能性があります。

従来の net/http.Transport には、TCP接続自体のタイムアウト(Dial.Timeout)は存在しましたが、TLSハンドシェイクに特化したタイムアウト設定がありませんでした。このため、TCP接続は確立されたものの、TLSハンドシェイクが完了しない場合に、クライアントが無限に待機してしまうという問題が発生する可能性がありました。これは、リソースの枯渇やアプリケーションの応答性低下につながる重大な問題です。

このコミットは、この問題を解決するために TLSHandshakeTimeout という新しいフィールドを Transport 構造体に追加し、デフォルトで適切なタイムアウト値を設定することで、TLSハンドシェイクのハングアップを防ぎ、クライアントの堅牢性を向上させることを目的としています。コミットメッセージに記載されている Update #3362 は、この変更が特定の課題やバグ報告に対応するものであることを示唆していますが、公開されているGoのIssueトラッカーでは該当するIssueは見つかりませんでした。これは、内部的なIssue番号であるか、あるいは古いIssueがアーカイブされたためかもしれません。

前提知識の解説

1. Go言語の net/http パッケージ

net/http パッケージは、Go言語でHTTPクライアントおよびサーバーを実装するための標準ライブラリです。このパッケージは、HTTP/1.1およびHTTP/2プロトコルをサポートし、リクエストの送信、レスポンスの受信、ヘッダーの操作、クッキーの管理など、HTTP通信に必要な基本的な機能を提供します。

2. http.Transport 構造体

http.Transport は、HTTPリクエストの単一のラウンドトリップ(リクエストの送信からレスポンスの受信まで)を実行するためのHTTPクライアントの低レベルな実装を提供します。これには、TCP接続の確立、TLSハンドシェイク、プロキシの処理、接続の再利用(Keep-Alive)などの詳細が含まれます。開発者は http.Client を通じて Transport を利用することが一般的ですが、Transport を直接カスタマイズすることで、より詳細なネットワーク挙動を制御できます。

3. TLS (Transport Layer Security)

TLSは、インターネット上で安全な通信を提供するための暗号化プロトコルです。HTTPS(HTTP Secure)は、HTTP通信をTLSで暗号化することで実現されます。TLSハンドシェイクは、クライアントとサーバーが安全な通信チャネルを確立するために行う一連のネゴシエーションプロセスです。このプロセスには、証明書の交換、暗号スイートの選択、セッションキーの生成などが含まれます。

4. tls.Clienttls.Conn.Handshake()

Go言語の crypto/tls パッケージは、TLSプロトコルを実装するための機能を提供します。

  • tls.Client(conn net.Conn, config *tls.Config): 既存の net.Conn (TCP接続など)をラップして、TLSクライアント接続を確立するための *tls.Conn を返します。
  • (*tls.Conn).Handshake(): TLSハンドシェイクを実行します。このメソッドが成功すると、安全な通信チャネルが確立されます。

5. time.Durationtime.AfterFunc

  • time.Duration: Go言語で時間の長さを表す型です。ミリ秒、秒、分などの単位で時間を表現できます。
  • time.AfterFunc(d Duration, f func()) *Timer: 指定された期間 d が経過した後に、関数 f を新しいゴルーチンで一度だけ実行するタイマーを作成します。タイマーは *time.Timer 型の値を返し、これを使ってタイマーを停止したりリセットしたりできます。

6. ゴルーチンとチャネル

  • ゴルーチン (Goroutine): Go言語における軽量な実行スレッドです。Goランタイムによって管理され、非常に効率的に多数のゴルーチンを並行して実行できます。
  • チャネル (Channel): ゴルーチン間で値を送受信するための通信メカニズムです。チャネルは、ゴルーチン間の同期と通信を安全に行うための主要な手段です。

技術的詳細

このコミットの主要な変更点は、net/http.Transport 構造体に TLSHandshakeTimeout フィールドを追加し、TLSハンドシェイクの実行方法を変更したことです。

TLSHandshakeTimeout フィールドの追加

Transport 構造体に TLSHandshakeTimeout time.Duration フィールドが追加されました。このフィールドは、TLSハンドシェイクが完了するまでに待機する最大時間を指定します。値がゼロの場合、タイムアウトは適用されません。

type Transport struct {
    // ... 既存のフィールド ...

    // TLSHandshakeTimeout specifies the maximum amount of time waiting to
    // wait for a TLS handshake. Zero means no timeout.
    TLSHandshakeTimeout time.Duration

    // ... 既存のフィールド ...
}

DefaultTransport の初期化時に、この TLSHandshakeTimeout10 * time.Second (10秒) がデフォルト値として設定されました。これにより、明示的に設定しない限り、すべてのHTTPクライアントはTLSハンドシェイクに最大10秒を費やすことになります。

var DefaultTransport RoundTripper = &Transport{
    // ... 既存の設定 ...
    TLSHandshakeTimeout: 10 * time.Second,
}

TLSハンドシェイクの並行処理とタイムアウトの実装

最も重要な変更は、Transport.dialConn メソッド内でのTLSハンドシェイクの実行ロジックです。以前は、tls.Client(conn, cfg).Handshake() が直接呼び出され、ハンドシェイクが完了するまでブロックしていました。

変更後、TLSハンドシェイクは新しいゴルーチンで実行されるようになりました。同時に、TLSHandshakeTimeout が設定されている場合、time.AfterFunc を使用してタイマーが開始されます。

		plainConn := conn
		tlsConn := tls.Client(plainConn, cfg)
		errc := make(chan error, 2) // エラーを送信するためのチャネル
		var timer *time.Timer       // タイマーを保持する変数

		// TLSHandshakeTimeout が設定されている場合、タイマーを開始
		if d := t.TLSHandshakeTimeout; d != 0 {
			timer = time.AfterFunc(d, func() {
				errc <- tlsHandshakeTimeoutError{} // タイムアウトエラーをチャネルに送信
			})
		}

		// TLSハンドシェイクを新しいゴルーチンで実行
		go func() {
			err := tlsConn.Handshake()
			if timer != nil {
				timer.Stop() // ハンドシェイクが完了したらタイマーを停止
			}
			errc <- err // ハンドシェイクの結果をチャネルに送信
		}()

		// ハンドシェイクの結果またはタイムアウトエラーを待機
		if err := <-errc; err != nil {
			plainConn.Close() // エラーが発生した場合、元の接続を閉じる
			return nil, err
		}

この新しいロジックでは、以下のステップが実行されます。

  1. plainConn (元のTCP接続) と tlsConn (TLSクライアント接続) を準備します。
  2. errc というバッファ付きチャネルを作成し、ハンドシェイクの結果またはタイムアウトエラーをこのチャネルに送信します。
  3. TLSHandshakeTimeout がゼロでない場合、time.AfterFunc を使ってタイマーを設定します。タイマーが期限切れになると、tlsHandshakeTimeoutError{}errc チャネルに送信されます。
  4. tlsConn.Handshake() を新しいゴルーチンで実行します。
  5. ハンドシェイクが完了すると、そのゴルーチンはタイマーを停止し(もしタイマーがまだ実行中であれば)、ハンドシェイクの結果(エラーまたはnil)を errc チャネルに送信します。
  6. メインのゴルーチンは <-errc でチャネルからの値を受信します。これにより、ハンドシェイクが完了するか、タイムアウトが発生するまでブロックされます。
  7. エラーが発生した場合(ハンドシェイクエラーまたはタイムアウトエラー)、元の plainConn を閉じ、エラーを返します。

tlsHandshakeTimeoutError 型の追加

タイムアウトエラーを区別するために、tlsHandshakeTimeoutError という新しい型が定義されました。この型は net.Error インターフェースを満たすように Timeout()Temporary() メソッドを実装しており、Error() メソッドでエラーメッセージを返します。これにより、呼び出し元はタイムアウトエラーを他のネットワークエラーと区別して処理できるようになります。

type tlsHandshakeTimeoutError struct{}

func (tlsHandshakeTimeoutError) Timeout() bool   { return true }
func (tlsHandshakeTimeoutError) Temporary() bool { return true }
func (tlsHandshakeTimeoutError) Error() string   { return "net/http: TLS handshake timeout" }

テストケースの追加

net/http/transport_test.goTestTransportTLSHandshakeTimeout という新しいテストケースが追加されました。このテストは、TLSハンドシェイクが意図的にハングアップする状況をシミュレートし、TLSHandshakeTimeout が正しく機能してタイムアウトエラーを発生させることを検証します。

テストでは、カスタムの Dial 関数を持つ Transport を作成し、TLSハンドシェイクが完了しないようにサーバー側で接続をすぐに閉じないようにします。そして、TLSHandshakeTimeout を短い時間(250ミリ秒)に設定し、http.Client.Get を呼び出します。期待される結果は、url.Error にラップされた net.Error であり、その Timeout() メソッドが true を返し、エラーメッセージに "handshake timeout" が含まれることです。

コアとなるコードの変更箇所

src/pkg/net/http/transport.go

  • Transport 構造体に TLSHandshakeTimeout time.Duration フィールドを追加。
  • DefaultTransport の初期化で TLSHandshakeTimeout: 10 * time.Second を設定。
  • (*Transport).dialConn メソッド内で、TLSハンドシェイクを新しいゴルーチンで実行し、TLSHandshakeTimeout を使用してタイムアウトを監視するロジックを追加。
  • tlsHandshakeTimeoutError 型とそのメソッド (Timeout, Temporary, Error) を追加。

src/pkg/net/http/transport_test.go

  • TestTransportTLSHandshakeTimeout テスト関数を追加。
  • newLocalListener ヘルパー関数を追加。

コアとなるコードの解説

src/pkg/net/http/transport.go の変更点

--- a/src/pkg/net/http/transport.go
+++ b/src/pkg/net/http/transport.go
@@ -36,6 +36,7 @@ var DefaultTransport RoundTripper = &Transport{
 		Timeout:   30 * time.Second,
 		KeepAlive: 30 * time.Second,
 	}).Dial,
+	TLSHandshakeTimeout: 10 * time.Second, // ここでデフォルトのタイムアウトを設定
 }
 
 // DefaultMaxIdleConnsPerHost is the default value of Transport's
@@ -69,6 +70,10 @@ type Transport struct {
 	// tls.Client. If nil, the default configuration is used.
 	TLSClientConfig *tls.Config
 
+// TLSHandshakeTimeout specifies the maximum amount of time waiting to
+// wait for a TLS handshake. Zero means no timeout.
+	TLSHandshakeTimeout time.Duration // 新しいフィールドの追加
+
 	// DisableKeepAlives, if true, prevents re-use of TCP connections
 	// between different HTTP requests.
 	DisableKeepAlives bool
@@ -542,16 +547,33 @@ func (t *Transport) dialConn(cm connectMethod) (*persistConn, error) {
 			// ... TLS設定のクローンと調整 ...
 		}
-		conn = tls.Client(conn, cfg)
-		if err = conn.(*tls.Conn).Handshake(); err != nil {
+		plainConn := conn // 元のTCP接続を保持
+		tlsConn := tls.Client(plainConn, cfg) // TLS接続をラップ
+		errc := make(chan error, 2) // エラー通知用のチャネル
+		var timer *time.Timer // タイムアウトタイマー
+		if d := t.TLSHandshakeTimeout; d != 0 {
+			timer = time.AfterFunc(d, func() {
+				errc <- tlsHandshakeTimeoutError{} // タイムアウト時にエラーを送信
+			})
+		}
+		go func() { // 新しいゴルーチンでハンドシェイクを実行
+			err := tlsConn.Handshake()
+			if timer != nil {
+				timer.Stop() // ハンドシェイク完了時にタイマーを停止
+			}
+			errc <- err // ハンドシェイクの結果を送信
+		}()
+		if err := <-errc; err != nil { // チャネルからの結果を待機
+			plainConn.Close() // エラーが発生した場合、元の接続を閉じる
 			return nil, err
 		}
 		if !cfg.InsecureSkipVerify {
-			if err = conn.(*tls.Conn).VerifyHostname(cfg.ServerName); err != nil {
+			if err := tlsConn.VerifyHostname(cfg.ServerName); err != nil {
+				plainConn.Close() // ホスト名検証失敗時も接続を閉じる
 				return nil, err
 			}
 		}
-		pconn.conn = conn
+		pconn.conn = tlsConn // 確立されたTLS接続をpconnに設定
 	}
 
 	pconn.br = bufio.NewReader(pconn.conn)
@@ -1084,3 +1106,9 @@ type readerAndCloser struct {
 \tio.Reader
 \tio.Closer
 }\n
+type tlsHandshakeTimeoutError struct{} // 新しいエラー型
+
+func (tlsHandshakeTimeoutError) Timeout() bool   { return true } // net.Error インターフェースの実装
+func (tlsHandshakeTimeoutError) Temporary() bool { return true } // net.Error インターフェースの実装
+func (tlsHandshakeTimeoutError) Error() string   { return "net/http: TLS handshake timeout" } // エラーメッセージ

この差分は、Transport 構造体への TLSHandshakeTimeout フィールドの追加と、dialConn メソッド内でのTLSハンドシェイク処理の変更を明確に示しています。特に、ハンドシェイクをゴルーチンで実行し、チャネルとタイマーを使ってタイムアウトを実装するパターンは、Go言語における並行処理とエラーハンドリングの典型的な例です。

src/pkg/net/http/transport_test.go の変更点

--- a/src/pkg/net/http/transport_test.go
+++ b/src/pkg/net/http/transport_test.go
@@ -1722,6 +1722,73 @@ func TestTransportClosesRequestBody(t *testing.T) {
 	}
 }
 
+func TestTransportTLSHandshakeTimeout(t *testing.T) { // 新しいテスト関数
+	defer afterTest(t)
+	if testing.Short() {
+		t.Skip("skipping in short mode")
+	}
+	ln := newLocalListener(t) // ローカルリスナーを作成
+	defer ln.Close()
+	testdonec := make(chan struct{})
+	defer close(testdonec)
+
+	go func() { // サーバー側のゴルーチン
+		c, err := ln.Accept() // 接続を受け入れる
+		if err != nil {
+			t.Error(err)
+			return
+		}
+		<-testdonec // テスト完了まで待機
+		c.Close() // 接続を閉じる
+	}()
+
+	getdonec := make(chan struct{})
+	go func() { // クライアント側のゴルーチン
+		defer close(getdonec)
+		tr := &Transport{
+			Dial: func(_, _ string) (net.Conn, error) {
+				return net.Dial("tcp", ln.Addr().String()) // ローカルリスナーに接続
+			},
+			TLSHandshakeTimeout: 250 * time.Millisecond, // 短いタイムアウトを設定
+		}
+		cl := &Client{Transport: tr}
+		_, err := cl.Get("https://dummy.tld/") // HTTPSリクエストを送信
+		if err == nil {
+			t.Fatal("expected error") // エラーが期待される
+		}
+		ue, ok := err.(*url.Error)
+		if !ok {
+			t.Fatalf("expected url.Error; got %#v", err)
+		}
+		ne, ok := ue.Err.(net.Error)
+		if !ok {
+			t.Fatalf("expected net.Error; got %#v", err)
+		}
+		if !ne.Timeout() { // タイムアウトエラーであることを確認
+			t.Error("expected timeout error; got %v", err)
+		}
+		if !strings.Contains(err.Error(), "handshake timeout") { // エラーメッセージを確認
+			t.Error("expected 'handshake timeout' in error; got %v", err)
+		}
+	}()
+	select {
+	case <-getdonec: // クライアントゴルーチンの完了を待機
+	case <-time.After(5 * time.Second): // テスト全体のタイムアウト
+		t.Error("test timeout; TLS handshake hung?")
+	}
+}
+
+func newLocalListener(t *testing.T) net.Listener { // ヘルパー関数
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		ln, err = net.Listen("tcp6", "[::1]:0")
+	}
+	if err != nil {
+		t.Fatal(err)
+	}
+	return ln
+}
+
 type countCloseReader struct {
 	n *int
 	io.Reader

このテストコードは、TLSHandshakeTimeout が正しく機能することを検証するための重要な部分です。サーバー側でTLSハンドシェイクを意図的に完了させないことで、クライアント側でタイムアウトが発生することを確認しています。net.Error インターフェースの Timeout() メソッドの利用や、エラーメッセージの文字列チェックなど、Goのエラーハンドリングのベストプラクティスが反映されています。

関連リンク

参考にした情報源リンク

  • Go言語の公式ドキュメント
  • Go言語のソースコード
  • TLSハンドシェイクに関する一般的な情報源 (RFCなど)