🦜
GoでP2Pトンネルをつなぐ
P2Pトンネル
- WebRTCを利用してP2Pトンネルをつなぐユーティリティの紹介
リポジトリ
github.com/rtctunnel/rtctunnel
必要な環境づくり
go install github.com/rtctunnel/rtctunnel/cmd/rtctunnel@latest
go install github.com/roerohan/wait-for-it@latest
operatorというシグナリングサーバーがインターネット側に必要です。
仮設ですが、「operator.irieda.com」というのを設置してあるのでそれを使って試してみます。
operatorリポジトリはこちら。
rtctunnel init
を実行すると、所定の場所に鍵を生成します。
Windows:
%APPDATA%\rtctunnel\rtctunnel.yaml
構成
つまり、「server-app=sshd、client-app=ssh」とすれば、遠隔でSSH接続が実現できる。
Host A(server)
rtctunnel init
echo "signalchannel: operator://operator.irieda.com" >> %APPDATA%\rtctunnel\rtctunnel.yaml
export SERVER_KEY=<public-key from host-a.yaml>
export CLIENT_KEY=<public-key from host-b.yaml>
rtctunnel add-route \
--local-peer=$CLIENT_KEY \
--local-port=8080 \
--remote-peer=$SERVER_KEY \
--remote-port=8080
# start server-app :8080
wait-fot-it -w :8080
rtctunnel run
Host B(client)
rtctunnel init
echo "signalchannel: operator://operator.irieda.com" >> %APPDATA%\rtctunnel\rtctunnel.yaml
export SERVER_KEY=<public-key from host-a.yaml>
export CLIENT_KEY=<public-key from host-b.yaml>
rtctunnel add-route \
--local-peer=$CLIENT_KEY \
--local-port=8080 \
--remote-peer=$SERVER_KEY \
--remote-port=8080
rtctunnel run
wait-fot-it -w :8080
# start client-app :8080
シーケンス
- サーバーアプリ起動
- rtctunnel(server)起動->operatorにサブスクライブ
- rtctunnel(client)起動->operatorにサブスクライブ
- WebRTC接続確立(operator利用は終了)
- クライアントアプリ起動ー>rtctunnel(client)に接続
- クライアントアプリ接続をTCPストリームとしてWebRTC接続に追加し中継開始
- TCPストリーム追加を検出したrtctunnel(server)がサーバーアプリに接続し中継開始
- 必要なら5.-7.を繰り返す
以上の仕組みでP2Pトンネルを通して遠隔のサーバーアプリとクライアントアプリを接続することができます。
応用
- Dockerコンテナ内のサーバーアプリをこのrtctunnelに載せておくと、インターネットに出られるホスト間ならほとんどのネットワーク環境でリモート接続可能になる(ポートを開く必要なし)
- ライブラリとしての利用も可能なのでサーバーやクライアントアプリをrtctunnnel機能内包して作ることもできる
- JS/WASM環境にも対応しているのでブラウザにサーバーやクライアント機能を持たせることもできる
JSON-RPCサーバー実装
あらかじめ以下のコマンドでサーバー用、クライアント用のキーを生成しておきます。
rtctunnel init --config-file ./server.yaml
rtctunnel init --config-file ./client.yaml
サーバー用.envファイルを作ります(上記のyamlの内容から転記します)
server.env
PUBLIC_KEY=<server-public-key>
PRIVATE_KEY=<server-private-key>
PEER_PUBLIC_KEY=<client-public-key>
サーバーサンプルコード
server.go
package main
import (
"log"
"net"
"net/rpc"
"net/rpc/jsonrpc"
"github.com/caarlos0/env/v10"
"github.com/rtctunnel/rtctunnel/channels"
"github.com/rtctunnel/rtctunnel/crypt"
"github.com/rtctunnel/rtctunnel/peer"
"github.com/rtctunnel/rtctunnel/signal"
)
var config = struct {
PublicKey string `env:"PUBLIC_KEY,required"`
PrivateKey string `env:"PRIVATE_KEY,required"`
PeerPubricKey string `env:"PEER_PUBLIC_KEY,required"`
}{}
type Node struct{}
func (n *Node) Ping(args struct{}, reply *struct{}) error {
log.Println("ping")
return nil
}
func init() {
if err := env.Parse(&config); err != nil {
log.Fatal(err)
}
signal.SetDefaultOptions(
signal.WithChannel(channels.Must(channels.Get("operator://operator.irieda.com"))),
)
rpc.Register(&Node{})
}
func main() {
pub, err := crypt.NewKey(config.PublicKey)
if err != nil {
log.Fatal(err)
}
priv, err := crypt.NewKey(config.PrivateKey)
if err != nil {
log.Fatal(err)
}
keyPair := crypt.KeyPair{Public: pub, Private: priv}
peerPub, err := crypt.NewKey(config.PeerPubricKey)
if err != nil {
log.Fatal(err)
}
for {
pc, err := peer.Open(keyPair, peerPub)
if err != nil {
log.Fatal(err)
}
l := peer.NewDispatcher(pc).Listen(8080)
conn, err := l.Accept()
if err != nil {
log.Println(err)
continue
}
go func(pc *peer.Conn, conn net.Conn) {
defer pc.Close()
codec := jsonrpc.NewServerCodec(conn)
defer conn.Close()
rpc.ServeCodec(codec)
}(pc, conn)
}
}
サーバー起動
docker run -it --rm --env-file server.env nobonobo/sample-rtctunnnel-node
クライアント用.envファイルを作ります(上記のyamlの内容から転記します)
client.env
PUBLIC_KEY=<client-public-key>
PRIVATE_KEY=<client-private-key>
PEER_PUBLIC_KEY=<server-public-key>
クライアントサンプルコード
client.go
package main
import (
"flag"
"log"
"net/rpc/jsonrpc"
"time"
"github.com/caarlos0/env/v10"
"github.com/joho/godotenv"
"github.com/rtctunnel/rtctunnel/channels"
"github.com/rtctunnel/rtctunnel/crypt"
"github.com/rtctunnel/rtctunnel/peer"
"github.com/rtctunnel/rtctunnel/signal"
)
var config = struct {
PublicKey string `env:"PUBLIC_KEY,required"`
PrivateKey string `env:"PRIVATE_KEY,required"`
PeerPubricKey string `env:"PEER_PUBLIC_KEY,required"`
}{}
func init() {
var dotenv string
flag.StringVar(&dotenv, "env", "secret.env", "load .env file")
flag.Parse()
if err := godotenv.Load(dotenv); err != nil {
log.Print(err)
}
if err := env.Parse(&config); err != nil {
log.Fatal(err)
}
signal.SetDefaultOptions(
signal.WithChannel(channels.Must(channels.Get("operator://operator.irieda.com"))),
)
}
func main() {
pub, err := crypt.NewKey(config.PublicKey)
if err != nil {
log.Fatal(err)
}
priv, err := crypt.NewKey(config.PrivateKey)
if err != nil {
log.Fatal(err)
}
keyPair := crypt.KeyPair{Public: pub, Private: priv}
peerPub, err := crypt.NewKey(config.PeerPubricKey)
if err != nil {
log.Fatal(err)
}
pc, err := peer.Open(keyPair, peerPub)
if err != nil {
log.Fatal(err)
}
defer pc.Close()
conn, err := pc.Open(8080)
if err != nil {
log.Fatal(err)
}
defer conn.Close()
client := jsonrpc.NewClient(conn)
client.Call("Node.Ping", nil, nil)
time.Sleep(time.Second)
client.Call("Node.Ping", nil, nil)
time.Sleep(time.Second)
client.Call("Node.Ping", nil, nil)
time.Sleep(time.Second)
}
サーバー側出力
{"level":"debug","data":"{\"method\":\"Node.Ping\",\"params\":[null],\"id\":0}\n","time":"2024-04-02T07:36:14Z","message":"datachannel message"}
2024/04/02 07:36:14 ping
{"level":"debug","data":"{\"method\":\"Node.Ping\",\"params\":[null],\"id\":1}\n","time":"2024-04-02T07:36:15Z","message":"datachannel message"}
2024/04/02 07:36:15 ping
{"level":"debug","data":"{\"method\":\"Node.Ping\",\"params\":[null],\"id\":2}\n","time":"2024-04-02T07:36:16Z","message":"datachannel message"}
2024/04/02 07:36:16 ping
利点
- dockerによるサーバー起動にポートを一切開けていないのに注目
- つまり、サーバーの配置やクライアントの配置はどこでもよい(NAT配下でも問題ない)
- 家に置いたラズパイ上にてサーバー起動しておいて、外部からクライアント起動してサーバー実装をリモートコントロールできる
まとめ
- TCP利用法をここではまとめたけど、UDPでも使えるみたい
- UDPトンネルって意外と外部サービスにはないのでそういう二ッチな需要でも助かるかも
- 例えばマイクラBEサーバーとのつなぎこみなど
- もちろん、SSHのTCP接続にも使える
- WebSocketと違い、HTTPSコンテンツからnon-TLSでも暗号化が有効で接続可能(WebSocketやHTTPはTLS/non-TLSの混在が許されない)
- P2Pトンネル便利~。
- こんなに便利なP2PトンネルをGoだけで書けるのうれしい(ほとんどのプラットフォームで問題なく動く)
- ファイヤーウォールやDMZ設定、uPnPなどの
問題の多い扱いの難しい機能に依存せずにP2P接続ができるよ
追記
LAN内クライアントNative | LAN内クライアントWASM | LAN外クライアントWASM | |
---|---|---|---|
dockerサーバー | OK | NG | OK |
Nativeサーバー | OK | OK | OK |
- BrowserのWebRTCとネイティブWebRTCの組み合わせかつdocker-NAT配下とLAN内の接続時だけネゴに失敗する
- たぶんサーバーがネットに出る経路とクライアントがネットに出る経路が途中から同じなのでこの時のネゴが複雑
- Pion実装とBrowser実装のネゴ結果が期待するものが食い違っている可能性がある?
- でもまぁGo製プロダクトはNativeバイナリは簡単に起こせるのでサーバーをNative化すれば問題なさそう
あー。わかった。dockerの「Networking Tunnel」機能有効にするとホストネットワークと同じサブネットがdockerホストVMに作られるからだった。これを外せば問題なさそう。
Discussion
dockerじゃないサーバーならWASMからでもつながった。
dockerネットワーク配下だといろいろ厳しいのかも。
ただし、ちょっとバグあり(ブラウザの挙動差を吸収できていない?)。 上記を以下のように変更すると動いた。
よし、コントリビュートだ!
マージされたー。
SSHもつながったー。これでssh-p2pの役割も終了かな。
LAN経由で片方向アクセス可能な時などにRTCPeerConnectionのネゴに失敗することがある模様。
完全に別ホストであれば問題ないかも。