🦜

GoでP2Pトンネルをつなぐ

2024/04/02に公開
4

P2Pトンネル

  • WebRTCを利用してP2Pトンネルをつなぐユーティリティの紹介

リポジトリ

github.com/rtctunnel/rtctunnel
https://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

シーケンス

  1. サーバーアプリ起動
  2. rtctunnel(server)起動->operatorにサブスクライブ
  3. rtctunnel(client)起動->operatorにサブスクライブ
  4. WebRTC接続確立(operator利用は終了)
  5. クライアントアプリ起動ー>rtctunnel(client)に接続
  6. クライアントアプリ接続をTCPストリームとしてWebRTC接続に追加し中継開始
  7. TCPストリーム追加を検出したrtctunnel(server)がサーバーアプリに接続し中継開始
  8. 必要なら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

NoboNoboNoboNobo

dockerじゃないサーバーならWASMからでもつながった。
dockerネットワーク配下だといろいろ厳しいのかも。
ただし、ちょっとバグあり(ブラウザの挙動差を吸収できていない?)。
https://github.com/rtctunnel/rtctunnel/blob/9a8e61fd7a477116fc9246ecaeca1e6d3569514a/peer/webrtc_js.go#L182
上記を以下のように変更すると動いた。

webrtc_js.go
				sdpc <- desc.Get("sdp").String()

よし、コントリビュートだ!

NoboNoboNoboNobo

SSHもつながったー。これでssh-p2pの役割も終了かな。

NoboNoboNoboNobo

LAN経由で片方向アクセス可能な時などにRTCPeerConnectionのネゴに失敗することがある模様。
完全に別ホストであれば問題ないかも。