Real-Time Communication — WebSocket, gRPC Streaming and WebRTC
Build a WebSocket server from the bytes up
한국어 원문으로 표시합니다.
목표
RFC 6455 의 핸드셰이크·프레임·마스킹·제어 프레임·닫기 코드를 표준 라이브러리만으로 구현하고, 실제 라이브러리 클라이언트와 붙여 규칙을 지키는지 확인합니다.
왜 중요한가
WebSocket 장애 기록에는 대개 닫기 코드 한 줄만 남습니다. 1002 와 1006 과 1009 가 무엇을 뜻하는지, 누가 어떤 규칙을 어기면 그 코드가 나오는지 모르면 그 한 줄을 읽을 수 없습니다. 프록시 뒤에서만 연결이 끊기거나 큰 메시지에서만 끊기는 문제도 결국 이 바이트들의 문제입니다. 라이브러리가 대신 해 주던 일을 한 번 손으로 해 두면, 다음부터는 라이브러리의 오류 메시지가 무슨 말인지 읽힙니다.
단계
- 핸드셰이크의 열쇠를 계산한다 — /root/rt/ws/wsproto.py 에 accept_key(key) 를 만드세요. 클라이언트가 보낸 Sec-WebSocket-Key 문자열 뒤에 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 을 붙여 SHA-1 로 해시하고, 그 20바이트를 base64 로 부호화한 문자열을 돌려줍니다.
- 프레임을 만든다 — /root/rt/ws/wsproto.py 에 encode_frame(opcode, payload, fin=True, mask_key=None) 을 추가하세요. 첫 바이트는 FIN 비트와 opcode, 둘째 바이트는 MASK 비트와 길이입니다. 길이가 125 이하면 그대로, 65535 이하면 126 뒤에 2바이트, 그보다 크면 127 뒤에 8바이트 빅엔디언으로 적습니다. mask_key 로 4바이트가 오면 MASK 비트를 켜고 길이 뒤에 그 4바이트를 적은 다음, 본문의 i 번째 바이트를 mask_key[i % 4] 와 XOR 해서 붙입니다.
- 프레임을 읽고 규칙을 어긴 것을 가린다 — /root/rt/ws/wsproto.py 에 ProtocolError(code) 예외와 decode_frame(buf) 를 추가하세요. buf 에 프레임 하나가 아직 다 오지 않았으면 None, 다 왔으면 (fin bool, opcode int, 마스크를 벗긴 본문 bytes, masked bool, 쓴 바이트 수) 를 돌려줍니다. RSV 비트가 켜져 있거나, 정의되지 않은 opcode(3부터 7까지, 11부터 15까지)거나, 제어 프레임(8·9·10)의 본문이 125바이트를 넘거나 FIN 이 꺼져 있으면 ProtocolError(1002) 를 냅니다. ProtocolError 는 닫기 코드를 code 속성으로 들고 있어야 합니다.
- HTTP 요청을 101 로 바꾼다 — /root/rt/ws/wsproto.py 에 handshake_response(request) 를 추가하세요. request 는 빈 줄까지의 HTTP 요청 bytes 입니다. GET 이고, Upgrade 가 websocket 이고, Connection 에 Upgrade 토큰이 있고, Sec-WebSocket-Version 이 13 이고, Sec-WebSocket-Key 가 있으면 "HTTP/1.1 101 Switching Protocols" 와 Upgrade·Connection·Sec-WebSocket-Accept 머리를 담은 응답 bytes 를 돌려줍니다. 버전만 다르면 426 에 Sec-WebSocket-Version: 13 머리를, 그 밖의 결함은 400 을 돌려줍니다. 머리 이름과 websocket·upgrade 값은 대소문자를 가리지 않습니다.
- 서버를 세워 메아리를 돌려준다 — /root/rt/ws/wsproto.py 에 serve(host, port, max_size=1048576) 를 추가하세요. 연결마다 스레드 하나로 핸드셰이크를 하고, 101 이 아니면 응답을 보낸 뒤 닫습니다. 그 뒤로는 프레임을 읽어 텍스트·바이너리 메시지를 같은 opcode 로 되돌려줍니다. 조각난 메시지(FIN 0 과 이어짐 프레임 0)는 모아서 하나로 돌려주고, 조각 사이에 끼어든 ping 에는 같은 본문의 pong 으로 곧바로 답합니다. 서버가 보내는 프레임은 마스킹하지 않습니다.
- 규칙을 어긴 상대를 알맞은 코드로 닫는다 — serve 를 고쳐 닫기를 처리하세요. 상대의 close 프레임에 코드가 있으면 같은 코드로, 없으면 빈 본문으로 close 를 돌려보낸 뒤 TCP 를 닫습니다. 마스킹하지 않은 클라이언트 프레임은 1002, UTF-8 이 아닌 텍스트 메시지는 1007, 모은 메시지가 max_size 바이트를 넘으면 1009 로 close 를 보내고 닫습니다. decode_frame 이 ProtocolError 를 내면 그 code 로 닫습니다.
- 진짜 클라이언트와 붙여 본다 — 추가로 만들 것은 없습니다. 채점기가 websockets 라이브러리 클라이언트로 여러분의 서버에 붙어 한글 텍스트, 70000 바이트 바이너리(8바이트 길이를 쓰는 크기), ping 을 보내고 1000 으로 닫습니다. 라이브러리가 프로토콜 위반으로 연결을 끊지 않고 모든 메아리와 pong, 닫기 코드 1000 을 받아야 통과합니다.
참고
- 작업 폴더는 /root/rt/ws 입니다. mkdir -p /root/rt/ws 로 먼저 만드세요.
- 서버를 직접 띄워 보려면 /opt/rt-lab/bin/python -c "import sys; sys.path.insert(0, '/root/rt/ws'); import wsproto; wsproto.serve('127.0.0.1', 9001)" 을 쓰세요. 채점기는 빈 포트를 골라 따로 띄웁니다.
- serve 는 끝나지 않는 함수입니다. 채점기는 서버를 별도 프로세스로 띄웠다가 채점이 끝나면 내립니다.
- 흔한 실수 두 가지입니다. 서버 프레임까지 마스킹하는 것, 그리고 recv 한 번에 프레임 하나가 온다고 가정해 조각나 도착한 프레임을 놓치는 것입니다.
- 파이썬은 반드시 /opt/rt-lab/bin/python 으로 실행합니다. 이 실습의 라이브러리는 그 가상환경에만 들어 있고, 그냥 python3 로 돌리면 ModuleNotFoundError 가 납니다. alias rpy=/opt/rt-lab/bin/python 처럼 줄여 두면 편합니다.
- 실습 파드는 바깥으로 나가는 연결이 막혀 있습니다. 모든 통신은 같은 파드 안의 127.0.0.1 에서 일어나며, 설치나 다운로드는 필요 없습니다.
- 채점기는 코드를 별도 프로세스로 불러 실제 연결을 맺어 봅니다. 예시 파일은 함수 틀일 뿐이라 그대로 두면 통과하지 않습니다. 앞 단계에서 완성한 함수는 지우지 마세요.
- 실습 세션이 끝나면 /root 의 파일은 남지 않습니다. 필요한 코드는 끝내기 전에 따로 보관하세요.
핸드셰이크의 열쇠를 계산한다
/root/rt/ws/wsproto.py 에 accept_key(key) 를 만드세요. 클라이언트가 보낸 Sec-WebSocket-Key 문자열 뒤에 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 을 붙여 SHA-1 로 해시하고, 그 20바이트를 base64 로 부호화한 문자열을 돌려줍니다.
RFC 6455 1.3절의 예시 키 dGhlIHNhbXBsZSBub25jZQ== 는 s3pPLMBiTxaQ9kYGzzhZRbK+xOo= 가 되어야 합니다. 키를 base64 로 풀지 않고 문자열 그대로 붙이는 것이 핵심입니다.
프레임을 만든다
/root/rt/ws/wsproto.py 에 encode_frame(opcode, payload, fin=True, mask_key=None) 을 추가하세요. 첫 바이트는 FIN 비트와 opcode, 둘째 바이트는 MASK 비트와 길이입니다. 길이가 125 이하면 그대로, 65535 이하면 126 뒤에 2바이트, 그보다 크면 127 뒤에 8바이트 빅엔디언으로 적습니다. mask_key 로 4바이트가 오면 MASK 비트를 켜고 길이 뒤에 그 4바이트를 적은 다음, 본문의 i 번째 바이트를 mask_key[i % 4] 와 XOR 해서 붙입니다.
마스킹은 암호화가 아닙니다. 열쇠가 프레임에 그대로 실려 갑니다. 목적은 중간 캐시가 WebSocket 바이트를 HTTP 응답으로 오해하게 만드는 공격을 막는 것입니다(RFC 6455 10.3절).
프레임을 읽고 규칙을 어긴 것을 가린다
/root/rt/ws/wsproto.py 에 ProtocolError(code) 예외와 decode_frame(buf) 를 추가하세요. buf 에 프레임 하나가 아직 다 오지 않았으면 None, 다 왔으면 (fin bool, opcode int, 마스크를 벗긴 본문 bytes, masked bool, 쓴 바이트 수) 를 돌려줍니다. RSV 비트가 켜져 있거나, 정의되지 않은 opcode(3부터 7까지, 11부터 15까지)거나, 제어 프레임(8·9·10)의 본문이 125바이트를 넘거나 FIN 이 꺼져 있으면 ProtocolError(1002) 를 냅니다. ProtocolError 는 닫기 코드를 code 속성으로 들고 있어야 합니다.
스트림에서 읽으므로 한 번에 프레임이 다 온다는 보장이 없습니다. 머리 2바이트 → 확장 길이 → 마스크 열쇠 → 본문 순서로, 각 자리까지 바이트가 모자라면 None 입니다. 쓴 바이트 수를 돌려줘야 호출한 쪽이 다음 프레임을 이어 읽습니다.
HTTP 요청을 101 로 바꾼다
/root/rt/ws/wsproto.py 에 handshake_response(request) 를 추가하세요. request 는 빈 줄까지의 HTTP 요청 bytes 입니다. GET 이고, Upgrade 가 websocket 이고, Connection 에 Upgrade 토큰이 있고, Sec-WebSocket-Version 이 13 이고, Sec-WebSocket-Key 가 있으면 "HTTP/1.1 101 Switching Protocols" 와 Upgrade·Connection·Sec-WebSocket-Accept 머리를 담은 응답 bytes 를 돌려줍니다. 버전만 다르면 426 에 Sec-WebSocket-Version: 13 머리를, 그 밖의 결함은 400 을 돌려줍니다. 머리 이름과 websocket·upgrade 값은 대소문자를 가리지 않습니다.
Connection 머리에는 "keep-alive, Upgrade" 처럼 토큰이 여럿 올 수 있습니다. 쉼표로 나눠 하나씩 보세요. 426 에 지원 버전을 적어 주면 클라이언트가 무엇으로 다시 시도할지 압니다.
서버를 세워 메아리를 돌려준다
/root/rt/ws/wsproto.py 에 serve(host, port, max_size=1048576) 를 추가하세요. 연결마다 스레드 하나로 핸드셰이크를 하고, 101 이 아니면 응답을 보낸 뒤 닫습니다. 그 뒤로는 프레임을 읽어 텍스트·바이너리 메시지를 같은 opcode 로 되돌려줍니다. 조각난 메시지(FIN 0 과 이어짐 프레임 0)는 모아서 하나로 돌려주고, 조각 사이에 끼어든 ping 에는 같은 본문의 pong 으로 곧바로 답합니다. 서버가 보내는 프레임은 마스킹하지 않습니다.
제어 프레임은 조각난 메시지 한가운데 끼어들 수 있습니다(RFC 6455 5.4절). 조각을 모으는 버퍼와 제어 프레임 처리를 따로 두세요. 서버가 마스킹하면 규칙을 지키는 클라이언트는 연결을 끊어야 합니다.
규칙을 어긴 상대를 알맞은 코드로 닫는다
serve 를 고쳐 닫기를 처리하세요. 상대의 close 프레임에 코드가 있으면 같은 코드로, 없으면 빈 본문으로 close 를 돌려보낸 뒤 TCP 를 닫습니다. 마스킹하지 않은 클라이언트 프레임은 1002, UTF-8 이 아닌 텍스트 메시지는 1007, 모은 메시지가 max_size 바이트를 넘으면 1009 로 close 를 보내고 닫습니다. decode_frame 이 ProtocolError 를 내면 그 code 로 닫습니다.
닫기 코드는 상대에게 남기는 유일한 진단입니다. 1006 은 보내는 코드가 아니라 "close 프레임 없이 끊겼다" 는 뜻으로 받는 쪽이 붙이는 이름입니다. 닫을 이유가 있으면 반드시 close 프레임을 먼저 보내세요.
진짜 클라이언트와 붙여 본다
추가로 만들 것은 없습니다. 채점기가 websockets 라이브러리 클라이언트로 여러분의 서버에 붙어 한글 텍스트, 70000 바이트 바이너리(8바이트 길이를 쓰는 크기), ping 을 보내고 1000 으로 닫습니다. 라이브러리가 프로토콜 위반으로 연결을 끊지 않고 모든 메아리와 pong, 닫기 코드 1000 을 받아야 통과합니다.
손으로 만든 구현은 손으로 만든 시험만 통과하기 쉽습니다. 라이브러리는 서버 프레임의 MASK 비트, 길이 필드의 최소 부호화, 닫기 순서를 엄격하게 봅니다. 실패하면 메시지에 라이브러리가 남긴 이유가 나옵니다.