golang

Golang: gorilla/websocket 으로 보는 websocket handshake

주먹불끈 2021. 11. 30. 18:38

Photo by Christopher Robin Ebbinghaus on Unsplash

 

이전 포스팅에서 websocket 전반을 둘러보며 처음 연결시의 handshake 정리했었는데 부분의 코드가 gorilla/websocket 패키지에서는 어떻게 구현되어 있는지를 살펴보자

 

이전 포스팅: https://jusths.tistory.com/249

GitHub(gorilla/websocket): https://github.com/gorilla/websocket

GitHub(gorilla/websocket echo example): https://github.com/gorilla/websocket/tree/master/examples/echo 

 

Websocket connection 위한 handshake 다시 보기

 

클라이언트가 HTTP request websocket 연결을 요청하면, websocket을 지원하는 서버는 response 해주면 websocket connection 되며, 이후 message 주고 받을 있게 된다.

이러한 handshake 다음과 같이 이루어진다.

 

클라이언트 HTTP request의 header는 다음과 같이 서버에게 말한다

Connection: Upgrade 연결을 유지하며(keep-alive) HTTP가 아닌 request로 쓰고 싶다.
Upgrade: websocket HTTP 아닌 request 중에서 꼬집어 websocket connection을 하고 싶다
Sec-WebSocket-Key 일회성 랜덤값 16바이트를 base64로 인코딩한 값이다.
- 서버가 websocket protocol 지원한다면 랜덤한 문자열에 대한 회신을 있다.
Sec-WebSocket-Version: 13 시점 (2021-11-24) 무조건 13이라고 적으면 된다

서버의 response 는 다음과 같은 의미를 가진다.

 

HTTP StatusCode는 HTTP 101(Switching Protocols) 이다. 클라이언트가 요청한 Upgrade 인 websocket protocol swithinng 한다는 것.

Upgrade: websocket
Connection: Upgrage
클라이언트의 요청값과 같다. 요청대로 switching 한다는 의미이며 확인이 되겠다.
Sec-WebSocket-Accept 클라이언트가 보낸 Sec-WebSocket-Key 문자열에 RFC 6455 스펙에 정의되어 있는
상수값 문자열 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 를 붙인 다음
SHA-1으로 해싱하고 base64 인코딩을 해준 값이다

 

Sec-WebSocket-Accept 생성하는 예제코드를 덧붙여둔다. https://play.golang.org/p/zdMo8mv_rSJ

gorilla/websocket 패키지에서의 websocket connection

 

클라이언트와 서버의 연결을 코드레벨에서 들여다보기 위해서 gorilla/websocket에서 제공하는 echo 예제 코드가 패키지를 어떻게 사용하는지를 보며 websocket connection이 이루어지는 과정을 들여다 보자. 핵심적인 코드만 발췌하여 정말 스펙대로 구현되어 있는지를 보겠다. 

 

예제코드 링크: https://github.com/gorilla/websocket/tree/master/examples/echo

 

websocket connection 위한 handshake: 클라이언트 코드

클라이언트는 gorilla/websocket 패키지의 DefaultDialer Dial 메서드로 연결을 시도하고 리턴값으로 websocket connection인 c를 받는다. 클라이언트를 이 connection을 이용해서 서버와 메시지를 주고 받을 수 있게 된다. 

 

GitHub: https://bit.ly/3o6yweh

c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
	log.Fatal("dial:", err)
}
defer c.Close()

Dial 메서드는 다시 DialContext 메서드를 호출하는데 여기에서 websocket protocol 스펙대로 handshake 위한 클라이언트의 HTTP request 만드는 것을 확인할 수 있다.

 

GitHub: https://bit.ly/3xKuDPC

func (d *Dialer) DialContext(ctx context.Context, urlStr string, requestHeader http.Header) (*Conn, *http.Response, error) {
	// omition...
	
	challengeKey, err := generateChallengeKey()
	if err != nil {
		return nil, nil, err
	}
	
	// omition...

	req.Header["Upgrade"] = []string{"websocket"}
	req.Header["Connection"] = []string{"Upgrade"}
	req.Header["Sec-WebSocket-Key"] = []string{challengeKey}
	req.Header["Sec-WebSocket-Version"] = []string{"13"}
    
	// omition...    
}

 

generateChallengeKey 함수는 간단히 랜덤 16바이트를 생성해서 base64 string으로 인코딩하는 것이 전부이다. 

 

GitHub: https://bit.ly/3o4l6iW

func generateChallengeKey() (string, error) {Gary Burd, 8 years ago: • Initial commit
	p := make([]byte, 16)
	if _, err := io.ReadFull(rand.Reader, p); err != nil {
		return "", err
	}
	return base64.StdEncoding.EncodeToString(p), nil
}

Request를 서버로 보내고, Response를 받으면 스펙대로 원하는 값들이 있는지를 확인한 후 연결이 된다. 

 

GitHub: https://bit.ly/3E6uHeU

	if resp.StatusCode != 101 ||
		!tokenListContainsValue(resp.Header, "Upgrade", "websocket") ||
		!tokenListContainsValue(resp.Header, "Connection", "upgrade") ||
		resp.Header.Get("Sec-Websocket-Accept") != computeAcceptKey(challengeKey) {
		// Before closing the network connection on return from this
		// function, slurp up some of the response to aid application
		// debugging.
		buf := make([]byte, 1024)
		n, _ := io.ReadFull(resp.Body, buf)
		resp.Body = ioutil.NopCloser(bytes.NewReader(buf[:n]))
		return nil, resp, ErrBadHandshake
	}

서버가, 클라이언트가 보내준 challengeKey와 스펙상의 상수로 만든 값과 클라이언트가 만들어본 값을 비교해보는 코드가 보일 것이다. computeAcceptKey 함수는 스펙에 명시된 방법대로 키를 만드는 함수이다. 

 

GitHub: https://bit.ly/31ctpAI

var keyGUID = []byte("258EAFA5-E914-47DA-95CA-C5AB0DC85B11")Gary Burd, 8 years ago: • Initial commit

func computeAcceptKey(challengeKey string) string {
	h := sha1.New()
	h.Write([]byte(challengeKey))
	h.Write(keyGUID)
	return base64.StdEncoding.EncodeToString(h.Sum(nil))
}

이제는 서버쪽 코드를 챙겨보자 

 

websocket connection 위한 handshake: 서버 코드

echo 예제의 server.go에서는 /echo endpoint 들어온 request echo 핸들러 함수로 처리한다. 핸들러에서는 upgrader.Upgrade 메서드를 호출하며 websocket connection c 리턴되어 읽고 쓰는 메서드인 c.ReadMessage와 c.WriteMessage로 websocket connection을 통해 메시지를 주고 받는 것을 보여준다. 

 

GitHub: https://bit.ly/3o3EtZE

func echo(w http.ResponseWriter, r *http.Request) {
	c, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Print("upgrade:", err)
		return
	}
	defer c.Close()
	for {
		mt, message, err := c.ReadMessage()
		if err != nil {
			log.Println("read:", err)
			break
		}
		log.Printf("recv: %s", message)
		err = c.WriteMessage(mt, message)
		if err != nil {
			log.Println("write:", err)
			break
		}
	}
}

 

이제 핵심인 upgrader.Upgrade 메서드를 살펴보자. handshake의 핵심만 챙겨보겠다. 

 

아래와 같이 클라이언트가 handshake에 필요한 키와 값들을 보내었는지 확인한다.

 

GitHub: https://bit.ly/3lkPiEX

func (u *Upgrader) Upgrade(w http.ResponseWriter, r *http.Request, responseHeader http.Header) (*Conn, error) {
	const badHandshake = "websocket: the client is not using the websocket protocol: "

	if !tokenListContainsValue(r.Header, "Connection", "upgrade") {
		return u.returnError(w, r, http.StatusBadRequest, badHandshake+"'upgrade' token not found in 'Connection' header")
	}

	if !tokenListContainsValue(r.Header, "Upgrade", "websocket") {
		return u.returnError(w, r, http.StatusBadRequest, badHandshake+"'websocket' token not found in 'Upgrade' header")
	}

	if r.Method != "GET" {
		return u.returnError(w, r, http.StatusMethodNotAllowed, badHandshake+"request method is not GET")
	}

	if !tokenListContainsValue(r.Header, "Sec-Websocket-Version", "13") {
		return u.returnError(w, r, http.StatusBadRequest, "websocket: unsupported version: 13 not found in 'Sec-Websocket-Version' header")
	}
    
	// omition...   
}

 

그리고 스펙에 맞게 회신을 준비한다. handshake와 관련한 부분은 이게 전부이다. 

 

GitHub: https://bit.ly/3FZ1bbj

	// omition...
    
	challengeKey := r.Header.Get("Sec-Websocket-Key")
    
	// omition...

	p := bufSteven Scott, 3 years ago: • Add write buffer pooling
	if len(c.writeBuf) > len(p) {
		p = c.writeBuf
	}
	p = p[:0]

	p = append(p, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: "...)
	p = append(p, computeAcceptKey(challengeKey)...)
	p = append(p, "\r\n"...)
	if c.subprotocol != "" {
		p = append(p, "Sec-WebSocket-Protocol: "...)
		p = append(p, c.subprotocol...)
		p = append(p, "\r\n"...)
	}
	
    // omition...

 

다음 포스팅에는 gorilla/websocket 의 채팅 예제를 들여다 볼 예정이며, 이후 protobuf, buf를 사용하는 방법과 heroku에 올리는 것을 이어서 써보려 한다. 

반응형