連載「rustx86 ネットワーク編」: Rust + WebAssemblyのx86エミュレータをインターネットに繋ぐまでの開発記。#1はSLiRP backendとなるユーザーモードNATデーモン(Go)、#2はゲスト側の仮想NIC(Rustでvirtio-net)、#3はブラウザ統合 — 「ブラウザの中のAlpine Linuxから ping 1.1.1.1 が返る」までを予定している。
Rust + WebAssemblyで書いているx86エミュレータ「rustx86」をブラウザで動かすと、当然だがゲストOSはインターネットに出られない。ブラウザのWASMには生ソケットが無いからだ。TCPを喋りたければ、誰かがどこかでWebSocketを本物のソケットに変換してやる必要がある。
その「誰か」を独立したデーモンとして書いた。名前は wsslirp。QEMUの -netdev user(内蔵slirp)を単体のプロセスに切り出して、WebSocketという口を付けたもの、と考えると正確だ。今回はエミュレータ本体より先に、この下回りを完成させた開発記である。
コードはGitHubで公開している: yoshiharu-ishii/wsslirp(設計判断の詳細は docs/architecture.md)

境界プロトコルは1行で決めた
設計で最初に固定したのはワイヤ形式で、これは1行で書ける。
1 WebSocketバイナリメッセージ = 1 Ethernetフレーム(最大1514バイト)
これだけ。フレーミングもヘッダも独自型も無い。ゲストの仮想NIC(virtio-netやe1000)が吐くEthernetフレームを、そのままバイナリメッセージとして運ぶ。
この境界のおかげで、コアはトランスポートを一切知らずに済む。Goのコードではこうなる。
// FrameIO carries Ethernet frames between a guest and the slirp stack.
type FrameIO interface {
ReadFrame() ([]byte, error)
WriteFrame([]byte) error
}
コアの pkg/slirpstack はこのインターフェースにしか依存しない。WebSocketは pkg/wsstransport が実装する一形態にすぎず、後からUnixドメインソケットでもインプロセスchannelでも足せる。テストではこの性質がそのまま効いてくる(後述)。
なぜRustプロジェクトのSLiRP backendがGoなのか
rustx86はRustで書いているのに、ネットワークデーモンはGoにした。理由は一点で、gVisorのnetstackにTCP終端のforwarder APIがあり、それがGoにしかないからだ。
ユーザーモードNATの本質は「ゲストのTCPをユーザーランドで終端して、ホスト側で張り直す」ことだ。つまりTCP/IPスタックの実装が丸ごと要る。gVisor netstackは本番品質のフルスタックで、ゲストからのSYNをコールバックで受け取れる。
tcpFwd := tcp.NewForwarder(s, 0, 1024, ns.handleTCP)
s.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpFwd.HandlePacket)
handleTCP の中でゲスト側の接続を受理し、net.Dial で本物のソケットを外に張る。これと同等のものをRustで揃えようとすると選択肢が薄く、自作は完全に本末転倒だ。Rust側(エミュレータ本体)にはNIC実装とFrameIO相当のtraitだけを持たせ、OS依存で非決定的なI/Oは全部デーモン側に寄せる。決定性が欲しいエミュレータコアと、雑事を引き受けるSLiRP backend、という分担でもある。
ゲストから見えるネットワークはslirp互換にした。QEMUで慣れ親しんだレイアウトそのままである。
| アドレス | 役割 |
|---|---|
| 10.0.2.2 | ゲートウェイ(ARP/ICMP echo応答) |
| 10.0.2.3 | DNS(上流リゾルバへ転送) |
| 10.0.2.15 | ゲスト(DHCPで配布) |
DHCPはnetstackに入れる前に捌く
最初の技術判断はDHCPだった。ゲストは起動するとまずDHCP discoverをブロードキャストする。これをgVisor netstackに処理させようとすると、ブロードキャストUDPの扱いが微妙に面倒だ。
そこで、netstackに注入する前のフレーム層でgopacketを使って捌くことにした。
for {
frame, err := fio.ReadFrame()
if err != nil {
return err
}
if ns.maybeHandleDHCP(frame, fio) {
continue // netstackには入れない
}
if ns.maybeHandleICMP(frame, fio) {
continue // 外向きpingもフレーム層(後述)
}
ns.inject(frame)
}
discover→offer、request→ack を組み立てて返すだけなので、gopacketなら素直に書ける。netstackの繊細な部分を避けられるうえ、フレームを入れて出すだけの決定的な処理になるのでテストも書きやすい。
ハーフクローズを落とすと何が壊れるか
PoCのTCP中継は、教科書どおり io.Copy を両方向に走らせていた。
// PoC版: どちらかのEOFで両方向とも畳む
go cp(host, guest)
go cp(guest, host)
select {
case <-done: // 片方向が終わった時点で
case <-ns.ctx.Done():
} // → deferで両方Close
これは動く。curlも通る。だが「リクエストを送り終えたら shutdown(WR) で書き側だけ閉じ、EOFを見たサーバが応答を返す」型のクライアントが来ると、応答が丸ごと失われる。ゲスト→ホスト方向のコピーがFINで終了した瞬間、逆方向もろとも接続を畳んでしまうからだ。HTTP Connection: close やgitプロトコルが該当する。
直し方は、FINを片方向のまま伝搬すること。gonet.TCPConn(ゲスト側)も net.TCPConn(ホスト側)も CloseWrite を持っているので、コピー完了時にそれを呼び、全体のクローズは両方向が終わるまで待つ。
cp := func(dst, src net.Conn) {
io.Copy(dst, src)
closeWrite(dst) // FINだけ相手に届ける。逆方向は流れ続ける
done <- struct{}{}
}
go cp(host, guest)
go cp(guest, host)
for range 2 { // 両方向の完了を待つ
select {
case <-done:
case <-ns.ctx.Done():
return
}
}
このバグはテストで再現させてから直した。「ゲストが書き側を閉じる→サーバはEOFを確認してから応答」というシナリオをテストにすると、旧実装は response = "", want "resp:ping" で落ちる。修正後は通る。回帰テストとして残してある。
rootなしで ping 1.1.1.1 を通す
ゲストOSに入ってまずやることは ping 8.8.8.8 だろう。疎通確認の定番が通らないネットワークは信用されない。しかしICMPはTCP/UDPと違って、普通のソケットでは送れない。rawソケットはrootが要る。デーモンをrootで動かすのは論外だ。
答えは非特権pingソケット(SOCK_DGRAM + IPPROTO_ICMP)である。golang.org/x/net/icmp なら ListenPacket("udp4", "") で開ける。macOSはそのまま動き、LinuxはデーモンのグループIDが sysctl net.ipv4.ping_group_range の範囲に入っていればよい。
c, err := icmp.ListenPacket("udp4", "") // root不要
// ...
msg := icmp.Message{
Type: ipv4.ICMPTypeEcho,
Body: &icmp.Echo{Seq: seq, Data: data},
}
ひとつ面白い罠がある。このソケットではカーネルがecho IDを勝手に書き換える。IDはソケットごとの多重化キーとして使われるためだ。だからゲストが送ったIDをそのまま外に出すことはできないし、する必要もない。応答はシーケンス番号で照合し、ゲストへ返すecho replyフレームにはゲストが元々使っていたid/seqを載せ直す。ゲストから見れば、自分のpingがそのまま返ってきたように見える。
これもDHCPと同じフレーム層で捌く。ゲートウェイ(10.0.2.2)宛のechoだけはnetstackに落として自己応答させ、外向きだけを拾って代理送信する。
公開リレーはオープンプロキシと紙一重
wsslirpdはWebSocketで繋げば誰のフレームでも中継する。つまり認証もフィルタも無しに公開すると、踏み台(オープンプロキシ)を配っているのと同じになる。最悪なのはクラウド内部への横穴(SSRF)だ。
対策は2枚。接続時の共有トークンと、宛先のegressフィルタである。
// allowDest is the egress policy: with AllowPrivate unset, only public
// unicast IPv4 destinations may leave the relay.
func (c Config) allowDest(a netip.Addr) bool {
if c.AllowPrivate {
return true
}
switch {
case a.IsLoopback(), a.IsPrivate(), a.IsLinkLocalUnicast(),
a.IsLinkLocalMulticast(), a.IsMulticast(), a.IsUnspecified():
return false
}
return true
}
このフィルタはTCP・UDP・外向きICMP echoのすべてに同じ関数で効かせる。プロトコルを増やすたびに個別のフィルタを書く設計にすると、いつか1個忘れて穴になる。
ゲストがまだ存在しないのに、どうE2Eテストするか
ここが今回いちばん気に入っている部分だ。rustx86側のvirtio-netはまだ無い。つまり本物のゲストは居ない。それでもフレーム経路のE2Eテストは書ける。クライアント側にもgVisor netstackを立てて、合成ゲストにするのだ。
// テスト側: 10.0.2.15を名乗るゲストスタックを作り、
// FrameIO(インメモリのフレームパイプ)に接続する
g, _ := testutil.NewGuest(ctx, guestEnd)
// このDialは本物のARP解決とTCPハンドシェイクを
// Ethernetフレームとして喋る
conn, _ := g.DialTCP(ctx, netip.MustParseAddrPort("192.0.2.10:9999"))
境界プロトコルが「1メッセージ=1フレーム」しかないので、ゲストの代役はフレームさえ吐ければ何でもいい。合成ゲストの DialTCP は、将来rustx86のAlpineが通るのと寸分違わぬ経路(ARP→SYN→ESTABLISHED)を通る。外部依存ゼロで決定的に回るから、CIにそのまま載る。
実インターネットへの疎通は、環境変数で門を開けるopt-inテストにした。デーモンを立てて指すと、仮想ネットワークの中からDNS解決→HTTP GET→pingまでやる。
$ WSSLIRP_E2E_URL='ws://127.0.0.1:8087/net?token=test' \
go test ./pkg/wsstransport -run RealInternet -v
dns: example.com resolved through the relay (2 answers)
http: HTTP/1.1 200 OK from example.com via 104.20.23.154
ping: echo reply from 1.1.1.1 (seq=1, 16 bytes)
PASS
謎解き: pingだけが通らない
ICMPプロキシを実装してE2Eを回したときのこと。DNSもHTTPも通るのに、pingだけがタイムアウトする。実装を疑ってコードを睨みたくなる症状だが、手がかりは別の場所にあった。バックグラウンドで起動したはずのデーモンが、こんなログを残して死んでいたのだ。
wsslirpd listening on 127.0.0.1:8098 (endpoint /net)
listen tcp 127.0.0.1:8098: bind: address already in use
ポートが既に使われている。lsof で見ると、前回の検証で止め忘れた古いwsslirpdが同じポートに居座っていた。テストが繋いでいたのはこの古いバイナリで、そこにはICMPプロキシがまだ入っていない。だからTCP/UDP(DNS・HTTP)は通り、pingだけが静かに落ちる。「新機能だけ動かない」ときは、新機能のコードではなく動いているバイナリが本当に新しいかを先に疑うべし、という教訓である。
$ lsof -nP -iTCP:8098 -sTCP:LISTEN
COMMAND PID ... NAME
wsslirpd 59797 ... 127.0.0.1:8098 (LISTEN) # ← 先週の残骸
古いプロセスを止めて再実行したら、3経路とも一発で通った。
台帳と次の一手
やっていないことは棄却せず、README末尾の台帳に寝かせてある。UDS/インプロセストランスポート、IPv6、帯域制限、DHCPオプションのカスタマイズ。どれも「必要になった実測時点で取り出す」枠だ。TAPデバイス版(ゲストを実LANに参加させる)も検討したが、境界プロトコルが同じなので必要になったら別デーモンとして足せる。今は要らない。
SLiRP backendは完成して待っている。次回(#2)はいよいよrustx86側だ。coreにvirtio-mmioとvirtio-netをRustで実装して、ゲストOSがこのSLiRP backendにフレームを流し込めるようにする。ブラウザの中のAlpine Linuxから ping 1.1.1.1 が返ってくる日まで、この連載は続く。
次回: 1993年のDOSから ping 1.1.1.1 — ISAバスのNE2000をRustで実装する【rustx86 ネットワーク編 #2】。予告していたvirtio-netではなく、30年前のISAバスのNIC「NE2000」で攻めることにした。その理由から始まる。


コメント