とてもとても(4ヶ月)遅刻してしまいましたが、この記事はDeNA 25新卒 Advent Calendar 2024の記事です。他の方々の記事もぜひご覧ください!
前回、前々回の記事と、C言語およびGo言語でTLSサーバを実装することに挑戦しました。
ここで、近年、あらたなWeb技術としてQUICというものが登場し普及しだしているのをご存知でしょうか。従来のTCPおよびHTTP/1.1, HTTP/2の問題点を解決すべく、新たなトランスポート層のプロトコルとして策定されたプロトコルです。
「新たな」と言いましたがその実装は内部的にはUDPのパケットを利用しており、UDPにQUICのパケットを乗せて送受信するという仕組みになっています。それにより、マシン側(OSなど)の対応は最低限でQUICの通信を利用できるというメリットもあります。
また、このQUICを下層に使ったアプリケーション層のプロトコルとしてHTTP/3というものも標準化されました。これらのプロトコルでは、特に低品質な通信環境において従来の通信プロトコルよりも高い通信性能をもたらすと言われています。
QUICの詳細な説明はより詳しい別の記事に任せるとして、QUICが自分の卒業研究のテーマだったこともあり、この記事ではGo言語でQUICのクライアント・サーバアプリケーションを実装し、その通信を見てみるということに挑戦してみたいと思います。
ちなみに、QUICのプロトコルでは内部的にTLSを利用するという仕様になっており、QUICで通信することはすなわちTLSで暗号化された通信をすることに該当します。そのため今回の実装は、前回の記事の実装と非常に似ており、前回のものにQUIC特有の記述を追加したといってもいい程です。以下の実装の説明では、その差分を中心にまとめます。
先にまとめ
サーバのコード
package main
import (
"bufio"
"context"
"crypto/tls"
"log"
"github.com/quic-go/quic-go"
)
func main() {
cer, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
log.Fatal("loadkeys: ", err)
}
tlsConf := &tls.Config{Certificates: []tls.Certificate{cer}}
quicConf := &quic.Config{}
ln, err := quic.ListenAddr("127.0.0.1:12345", tlsConf, quicConf)
if err != nil {
log.Fatal("listen addr: ", err)
}
defer ln.Close()
log.Print("Start Server:")
for {
conn, err := ln.Accept(context.Background())
if err != nil {
log.Fatal("accept: ", err)
}
stream, err := conn.AcceptStream(context.Background())
if err != nil {
log.Fatal("accept stream: ", err)
}
log.Print("New Client Connection Accepted")
go func(stream quic.Stream) {
s := bufio.NewScanner(stream)
for s.Scan() {
msg := s.Text()
log.Printf("Accept Message: `%s`", msg)
_, err := stream.Write([]byte(msg))
if err != nil {
log.Print("write: ", err)
}
}
}(stream)
}
}
クライアントのコード
package main
import (
"context"
"crypto/tls"
"log"
"os"
"github.com/quic-go/quic-go"
)
func main() {
w, err := os.Create("keylog.log")
if err != nil {
log.Fatal("open file: ", err)
}
tlsConf := &tls.Config{
InsecureSkipVerify: true,
KeyLogWriter: w,
}
quicConf := &quic.Config{}
conn, err := quic.DialAddr(context.Background(), "127.0.0.1:12345", tlsConf, quicConf)
if err != nil {
log.Fatal("dial: ", err)
}
stream, err := conn.OpenStreamSync(context.Background())
if err != nil {
log.Fatal("open stream: ", err)
}
defer stream.Close()
if _, err = stream.Write([]byte("Hello, golang QUIC!!\n")); err != nil {
log.Fatal("write: ", err)
}
buf := make([]byte, 100)
n, err := stream.Read(buf)
if err != nil {
log.Fatal("read: ", err)
}
log.Printf("Accept Message: `%s`", buf[:n])
}
ソースコードのリポジトリ
実装と動作確認
準備
準備が必要なものは前回とほとんど同じで、「Go言語の実行環境」とパケットをキャプチャするための「tshark
」、サーバ証明書と秘密鍵です。フォルダ構成なども前回と同様なためここでは省略します。
加えて、今回のQUICの実装にはgithub.com/quic-go/quic-go
というパッケージを利用しました。準標準パッケージのgolang.org/x/net/quic
というものもあったのですが、自分が使い慣れていたこともありquic-go
の方を採用しました。
以下のコマンドで、このパッケージも追加しておきます。
go get github.com/quic-go/quic-go
サーバの実装
まずはサーバの実装です。
サーバのコード
package main
import (
"bufio"
"context"
"crypto/tls"
"log"
"github.com/quic-go/quic-go"
)
func main() {
cer, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
log.Fatal("loadkeys: ", err)
}
tlsConf := &tls.Config{Certificates: []tls.Certificate{cer}}
quicConf := &quic.Config{}
ln, err := quic.ListenAddr("127.0.0.1:12345", tlsConf, quicConf)
if err != nil {
log.Fatal("listen addr: ", err)
}
defer ln.Close()
log.Print("Start Server:")
for {
conn, err := ln.Accept(context.Background())
if err != nil {
log.Fatal("accept: ", err)
}
stream, err := conn.AcceptStream(context.Background())
if err != nil {
log.Fatal("accept stream: ", err)
}
log.Print("New Client Connection Accepted")
go func(stream quic.Stream) {
s := bufio.NewScanner(stream)
for s.Scan() {
msg := s.Text()
log.Printf("Accept Message: `%s`", msg)
_, err := stream.Write([]byte(msg))
if err != nil {
log.Print("write: ", err)
}
}
}(stream)
}
}
Listnerを用意しforループで接続を待ち続けるのはTLSのときと同じです。ただし、Listnerはquic.ListenAddr()
で生成した*quic.Listener
型のものです。
接続を受け付けたら、TLSではコネクションを取得してそれを用いてやり取りしていましたが、QUICの処理ではコネクションを受け付けた後に ストリーム(quic.Stream
) を用意して通信を行いました。
QUICとTLSのサーバ側の処理の大きな差分は、このループ内の処理でnet.Conn
を使うかquic.Stream
を使うかという部分でした。
for {
conn, err := ln.Accept(context.Background())
if err != nil {
log.Fatal("accept: ", err)
}
stream, err := conn.AcceptStream(context.Background())
if err != nil {
log.Fatal("accept stream: ", err)
}
log.Print("New Client Connection Accepted")
go func(stream quic.Stream) {
// TLSの際とほぼ同じ処理のため省略
}(stream)
}
クライアントの実装
続いてクライアントの実装です。
クライアントのコード
package main
import (
"context"
"crypto/tls"
"log"
"os"
"github.com/quic-go/quic-go"
)
func main() {
w, err := os.Create("keylog.log")
if err != nil {
log.Fatal("open file: ", err)
}
tlsConf := &tls.Config{
InsecureSkipVerify: true,
KeyLogWriter: w,
}
quicConf := &quic.Config{}
conn, err := quic.DialAddr(context.Background(), "127.0.0.1:12345", tlsConf, quicConf)
if err != nil {
log.Fatal("dial: ", err)
}
stream, err := conn.OpenStreamSync(context.Background())
if err != nil {
log.Fatal("open stream: ", err)
}
defer stream.Close()
if _, err = stream.Write([]byte("Hello, golang QUIC!!\n")); err != nil {
log.Fatal("write: ", err)
}
buf := make([]byte, 100)
n, err := stream.Read(buf)
if err != nil {
log.Fatal("read: ", err)
}
log.Printf("Accept Message: `%s`", buf[:n])
}
こちらも、サーバ側の処理と同様にダイアルしてサーバとのコネクションを生成するまではほとんど同じですが、コネクションを確立した後でconn.OpenStreamSync()
によってストリームを生成しそれを介してデータのやり取りを行いました。
ここで生成したストリームのインスタンスは、io.Writer
とio.Reader
のインタフェースを実装しているようで、Write()
やRead()
などのメソッドを使うことができました。
stream, err := conn.OpenStreamSync(context.Background())
if err != nil {
log.Fatal("open stream: ", err)
}
defer stream.Close()
if _, err = stream.Write([]byte("Hello, golang QUIC!!\n")); err != nil {
log.Fatal("write: ", err)
}
実行
実行は、ターミナルでgo run ./server/main.go
としてサーバを起動し、別のターミナルでgo run ./client/main.go
として実行します。
少しエラーも出ていますが、これで以下のように通信が行えました。
サーバ側のログ
クライアント側のログ
パケットの確認と暗号化通信の復号
まずは暗号化されている状態のパケットを見てみます。TLSの実装の時には各パケットのプロトコルがTCP
やTLS
と表示されていました。今回のキャプチャではプロトコルがしっかりとQUIC
と表示されています。
そして、各パケットのペイロードは案の定暗号化されており、読むことはできませんでした。
この暗号化を解く方法は、QUICの通信でもTLSの時と同じ手順で可能でした。上部タブから「編集」→「設定」→「Protocols」→「TLS」と選んでいき、「(Pre)-Master-Secret log filename」にkeylog.log
を設定します。
QUICの設定ではなくTLSの設定から変更するのでお気をつけください。
こうすることで、今回も送受信した文字列を読むことができました!
おわりに
この記事ではQUICによるデータ通信をGo言語で実装し、そのパケットを見てみました。QUICは大学の卒業研究で取り組んだテーマだったので、研究の過程で仕様については色々と学習してきました。そこで得ていた知識が、これまた実際に動いているデータとして見ることができ、また生きた学びを得られたと感じています。
QUICの通信も、色々とカスタマイズできたはずなので、それをしていってパケットがどのように変化するのか見てみるのも面白いかもしれません。
それから、QUICは現在も発展と普及の最中にある技術であると思っています。卒業研究でのゆかりもあり、QUICという技術が今後どう展開していくのかとても楽しみです。このあたり、この先も広くアンテナを広げて情報を得続けていけたらなと思います!