目次を表示する

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

80 行の Go で `ssh -R` を再発明する

80 行の Go で ssh -R を再発明する

前章までで分かったこと、ここで作るもの

第 2 章で見たのは、NAT の壁が「内側から外」にだけ薄く、「外から内」には固いという非対称性だった。conntrack エントリは outbound パケットを送った瞬間にだけ作られるため、外向きの TCP 接続を 1 本張ってさえしまえば、その上に逆向きの通路を作れる ── そこまでが前章までの到達点だ。

本章で書くのは、その「逆向きの通路」を実現する最小のコードだ。具体的にはこんな構図を作る。

  • ターミナル A:nc -l 3000 ── ローカルのアプリ役(外から見せたいサービス)
  • ターミナル B:./mintunnel-server ── 公開側のサーバー
  • ターミナル C:./mintunnel-agent ── NAT 内側のエージェント

この状態で curl localhost:8080 を叩くと、リクエストが mintunnel-servermintunnel-agentnc まで届く。外向き接続が 1 本あれば、外部リクエストを内部に届けられる、という事実を 80 行の Go で確認するのが本章のゴールだ。

ssh -R を使ったことがある人なら「それ、ssh -R 8080:localhost:3000 でできるやつでは?」と感じるはずだ。その通りで、本章で書くのは ssh の -R から TCP リバースフォワードだけを切り出した、教育用のミニチュア版 だ。

寄り道:ssh -R は内側で何をしているのか

ssh -R は OpenSSH の有名機能だが、プロトコルレベルで何が起きているかを知っている人は意外と少ない。RFC 4254 §7.1 “TCP/IP Port Forwarding” によれば、クライアントは SSH 接続の上で tcpip-forward というグローバルリクエスト をサーバーに送る。

byte      SSH_MSG_GLOBAL_REQUEST
string    "tcpip-forward"
boolean   want reply
string    address to bind (例: "0.0.0.0")
uint32    port number to bind

これを受けた SSH サーバーは、指定アドレス・ポートで listen(2) を開始する。誰かがその公開ポートに接続してくると、SSH サーバーは既存の SSH 接続の上に 新しいチャネル を開き、データをクライアント側に流す。クライアントはそれを自分のローカルのサービスに繋ぎ直す。

仕組みを 1 段抽象化するとこうだ。

  1. NAT 内側のクライアントが、外向きの TCP を 1 本張る
  2. クライアントは「外側でこのポートを開けてくれ」と頼む
  3. サーバーは公開ポートで listen を開始し、来た接続を 1 本目の TCP に流す
  4. クライアントは流れてきたデータを localhost のサービスに繋ぐ

ngrok もこの構図の発展形だ。違うのは「TLS で繋ぐ」「ストリーム多重化を入れる」「ホスト名でルーティングする」あたりで、骨格はそのまま ssh -R と同じ。だから ssh -R を Go で書けば、ngrok の核がいきなり見えてくる

設計:2 本の listener と 1 本の outbound

本章で書くのは spec.md の最終形のうち、cmd/mintunnel-server/main.gocmd/mintunnel-agent/main.go の 2 ファイルだけだ。internal/ 以下はまだ作らない(章を追って分離していく)。

mintunnel/
├── go.mod
└── cmd/
    ├── mintunnel-server/main.go    # ← 本章で書く
    └── mintunnel-agent/main.go     # ← 本章で書く

通信フローを図にするとこうなる。

sequenceDiagram
    autonumber
    participant C as curl<br/>(public client)
    participant S as mintunnel-server
    participant A as mintunnel-agent
    participant L as nc -l 3000<br/>(localhost)

    A->>S: (1) outbound TCP を 1 本張る<br/>net.Dial(":7000")
    Note over S: agent 接続を保持<br/>public listener で待機
    C->>S: (2) :8080 に HTTP 接続
    S->>A: (3) agent 接続にそのまま io.Copy
    A->>L: (4) net.Dial("localhost:3000")
    A->>L: (5) サーバーから来たデータを localhost に io.Copy
    L-->>A: (6) レスポンス
    A-->>S: (7) agent 接続に逆方向 io.Copy
    S-->>C: (8) public 接続に流し返す

ポイントは 3 つ。

  • server は 2 つの listener を持つ:一般ユーザー用(:8080)と agent 用(:7000)の 2 ポートで待つ
  • agent → server の接続は outbound 1 本のみ:これが NAT を越える唯一の経路
  • io.Copy 双方向:橋渡しはバイト列をそのまま流すだけ。プロトコル解釈はしない

実装:mintunnel-server

まず公開側のサーバーから書く。cmd/mintunnel-server/main.go の全文がこれだ。

// cmd/mintunnel-server/main.go
package main

import (
	"io"
	"log"
	"net"

	"golang.org/x/sync/errgroup"
)

const (
	publicAddr = ":8080" // 一般ユーザーが叩くポート
	agentAddr  = ":7000" // agent が接続してくるポート
)

func main() {
	// agent 用 listener を先に開ける
	agentLn, err := net.Listen("tcp", agentAddr)
	if err != nil {
		log.Fatalf("listen agent: %v", err)
	}
	log.Printf("waiting agent on %s", agentAddr)

	// agent からの接続を 1 本だけ受け付ける(ch03 は単一 agent 前提)
	agentConn, err := agentLn.Accept()
	if err != nil {
		log.Fatalf("accept agent: %v", err)
	}
	log.Printf("agent connected from %s", agentConn.RemoteAddr())

	// public listener を開けて、来た接続を agent 接続に橋渡しする
	publicLn, err := net.Listen("tcp", publicAddr)
	if err != nil {
		log.Fatalf("listen public: %v", err)
	}
	log.Printf("waiting client on %s", publicAddr)

	publicConn, err := publicLn.Accept()
	if err != nil {
		log.Fatalf("accept public: %v", err)
	}
	log.Printf("client connected from %s", publicConn.RemoteAddr())

	// 双方向 io.Copy。どちらか片方が閉じたら全体を畳む
	var g errgroup.Group
	g.Go(func() error { _, err := io.Copy(agentConn, publicConn); return err })
	g.Go(func() error { _, err := io.Copy(publicConn, agentConn); return err })
	if err := g.Wait(); err != nil && err != io.EOF {
		log.Printf("copy ended: %v", err)
	}
}

40 行に収まる。やっていることは「agent 用 listener と public 用 listener を開けて、来た 1 本ずつをバイト列レベルで繋ぐ」だけだ。

ここで脇道に逸れて、ナイーブな書き方と比較してみよう。io.Copy の双方向を裸の goroutine で書くとこうなる。

// ❌ よく見るが推奨しない書き方
go io.Copy(agentConn, publicConn)
go io.Copy(publicConn, agentConn)
// ここで main が抜けてしまう / どちらかが終わってもキャンセル伝搬しない

これだと「片方の Copy が終わったときにもう片方を止める」術がない。本物の TCP プロキシでは、片側の EOF を検知したら反対側もすぐ畳む必要がある(さもないと goroutine と接続が溜まり続ける)。errgroup.Group を使えば Wait() で両方の終了を待てる上、後の章でキャンセル付きに拡張しやすい。

// ✅ errgroup で両方向を束ねる
var g errgroup.Group
g.Go(func() error { _, err := io.Copy(agentConn, publicConn); return err })
g.Go(func() error { _, err := io.Copy(publicConn, agentConn); return err })
_ = g.Wait()

この差は ch04 で context.Context を入れる流れに繋がるので、今のうちに errgroup で書いておく。

実装:mintunnel-agent

NAT 内側のエージェントはさらに単純だ。cmd/mintunnel-agent/main.go の全文。

// cmd/mintunnel-agent/main.go
package main

import (
	"io"
	"log"
	"net"

	"golang.org/x/sync/errgroup"
)

const (
	serverAddr = "127.0.0.1:7000" // 同じマシン上で動かす想定
	localAddr  = "127.0.0.1:3000" // 公開したいローカルサービス
)

func main() {
	// server に outbound 接続を 1 本張る。これが NAT を越える唯一の経路
	serverConn, err := net.Dial("tcp", serverAddr)
	if err != nil {
		log.Fatalf("dial server: %v", err)
	}
	defer serverConn.Close()
	log.Printf("connected to server %s", serverAddr)

	// localhost のサービスにも接続を張る
	localConn, err := net.Dial("tcp", localAddr)
	if err != nil {
		log.Fatalf("dial local: %v", err)
	}
	defer localConn.Close()
	log.Printf("connected to local %s", localAddr)

	// server → local、local → server を双方向に流す
	var g errgroup.Group
	g.Go(func() error { _, err := io.Copy(localConn, serverConn); return err })
	g.Go(func() error { _, err := io.Copy(serverConn, localConn); return err })
	if err := g.Wait(); err != nil && err != io.EOF {
		log.Printf("copy ended: %v", err)
	}
}

これも 40 行で収まる。やっているのは 3 つだけだ。

  1. net.Dial("tcp", serverAddr)server に outbound TCP を 1 本張る。これが NAT を越える経路
  2. net.Dial("tcp", localAddr) で localhost のサービスに繋ぐ
  3. server ↔ local を io.Copy 双方向で橋渡し

mintunnel-agent の中に「サーバーに何かをリクエストする」コードは一切ない。ただ outbound で 1 本 TCP を張り、流れてきたバイトをそのまま localhost に渡している だけだ。これが NAT 越えの本質を裸で見せている。

動かしてみる

ここまでで合計 80 行ほどのコードが書けた。実際に動かしてみよう。3 つのターミナルを開いてほしい。

ターミナル A:公開したいローカルサービスの代わりに nc で適当に何か返すサーバーを立てる。

$ while true; do echo -e "HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nHello, world\n" | nc -l 3000; done

ターミナル Bmintunnel-server を起動する。

$ go run ./cmd/mintunnel-server
2026/05/23 10:00:00 waiting agent on :7000

ターミナル Cmintunnel-agent を起動する。

$ go run ./cmd/mintunnel-agent
2026/05/23 10:00:05 connected to server 127.0.0.1:7000
2026/05/23 10:00:05 connected to local 127.0.0.1:3000

このタイミングで mintunnel-server のログにも agent connected from ... が出るはずだ。

ターミナル D(4 つ目):curl で公開ポートを叩く。

$ curl -v http://127.0.0.1:8080
*   Trying 127.0.0.1:8080...
* Connected to 127.0.0.1 (127.0.0.1) port 8080
> GET / HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/8.7.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Length: 13
<
Hello, world

nc のターミナル A 側にも curl のリクエスト行が表示されているはずだ。curl が叩いた :8080 宛のバイト列が、mintunnel-server を経由して mintunnel-agent に流れ、最後に nc:3000 に届いたことになる。

ここで一度立ち止まって、「外向き接続 1 本」だけで何が起きたかを確認してみようmintunnel-server0.0.0.0:7000 ではなく VPS 等の公開アドレスで listen すれば、家庭の NAT 配下から mintunnel-agent が outbound で接続するだけで、世界中の誰もが :8080 経由で家庭内のサービスに届く。ssh -R でやっていたのと同じことが、80 行で再現できた。

試してみよう:2 リクエストを同時に投げる

ここで本章のキモになる実験をしてもらいたい。ターミナル D で curl を 2 つ同時に投げる とどうなるか、試してみてほしい。

$ curl http://127.0.0.1:8080 & curl http://127.0.0.1:8080 &

2 つ目の curl応答が返ってこない、もしくはハングするmintunnel-server のログを見ても client connected は 1 回しか出ない。

なぜか。理由はコードを見れば自明だ。

  • mintunnel-serverpublicLn.Accept()1 回しか呼んでいない
  • mintunnel-agentserverConnlocalConn1 本ずつしか張っていない
  • そして両者は io.Copy生のバイト列をそのまま流している だけなので、リクエストの境界もレスポンスの境界も認識していない

つまり、現状の実装は 「1 本の agent 接続 = 同時 1 リクエストしか処理できない」 という強い制約を持つ。本物の Web サービスとしては明らかに使い物にならない。

この実装の限界

「動いた」と「使える」の間には大きな差がある。本章のコードが抱える限界を、ここで素直に列挙しておく。

  • 同時 1 リクエストしか処理できない:上で実験したとおり。agent 接続が 1 本しかなく、その上をバイト列が一直線に流れているため、複数の会話を並行させられない
  • HTTP のホスト名を見ていない:複数のサービスを 1 台の mintunnel-server で公開する手段がない。:8080 に来た全リクエストを 1 つの agent に流すだけ
  • ヘッダーを書き換えていないHost: ヘッダーは 127.0.0.1:8080 のまま nc に届いてしまう。本物のリバースプロキシなら X-Forwarded-For 等を付与するところだ
  • 暗号化していない:すべて平文 TCP。本番なら TLS が必須
  • 認証していない:誰でも :7000 に繋いで agent を名乗れる。authtoken のような仕組みが必要
  • 再接続しない:1 リクエスト処理したら両方終わる。常駐サービスにならない

これらは「ngrok と何が違うのか」を分解した結果でもある。次章以降で 1 つずつ潰していくが、まず最初に取り組むのは 同時 1 リクエストしか処理できない問題 だ。これは他の制約と独立して効くため、ここを解かないと先のすべての機能拡張が形にならない。

解決策は ストリーム多重化(stream multiplexing)と呼ばれる。1 本の TCP 接続の上に、論理的な「ストリーム」を複数本走らせる技術だ。HTTP/2 や gRPC が中で使っているのと同じ考え方で、ngrok は muxado という Go 製のライブラリでこれを実現している。

次章では、mintunnel-agentmintunnel-server の間の TCP 1 本をそのまま使うのをやめて、その上に muxado でストリームを多重化する。コードは 50 行ほど増えるが、それだけで curl を何本同時に投げてもすべて捌けるようになる。


章末まとめ

  • ssh -R の正体は RFC 4254 §7.1 の tcpip-forward グローバルリクエスト。外向き接続 1 本の上に公開ポートを開いてもらう仕組み
  • 80 行の Go(mintunnel-server 40 行 + mintunnel-agent 40 行)で、その最小版が再現できる
  • 双方向の io.Copy は裸の goroutine ではなく errgroup で束ねると、片側終了で全体を畳めて拡張しやすい
  • この実装は「1 本の agent 接続 = 同時 1 リクエスト」という致命的制約を抱える。ホスト名ルーティングも TLS も認証もない
  • 次章で、この制約を ストリーム多重化(muxado) で解消する。1 本の TCP の上に複数の会話を流す