Network Go Start HTTP/2 running over cleartext TCP

![flow]({{ site.url }}/assets/images/h2c-flow.png)

前言

主流使用 HTTP/2 時都是基於 TLS protocol,不過在 HTTP/2 RFC7540 規範中, HTTP/2 其實也可以直接基於 cleartext TCP 來溝通。這次主要介紹 based on cleartext TCP 的 HTTP/2 server 與 client 實作,後續會再加入 HTTP/2 結合 TLS protocol 的相關內容。

HTTP/2 Version Identification

RFC7540 3.1 中有明確定義出 HTTP/2 on TLS protocol or cleartext TCP 的識別號,這個識別主要用於 client 端詢問 server 對於 HTTP/2 的 protocol 支援,以及切換到 HTTP/2 的過程。

1. h2

The string “h2” identifies the protocol where HTTP/2 uses Transport Layer Security (TLS).

2. h2c

The string “h2c” identifies the protocol where HTTP/2 is run over cleartext TCP.

為了方便辨識,以下會直接以 h2 h2c 代號來表示基於不同 protocol 的 HTTP/2。

而在h2c 部分,有更細節的說明:

This identifier is used in the HTTP/1.1 Upgrade header field and in any place where HTTP/2 over TCP is identified.

The “h2c” string is reserved from the ALPN identifier space but describes a protocol that does not use TLS.

Client 與 server 在決定要用什麼 protocol 溝通時,h2 基於 TLS ,因此是採用 TLS-ALPN(Application-Layer Protocol Negotiation) 方式;至於 h2c 沒有 TLS ,所以就走 HTTP/1.1 Upgrade 機制,在 request header 中會加入 Upgrade:h2c,讓 server 可以根據此來辨識是否支援並且 upgrade。

Upgrading from HTTP/2

(RFC7540 8.1.1)雖然 HTTP/1.1 可以透過 Upgrade 機制來切換到 HTTP/2 protocol,但是 HTTP/2 卻是明確禁止這樣的行為,也因此無法透過這樣的 upgrade 機制從 HTTP/2 轉換到其他 protocol。

h2c HTTP/2 Server

在上面內容有提到,h2c HTTP/2 server 要能識別 HTTP/1.1 Upgrade 機制,並且給予適當回應,因此這也是在實作 server 時的首要功能。

http.Server with h2c handler

用 Go 建立 h2c server 的方式很簡單,一樣是使用大家熟悉的 net/http package Server.ListenAndServe 來啟動 server 並接收後續的 request,不過因為必須處理 HTTP/2 型態的 request,所以可以猜到我們需要調整 Handler 設定,來讓 server 能了解 HTTP/2 格式的 request。

調整 Handler 的方式,可以透過 "golang.org/x/net/http2/h2c" package 來達成目的。使用 h2c.NewHandler method 就能產生 h2cHandler, h2cHandler 會根據 request 的內容來判斷後續執行方式:

  1. HTTP/2: 如果讀到 client HTTP/2 Connection Preface(RFC7540-3.5,後面會說明),就會建立額外的 http2.serverConn,後續 request 將由這新的 connect 來接收處理。

  2. HTTP/1.1 upgrade to HTTP/2 h2c: 第二種情況則是 request 包含 protocol upgrade 資訊(RFC7540-3.2),如果可以 upgrade,就會將 HTTP 內容轉換成 HTTP/2 格式,接著同樣地新建 http2.serverConn 來處理 request。

  3. HTTP: 如果沒有包含任何 HTTP/2 和 Upgrade 資訊,就當成一般 HTTP request 處理。

有了這些概念後,我們就可以知道 h2c server 該如何建立。

H2c Server Example

package main

import (
    "fmt"
    "log"
    "net/http"

    "golang.org/x/net/http2"
    "golang.org/x/net/http2/h2c"
)

func main() {
    var h2serv http2.Server

    handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "HTTP/2 Server Example")
    })

    serv := http.Server{
        Addr:    ":9090",
        // Create a h2c handler to dispatch incoming requests.
        Handler: h2c.NewHandler(handler, &h2serv),
    }

    log.Printf("Start server %s", serv.Addr)
    if err := serv.ListenAndServe(); err != http.ErrServerClosed {
        log.Panicln(err)
    }
}

在網路上可能會看到以下寫法:

func main() {
    var server http2.Server

    l, err := net.Listen("tcp", "localhost:9090")
    if err != nil {
        log.Fatalln(err)
    }

    for {
        conn, err := l.Accept()
        if err != nil {
            log.Fatalln(err)
        }

        server.ServeConn(conn, &http2.ServeConnOpts{
            Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                fmt.Fprintf(w, "HTTP/2 Server Example")
            }),
        })
    }
}

這種 low-level API l.Accept() Loop,並且直接使用 server.ServeConn 的做法等於省略了 h2cHandler 這層判斷,讓 request 直接進入 HTTP/2 格式處理。建議還是使用 h2c.NewHandler 作法,讓 server handler 比較有彈性。

H2c Client

有了 HTTP/2 server 後,就寫個簡單的 client 來進行測試吧!

首先,Client 端發出含有 Upgrade:h2c 的 HTTP request 到 server 端,然後確認 response 是否如預期。

1. 建立 upgrade request

req, err := http.NewRequest(http.MethodGet, "http://localhost:9090", nil)
req.Header.Add("Upgrade", "h2c")

2. 加入更多 HTTP/2 upgrade requirement

光有 Upgrade:h2c 還不夠,還要有 HTTP2-Settings Header Field 以及其內容。

The content of the HTTP2-Settings header field is the payload of a SETTINGS frame, encoded as a base64url string.

HTTP2-Settings 其實就是 HTTP/2 的 SETTINGS frame(RFC7540 6.5),其值用來進行溝通期間的參數設定,例如更新 window size 或是 設定 max frame size 等。只不過因為我們起初是發送 HTTP request,所以就將值 encoding 後放入到 HTTP2-Settings header field 中。

settings := []byte{0, 5, 0, 0xa, 0, 0, 0, 3, 0, 0, 0, 0xfa, 0, 6, 0, 10, 01, 0x40, 0, 4, 0, 10, 0, 0}
req, _ := http.NewRequest(http.MethodGet, "http://localhost:9000", nil)
req.Header.Add("Connection", "HTTP2-Settings")
req.Header.Add("HTTP2-Settings", base64.RawURLEncoding.EncodeToString(settings))

簡單說明一下,settings 的內容是由數個 Identifier (16 bit) + Value (32 bit) 所組成,其格式定義在 RFC7540 6.5.1 SETTINGS FORMAT

舉例來說:

[]byte{0, 5, 0, 0xa, 0, 0}
// big-endian
// 0x5 - SETTINGS_MAX_FRAME_SIZE, value - 0xa0000 = 655360

最後,為什麼 upgrade request 需要 HTTP2-Settings,在 RFC7540 3.2.1 有解答:

Providing these values (HTTP2-Settings) in the upgrade request gives a client an opportunity to provide parameters prior to receiving any frames from the server.

3. Send upgrade request and get response

Client sample code

conn, err := net.Dial("tcp", "localhost:9000")
if err != nil {
  log.Fatalln(err)
}

req := newUpgradeRequest()

// send a request
req.Write(conn)

// get data from server
buf := bufio.NewReader(conn)

// convert data to HTTP response format. 
resp, err := http.ReadResponse(buf, req)
if err != nil {
	log.Fatalln(err)
}

if resp.StatusCode != http.StatusSwitchingProtocols {
	log.Fatalln("server does not support h2c")
}

為了 re-use net.Conn 以及使用 http2.Client,因此就採用 low level API 的方式來發送 HTTP request。

我們從 Wireshark 可以看到結果,在正確設置了 upgrade header 後,就會回應 switching protocols response,而這也正是我們所預期的。

![flow]({{ site.url }}/assets/images/h2c-upgrade-1.png)

而從 Go server Debug tool 來看 (其實就是在 go run server.go 前面加上GODEBUG=http2debug=2)

2019/08/27 15:23:33 http2: Framer 0xc0001420e0: wrote SETTINGS len=24, settings: MAX_FRAME_SIZE=655360, MAX_CONCURRENT_STREAMS=250, MAX_HEADER_LIST_SIZE=655680, INITIAL_WINDOW_SIZE=655360

MAX_FRAME_SIZE value 也有正確被 server 讀取,皆大歡喜!

反之,如果只設置 Upgrade:"h2c" 而沒有設置 HTTP2-Settings 的情況下:

![flow]({{ site.url }}/assets/images/h2c-upgrade-2.png)

Server 只會把這個 request 當作 HTTP 來處理,就直接回 200 以及對應的 response data 啦~

4. Starting HTTP/2 with Prior Knowledge

根據規範指示,當我們收到 101 response,就需要接著發送 connection preface (RFC7540 3.4) 以利 server 繼續進行 HTTP/2 connection 建立。

Upon receiving the 101 response, the client MUST send a connection preface (Section 3.5), which includes a SETTINGS frame.

從 h2c.go source code 也可以看到有這樣的確認機制:

// h2cUpgrade establishes a h2c connection using the HTTP/1 upgrade (Section 3.2).
func h2cUpgrade(w http.ResponseWriter, r *http.Request) (net.Conn, error) {
	
	// more...
	
	rw.Write([]byte("HTTP/1.1 101 Switching Protocols\r\n" +
		"Connection: Upgrade\r\n" +
		"Upgrade: h2c\r\n\r\n"))
	rw.Flush()

	// A conforming client will now send an H2 client preface which need to drain
	// since we already sent this.
	if err := drainClientPreface(rw); err != nil {
		return nil, err
	}

	c := &rwConn{
		Conn:      conn,
		Reader:    io.MultiReader(initBytes, rw),
		BufWriter: newSettingsAckSwallowWriter(rw.Writer),
	}
	return c, nil
}

如果 client 沒有接著發送 connection preface 那該連線就無法成立。

因此,我們就繼續實作 client,在收到 101 status code 後,就接著使用 http2.Transport.NewClientConn 來建立 client conn 並發送 client connection preface。

var tr http2.Transport
http2Conn, err := tr.NewClientConn(conn)
if err != nil {
	log.Fatalln(err)
}

http2.Transport.NewClientConn

client connection preface 在 NewClientConn 階段就會發送給 server 端,從 source code 可以看到相關實作。

func (t *Transport) newClientConn(c net.Conn, singleUse bool) (*ClientConn, error) {
	cc := &ClientConn{
		t:                     t,
		tconn:                 c,
		readerDone:            make(chan struct{}),
		nextStreamID:          1,
		maxFrameSize:          16 << 10,           // spec default
		initialWindowSize:     65535,              // spec default
		maxConcurrentStreams:  1000,               // "infinite", per spec. 1000 seems good enough.
		peerMaxHeaderListSize: 0xffffffffffffffff, // "infinite", per spec. Use 2^64-1 instead.
		streams:               make(map[uint32]*clientStream),
		singleUse:             singleUse,
		wantSettingsAck:       true,
		pings:                 make(map[[8]byte]chan struct{}),
	}
	
	// skip..

	// send the client connection preface
	cc.bw.Write(clientPreface)
	
	cc.fr.WriteSettings(initialSettings...)
	cc.fr.WriteWindowUpdate(0, transportDefaultConnFlow)
	cc.inflow.add(transportDefaultConnFlow + initialWindowSize)
	cc.bw.Flush()
	if cc.werr != nil {
		return nil, cc.werr
	}

	go cc.readLoop()
	return cc, nil
}

5. Communicating communications

最後,連線建立完成,可以開始開啟新 stream 並且交換 frames!

req, _ := http.NewRequest(http.MethodGet, "http://localhost:9000", nil)
http2Conn.RoundTrip(req)

![flow]({{ site.url }}/assets/images/h2c-upgrade-3.png)

8/31 補充:關於 http2.Transport

在整合 demo code 成為一個完整的 h2c-client 時,發現一個連線 bug。

當 client 發送一個包含 HTTP/2 upgrade 的 HTTP request 時, server 在接收到後,會將其格式轉成 HTTP/2 ,並在後續建立起 HTTP/2 連線時,會把這個資訊放入 buffer 中。HTTP/2 連線建立成功, server 發送完初始 SETTINGS frames 後,就會緊接著將這個 request 處理完並且將 response 回應給 client 端。

此時,這個 response 的 stream ID 為 0x1 (RFC 7540 5.1.1),但是 client 端在接收到 response 後卻會顯示 error, error 內容為收到未預期的 stream ID ,並強制關閉 connection。

查看了一下 client transport source code,發現 transport 在接收 DATA frame 時會檢查 stream ID 是否超出 request 的 stream ID。

func (rl *clientConnReadLoop) processData(f *DataFrame) error {
	neverSent := cc.nextStreamID
	if f.StreamID >= neverSent {
		// We never asked for this.
		cc.logf("http2: Transport received unsolicited DATA frame; closing connection")
		return ConnectionError(ErrCodeProtocol)
	}
}

看的出來是 nextStreamID 的問題。這時候回去看 newClientConn source code,會看到 nextStreamID 會被初始化成 1,而此時因為 client 端在 HTTP/2 連線建立成功後,還沒有發送任何 request,因此 nextStreamID 維持 1,進而造成這樣的錯誤回報。

那麼要如何避免這樣的錯誤發生呢?

一個蠻 tricky 的做法,就是:

tr := &http2.Transport{AllowHTTP: true}

把 Transport 的 AllowHTTP 設定為 true。雖然根據官方 source code 的說法:

AllowHTTP, if true, permits HTTP/2 requests using the insecure, plain-text “http” scheme. Note that this does not enable h2c support.

看起來好像跟問題沒有什麼太大關係,不過這樣的設定會更動到 nextStreamID 的值。一樣是在 newClientConn method:

if t.AllowHTTP {
	cc.nextStreamID = 3
}

因為 nextStreamID 初始值變成 3 了,所以當接收到 response 的 stream ID 為 1 時,判斷就會是正常結果,避免掉上述的問題。

老實說,把 nextStreamID 設置為 3,對我來說是一個蠻 magic number 的行為,code 裡面也沒有解釋為什麼是 3 而不是 4,5,6,7 … ,不過既然可以解決了,就這樣吧 XD 畢竟 Go 本來的 HTTP2 package 本來就是預設 TLS 行為,這個錯誤只會發生在使用 HTTP/1.1 upgrade 後而已。

Sample Codes

Server

{%gist YuShuanHsieh/c902a1bd245b9ad52e30f832d2cc9ab3 %}

Client

{%gist YuShuanHsieh/e249dfe245df7612d7fdd9cb7333ba16 %}