Tunnel/Reverse Tunnel over websocket を作った
とある理由から 「Tunnel/Reverse Tunnel over websocket」 が必要になったので作ってみた。
「Tunnel/Reverse Tunnel over websocket」 が何かというと、 「websocket を tunnel にして別の TCP 通信を通すもの」だ。
「Tunnel/Reverse Tunnel over websocket」 とは
「Tunnel/Reverse Tunnel over websocket」を少し具体的にいうと、 次のような構成で通信を可能にするモノだ。
frame tunnelの例 {
rectangle network_1 {
node tcp_client_A
node tunnel_client_1
}
rectangle network_2 {
node tunnel_server_1
node tcp_server_B
}
}
tcp_client_A --> tunnel_client_1
tunnel_client_1 --> tunnel_server_1
tunnel_server_1 --> tcp_server_B
tcp_client_A ..> tcp_server_B
frame reverse_tunnelの例 {
rectangle network_A {
node tcp_server_C
node tunnel_client_2
}
rectangle network_B {
node tunnel_server_2
node tcp_client_D
}
}
tcp_server_C <-- tunnel_client_2
tunnel_client_2 --> tunnel_server_2
tunnel_server_2 <-- tcp_client_D
tcp_client_D ..> tcp_server_C
上の図は network_1, network_2, network_A, network_B の 4 つのネットワークを表わしている。
このネットワーク間でポートが制限されていると、 tcp_client_A は tcp_server_B と直接通信が出来ない(図の点線)。
開放されているポートで接続し、そのセッション上に仮想的な Tunnel を構築する。 そして、その Tunnel 内にポートの制限を受けない通信を実現する。
左は Tunnel の構成例である。 ポート制限されている network_1, network_2 を、 tunnel server / tunnel client で接続して tunnel を構成し、 その tunnel を使って tcp client A と tcp server B を接続する。
右は Reverse Tunnel の構成例である。 ポート制限されている network_A, network_B を、 tunnel server / tunnel client で接続して tunnel を構成し、 その tunnel を使って tcp client D と tcp server C を接続する。
Tunnel と Revers Tunnel の違いは、 tcp client/server の位置関係である。
具体的には、 Tunnel server と同じネットワークに tcp server が属する構成が Tunnel で、 逆に Tunnel server と同じネットワークに tcp client が属する構成が Reverse Tunnel である。
そして、 Tunnel server と client 間の通信経路として、 websocket を利用して tunnel を構築するのが 「Tunnel/Reverse Tunnel over websocket」である。
VPN (Virtual Private Network)
このように制限されたネットワーク間で通信路を構築する方法として、 WireGuard や OpenVPN などの低レイヤー VPN がある。
低レイヤー VPN は、その名の通り仮想的なネットワークを低レイヤーで構築する。 これによって、通常のネットワークと同様に扱えて利便性が高いが、 通常のネットワークと同様であるが故、逆にリスクになる可能性がある。
今回は VPN ではなく、Tunnel を実現するのが目的である。
なお、ここでは「レイヤー 2 あるいは 3 を仮想化する技術」を VPN とし、 「ネットワーク間で TCP セッションを転送する技術」を Tunnel とする。
開発した背景
制限されたネットワーク間での通信を確立できないかどうか、 当初はフリーのツールを探して tunnel ソフトを幾つか試してみたが、 tunnel が接続できなかったり、接続できてもすぐに切れてしまったりで イマイチ希望したものとは違った。
特に自分の環境は (A)/(B) 間のネットワーク環境が悪く、 tunnel を確立しても、ある程度経過すると切断されてしまう問題があった。
tunnel が切断されても tunnel を再接続することで、 tunnel 内の tcp 通信を継続させることは論理的に可能だ。 しかし、検討していた幾つかのフリーのツールでは、 tunnel が切断されると tunnel 自体のを再接続が出来ても tunnel 内を流れる tcp 通信が継続できなかった。 そもそも tunnel を再接続すること自体、 成功したり失敗したりしているような状況だった。
そこで、今回はフリーのツールを検討することは諦め、 自分の勉強も兼てスクラッチで開発することにした。
なお、ネットワーク間を接続することが目的であれば、 WireGuard や OpenVPN などの低レイヤー VPN や、 stunnel などの Tunnel ツールを利用するのが多くの場合ベストだろう。
ネットワーク環境
今回開発した Tunnel ツールを使って、 自分のネットワーク環境の Tunnel 間通信強制切断状況を確認したところ、 次のようになった。
- (a)
- 接続は最大でも 15 分程度で切断される
- (b)
- 昼間は 30 秒程度で切断される
- (c)
- 接続の 7 割強は 1 分以内で切断される
(a) について、 どうやら自分の環境では http 通信は 15 分程度でセッションが強制切断されるらしい。
(b), (c) について、 無通信が続くと 30 〜 60 秒程度で強制切断されるようなので、 無通信を回避するために 20 秒毎にトンネル間でダミーの通信を行なうよう対応した。
ただ、これでも通信負荷が高くなると数分で切断されることがある。
使用方法
このツールは Go で開発しているため、 事前に Go(1.14.2) の環境を構築してあることが前提である。
注意事項
tunnel 間の通信がインターネットを経由する場合、セキュリティには十分注意すること。
- tunnel client/server 間通信の暗号化や、client 認証を実装しているが、 tunnel 内の TCP セッションは raw な tcp 接続をせずに、 ssh などで接続すること。
- tunnel server は常駐させず、必要な時にだけ起動するように運用すること。
- pass , encPass オプションを必ず指定し、適切な期間で変更すること。
- ip オプションを指定し、接続可能な client を制限すること。
ビルド
次のコマンドを実行することで、 tunnel ディレクトリ内に tunnel コマンドがカレントディレクトリに生成される。
$ git clone --depth 1 https://github.com/ifritJP/kptunnel.git
$ cd kptunnel
$ make build
kptunnel コマンド
kptunnel コマンドは tunnel server と、 tunnel client の両方の役割を持ち、 オプションで切り替える。
kptunnel コマンドは、次の書式をもつ。
$ kptunnel <mode> <server> [forward [forward [...]]] [options]
-
mode
- 次のいずれかを指定する
-
サーバ
- wsserver
- r-wsserver
- server
- r-server
-
クライアント
- wsclient
- r-wsclient
- client
- r-client
- "r-" が付くものは、 reverse tunnel である。
- ws が付くものは、 over websocket である。
-
ws が付かないものは、 tcp で直接接続する。
- tcp による接続は、実験的なサポートである。
- tcp で接続できる環境なら、 このツールを使わずに ssh した方が良いだろう。
- "r-", "ws" は client/server で一致している必要がある。
-
server
- server を示す。
-
サーバ側で指定する場合は、開放するポートを指定する。 (:1234 or localhost:1234)
- この port に接続可能なネットワークを制限する場合は、
そのネットワークを指定する。
例えば localhost に制限する場合は
localhost:1234
として指定する。
- この port に接続可能なネットワークを制限する場合は、
そのネットワークを指定する。
例えば localhost に制限する場合は
- クライアント側で指定する場合は、ホスト名を含めて指定する (hoge.com:1234)
-
forward
- tunnel で転送するポートの情報。
- forward は複数指定できる。
- server 側に forward が指定されている場合、 client 側の設定は server 側の設定で上書きされる。
- "localのポート,forward先のポート" の書式で指定する。
- localのポートに接続可能なネットワークを制限する場合は、
そのネットワークを指定する。
例えば localhost に制限する場合は
localhost:1234
として指定する。 -
forward 先のポート情報は、相手にそのまま伝わる。
- 例えば reverse tunnel で localhost を指定した場合、localhost は tunnel クライアント自身になり、 通常の tunnel の場合、 localhost はサーバ自身になる。
-
forward の書式は old_forward と new_forward の 2 つの書式がある。
-
old_forward は、
local-port,remote-port
の書式で指定する。- ここで reverse-tunnel の場合、
local-port
はサーバ側のhost:port
を指定し、remote-port
はクライアントからアクセスするhost:port
を指定する。 - tunnel の場合、 reverse-tunnel と逆になる。
- ここで reverse-tunnel の場合、
-
new_forward は、
<r|t>,old_forward
の書式で指定する。- ここで r は、 old_forward を reverse-tunnel として指定する。
- ここで t は、 old_forward を tunnel として指定する。
- つまり、old_forward は転送設定を mode 指定に従うのに対し、 new_forward は forward 設定毎に tunnel, reverse 設定を行なうかの違いである。
-
さらに言えば、 new_forward 書式を利用すると、 mode の tunnel, reverse の違いは意味がない。
- mode の reverse (r-client, r-server 等)は、互換性のために残しているが、 将来は削除する可能性がある。
-
次に代表的なコマンド例を示す。
- server
server のコマンド例を示す。
$ kptunnel r-wsserver :6666 :8001,localhost:22 -pass XXXXXXX -encPass YYYYYYYY
これは次のサーバの実行を指定している。
option | 意味 | サンプルの意味 |
---|---|---|
r-wsserver | client/server の種類 | reverse websocket server |
:6666 | tunnel サーバの情報 | ポート 6666 を使用して websocket server を建てる |
:8001,localost:22 | tunnel で forward するポート番号 | server の 8001 を client の localhost:22 に forward |
-pass | client の認証用パスワード | XXXXXXX |
-encPass | client/server 間の通信路の暗号パスワード | YYYYYYYY |
- client
client のコマンド例を示す
$ kptunnel r-wsclient hoge.hoge.com:80 -proxy http://user:pass@proxy.hoge.com:8080/ -pass XXXXXXX -encPass YYYYYYYY
これは次のクライアントの実行を指定している。
option | 意味 | サンプルの意味 |
---|---|---|
r-wsclient | client/server の種類 | reverse websocket client |
hoge.hoge.com:80 | tunnel サーバの情報 | hoge.hoge.com の 80 に接続する |
-proxy | proxy サーバの情報 | http://proxy.hoge.com::8080/ に user, pass で接続 |
-pass | client の認証用パスワード | XXXXXXX |
-encPass | client/server 間の通信路の暗号パスワード | YYYYYYYY |
- tunnel への接続
上記のサンプルは localhost の 22 番ポートに接続するための reverse tunnel を構築している。 つまり、このサーバ側の 8001 ポートに繋げると、 client 側の ssh に接続されることになる。
よって、サーバ側で次のコマンドを実行することで、クライアントの ssh に接続できる。
$ ssh -p 8001 localhost
オプション一覧
kptunnel コマンドで使用可能なオプションについて説明する
基本
-
-proxy string
- websocket server に接続するための proxy
- proxy 不要なら省略する。
- 認証が必要な proxy の場合、 http://user:pass@proxy.hoge.com:port/ の形式で指定する。
- 現状は HTTP proxy のみ対応している。
- client 側で指定する
-
-UA string
- Proxy に接続する際の User Agent を指定する
- websocket の client で有効
セキュリティ関連
-
-pass string
- client 認証で使用する。
- client/server で共通のものを指定する必要がある。
- client 認証は challenge/respose で行なう。
-
-encPass string
- client/server 間通信の暗号パスワード。
- client/server で共通のものを指定する必要がある。
-
-encCount int
-
client/server 間の暗号処理回数を指定する。 (default -1)
- -1 : infinity
- 0 : plain, no encrypt.
- N > 0 : packet count
- このツールは tunnel client/server 間の通信を暗号化するが、tunnel 内を通すのが ssh などの場合、 二度の暗号化が走ることになり、tunnel client/server 間の暗号は無駄になる。 そこで、tunnel client/server 間の暗号化回数を指定することで、暗号化にかかる負荷軽減を可能にする。
- 回数は tunnel の通信パケット単位
- 暗号アルゴリズムは AES256 CFB を使用している。
-
-
-ip string
-
server に接続可能な client の、 IP アドレス範囲を指定する。
- e.g. 192.168.0.0/24
- このオプションを省略した場合、 client の IP を限定しない。
-
動作デモ
次を実行しているデモ動画を示す。
- remote と local と、それらを仲介する proxy がある。
- remote で tunnel の wsserver を起動
- proxy を起動
- local から wsclient を使って、proxy 経由で remote と tunnel を構築する
- local から tunnel 経由で remote と ssh 接続する
- ssh のコンソースから X11 アプリ (ico) を起動
-
proxy を停止
- tunnel が切断される
- X11 アプリ (ico) の更新が止まるが、 ssh のセッションは継続する
-
proxy を起動
- tunnel が再接続される
- ssh のセッションが再開する
- X11 アプリ (ico) の更新が再開する
- 以降 proxy 停止、起動を繰り返し
dispatcher
この Tunnel ツールは、 一つの tunnel server で複数の tunnel client と接続できる。
しかしその場合、次の問題がある。
- tunnel server が落ちると、接続していた全ての tunnel client に影響する
-
client 毎にパラメータを設定できない
- パスワード
- forward 設定
- 等々
これを解決するには、 tunnel server を複数起動する必要がある。
この場合、tunnel server ごとに tcp port を割り当てる必要が出てくる。
しかし、複数のポートを開けるのは一般的にセキュリティ上好ましくない。
そこで、 kptunnel では dispatcher サーバを用意している。
dispatcher サーバは、 個々の tunnel client からの要求をまとめて受け、 reverse proxy のように各 tunnel server に振り分ける。
client ----->| |---> server
client ------|--> dispatcher ---|---> server
client ----->| |---> server
dispatcher の処理
dispatcher は、 client から接続要求を受けると次の処理を行なう。
- 接続要求の uri, http header をもとに、 その接続要求を受け付けるかどうかを判定する (canAccept)
- 接続要求を受け付ける場合、 その client に対応する server の起動パラメータを決定する (getTunnelInfo)
- server を起動する
- 起動した server に、 client からの接続要求を転送する
これにより、 dispatcher を介して client と server が接続される。
dispatcher の処理のカスタマイズ
dispatcher は、 tunnel client の要求毎に起動する tunnel server の構成を変更する。
この tunnel server の具体的な制御は、ユーザによって異なる。
この制御のカスタマイズは、 LuneScript のコードによって行なう。
LuneScript のコードは、次の 2 つからなる。
-
CanAccept.lns
- 非 排他処理
- 接続要求の uri, http header をもとに、 client からの接続要求を受け付けるかどうかを判定する
- この処理は 非 排他処理で、 client のリクエスト毎に複数同時に動作する
- 1リクエスト毎に、 独立 した VM 上で動作するため、 オブジェクトが毎回初期化される
-
UserHandler.lns
- 排他処理
-
次を処理する
- client からの接続要求を受け付けた後の、サーバ構成を決定する
- 切断後の処理
- この処理は、排他処理で、client のリクエスト毎にシーケンシャルに動作する
- 全てのリクエストで 共通 した VM 上で動作するため、 全てのリクエストでオブジェクトが保持される
sample コード
CanAccept.lns, UserHandler.lns の処理サンプルは、以下にある。
dispatcher/lns/user/sample/
dispatcher 起動コマンドオプション
dispatcher の起動コマンドオプションを示す。
$ ./dispatcher wsserver
Usage: ./dispatcher wsserver <server> [option]
server: e.g. localhost:1234/path or :1234
options:
-console string
console port. (:1234)
-ip string
allow ip range (192.168.0.1/24)
-u string
userHandler path. (ex: handler.lns,canAccess.lns)
-verbose
verbose. (true or false)
次は、 localhost の 10000 ポートの /path に dispatcher を立ち上げ、 カスタマイズ処理として lns/user/UserHandler.lns lns/user/canAccess.lns を指定している。
./dispatcher wsserver localhost:10000/path -u lns/user/UserHandler.lns,lns/user/canAccess.lns
開発に関して
これ以降の章では、この Tunnel ツール開発に関する技術的な内容について記載する。
スレッド
この Tunnel ツールは、主に次の 6 つのスレッドで構成される。
- tunnel session 制御
- WriteQeue → tunnel のパケット送信制御 (packetWriter)
- tunnel → ReadQueue のパケット受信制御 (packetReader)
- ReadQueue → host のパケット転送制御 (tunnel2Stream)
- WriteQeue → tunnel のパケット転送制御 (stream2Tunnel)
- 無通信が一定時間続かないようにするダミーパケット送信制御 (keepalive)
スレッド多す過ぎという気もするが、 メニーコア時代な現代であれば、 少ないスレッドで複雑なコードを書くよりも、 処理毎にスレッドを分けた方がメンテナンス性も性能も良いんじゃないだろうか?
下図は、各スレッドの役割を図示している。
digraph G {
rankdir = RL;
tunnel [shape=doublecircle];
subgraph clusterA {
packetWriter [shape=rect; margin=0.2;];
packetReader [shape=rect; margin=0.2;];
keepalive [shape=rect; margin=0.2;];
WriteQueue
tunnel2Stream [shape=rect; margin=0.2;];
stream2Tunnel [shape=rect; margin=0.2;];
ReadQueue
{rank = max; packetReader; packetWriter}
{rank = same; WriteQueue; ReadQueue}
{rank = min; tunnel2Stream; stream2Tunnel; keepalive}
}
host [shape=box3d];
tunnel -> packetReader
packetReader -> ReadQueue
ReadQueue -> tunnel2Stream
stream2Tunnel -> WriteQueue
WriteQueue -> packetWriter
packetWriter -> tunnel
keepalive -> WriteQueue
tunnel2Stream -> host
host -> stream2Tunnel
{rank=min;host}
}
- packetReader は tunnel からデータを読み取り ReadQueue に送る
- tunnel2Stream は ReadQueue からデータを読み取り host に送る
- stream2Tunnel は host からデータを読み取り WriteQueue に送る
- packetWriter は WriteQueue からデータを読み取り tunnel に送る
- keepalive は WriteQueue にダミーデータを送る
tunnel 内に複数の TCP セッションを通す場合
tunnel には複数の TCP セッションを通すことができる。 次の要素は、tunnel 内の TCP セッション毎に増える。
- tunnel2Stream
- stream2Tunnel
- ReadQueue
これらをまとめて CITI (connection in tunnel information ) とすると、 2 つの TCP セッションを通す場合は次のような構成になる。
digraph G {
rankdir = RL;
tunnel [shape=doublecircle];
subgraph clusterA {
packetWriter [shape=rect; margin=0.2;];
packetReader [shape=rect; margin=0.2;];
keepalive [shape=rect; margin=0.2;];
WriteQueue
CITI1 [shape=component; margin=0.2;];
CITI2 [shape=component; margin=0.2;];
{rank = max; packetReader; packetWriter}
{rank = same; WriteQueue; }
{rank = min; CITI1; CITI2; keepalive}
}
host1 [shape=box3d];
host2 [shape=box3d];
tunnel -> packetReader
WriteQueue -> packetWriter
packetWriter -> tunnel
keepalive -> WriteQueue
packetReader -> CITI1
CITI1 -> host1
CITI1 -> WriteQueue
host1 -> CITI1
packetReader -> CITI2
CITI2 -> host2
CITI2 -> WriteQueue
host2 -> CITI2
{rank=min;host1;host2}
}
Tunnel の再接続
tunnel が切断されても、 tunnel を再接続すれば tunnel 内に流れる tcp セッションは継続通信可能である。
ただし、tcp 通信のタイムアウト以内に再接続できることが条件である。
tunnel を再接続すれば tcp セッションは継続通信可能だ。 しかし、そう単純にはいかないケースがある。 それは『送信したつもりになっているパケットが、相手に届いていないことがある』からだ。 この場合、相手に届いていないパケットを送信しなおす必要がある。
「tcp は udp と違って再送制御などを行なって信頼性を確保しているんじゃないのか?」 と思う人もいるだろう。私も最初はそう思っていた。 しかし、実際はそうではない。 なぜなら、再送制御などはあくまでも TCP セッションが続いている場合に行なわれることで、 TCP セッションが切断された場合は再送制御なども当然破棄される。
つまり、強制的にセッションが切断された場合は、 送ったつもりのデータが相手に届いていないことが普通にありえる。
このような「送ったつもりが相手に届いていないデータ」がある場合、 TCP セッションを継続させるにはそのデータを再送してやる必要がある。 この再送処理は、 packetWriter スレッドが実行する。
フロー制御
前述の通り、再接続後は送信側と受信側とでデータの不整合を確認し、 受信されていないデータの再送信が必要になる。
これを実現するには、送信済みデータを保持しておく必要がある。 しかし、全ての送信済みのデータを保持しておく訳にもいかないので、 保持可能なパケット数を決めておく。 そして保持可能なパケット数と相手が受信していないパケット数のバランスが 崩れないようにフロー制御を行なう。
もっとも単純なのは、送信するたびに相手の受信を持ってから次の送信を行なうことだが、 これだと通信効率が悪すぎる。 そこで、保持可能なパケット数の半分づつ確認を行なっている。
participant stream2Tunnel_client
participant packetReader_client
participant packetWriter_client
participant packetWriter_server
participant packetReader_server
participant tunnel2Stream_server
stream2Tunnel_client -> stream2Tunnel_client : check the count send packets.
stream2Tunnel_client ->> packetWriter_client : write the packet to client queue
packetWriter_client ->> packetReader_server : write the packet
packetReader_server ->> tunnel2Stream_server : read the packet to server queue
tunnel2Stream_server -> tunnel2Stream_server : count received packets.
tunnel2Stream_server ->> packetWriter_server : write the sync to server queue
packetWriter_server ->> packetReader_client : write the sync
-
stream2Tunnel は、パケットを queue に書き込む前に送信済みパケット数を確認する。
- 保持可能なパケット数の半分であれば、 sync を待つ
- tunnel2Stream は、受信したパケット数をカウントし、 保持可能なパケット数の半分であれば sync を queue に入れる
リングバッファ
前述の通り再送信のデータ保持のためにフロー制御を行なっている。 このデータ保持用のバッファは、 保持可能なパケット数分のバッファを通信開始時に用意しておき、 それをリングバッファにして使い回している。
digraph G {
rankdir = TB;
node0 [shape=rect; label = "buf"]
node1 [shape=rect; label = "buf"]
node2 [shape=rect; label = "buf"]
node3 [shape=rect; label = "buf"]
node4 [shape=rect; label = "buf"]
node5 [shape=rect; label = "buf"]
node0 -> node1
node1 -> node2
node2 -> node3
node3 -> node4
node4 -> node5
node5 -> node0
{rank=same; node1;node5}
{rank=same; node2;node4}
}
送信パケットの結合
tunnel は 2 つの Host の間のパケットを中継する。 一つのパケットは、MTU サイズに近いほど効率よく送信することができる。
そこで、細かいパケットを 1 つのパケットに結合して送信する処理を行なう。
次の図で示す通り tunnel に送信するパケットは stream2Tunnel から WriteQueue に入れられる。 そして packetWriter でパケットを取り出して tunnel に送信する。
この packetWriter でパケットを取り出す時に、 WriteQueue に複数のパケットが入っている場合、 そのパケットを結合して送信する。
packetWriter は、パケットを結合するために積極的にパケットが溜るのを待つことはない。 よって、通信のリアルタイム性が損なわれることはない。
protocol
ここでは tunnel client/server 間で通信を開始する時の protocol について説明する。
protocol は 3 つの情報をやり取りする。
participant server
participant client
server ->> client : AuthCallenge
server <<- client : AuthResponse
server ->> client : AuthResult
この protocol の後は、-port オプションで指定されたポートをリスニングし、 アクセス毎に TCP 接続セッションを開始する。
AuthCallenge
AuthCallenge は、次の情報を client に通知する。
- Challenge/Response 認証の Challenge 情報
- バージョン
- サーバの動作モード
client は、この情報から Challenge/Response の Response 情報を生成する。
AuthResponse
AuthResponse は、次の情報を server に通知する。
- Challenge/Response 認証の Response 情報
-
セッションID
- 新規接続か、切断時の再接続かを示す。
- 新規の場合 0。再接続の場合、再接続先を示すセッションID。
-
client 側パケットの WriteNo/ReadNo
- 再接続する時、再送信が必要かどうかを確認するためのパケット情報
-
制御コード
- 特殊な処理を行なう場合に指定する。
- 例えば tunnel 間のラウンドトリップタイムを計測するモードを指定できる。
server は、この情報から client 認証を行なう。
AuthResult
AuthResult は、次の情報を client に通知する。
- 認証結果
-
セッションID
- どのセッション ID を使用して通信を行なうかを示す。
- Server 側パケットの WriteNo/ReadNo
以上で、 tunnel の client/server 間の接続が確立する。
開発言語
この Tunnel ツールの開発には、次の技術が不可欠である。
- TCP
- Proxy Client
- HTTP Client/Server
- WebSocket Client/Server
これら技術との相性の良さという意味では、 node.js が一番始めに候補に上りそうな気がする。 しかし、今は Go の勉強中ということもあり Go で開発を行なった。