作って理解するローカルトンネルの仕組み

### １．目的

本記事の目的は、Pythonでのトイプログラムの実装を通して、ngrokに代表されるローカルトンネルの仕組みを理解することです。
- はじめに、ローカルトンネル技術が使われる様になった背景について説明します。
- 次に、その代表的なツールであるngrokのアルゴリズムを説明します。
- 最後に、そのアルゴリズムの仕組みを理解するために、Pythonで簡略化したプログラムを作成し、その挙動を確認します。

### ２．前提知識

- Linuxの知識・経験（LPIC Level 1程度）
  - 必須：TCP/IPの基本的知識
- Pythonの基礎

### ３．背景の説明

通常、ネットワーク上のホストPCに対して外部のインターネットからアクセスするためには、以下の要件を満たす必要があります。

- ホストPCがネットワーク層（L3）でアクセス可能なIPアドレスを設定している
- ホストPCがトランスポート層（L4）で内向きの通信を許可している

これ以外にも、セキュリティ確保の目的で、SSL暗号化などのアプリケーション層上の制約がしばしば設けられます。

しかし、家庭のローカルネットや企業内イントラネットでは、この条件を満たすことは困難です。

- パブリックな固定IPアドレスを持つことは基本的に無料ではないです。個人の場合、インターネットサービスを提供するプロバイダーと個別に固定IPアドレスの契約を結ぶ必要があります
  - また、仮に固定IPアドレスを確保しても、ルーター（L3スイッチ）を通じてネットワーク上の特定のホストPCへ通信するためにはリバースプロキシのような仕組みが必要になります
- 企業のネットワークのゲートウェイサーバーは、外部からのポート通信を許可しないように設定されていることがあります。しかし、ゲートウェイの設定変更はネットワーク上の他のホストに少なくないセキュリティ上の影響があります

一方で、こうしたローカル環境上のホストへのアクセスを容易に実現することのニーズは一定程度あります。

- 一例として、個人がGPUリソースを備えたサーバーを持っている際に、機械学習アルゴリズムを使ったウェブアプリケーションを開発する場面を考えます
  - 学習モデルの評価の為に小さなデータセットを用いてローカルのホスト上でプログラムを動作させます。特に顔写真や氏名・生年月日などの個人情報の使用など、パブリッククラウド上へ転送したくない場面ではローカル環境でのモデル実行が必要となります
  - その後、ウェブアプリケーションのうちのクライアントへの表示部をクラウド上で実装します。クラウド環境からローカル環境の学習モデルAPIを呼び出す際には、上述の条件を満たす必要があります

こうした、ローカル環境上のホストへのアクセスを容易に実現する手法の一つは、「ローカルトンネル」と呼ばれる技術を使うことです。
「ローカルトンネル」の単語の定義が一般的にどの範囲に限定されるかは不明ですが、当記事では「**パブリックなIPアドレスがなく、内向きの通信を許可しないホストに対して、インターネット上のクライアントからアクセスする方法**」とします。

### ４．仕組み

ローカルトンネルは、基本的には以下の３段のプロセスで実行します。

1. ローカルホストから外向きの通信でトンネルサービスのエンドポイントにアクセスする。トンネルサービスからURLが払い出された後、クライアントからの通信を待ち受ける（通常この部分はデーモンプロセスとして起動する）
2. クライアント側から払い出されたURLにアクセスする（このとき、トンネルサービスはローカルホストに対して通信の情報を受け渡す）
3. ローカルホスト上でアプリケーションを実行し、その結果をトンネルサービスを経由してクライアントに返す（**注意：このときにはクライアントに対して外側から内向きの通信は行われず、あくまで「外向きのTCP通信」に対する応答としてメッセージが渡されている。**）

一言でいうと、外部から通信を受け付けられないローカルホストに変わって、トンネルサービス（とデーモン）がその通信を媒介します。
実際の実装では、上記以外のユーザー認証や暗号化プロセスなどが入りもっと複雑になっています。

### ５．実装の紹介

ここでは、ローカルトンネルを使っている有名なサービスを紹介いたします。

- [ngrok](https://ngrok.com/)：OSSのローカルトンネルサービスで最も有名。ソースコードからビルドする事もできる他、セルフホスティングで独自のトンネルサービスの開発も可能。
- [Cloudflare Tunnel](https://www.cloudflare.com/products/tunnel/)：Cloudflare社Zero Trustサービスのコンポーネントの一つとして提供。高度なユーザー認証やデバイスの検疫等を設定することで、セキュアなトンネリングの実現が可能（一部無料で利用可）。
- [VSCode Remote Tunnels](https://code.visualstudio.com/docs/remote/tunnels)：上記２つとは違い、一般的なアプリケーション層のプロトコルではなく、VSCodeのリモート接続のみを可能にするサービス。ブラウザ上でVSCode環境に簡単にアクセスできる[vscode.dev](https://vscode.dev)と組み合わせることで、VSCode Tunnelをインストールした開発環境へのアクセスがより広範囲にできる。

### ６．トイプログラムの実装

ここではさらにアルゴリズムの理解を深めるために、ローカルトンネルのトイプログラムを実装します。[ngrokのDevelopmentドキュメント](https://github.com/inconshreveable/ngrok/blob/master/docs/DEVELOPMENT.md)を手本として、そのプロセスを簡略化したものを実装します。

#### 6.1 ngrokトンネリングの概要説明

手始めに、ngrokのDevelopmentドキュメントで記載されている[トンネリング手順](https://github.com/inconshreveable/ngrok/blob/master/docs/DEVELOPMENT.md#network-protocol-and-tunneling)を説明します。

- １：セットアップと認証
  - ①：ホストPC上でngrokクライアントを実行すると、ngrokサーバーに向けてTCP接続を開始する（これは*Control Connection*と呼ばれる/下図の青色のトンネル）。このとき作成されたTCPソケットはサービス利用中常に維持される。
  - ②：ngrokクライアントから*Control Connection*を通じて*Auth*メッセージが送信される。この*Auth*メッセージにはユーザー名やパスワードなどの認証情報が含まれる。
  - ③：ngrokサーバーから認証の可否やサーバー中で払い出されたクライアントIDが含まれた*AuthResp*が返される。

![スライド1.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3565355/c66d8d5c-d7b1-09a3-cfdc-599727b9cbeb.png)

- ２．トンネル構築

  - ①：ngrokクライアントから*ReqTunnel*メッセージが送信される。ngrokサーバーに対して公開したいポート番号やHTTPのドメイン名などのL4~L7の情報が渡される。
  - ②：ngrokサーバーはクライアントに割り当てられたURLを格納した*NewTunnel*メッセージを返す。

![スライド2.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3565355/482c5fbb-f1e1-f567-e4a7-e2a56ef4e4ea.png)

- ３．トンネル接続

  - ①：クライアントPCから*NewTunnel*のURLに対してアクセスする（これは*Public Connection*と呼ばれる）。ngrokサーバーはこのアクセスを検知すると、HTTPリクエスヘッダやTCPポート番号から対応するトンネルを特定する。
  - ②：*Control Connection*を通じてクライアントに対して*ReqProxy*メッセージが送信される。
  - ③：クライアントからngrokサーバーに対して新規にTCP接続が作成される（これは*Proxy Connection*と呼ばれる/図中の赤色のトンネル）。
  - ④：クライアントから*RegProxy*メッセージが*Proxy Connection*を通じて送信される（このメッセージにはクライアントIDが含まれているので、サーバーは*Control Connection*と*Proxy Connection*を対応付けられる）。
  - ⑤：サーバーは*Proxy Connection*を通じて*StartProxy*メッセージを送信する。
  - ⑥：ngrokサーバーは*Public Connection*から来たリクエストのトラフィックを*Proxy Connection*に転送する（逆方向も同様）。
  - ⑦：ngrokクライアントはローカルホスト上のサービス（webサーバーなど）に対してTCPソケットを作成する（これは*Private Connection*と呼ばれる）。
  - ⑧：ngrokクライアントは*Proxy Connection*から来たリクエストのトラフィックを*Private Connection*に転送する（逆方向も同様）。

![スライド3.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3565355/fedbc2ec-844b-b585-e3ef-6c5973d5c208.png)

以上の流れで注目するべき点は、「ngrokサーバーを起点とした通信は発生し得ない」ということです。ngrokクライアントが動くホストPCとアクセス元のクライアントPCはいずれもパブリックIPアドレスを持たないため、この２つからの通信をngrokサーバーがうまく媒介することで擬似的にクライアントPCからローカルホストPCへのアクセスを実現します。

#### 6.2 簡略版プロセスの考案

上記のアルゴリズムのうち、「*Control Connection*/*Proxy Connection*という2つのTCPソケットを入れ子構造となるように使用する」という部分をトイプログラムで再現します。

  - ただし、簡易化のためにTLS暗号化やユーザー認証、IPアドレス/URLの払い出し等については実装しません。
  - ngrokそのものはL4エコーサーバーとして機能しますが、今回はHTTP GETリクエストのみに対応し、リクエストパスやパラメータの解析等も行いません。

実行環境と動作条件は下図の通りです。
  - ネットワーク環境：同じネットワークに属する２つのLinuxとクライアントPC
    - ローカルホストPC：トンネルエンドポイントPCに対して疎通している（`ping HOST_IP`で宛先に到達できる状態）。
      - `Python 3`実行環境がインストールされている
    - トンネルエンドポイントPC：IPアドレスが設定されている（以下`HOST_IP`と表記）
      - `Python 3`実行環境がインストールされている
    - クライアントPC：トンネルエンドポイントPCに対して疎通している（`ping HOST_IP`で宛先に到達できる状態）

![ngrok.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3565355/b38125e5-82fa-a625-8459-8f330769d3b7.png)

このうち、サーバー側で起動するプログラム`server.py`は以下のとおりです。コード中の`<IP>`をサーバーのIPアドレスに置き換える必要があります。

```python
import http.server
import socket

with socket.create_server(("<IP>", 8088)) as server1:
    print(server1)
    conn1, addr1 = server1.accept()
    while True:
        print(conn1, addr1)

        class Handler(http.server.BaseHTTPRequestHandler):
            def do_GET(self):
                conn1.send(bytes("{} {} {}".format(self.command, self.path, self.protocol_version), encoding="ascii"))
                conn2, addr2 = server1.accept()
                print(conn2, addr2)
                data = conn2.recv(2048)
                self.send_response(200)
                self.end_headers()
                self.wfile.write(data)

        with http.server.HTTPServer(("<IP>", 8089), Handler) as server2:
            print(server2)
            while True:
                server2.handle_request()
```

また、ローカルホスト側で起動するプログラム`client.py`は以下のとおりです。

```python
import socket
import requests

with socket.create_connection(("<IP>", 8088)) as s1: # control connection
    print(s1)
    while True:
        data = s1.recv(1024)
        print(data)
        with socket.create_connection(("<IP>", 8088)) as s2: #private connection
            print(s2)
            response = requests.get("http://localhost:8080")
            s2.sendall(bytes(response.text, encoding="ascii"))
```

上記２つのプログラムを配置し、各種サーバーを起動します。
```bash
$ # client
$ python3 -m http.server 8080 & # バックグラウンドでwebサーバーを実行
$ python3 client.py
```

```bash
$ # server
$ python3 server.py
```

この状態で、クライアントPCのブラウザで`http://<IP>:8089`にアクセスすると、クライアントで起動しているwebサーバーからのレスポンスが表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3565355/9fcfd34e-6501-a1d6-6eb4-fb2ba085859b.png)

確かに、ngrokと同様にローカル環境のホストPCに直接通信することなく、HTTPレスポンスを取得する事ができました。

### ７．まとめ

本記事では、ngrokの仕組みについて技術的な観点から説明いたしました。
Qiitaに掲載されている他のngrokに関する記事とは異なり、その内部でどのように通信が成り立っているかを、実際のプログラム実装を踏まえてまとめました。