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-server → mintunnel-agent → nc まで届く。外向き接続が 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 段抽象化するとこうだ。
- NAT 内側のクライアントが、外向きの TCP を 1 本張る
- クライアントは「外側でこのポートを開けてくれ」と頼む
- サーバーは公開ポートで
listenを開始し、来た接続を 1 本目の TCP に流す - クライアントは流れてきたデータを localhost のサービスに繋ぐ
ngrok もこの構図の発展形だ。違うのは「TLS で繋ぐ」「ストリーム多重化を入れる」「ホスト名でルーティングする」あたりで、骨格はそのまま ssh -R と同じ。だから ssh -R を Go で書けば、ngrok の核がいきなり見えてくる。
設計:2 本の listener と 1 本の outbound
本章で書くのは spec.md の最終形のうち、cmd/mintunnel-server/main.go と cmd/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 つだけだ。
net.Dial("tcp", serverAddr)で server に outbound TCP を 1 本張る。これが NAT を越える経路net.Dial("tcp", localAddr)で localhost のサービスに繋ぐ- 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
ターミナル B:mintunnel-server を起動する。
$ go run ./cmd/mintunnel-server
2026/05/23 10:00:00 waiting agent on :7000
ターミナル C:mintunnel-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-server を 0.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-serverはpublicLn.Accept()を 1 回しか呼んでいないmintunnel-agentもserverConnとlocalConnを 1 本ずつしか張っていない- そして両者は
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-agent と mintunnel-server の間の TCP 1 本をそのまま使うのをやめて、その上に muxado でストリームを多重化する。コードは 50 行ほど増えるが、それだけで curl を何本同時に投げてもすべて捌けるようになる。
章末まとめ
ssh -Rの正体は RFC 4254 §7.1 のtcpip-forwardグローバルリクエスト。外向き接続 1 本の上に公開ポートを開いてもらう仕組み- 80 行の Go(
mintunnel-server40 行 +mintunnel-agent40 行)で、その最小版が再現できる- 双方向の
io.Copyは裸の goroutine ではなくerrgroupで束ねると、片側終了で全体を畳めて拡張しやすい- この実装は「1 本の agent 接続 = 同時 1 リクエスト」という致命的制約を抱える。ホスト名ルーティングも TLS も認証もない
- 次章で、この制約を ストリーム多重化(muxado) で解消する。1 本の TCP の上に複数の会話を流す