TL;DR
- Goはネットワークライブラリを書くのに非常に良かった
- libp2pやIPFSに期待
はじめに
はじめにお断りしておくとあまり実用的な話題ではありません・・・。
以前GoでP2Pファイル転送コマンドを作ったという記事を書いたときにも結構触れたのですがP2Pの仕組みを個人的に勉強したい、またライブラリを作ってGoで使いたい、という思いになり、P2Pネットワークライブラリを実装してみました。
より実装寄りの話をしたいと思います。
技術選択
P2Pで接続するにおいて一番の難所となるのはやはりNATです。ここらへんをうまく突破して接続するための方法をまず検討しました。
といってもどうするのが最適かイマイチわからなかったのでWebRTCを参考にしました。(WebRTCは最近のWebブラウザについているブラウザ同士でP2Pで動画やデータを流すやつです)
具体的な接続ステップを簡単に示すと
- UDPのポートをlistenする
- 上で開いたポートに紐づけられたIPアドレス、ポートの取得をする
- ローカルのIPアドレスをリストアップ(LAN内など)
- STUNを使ってインターネットに対して公開されるIPアドレス、(NAPTの場合は)ポートを取得
- (それでもできない環境向けにTURNサーバ経由での接続環境を用意)
- 接続するノード同士で接続情報をシグナリングサーバ経由(あるいは手動)で交換
- 交換された接続情報に対して接続を試みる
いわゆるUDPホールパンチングという方法ですね。実装もそこまでつらくなくてわかりやすくて良い感じです。
問題は接続確立後にファイル転送とかしようにもパケロスでデータが破損したら困ります。再送制御とかまで自分で実装するのは嫌だったのでライブラリを探し、QUICを採用することにしました。(最初はµtpというプロトコルを採用していたのですが後にそのライブラリがdeprecatedになってしまったので代替を探しました)
QUICは最近IETFにおいて標準化が進行しているプロトコルで、HTTP/3という名称1になるようです。 今回はP2Pでつなぐ2つの機器の一方をQUICサーバ、もう一方をQUICクライアントとしました。
net.PacketConn
Goのnet packetにはPacketConnというinterfaceがあります。
TCPでは1対1の通信しかできないので
Read([]byte) (int, error)
Write([]byte) (int, error)
のようになっていますが、UDPでは不特定多数と通信できるため以下のようになっています。
type PacketConn interface {
ReadFrom(p []byte) (n int, addr Addr, err error)
WriteTo(p []byte, addr Addr) (n int, err error)
Close() error
LocalAddr() Addr
SetDeadline(t time.Time) error
SetReadDeadline(t time.Time) error
SetWriteDeadline(t time.Time) error
}
既存のライブラリのベースとなっているソケットとうになんらかの処理を加えるのは他の言語だと難しい場合も多いですが、Goではquic.Listen
やquic.Dial
の引数に渡すPacketConn interfaceを実装したstructを自分で用意するだけで済みます。これもGoのinterfaceの仕組みや、標準ライブラリにinterfaceが豊富に予め定義されているおかげだと思います、非常にありがたかったです。
sharablePacketConn
今回用いたQUICはTCPと同じような感覚のため、クライアントはサーバと1対1で通信することが想定されています。ですが接続先の候補となるIPアドレス・ポートのペアは複数存在するためそれに対して順番に接続を試しているとめちゃくちゃ時間がかかります。これを防ぐためには1つのポートのPacketConnで複数のサーバと通信できる必要があります。
そのためにsharablePacketConnをまず作りました。Register関数に使いたいアドレスを渡すとそのアドレスと通信するためのchildPacketConnを返します。それを使って通信する仕組みになっています。また、接続確立後も複数のサーバと通信する仕組みとなっている必要はなく、オーバーヘッドとなってしまうため、1つのchildPacketConnのみにしてそれ以外の通信を弾く機構をつけました。
サーバ側のファイアウォール対応
また、サーバ側もただ待っているだけでよいかというとそうでもないことが多いです。セキュリティ的な問題でファイアウォールが一度もパケットを送信していないところからの受信を制限している場合があります。こういう環境に備えてサーバ側からクライアントに対して適当な小さなパケットをあらかじめ飛ばし、そのパケットはクライアント側で無視するようにしました。
認証機構
接続が完了してもそれが正しい通信相手であると保証できていません。それを確かめるための認証機構が必要です。MITM攻撃などが成立しては困ります。
今回は認証機能をまるごとquic-goに任せてしまいました。QUICはTCP+HTTP2+TLSを置き換えるようなものなので公開鍵認証がついています。Goでオレオレ認証局と証明書を発行する方法がよくわからず、色々と調べながら書きましたが何か問題があるかもしれません。これです。ここで生成した認証局の公開鍵を通信相手にIPアドレス・ポートのペアと共に渡すことで認証します。
また、TLSではクライアント認証も行うことができるため同様の方式でサーバ認証もクライアント認証もできてしまいます
実装
あとは頑張って実装する、というお話なので特に話すことがありません・・・。そんなこんなでなんとか実装したのですが、一つの関数に載っている処理量が多すぎるためそれを分割したり、テストコードを書いたりしたいと思っているのですがなかなかできていません、これから頑張ってテストコード書きます。また、せっかくQUICを採用したにもかかわらず、前のµtpと方針を変えていないためマルチストリームに対応できていないのも対応したいです。
余談
IPFS/libp2p
上に載っけた記事にも書いたようにキーワードを交換するだけで簡単にファイルを共有できるコマンドとかを作ったりリバースプロキシを作って自分で使って勝手に満足していたのですが、このライブラリが完成に近づいてきた頃libp2pの存在を知りました。
libp2pはIPFSプロジェクトの一部?のようです。IPFSはInterPlenetary File System、惑星間ファイルシステムという非常にロマンあふれるものでして、要は静的ファイルをモダンなP2Pネットワークで分散管理しよう、というやつ。
先日、静的なある程度の容量のあるファイルをどうやって公開するか悩んだ時ふと思い出してIPFSを試してみたのですが思いのほか使いやすくて良かったです。このIPFSも基本的な実装は全てGoで書かれています。
libp2pではtransportとしてquicが使えるようなので試してみたいと思ったのですが、おそらく最近quic-goがGoogle実装からIETF実装へ切り替えた影響でビルドが通りませんでした。
WebRTC
WebRTCはJavaScriptとC++、それとモバイル端末向けライブラリが多く他の言語で使われている例があまりありません。ですが最近GoでWebRTCを(libwebrtcへの依存なく)実装しているライブラリ、pions/webrtcが開発されています。libwebrtc以外の実装が現れるのはとても良いことだと思うので期待したいです。
おわりに
あまり中身のない内容となってしまいましたが、ここまでお読みいただきありがとうございました。最近再び注目を集めているP2Pが今後発展していって欲しいと思っています。