Golang: gorilla/websocket 으로 보는 websocket handshake
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에 올리는 것을 이어서 써보려 한다.