WebSocket
実装:
network/websocket// 実行:go test ./network/websocket/
HTTPは要求と応答が対になるので、サーバから勝手に送れない。チャットや通知で困る。WebSocketは普通のHTTPとして始まり、「この接続を昇格させてくれ」という合図で切り替わる。以後その1本の接続は要求応答の型を脱ぎ、両側がいつでも送れる全二重になる。この章では昇格を証明する計算と、メッセージを運ぶ枠の形式を実装し、クライアント側だけ中身をかき混ぜて送る決まりの理由まで確かめる。
この章で作るもの
HTTPサーバで作った通信は、要求と応答が対になっていた。クライアントが尋ね、サーバが答える。この型は Web の大半でうまく機能するが、限界もある。サーバの側から、クライアントの求めを待たずにデータを送りたいときだ。チャットの新着メッセージ、株価の更新、通知。これらはサーバ発の即時配信が要る。HTTP でこれを実現しようとすると、クライアントが定期的に尋ね続ける(ポーリング)か、応答を長く保留する(ロングポーリング)といった、回りくどい手を使うしかない。
WebSocket は、この双方向・全二重の通信を正面から解く。始まりは普通の HTTP リクエストだ。クライアントは Upgrade: websocket ヘッダを付けて、「この接続を WebSocket に昇格させてくれ」と頼む。サーバが受理すると、その 1 本の TCP 接続は、以後 HTTP の要求応答の型を脱ぐ。両側が、いつでも、相手の求めを待たずにメッセージを送れる全二重の通路になる。やり取りはフレームという単位で行い、先頭に FIN・オペコード・マスクの有無・長さが並ぶ。この章では、昇格を成立させる Accept 計算と、フレームの符号化・復号を実装する。
Client ── HTTP GET Upgrade: websocket, Key: xxx ──▶ Server
Client ◀── 101 Switching Protocols, Accept: yyy ── Server (昇格成立)
═══════════ ここから全二重 ═══════════
Client ── フレーム(マスクあり) ──▶ ◀── フレーム(マスクなし) ── Server
両側がいつでも送れる。要求応答の対ではない順に作る。
- HTTP から昇格する: 普通の HTTP で始まり、Upgrade で全二重に切り替える。既存のポート・経路をそのまま使える
- Accept 鍵で昇格を証明: Key に固定文字列を足してハッシュした値を返す。正しく返せることが WebSocket 理解の証明になる
- クライアントのフレームはマスク: 鍵で XOR する。中継プロキシのキャッシュ汚染攻撃を防ぐための決まり
① Upgrade: HTTPから全二重へ昇格する
昇格の核心は Accept 計算だ。クライアントはランダムな Sec-WebSocket-Key を送る。サーバはそれに固定の文字列(magic GUID)を連結し、SHA-1(どんな入力からも固定長 20 バイトの値を作るハッシュ関数)にかけ、その結果を base64(バイト列を印字できる文字だけで表す符号化)で文字列にして Sec-WebSocket-Accept として返す:
// magicGUID は Upgrade の応答鍵を作るための固定文字列(RFC 6455)。
const magicGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
// Accept は Sec-WebSocket-Key から Sec-WebSocket-Accept を計算する。
// key に magicGUID を連結し、SHA-1 して base64 する。サーバがこの値を返せる
// ことが「WebSocket を理解している」証明になり、偶然の昇格を防ぐ。
func Accept(key string) string {
sum := sha1Sum([]byte(key + magicGUID))
return base64Encode(sum[:])
}なぜこんな計算をするのか。目的は、サーバが本当に WebSocket を理解していることの証明だ。もし普通の HTTP サーバやキャッシュが、たまたまこのリクエストに 101 を返しても、正しい Accept 値は返せない。クライアントは Accept を検証することで、偶然や誤設定による誤った昇格を弾く。ここで使う SHA-1 と base64 も自作した(ハッシュの章は玩具のハッシュだったが、ここは本物の SHA-1)。テストで、RFC 6455 が載せる例(dGhlIHNhbXBsZSBub25jZQ== → s3pPLMBiTxaQ9kYGzzhZRbK+xOo=)と一致すること、SHA-1 が既知のベクトルと一致することを固定した。自作の SHA-1 が実物と同じ値を出す。
② フレーミング: メッセージを型に載せる
昇格が済めば、以後のやり取りはフレームで行う。フレームは先頭の 2 バイトに、FIN(このメッセージの最終フレームか)・オペコード(テキストか、バイナリか、制御か)・マスクの有無・ペイロード長が詰まっている。長さは 125 以下ならその 2 バイト目に、超えるなら拡張フィールドに入る:
// オペコードはフレームの種類。
const (
OpContinuation byte = 0x0
OpText byte = 0x1
OpBinary byte = 0x2
OpClose byte = 0x8
OpPing byte = 0x9
OpPong byte = 0xa
)
// Frame は WebSocket の 1 フレーム。
type Frame struct {
Fin bool // このメッセージの最終フレームか
Opcode byte // 種類(text/binary/close/ping/pong)
Masked bool // ペイロードがマスクされているか(client→server は必須)
MaskKey [4]byte // マスク鍵
Payload []byte // 本体(マスク前の平文で保持)
}
// Encode はフレームをバイト列にする。マスク指定があればペイロードを鍵で XOR する。
func Encode(f Frame) []byte {
var b []byte
b0 := f.Opcode & 0x0f
if f.Fin {
b0 |= 0x80
}
b = append(b, b0)
// 長さの符号化。125 以下は 1 バイト、65535 以下は 126+2 バイト、以上は 127+8 バイト。
n := len(f.Payload)
maskBit := byte(0)
if f.Masked {
maskBit = 0x80
}
switch {
case n <= 125:
b = append(b, maskBit|byte(n))
case n <= 0xffff:
b = append(b, maskBit|126, byte(n>>8), byte(n))
default:
b = append(b, maskBit|127)
for s := 56; s >= 0; s -= 8 {
b = append(b, byte(n>>uint(s)))
}
}
if f.Masked {
b = append(b, f.MaskKey[0], f.MaskKey[1], f.MaskKey[2], f.MaskKey[3])
for i, p := range f.Payload {
b = append(b, p^f.MaskKey[i%4]) // 鍵で XOR してマスク
}
} else {
b = append(b, f.Payload...)
}
return b
}オペコードでフレームの種類を分ける。テキスト、バイナリ、そして接続を閉じる close、生存確認の ping / pong。長さの符号化が少し凝っていて、小さなメッセージは 1 バイトで、大きなものは 2 バイトか 8 バイトの拡張長で表す。頻繁に飛び交う小さなメッセージのオーバーヘッドを抑える設計だ。テストで、テキスト・バイナリ・部分フレーム・close の各フレームが符号化・復号で元に戻ること、125 を超えるペイロードが拡張長(126)で正しく扱われることを固定した。
③ マスキング: なぜクライアントは隠すのか
フレーム形式に、一見奇妙な決まりがある。クライアントからサーバへ送るフレームは、ペイロードを 4 バイトの鍵で XOR して必ずマスクしなければならない。逆にサーバからクライアントへはマスクしない。暗号化が目的ではない(鍵はフレームに平文で入っている)。ではなぜか:
// ErrShort はバイト列が 1 フレームに満たないとき。
type ErrShort struct{}
func (ErrShort) Error() string { return "websocket: short frame" }
// Decode はバイト列の先頭から 1 フレームを読み、消費バイト数とともに返す。
// マスクされていれば鍵で外し、Payload には平文を入れる。
func Decode(b []byte) (Frame, int, error) {
if len(b) < 2 {
return Frame{}, 0, ErrShort{}
}
var f Frame
f.Fin = b[0]&0x80 != 0
f.Opcode = b[0] & 0x0f
f.Masked = b[1]&0x80 != 0
n := int(b[1] & 0x7f)
pos := 2
switch n {
case 126:
if len(b) < pos+2 {
return Frame{}, 0, ErrShort{}
}
n = int(b[pos])<<8 | int(b[pos+1])
pos += 2
case 127:
if len(b) < pos+8 {
return Frame{}, 0, ErrShort{}
}
n = 0
for i := 0; i < 8; i++ {
n = n<<8 | int(b[pos+i])
}
pos += 8
}
if f.Masked {
if len(b) < pos+4 {
return Frame{}, 0, ErrShort{}
}
copy(f.MaskKey[:], b[pos:pos+4])
pos += 4
}
if len(b) < pos+n {
return Frame{}, 0, ErrShort{}
}
f.Payload = make([]byte, n)
if f.Masked {
for i := 0; i < n; i++ {
f.Payload[i] = b[pos+i] ^ f.MaskKey[i%4] // マスクを外す
}
} else {
copy(f.Payload, b[pos:pos+n])
}
return f, pos + n, nil
}理由は、間に挟まる古い中継プロキシを守るためだ。WebSocket を知らないプロキシは、流れるバイト列を HTTP だと思い込むことがある。攻撃者が、WebSocket のペイロードに巧妙に HTTP リクエストらしきバイト列を混ぜて送ると、プロキシがそれを本物の HTTP リクエストと誤解し、応答をキャッシュしてしまう(キャッシュ汚染)。以後、他の利用者にその汚染された応答が配られる。マスキングは、クライアントが送るバイト列を毎回ランダムな鍵で撹拌することで、攻撃者が「プロキシに見せたい特定のバイト列」を意図的に作れないようにする。サーバ→クライアント方向にこの危険がないのは、その経路にキャッシュを狙う攻撃者制御のプロキシが挟まりにくいからだ。テストで、マスクしたフレームのワイヤ上に平文がそのまま現れないこと、復号で正しく平文に戻ることを固定した。
動かす
下のデモは、Upgrade の Accept 計算と、フレームの符号化を見る。鍵から Accept 値が導かれる様子、メッセージがフレームのバイト列になる様子、そしてマスクの有無でワイヤ上の見え方がどう変わるかを確かめてほしい。
Key に magic GUID を足して SHA-1 → base64 が Accept。これは RFC 6455 の例で、実物と同じ値が出る。サーバがこの値を返せることが WebSocket 理解の証明になり、偶然の昇格を防ぐ
SHA-1・base64・フレーム符号化を移植して実際に計算している。WebSocket は HTTP として始まり、Key から 導いた Accept 値の交換で全二重に昇格する。以後は両側がフレームで自由に送れる。クライアント→サーバの フレームはマスク(鍵で XOR)が必須で、これは中継プロキシのキャッシュ汚染攻撃を防ぐためのもの。暗号化とは別物だ。
設計の観点
- 昇格は HTTP を再利用する: 新しいポートを開かず、既存の 80/443 の上で昇格する。ファイアウォールやプロキシを通りやすい。TLS の上なら wss
- マスクは暗号でなく防御: 鍵は平文で入っており秘匿性はない。目的はキャッシュ汚染攻撃の無効化。だからクライアント→サーバのみ必須
- フレームとメッセージは別: 1 メッセージは FIN=false の継続フレームに分割できる。大きなデータをストリーミングしつつ、間に制御フレームを挟める
- ハートビート(ping/pong): アイドル接続の生存確認と、経路上の NAT/プロキシのタイムアウト防止。応答がなければ切れたと判断する
- HTTP/2 との違い: HTTP/2 の多重化はあくまで要求応答の効率化。WebSocket はサーバ発の任意配信ができる別物。用途が違う(HTTP/2)
対照と実例
| 方式 | サーバ発信 | オーバーヘッド | 用途 |
|---|---|---|---|
| ポーリング | 疑似(定期取得) | 高(毎回 HTTP) | 低頻度の更新確認 |
| ロングポーリング | 疑似(応答保留) | 中 | 昔の擬似リアルタイム |
| SSE(Server-Sent Events) | 可(単方向) | 低 | サーバ→クライアントのみ |
| WebSocket | 可(全二重) | 低(フレーム) | チャット・ゲーム・協調編集 |
裏どり:
- RFC 6455 (WebSocket): ハンドシェイク・フレーム形式・マスキングの仕様。Accept 計算と magic GUID の出所
- マスキングの根拠(RFC 6455 §10.3): 中間プロキシのキャッシュ汚染攻撃への対策。なぜクライアントのみマスクするか
- Server-Sent Events (SSE): サーバ→クライアント単方向でよいなら、より単純な選択肢。WebSocket との使い分け
- Socket.IO / SignalR: WebSocket の上に再接続・フォールバック・部屋管理を載せた実用ライブラリ
簡略化したこと
- ハンドシェイクは Accept のみ: 実際の HTTP ヘッダ交換全体は扱わない
- 制御フレームの意味論なし: close/ping/pong はオペコードのみ。クローズ手順や自動 pong は省略
- フラグメント再結合なし: FIN=false の継続フレームの結合は扱わない
- マスク鍵は手で与える: 実物は暗号的乱数で毎フレーム生成する
参考資料
- RFC 6455: The WebSocket Protocol — ハンドシェイク・フレーム・マスキングの仕様
- MDN: Writing WebSocket servers — フレーム形式の実装解説
- WebSocket vs SSE — 用途による使い分け
- 実装: network/websocket