低速だが透明性の高いIP over HTTPSによる仮想ネットワークを構築するPiping Serverを使った手法
やりたいこと
NATやファイアウォールを意識せず遠隔の端末間でネットワークの構築。
透明性の高いコマンドの組み合わせでVPNのようなネットワークの構築。
以下はGoogle Compute Engine (us-central1-a)のNginxとUDPのサーバーに手元のM1 MacからローカルネIPアドレスでアクセスしているデモ。
https://gyazo.com/5134959177a7fa5366b43b396dff17d0
以下のことの具体的な手法についてこのページでは触れる。
ローカルIPアドレスで相互にTCPやUDPでの通信をできること。
端末間の通信をE2E暗号化してVPNの構築。
全てのIPの通信の出口を遠隔のマシンにする方法。
これまで
TCPのポートフォワーディングは汎用性が非常に高い。例えばSSHやVNCによって離れた端末があたかも近くにあるように操作ができるようになる。SSHが使えればSSHサーバー・クライアント次第で、SFTPでのファイル転送、SSHFSでファイルシステムをマウント、ローカル/リモートポートフォワーディング、SOCKSプロキシ、rsyncでファイル転送などが使える。OpenSSHには-wオプションでTUNデバイスを使えるようなので今回の話題に似たことをSSHで実現できるかもしれない(要検証)。USB/IPを使えば遠隔USBも可能ではないかと考えている。 先述の手法の制限としては、TCPのみのフォワーディングでありUDP等の他のIPの上によるプロトコルは対象外。一本のストリームを転送するため単一のTCPコネクションのみ。そのため複数のTCPコネクションが発生すること場合は多重化の仕組みと組み合わせるなどする必要がある。多重化にはyamuxを使ったり、SSHフォワーディングしてからssh -Lで再度フォワーディングしていた。 これを超える汎用性を追求するにはInternet Protocol (IP)を転送させることだろうというのは自然な発想。つまりTCP、 UDP、SCTPなどのIP上のプロトコルが転送でき、仮想のネットワークが構築できるようになる。この構想がよく広まったコマンドの組み合わせで実現できたので残したい。socatの対応の都合上Linux限定。Dockerを使ってMacでも可能。WindowsのWSLでは現状厳しい可能性がある。 実現方法
互いに離れているマシンAとマシンB間で仮想のネットワークを構築する。
curlとsocatコマンドとルート権限があれば良い。
以下をマシンAで実行する。
code:bash
以下をマシンBで実行する。
code:bash
これで完了。
実際に使うときは上記の/atobと/btoaのパスは好きな文字列に変えることを想定している。
Dockerコンテナ内でする際はdocker run -it --privileged ubuntu:20.04が使える。もしくはdocker run -it --cap-add=NET_ADMIN -v /dev/net/tun:/dev/net/tun ubuntu:20.04。
これでマシンAは192.168.255.1、マシンBは192.168.255.2でアクセス可能。
そのためマシンBで、以下を実行するとマシンAに対してpingできる。
code:bash
ping 192.168.255.1
以下はデモ動画。
https://scrapbox.io/files/6463986ba89b3c001c6456d5.mp4
以下のようにip routeコマンドをするとtun0が増えているのがわかる(マシンB側での確認)。このTUNデバイスの作成ためにルート権限が必要。
code:console
$ ip route
...
192.168.255.0/24 dev tun0 proto kernel scope link src 192.168.255.2
curl | socat | curlをCtrl+Cすればtun0は自動で削除される。何度もsocatを実行しやすい。
図解
先ほどの2つのコマンドのつながりを図にすると以下になる。
https://gyazo.com/dae1241b20e3c18799041ea77349f232
Webブラウジングと同じ外向きの443ポートへのアクセスだけあれば良い。
Aの中のtun0とBの中のtun0はそれぞれのsocatが作成する。
tun0の名前はsocatの,tun-name=オプションで名前を別の名前にできる。
Piping Serverが2つあるのは、A→BとB→Aの通信で異なるPiping Serverを使っても良いから。
E2E暗号化
コマンドからわかる通りマシンとPiping Serverの経路はHTTPSで暗号化はされている。VPNと呼ぶためにはマシン間での暗号化して論理的にプライベートにする必要があると思う。 イメージとしては以下の箇所に<暗号化コマンド>と<復号コマンド>を入れることでE2E暗号化になる。 code:bash
# マシンA
code:bash
# マシンB
curl -T- ...に入るデータは暗号化して、curl ...から受け取ったデータは復号している。
具体的なコマンドの一例を上記の<暗号化コマンド>と<復号コマンド>に入れたのが以下のコマンド。長いコマンドだがopensslコマンドと互換性がある点にこだわった。
code:bash
# マシンA
curl -sSN https://ppng.io/btoa | node -e 'crypto=require("crypto");password="mypass";readSalt=()=>{if(!(salt=process.stdin.read(8+8)))return;process.stdin.off("readable",readSalt);keyIv=crypto.pbkdf2Sync(password,salt.slice(8),100000,32+16,"sha256");process.stdin.pipe(crypto.createDecipheriv("aes-256-ctr",keyIv.slice(0,32),keyIv.slice(32))).pipe(process.stdout)};process.stdin.on("readable",readSalt)' | sudo socat TUN:192.168.255.1/24,up - | node -e 'crypto=require("crypto");password="mypass";salt=crypto.randomBytes(8);process.stdout.write("Salted__");process.stdout.write(salt);keyIv=crypto.pbkdf2Sync(password,salt,100000,32+16,"sha256");process.stdin.pipe(crypto.createCipheriv("aes-256-ctr",keyIv.slice(0,32),keyIv.slice(32))).pipe(process.stdout)' | curl -sSNT - https://ppng.io/atob code:bash
# マシンB
curl -sSN https://ppng.io/atob | node -e 'crypto=require("crypto");password="mypass";readSalt=()=>{if(!(salt=process.stdin.read(8+8)))return;process.stdin.off("readable",readSalt);keyIv=crypto.pbkdf2Sync(password,salt.slice(8),100000,32+16,"sha256");process.stdin.pipe(crypto.createDecipheriv("aes-256-ctr",keyIv.slice(0,32),keyIv.slice(32))).pipe(process.stdout)};process.stdin.on("readable",readSalt)' | sudo socat TUN:192.168.255.2/24,up - | node -e 'crypto=require("crypto");password="mypass";salt=crypto.randomBytes(8);process.stdout.write("Salted__");process.stdout.write(salt);keyIv=crypto.pbkdf2Sync(password,salt,100000,32+16,"sha256");process.stdin.pipe(crypto.createCipheriv("aes-256-ctr",keyIv.slice(0,32),keyIv.slice(32))).pipe(process.stdout)' | curl -sSNT - https://ppng.io/btoa あくまでも上記の<暗号化コマンド>と<復号コマンド>は具体例。上記のpassword="mypass"の部分を適切なパスワードにすることを想定している。公開鍵暗号を使っても良いし、将来登場するもっと安全な方式に好きに変えて使える。
https://scrapbox.io/files/646398df3f7ec1001c487318.mp4
node -e '...'は以下のopensslコマンドと互換性がある。
暗号化: openssl aes-256-ctr -pass "pass:mypass" -bufsize 1 -pbkdf2 -iter 100000 -md sha256
復号: openssl aes-256-ctr -d -pass "pass:mypass" -bufsize 1 -pbkdf2 -iter 100000 -md sha256
Node.jsになった経緯に関して。まずワンラインでかける必要があるのでスクリプト言語が良い。インストールされている可能性が高いPythonの標準ライブラリに暗号化できそうなのがなかった。Perlにもなさそう。残るはストリーミング処理に強くてaptでも古いNode.jsなら手に入るのでNode.jsになった。Node.js 10以上なら上記は動く。 Ubuntu 20.04だとapt install -y nodejsで入るのはNode.js 10なので動く, Ubuntu 18.04だとNode.js 8。もちろんリポジトリを追加したりnを使って最新版を入れれば古いUbuntuでも動く。
遠隔マシンを通信の出口にする
無料Wi-Fiを安全に使う用途などで登場するタイプのVPNにように振る舞わせる。
つまり、あたかも遠隔地からアクセスされたような通信する方法。
以下は手元のM1 MacでのグローバルIPがGoogle Compute EngineのIPになっている様子。
https://gyazo.com/35676ced5d29429edd5da711b7536da8
以下がCompute Engineの外部IPアドレス。
https://gyazo.com/87b68d1671bf8fd62960750f6442f77a
この実例では通信の出口をマシンAにする。つまり、マシンBがexample.comにアクセスすると、example.comサーバーへはマシンAからアクセスされるようにする。
以下をマシンAで実行する。
code:bash
以下をマシンBで実行する。
code:bash
ここまでは今までと同じ。E2E暗号化をしても良い。マシンBで実行する際にsudo socat TUN:192.168.255.2/24,tun-name=mytun,up -。のように暗黙的なtun0を避けてtun-name=mytunのようにTUNの名前を決めることも出来る。明示的に指定した場合は下記のマシンBのコマンドにあるtun0はその名前に置き換えれば良い。
以下をマシンAで実行する。
code:bash
sudo sysctl -w net.ipv4.ip_forward=1
sudo iptables -t nat -A POSTROUTING -o $(ip route show default | grep -oP ' dev \K\S+') -j MASQUERADE
上記の$(ip route show default ...)の結果は多くの場合はeth0。
自分宛でないパケットを転送するようにnet.ipv4.ip_forward=1している。Dockerを自動で起動しているとこれはすでに有効だと思う。
上記の設定は再起動すれば自動で元に戻ってくれる。
以下をマシンBで実行する。
code:bash
sudo ip route add $(dig +short ppng.io) $(ip route | grep '^default' | cut -d ' ' -f 2-)
sudo ip route add 0.0.0.0/0 via 192.168.255.1 dev tun0
上記で0.0.0.0/0というIPv4全ての通信がtun0を通るようになる。ただしPiping Server ppng.ioへの通信は今まで通りのeth0などを通るにように設定している。 上記の設定は再起動すれば自動で元に戻ってくれる。tun0に関連はcurlが終了すれば設定が消えてくれる。
確認するためにマシンBで以下のコマンドでグローバルIPを調べる。-4はIPv4を明示するためにしているがなくても期待通りの結果だった。
code:bash
digを使った手法でもうまくいっていることが分かる。
code:bash
dig +short -4 o-o.myaddr.l.google.com @ns1.google.com TXT
マシンAを検証をDockerで検証する
docker run -it --privileged ubuntu:20.04が使える。もしくはdocker run -it --cap-add=NET_ADMIN --sysctl net.ipv4.ip_forward=1 -v /dev/net/tun:/dev/net/tun ubuntu:20.04でもいける。
マシンBの検証をM1 Mac上でするためにlimaを使った。ubuntu-lts.yamlのyamlを使ってlimactl start --name myubuntu ./ubuntu-lts.yaml、limactl shell myubuntuのようにして使う。 L2 データリンク層 - TAP
TUNより一層低く。
以下のように,tun-type=tapを互いに指定することもできる。
code:bash
# マシンA
code:bash
# マシンB
以下のようにtun0のときはlink/noneだったところが、link/etherになってMACアドレスが確認できる。
code:console
$ ip a show tap0
11: tap0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UNKNOWN group default qlen 1000
link/ether <MACアドレス> brd ff:ff:ff:ff:ff:ff
inet 192.168.255.2/24 brd 192.168.255.255 scope global tap0
valid_lft forever preferred_lft forever
inet6 <IPv6アドレス>/64 scope link
valid_lft forever preferred_lft forever
ping 192.168.255.1もできるし今まで通りのTCPやUDPでの通信も可能。
E2E暗号化と組みわせても良い。
速度に関して
タイトルに含めた通り低速。この低速の理由を示すためにローカル環境でTCP経由でTUNを転送する。つまりIP over TCPする。Piping Serverにかかわらず低速であることを示したい。 ローカルでPiping Serverなし
検証方法としては、docker run -it --privileged ubuntu:20.04を2つ実行する。
一つをマシンA、もう一つをマシンBとする。
両方とものコンテナ内でapt update && apt install -y socat iperf3でツールをインストール。
マシンAはhostname -Iで取得したローカルIPが172.17.0.4だった。
以下のコマンドを各マシンで実行する(コンテナ内で実行する)。
code:bash
# マシンA
iperf3 -s
code:bash
# マシンA
socat TCP-LISTEN:11443,reuseaddr TUN:192.168.255.1/24,up
code:bash
# マシンB
socat TCP:172.17.0.4:11443 TUN:192.168.255.2/24,up
code:bash
# マシンB
iperf3 -c 192.168.255.1 -i 0
結果としては、以下のぐらいの速度。1.09 Mbits/sec sender、995 Kbits/sec receiver。
code:console
# iperf3 -c 192.168.255.1 -i 0
Connecting to host 192.168.255.1, port 5201
5 local 192.168.255.2 port 50512 connected to 192.168.255.1 port 5201 ID Interval Transfer Bitrate Retr Cwnd 5 0.00-10.00 sec 1.30 MBytes 1.09 Mbits/sec 137 5.66 KBytes - - - - - - - - - - - - - - - - - - - - - - - - -
ID Interval Transfer Bitrate Retr 5 0.00-10.00 sec 1.30 MBytes 1.09 Mbits/sec 137 sender 5 0.00-10.21 sec 1.21 MBytes 995 Kbits/sec receiver iperf Done.
低速。TCP over IP over TCPは無駄が多いのだと思う。一番下のTCPは順序保証も不要で良いがしてしまうし、再送も不要なのにする。メインのPiping Server経由の場合も同じことが起こる。上記のsocatをUDP:とUDP-LISTEN:にするとだいぶ速くなる(sender, receverともに1.45 Gbits/secほど)。Piping ServerのWebTransport対応のモチベーションになり得る。
Piping Server - ppng.io
limaのVM内で以下を使ってネットワークを構築している。
code:A
code:B
以下はTCPでの結果。TCP over IP over HTTPS。やはり低速。
code:console
$ iperf3 -c 192.168.255.1 -i 0
Connecting to host 192.168.255.1, port 5201
5 local 192.168.255.2 port 57052 connected to 192.168.255.1 port 5201 ID Interval Transfer Bitrate Retr Cwnd 5 0.00-10.00 sec 450 KBytes 368 Kbits/sec 105 1.41 KBytes - - - - - - - - - - - - - - - - - - - - - - - - -
ID Interval Transfer Bitrate Retr 5 0.00-10.00 sec 450 KBytes 368 Kbits/sec 105 sender 5 0.00-10.04 sec 393 KBytes 321 Kbits/sec receiver iperf Done.
以下はUDPでの結果。UDP over IP over HTTPS。上記のTCPよりはマシ。
code:console
$ iperf3 -uc 192.168.255.1 -i 0
Connecting to host 192.168.255.1, port 5201
5 local 192.168.255.2 port 50727 connected to 192.168.255.1 port 5201 ID Interval Transfer Bitrate Total Datagrams 5 0.00-10.00 sec 1.25 MBytes 1.05 Mbits/sec 906 - - - - - - - - - - - - - - - - - - - - - - - - -
ID Interval Transfer Bitrate Jitter Lost/Total Datagrams 5 0.00-10.00 sec 1.25 MBytes 1.05 Mbits/sec 0.000 ms 0/906 (0%) sender 5 0.00-10.07 sec 1.25 MBytes 1.04 Mbits/sec 2.319 ms 3/906 (0.33%) receiver iperf Done.
以下はSCTPでの結果。SCTP over IP over HTTPS。
code:consle
$ iperf3 --sctp -c 192.168.255.1
Connecting to host 192.168.255.1, port 5201
5 local 192.168.255.2 port 42360 connected to 192.168.255.1 port 5201 ID Interval Transfer Bitrate 5 0.00-1.01 sec 128 KBytes 1.04 Mbits/sec ID Interval Transfer Bitrate 5 0.00-1.01 sec 128 KBytes 1.04 Mbits/sec iperf3: error - unable to write to stream socket: Connection reset by peer
SCTPはなぜか「unable to write to stream socket」になる。
まとめ
おまけ: v86
実はTAPデバイス + Piping Serverの組み合わせはこれが初めてではなく、2年前ほどにv86というWebブラウザ上で動くx86のエミュレータの通信部分をPiping Serverに置き換えて、python-pytunを使ってTAPデバイスと繋いでv86上でインターネットが使えるというのを実現していた。より汎用的な方法が分かったので情報の公開順序が逆転した。v86の方も気力が生み出して公開したいが、rsyncを活用した別の話題の役に立ちそうなので気力はそっちを優先したい気持ちがある。