目次を表示する

ngrokの魔法を解き直す ── 仕組みを理解し、Goで自作してみる

NAT の非対称性を解剖する —— なぜ localhost は世界に出ないのか

NAT の非対称性を解剖する —— なぜ localhost は世界に出ないのか

なぜ家のラップトップは「外から見えない」のか

前章の終わりに、こう書いた。

なぜ自分のラップトップが普段は外から見えないのか、それは何という名前の現象で、どこに記述されているのか。

これは、ngrok http 3000 を初めて打った瞬間に多くの人が抱える疑問だ。家庭の Wi-Fi につながったラップトップで python -m http.server を立ち上げて、スマホの 4G 回線から http://192.168.1.42:8000 を叩いても、当たり前に届かない。curl はタイムアウトする。だが、同じラップトップから curl https://example.com は何の苦労もなく通る。

同じ TCP の上を流れるはずのパケットなのに、「内から外」は通って「外から内」は通らない。 この非対称性は偶然ではなく、設計だ。そして、その設計の名前を持っているのが NAT(Network Address Translation)であり、その挙動を最初に体系的に書き下した文書が RFC 2663 である。

この章では、その非対称性を 3 段階で解剖していく。

  1. NAT が「outbound のみ」を前提に作られていること(RFC 2663)
  2. その前提を支える内部データ構造としての conntrack table の非対称性
  3. 後発の RFC 群(3489 → 5389 → 4787 → 5128)が、この非対称性をどう分類し、どう越えようとしてきたか

最後に、ngrok 型リバーストンネルが、これら NAT 越え 4 手法のどこに位置づけられるかを示す。読み終える頃には、「ngrok http 3000 が outbound 1 本だけで魔法を成立させている」という言い方の意味が、RFC 番号付きで説明できるようになっているはずだ。

対象読者:TCP/UDP の基本(ポート・3-way handshake)が分かる Web 開発者 難易度:★★★☆☆ 読了時間:約 12〜15 分


1. NAT は「片道」を前提に設計された

1.1 RFC 2663 が定めた基本動作

NAT という言葉は今や日常語だが、用語と動作モデルを最初に体系化したのは 1999 年の RFC 2663 “IP Network Address Translator (NAT) Terminology and Considerations” だ。この文書は、NAT を 4 種類に分類した上で、ほとんどの家庭用ルーターが実装している Traditional NAT について次のように書いている。

Sessions are uni-directional, outbound from the private network. (セッションは一方向であり、プライベートネットワークから外向きに開始される) —— RFC 2663

「uni-directional, outbound from the private network」── たったこれだけの一文が、家のラップトップが外から見えない最も根本的な理由を言い切っている。Traditional NAT の世界観では、内部から始まる接続だけが想定されている。外部から始まる接続は、そもそも仕様の埒外なのだ。

RFC 2663 はもう少し細かく、4 つの種類を区別している。

種類説明
Traditional NAT片方向。プライベート → 外、のみ
Basic NAT送信元 IP のみ書き換え。外部アドレスのプールを用意する
NAPT(Network Address Port Translation)IP に加えてポート番号も書き換える。家庭用ルーターの大多数はこれ
Bi-directional NAT外部からのセッション開始も受け付ける例外的な構成

普段我々が「NAT」と呼んでいるものは、ほぼ NAPT であり、その上位概念として Traditional NAT の前提(uni-directional, outbound)が乗っている。「外から内」を通すには、Bi-directional NAT として明示的に設定するか、ポートフォワーディングなどの個別の例外ルールを足す必要がある ── そして、家庭用ルーターでそれをやる人は普通いない。

1.2 「内から外」だけが想定されている

ここで一度、感覚を整理しておきたい。NAT は「内側のたくさんの端末を、1 つの外側の IP に押し込める」装置として知られている。だが、なぜそれが「外から始まる接続」を遮断する効果まで生むのか。理由は単純で、NAT が「内側から始まったセッションを覚えておく」必要があるから だ。

家のラップトップが curl https://example.com を叩いた瞬間、ルーターは「このラップトップが example.com の 443 に話しかけ始めた」という事実を覚える。覚えていないと、example.com から返ってきた応答パケットを、どのラップトップに戻していいか分からなくなるからだ。

この「覚えておくための表」が、次に見る conntrack table である。


2. conntrack table の非対称性

2.1 5-tuple で記録される「会話の足跡」

Linux カーネルで言う conntrack(connection tracking)、家庭用ルーターのファームウェアで言うステートテーブル、企業ファイアウォールで言うステートフルインスペクションテーブル ── 呼び方は違うが、本質は同じだ。NAT/ファイアウォール装置は、通過する接続ごとに以下の 5-tuple をエントリとして記録する。

(内部 IP : 内部 Port,
 外部 IP : 外部 Port,   ← NAT が割り当てた書き換え後
 宛先 IP : 宛先 Port,
 プロトコル,
 状態 = NEW / ESTABLISHED / RELATED / INVALID)

このエントリは、内部ホストが outbound パケットを送信したまさにその瞬間に作られるSYN パケットがルーターを通り抜けるとき、ルーターは「内部 192.168.1.42:51234 が、外部 93.184.216.34:443 に話しかけ始めた」というエントリを書き込み、同時に外部に出すパケットの送信元を (自分のグローバル IP):51234 に書き換える。

そして決定的なのは、戻ってくるパケットへの扱いだ。外から来たパケットが内部に入る条件はただひとつ、「そのパケットの宛先が、既存のエントリと合致するか」。一致しなければ、NEW 状態の inbound 接続として扱われ、デフォルトでは破棄される。

2.2 図で見る「片道だけ開く」構造

文章でなぞるよりも、シーケンスを見たほうが早い。

sequenceDiagram
    autonumber
    participant L as ラップトップ<br/>192.168.1.42
    participant R as NAPT ルーター<br/>(conntrack 保有)
    participant E as 外部サーバー<br/>example.com:443

    Note over L,E: ケース①: 内側から始める接続(outbound)
    L->>R: SYN src=192.168.1.42:51234 dst=93.184.216.34:443
    Note over R: conntrack エントリ作成<br/>(192.168.1.42:51234,<br/> 198.51.100.7:51234,<br/> 93.184.216.34:443,<br/> TCP, NEW)
    R->>E: SYN src=198.51.100.7:51234 dst=93.184.216.34:443
    E-->>R: SYN/ACK dst=198.51.100.7:51234
    Note over R: 既存エントリと合致 → 通す<br/>state を ESTABLISHED へ
    R-->>L: SYN/ACK dst=192.168.1.42:51234
    Note over L,E: ✅ 通信成立

    Note over L,E: ケース②: 外側から始める接続(inbound)
    E->>R: SYN dst=198.51.100.7:8000
    Note over R: conntrack に該当エントリなし<br/>= state NEW の inbound
    R--xE: ❌ パケット破棄(または ICMP unreachable)
    Note over L,E: ❌ ラップトップまで到達しない

ケース①では、5-tuple エントリが内側起点で作られ、戻りのパケットはそれに「マッチ」して通過する。ケース②では、そもそも対応するエントリが存在しないので、ルーターはパケットの行き先を知らない ── どの内部ホストに渡せばいいのか判断できないから、捨てるしかない。

これが conntrack の非対称性 の正体だ。「内から外」と「外から内」を等しく扱っているのではない。「内から外」のたびに、戻り道を 1 本ずつ刻んでいるだけで、刻まれていない道は単純に存在しないのだ。

2.3 ステートフルファイアウォールも同じ理屈

企業ネットワークで使われるステートフルファイアウォールも、原理は同じである。Linux の iptables -m state --state ESTABLISHED,RELATED -j ACCEPT のようなルールは、まさに「conntrack に乗っているものだけ通す」という宣言だ。

つまり、家庭の NAPT 越え・キャリアグレード NAT 越え・企業ファイアウォール越え ── 場面はそれぞれ違うが、立ち向かう本質はすべて同じ非対称性に集約される。outbound のときに表を作り、inbound はその表を見るだけ。この単一の仕組みが、ラップトップを「世界からは見えないが、世界を見ることはできる」状態に固定している。


3. 古い分類(RFC 3489)と新しい分類(RFC 4787)

3.1 Cone 分類 ── 一時代を作り、廃止された地図

NAT の非対称性を「越える」側、つまり P2P やリアルタイム通信の文脈では、もう少し細かい分類が必要になった。2003 年の RFC 3489 “STUN - Simple Traversal of UDP Through NATs” は、UDP マッピングについて 4 種類の Cone 分類を提示し、長く参照され続けた。

Cone タイプマッピング規則フィルタリング規則
Full Cone同じ内部 (IP, Port) には常に同じ外部 (IP, Port) を割り当てどの外部ホストからでも到達可能
Restricted Cone同上内部が事前に送った宛先 IP からのみ受信可
Port Restricted Cone同上宛先の (IP, Port) が一致する場合のみ受信可
Symmetric NAT宛先 (IP, Port) ごとに 別の 外部マッピングを生成送信先の外部ホストからのみ受信可

注目すべきは Symmetric NAT で、「宛先が違えば外部ポートも変わる」ため、STUN サーバから観測した外部マッピングは別のピアにとっては役に立たない。「ホールパンチングが失敗する代表例」と言われる NAT がこれだ。

3.2 RFC 5389 が認めた「Cone 分類は壊れていた」

ただし、Cone 分類は現代の規格ではすでに「廃止された分類」である。2008 年の RFC 5389 “Session Traversal Utilities for NAT (STUN)” は RFC 3489 を完全に廃止し、その理由を明示的にこう書いている。

Classic STUN’s algorithm for classification of NAT types was found to be faulty, as many NATs did not fit cleanly into the types defined there. (Classic STUN の NAT 分類アルゴリズムには欠陥があると判明した。多くの NAT が、そこで定義された分類のいずれにも綺麗に収まらなかったからである) —— RFC 5389

実装者にとって衝撃的なのは、「分類できると思っていた表が、現実の NAT 装置に対しては機能していなかった」と仕様自身が認めたことだ。同じ NAT 装置でも、UDP と TCP で挙動が違う、時間経過でマッピングが変わる、フィルタリングとマッピングが独立に振る舞う ── そういう複雑さを、4 つの Cone では捉えきれなかった。

3.3 RFC 4787 ── マッピングとフィルタリングを分離する

代わりに現代の標準として参照すべきは、2007 年の RFC 4787(BCP 127) “NAT Behavioral Requirements for Unicast UDP” だ。この文書は、NAT の挙動を「マッピング動作」と「フィルタリング動作」の 2 軸に分けて再定義した。

マッピング動作(外部から見えるアドレス・ポートの割り当て方)の分類:

RFC 4787 用語旧 Cone 分類との対応意味
Endpoint-Independent MappingFull / Restricted / Port Restricted Cone(の Mapping 側)内部 (IP, Port) ごとに外部マッピングが固定。宛先に依存しない
Address-Dependent Mapping(部分的に Restricted Cone)宛先 IP ごとに別マッピング
Address-and-Port-Dependent MappingSymmetric NAT宛先 (IP, Port) ごとに別マッピング

そしてこの章で覚えておいてほしい最も重要な要件 ── RFC 4787 は REQ-1 として「Endpoint-Independent Mapping を実装すべし」 と書いている。つまり、現代の NAT は「内部 (IP, Port) ごとにマッピングを固定する」のがあるべき姿、と仕様レベルで宣言されている。

このシリーズの最終章(エピローグ)で、この Endpoint-Independent Mapping という名前にもう一度出会う。ngrok 型のリバーストンネルが、なぜこのマッピング動作の上で安定して動くのか ── という伏線として、今は名前だけ覚えておいてほしい。


4. NAT 越え 4 手法 ── RFC 5128 の地図

「内から外」しか通らない非対称性を、どうすれば越えられるのか。この問いに対する手法を、2008 年の RFC 5128 “State of Peer-to-Peer (P2P) Communication across NATs” が 4 種類に整理している。

4.1 Relaying ── 中継サーバが全部背負う

両端の中間にパブリックなランデブー(リレー)サーバを置き、すべてのパケットをそこで中継する。最も確実で、最も帯域コストが高い。WebRTC で言う TURN サーバ(RFC 8656)はこの方式の代表例だ。NAT の種類を問わずに動く一方、ピア同士の直接通信ではないため遅延が積み増しになる。

4.2 Connection Reversal ── 公開側から「逆向きに繋いで」もらう

一方のピアだけが NAT 配下にいるケース(典型的にはサーバが公開・クライアントが NAT 内、あるいはその逆)で使える手法。公開側のピアが、シグナリング経路を通じて NAT 内のピアに「こちらに接続してくれ」と要求し、NAT 内側からの outbound 接続として通信路を確立する。

この発想こそが、リバーストンネルの原型だ。 SSH の ssh -R も ngrok も、本質的には Connection Reversal の応用である。「外から内が無理なら、内から外を最初に張っておけばいい」── 言われてみれば当たり前の発想だが、これを 1 つのパターンとして仕様文書に名前を付けた意義は大きい。

4.3 UDP Hole Punching ── 同時送信で表に穴を開ける

両側のピアが、シグナリング経由で相手の外部エンドポイントを知った上で、ほぼ同時にお互いに UDP パケットを送る。それぞれの NAT に「私はあのアドレスに送信した」という conntrack エントリができるので、結果として双方向で通信できるようになる ── という手品のような手法。

ただし、これが機能するのは Endpoint-Independent Mapping(先ほどの RFC 4787 用語)の NAT に限られる。Symmetric NAT(Address-and-Port-Dependent Mapping)相手では、観測したマッピングが「他のピアに送るとき」には使われないため、ホールパンチングは成立しない。Symmetric NAT 越えはホールパンチングでは不可能で、リレーかリバーストンネルを使うしかない ── これは現代でも頻繁に問題になる制約だ。

4.4 TCP Hole Punching ── 理論上可能だが不安定

TCP の Simultaneous Open(双方向同時 SYN)を利用すれば、原理的には TCP でもホールパンチングは可能だ。だが、NAT 装置の TCP 状態機械の実装は UDP に比べて種類が多く、商用 NAT での成功率は安定しない。RFC 5128 自身も「実装依存性が高い」と注意を書いている。実務的には、TCP で確実に NAT を越えたいなら、リバーストンネルを選んだ方がよい。

4.5 4 手法の対比

graph TB
    NAT[NAT の非対称性を越えたい]
    NAT --> A[Relaying]
    NAT --> B[Connection Reversal]
    NAT --> C[UDP Hole Punching]
    NAT --> D[TCP Hole Punching]

    A --> A1[ランデブーサーバが<br/>全パケット中継<br/>例: TURN]
    B --> B1[NAT 内側に<br/>outbound 接続を<br/>張らせる<br/>例: SSH -R, ngrok]
    C --> C1[両側で同時送信し<br/>conntrack に穴<br/>例: WebRTC P2P]
    D --> D1[Simultaneous TCP Open<br/>実装依存・不安定]

    A1 -.-> P1[コスト大・遅延大<br/>Symmetric NAT OK]
    B1 -.-> P2[公開側が固定で必要<br/>Symmetric NAT OK]
    C1 -.-> P3[Symmetric NAT NG<br/>Endpoint-Independent 必須]
    D1 -.-> P4[NAT 実装次第]

4 種類の対比を表にまとめると次の通り。

手法対称 NAT 越え確実性コスト代表例
Relaying帯域・遅延が増えるTURN(RFC 8656)
Connection Reversal可(outbound のみ使う)公開側ランデブー必要SSH -R、ngrok
UDP Hole Punching不可シグナリング経路必要WebRTC(ICE)
TCP Hole PunchingNAT 依存同上P2P ファイル転送試行

5. ngrok 型リバーストンネルはどこに属するか

ここまでの地図の上に、ngrok を置いてみよう。

ngrok のエージェントは、起動するとまず connect.ngrok-agent.com:443 への TLS 接続 を、内側から外向きに張る。これは、家庭の NAT・キャリアの NAPT・企業ファイアウォールのいずれにとっても、「内から外への普通の HTTPS 接続」にしか見えない。conntrack に普通のエントリが作られ、戻り道が確保される。

そして、その 1 本の TLS 接続を使って、ngrok のエッジ(クラウド側)が「いま外部から https://1a2b-203-0-113-42.ngrok-free.app に新しいリクエストが来た」という通知を agent に送り、その応答として agent がリクエスト本体を多重化ストリームの上で受け取り、ローカルの http://localhost:3000 に転送する。

つまり ngrok は、RFC 5128 の 4 手法のうち Connection Reversal の発展形 にあたる。違いは、

  • 公開側(ngrok エッジ)が常駐サービスで、グローバルな PoP として動く
  • 通信路が SSH の単一ストリームではなく、1 本の TLS 上で多重化されたストリーム群 になっている
  • TLS 終端・公開 URL の払い出し・HTTPS 証明書の自動化まで、エッジ側が引き受けている

という点だ。Connection Reversal の発想を、Webhook を受けるユースケース向けに「インバウンド到達性・公開 URL・HTTPS 証明書・持続性」の 4 要件を一気通貫で満たす形に拡張したもの ── それが ngrok 型リバーストンネルの正体だ、と言える。

この 4 要件は、このシリーズで何度も顔を出すことになるので、ここで名前を覚えておいてほしい。

Webhook 受信に必要な 4 要件ngrok の解き方
インバウンド到達性エッジが外部接続を受け、agent への TLS 接続上に多重化して流す
公開 URL(DNS 名)*.ngrok-free.app などのサブドメインを払い出す
HTTPS / 有効な証明書エッジで TLS 終端し、ngrok 側で公開 CA 由来の証明書を提供
持続性application-level のハートビートと自動再接続

「インバウンド到達性」は本章で扱った NAT の非対称性の話、残り 3 つはこの後の章で順番に出会っていくテーマだ。


6. 次章への橋渡し

ここまでの議論を、1 行に圧縮するとこうなる。

「outbound だけは通る」── ならば、その outbound 1 本を恒久化して、逆向きの通信路に仕立てればいい。

これが ngrok の、というよりリバーストンネル一般の核心だ。そして、この発想を最も素朴に実装した先輩が、実は手元に既にいる ── ssh -R だ。SSH のリモートポートフォワード(RFC 4254 §7.1 の tcpip-forward グローバルリクエスト)は、まさに「内側から張った 1 本の接続の上に、外向きのリスニングソケットを開かせる」プロトコルとして 20 年以上前から存在している。

次章では、その ssh -R の最小機能を、Go で 80 行ほどで再発明する。コードを書くと、「内から外への 1 本」がどうやって「外から内への到達性」に化けるのか ── その変身の様子が、文章を読むよりずっと鮮明に見えてくるはずだ。


章末まとめ

  • Traditional NAT は RFC 2663 が「セッションは uni-directional, outbound」と定めており、これが家のラップトップが外から見えない最も根本的な理由
  • 内部実装としては conntrack table の 5-tuple エントリ が outbound 時のみ作られ、inbound パケットは既存エントリと合致しなければ破棄される、という非対称性が支えている
  • 古い Cone 分類(RFC 3489)は RFC 5389 が “found to be faulty” として廃止し、現代は RFC 4787 の Endpoint-Independent / Address-Dependent / Address-and-Port-Dependent Mapping の用語を使う(REQ-1 で Endpoint-Independent Mapping が必須)
  • RFC 5128 が NAT 越えを 4 種に整理:Relaying / Connection Reversal / UDP Hole Punching / TCP Hole Punching。Symmetric NAT 越えはホールパンチング不可、リレーかリバーストンネルが必要
  • ngrok 型リバーストンネルは Connection Reversal の発展形。outbound 1 本だけを使って、Webhook 受信の 4 要件(インバウンド到達性・公開 URL・HTTPS 証明書・持続性)を一気通貫で解く
  • 次章では、この発想を ssh -R 風に Go で 80 行で再発明する