付録 A:TLS 終端と SNI ルーティングを実装する
なぜ本編では TLS を扱わなかったか
本編の第 3 章から第 6 章までで作った mintunnel は、edge も agent も HTTP しか喋らない。https://abc123.example.test/ ではなく http://abc123.example.test:8080/ のような URL を払い出して動かしてきた。これは説明を最短にするための割り切りだ。
しかし、プロローグでも書いたとおり、本物の ngrok の魔法のうち少なからぬ部分が 「何も設定していないのに、有効な TLS 証明書付きの公開 URL が降ってくる」 という体験に依存している。Webhook も、MCP も、ブラウザからのテストも、HTTPS でないと話が始まらないシーンはいくらでもある。
この付録では、第 6 章までで完成した mintunnel に、次の 3 つを後付けする。
- エッジでの TLS 終端:edge が
:443をtls.Listenで受け、平文化してから内部の muxado に流す - SNI ベースのサブドメイン振り分け:1 つの IP・1 つのポートで、
abc123.example.testとxyz789.example.testを別々に処理する - ACME 自動証明書発行:
golang.org/x/crypto/acme/autocertで Let’s Encrypt から証明書をオンデマンドに取り、*.example.test系を勝手に賄う
新規追加コードは約 70 行。第 6 章の mintunnel のソースに対して internal/server/tls.go 1 ファイルを増やすイメージで進める。
TLS 終端は「どこで」やるのか ── 3 つの選択肢
実装の前に、ngrok が TLS をどう扱っているかを整理しておく。本物の ngrok には TLS をどこで終端するか に 3 つの選択肢がある(ngrok docs — TLS Routing / ngrok blog — Terminate TLS however you want)。
graph LR
C[Client] -- TLS --> EDGE[ngrok Edge / PoP]
EDGE -- 平文 muxado on TLS --> AG[Agent]
AG -- 平文 HTTP --> UP[Upstream<br/>localhost:3000]
EDGE -. (a) edge で終端 .-> EDGE
AG -. (b) agent で終端 .-> AG
UP -. (c) upstream で終端 .-> UP
それぞれの輪郭はこうだ。
- (a) エッジで終端:HTTPS エンドポイント(
https://...ngrok.app)のデフォルト。edge が TLS を解いて平文 HTTP として agent に流す。証明書管理は ngrok 側の責務。本付録で実装するのもこれ。 - (b) agent で終端:edge は SNI だけ見て暗号化したまま agent に渡す。証明書は agent 側で持つ。
- (c) upstream で終端:TLS エンドポイント(
tls://...)のデフォルト。edge も agent も復号せず、ペイロードを最後の upstream まで pass-through する。mTLS で社内システムに繋ぐ場合などに使う。
ngrok ではこれらは Traffic Policy という DSL の terminate-tls アクションで切り替えられる。たとえば「edge では終端せず、agent で終端」したい場合は以下のように書く(公式 docs の例を整形)。
on_tcp_connect:
- actions:
- type: terminate-tls
config:
terminate_at: agent
server_certificate: !secret server-cert
server_private_key: !secret server-private-key
このうち本付録で再現するのは (a) edge 終端のデフォルト だけだ。(b)(c) は SNI を読んでルーティングだけして暗号化のまま流す挙動になるが、考え方は同じで「TLS の最初の ClientHello を見て、何かを決める」だけのことだ。
SNI ルーティングという発想
SNI(Server Name Indication)は、TLS ハンドシェイクの一番最初に、クライアントが平文で「これからどのホスト名と話すつもりか」を教えてくれる仕組みだ。HTTPS の場合、Host ヘッダーは TLS の中に隠れて見えないが、SNI は外から見える。
なぜこれが嬉しいか。1 つの IP・1 つの 443 ポートに何万ものサブドメインを相乗りさせたいとき、TLS の前段では Host を見られない。SNI だけが、終端する前にホスト名で振り分けられる唯一の手がかり になる。
ngrok の edge が abc123.ngrok.app と xyz789.ngrok.app を同じ PoP の同じ IP で受けられるのは、SNI を読んでいるからだ。具体的な流れは次のようになる。
sequenceDiagram
autonumber
participant C as Client
participant E as Edge (:443)
participant A as Agent
participant U as Upstream
C->>E: TCP SYN/ACK
C->>E: TLS ClientHello (SNI=abc123.example.test)
Note over E: GetCertificate(*ClientHelloInfo)<br/>SNI を見て証明書を選ぶ
E-->>C: Certificate (CN=*.example.test)
C->>E: TLS Finished
Note over E: 平文化された HTTP リクエスト到着<br/>Host=abc123.example.test
E->>A: muxado stream(NewRequest)
A->>U: HTTP /
U-->>A: 200 OK
A-->>E: HTTP response
E-->>C: TLS で暗号化して返す
TLS 終端版 mintunnel でやることは、ほぼこの図の通りだ。ポイントは 2 つ。
tls.Config.GetCertificateを使う。証明書をハードコードせず、SNI を見て動的に返す関数を登録できる。- 証明書取得を autocert に委ねる。
*.example.testを要求された瞬間に Let’s Encrypt から取りに行く。
ACME と autocert.Manager の仕組み
golang.org/x/crypto/acme/autocert パッケージは、Let’s Encrypt のような ACME プロトコルを喋るサーバーから、オンデマンドで証明書を取得・更新してくれる ライブラリだ。pkg.go.dev に説明があるとおり、中心になるのは autocert.Manager 型ひとつ。
import "golang.org/x/crypto/acme/autocert"
m := &autocert.Manager{
Cache: autocert.DirCache("cert-cache"), // 取得した証明書のディスクキャッシュ
Prompt: autocert.AcceptTOS, // Let's Encrypt の利用規約に同意
HostPolicy: autocert.HostWhitelist("example.test", "*.example.test"),
Email: "[email protected]", // 期限通知メール(任意)
}
これを tls.Config に組み込む方法は 2 通りある。
tls.Config{ GetCertificate: m.GetCertificate }を自分で組む(今回のやり方。他のtls.Config設定と混ぜたいので)m.TLSConfig()が返す*tls.Configをそのまま使う(一番楽だが、自由度が低い)
m.GetCertificate は *tls.ClientHelloInfo を受け取り、ServerName(= SNI)を見て、キャッシュにあれば返し、なければ ACME サーバーに取りに行く。SNI ごとに別の証明書を返せるので、ワイルドカード証明書を取らなくても、サブドメインそれぞれの単独証明書をオンデマンドに賄える ── これが、ngrok の HTTPS endpoint が短命なホスト名を許せる理由の核心だ。
ただし注意点が 2 つある。
- HTTP-01 チャレンジは 80 番ポートが要る。Let’s Encrypt が
http://abc123.example.test/.well-known/acme-challenge/...を叩きに来る。m.HTTPHandler(nil)を 80 番で立てて応答する。 - ワイルドカード証明書(
*.example.test全部を 1 枚で覆う)は HTTP-01 では取れない。Let’s Encrypt 仕様で、ワイルドカードには DNS-01 チャレンジ(DNS に TXT レコードを書く)が必要になる。autocert は DNS-01 を標準でサポートしないので、ワイルドカードを取りたい場合はlegoなどの別ライブラリに乗り換えるのが定石だ。本付録では「サブドメインごとに HTTP-01 で取る」方式で書く。
実装:internal/server/tls.go
第 6 章で完成した mintunnel の server.Server に、TLS 用エントリポイントを足す。
package server
import (
"context"
"crypto/tls"
"log/slog"
"net"
"net/http"
"golang.org/x/crypto/acme/autocert"
)
// RunTLS は :443 で TLS 終端 + SNI ルーティング付きの公開リスナーを起動する。
// 既存の Run() が HTTP の :8080 を扱うのに対し、こちらは HTTPS の窓口。
func (s *Server) RunTLS(ctx context.Context) error {
m := &autocert.Manager{
Cache: autocert.DirCache("cert-cache"),
Prompt: autocert.AcceptTOS,
HostPolicy: s.hostPolicy(), // SNI のホワイトリスト判定
Email: "ops@" + s.Domain,
}
// (1) HTTP-01 チャレンジ用:80 番で ACME 応答を返す。
// 既存の "/" は s.publicHandler に流したいので、autocert の HTTPHandler に
// fallback として元のハンドラーを渡す。
go func() {
_ = http.ListenAndServe(":80", m.HTTPHandler(s.publicHandler()))
}()
// (2) :443 の TLS リスナー。GetCertificate に autocert.Manager を刺すと、
// SNI ごとに ACME から証明書をオンデマンド取得してくれる。
tlsCfg := &tls.Config{
GetCertificate: m.GetCertificate,
NextProtos: []string{"h2", "http/1.1", "acme-tls/1"}, // ALPN
MinVersion: tls.VersionTLS12,
}
ln, err := tls.Listen("tcp", ":443", tlsCfg)
if err != nil {
return err
}
defer ln.Close()
slog.Info("tls listener started", "addr", ":443", "domain", s.Domain)
// (3) 既存の HTTP ルーター(ch05 で書いた s.publicHandler)を、
// 平文化された tls.Listener の上で動かすだけ。
srv := &http.Server{Handler: s.publicHandler()}
go func() {
<-ctx.Done()
_ = srv.Close()
}()
return srv.Serve(ln)
}
// hostPolicy は SNI が "*.<s.Domain>" にマッチするときだけ証明書発行を許す。
// Let's Encrypt のレート制限と、見ず知らずのドメイン名で証明書を取ろうとする
// 攻撃者からの防御の両方を兼ねる。
func (s *Server) hostPolicy() autocert.HostPolicy {
suffix := "." + s.Domain
return func(_ context.Context, host string) error {
if _, ok := extractSub(host, s.Domain); !ok && host != s.Domain {
return net.UnknownNetworkError("host not allowed: " + host + suffix[:0])
}
return nil
}
}
書いたコードは合計 63 行。第 6 章までの mintunnel 本体が約 200 行だったので、3 割増しで HTTPS まで喋れるようになる計算だ。
コードの読みどころ
3 箇所だけ強調しておく。
(1) m.HTTPHandler(s.publicHandler()) の fallback
autocert.Manager.HTTPHandler(fallback http.Handler) は、/.well-known/acme-challenge/* だけを横取りし、それ以外のパスを fallback に流す。fallback に nil を渡すと「ACME 以外は 302 で HTTPS にリダイレクト」になる。今回は 80 番にも実トラフィックを受けたい想定で、第 5 章で書いた publicHandler() を fallback に指定している。
(2) GetCertificate: m.GetCertificate だけで SNI ルーティングが終わる
tls.Listen 自体は SNI を解釈しない。だが GetCertificate コールバックは *tls.ClientHelloInfo 経由で ServerName(= SNI)を渡してくれる。m.GetCertificate の中で ServerName をキーにキャッシュを引き、無ければ ACME に取りに行く。「SNI ベースで証明書を切り替える」のが結果として「SNI ベースで処理を切り替える」になっている という構造だ。
(3) srv.Serve(ln) には平文化されたコネクションが流れる
tls.Listen で作った ln から Accept で出てくる net.Conn は、すでに復号済みの *tls.Conn だ。だから第 5 章で書いた s.publicHandler()(Host ヘッダーを見て routes を引くやつ)は TLS を意識せずに動く。Host ヘッダーと SNI が一致しているかの検証は、必要に応じて publicHandler 側に足す。
動かしてみる ── ローカルでの確認手順
Let’s Encrypt 本番は IP 制限や名前解決を要求するので、ローカルで :443 を試すときは少し工夫が要る。
1. /etc/hosts で名前を引かせる
開発用ドメイン example.test をループバックに向ける。
$ sudo sh -c 'echo "127.0.0.1 abc123.example.test xyz789.example.test" >> /etc/hosts'
2. ACME を staging または skip にする
本番の Let’s Encrypt は example.test のような fake TLD には証明書を出さない。試すときは以下のどちらか。
- staging エンドポイントを使う:
autocert.Manager.Client = &acme.Client{DirectoryURL: "https://acme-staging-v02.api.letsencrypt.org/directory"}で staging に向け、ブラウザは「信頼されていないが接続を続行」で開く。 - 自己署名でのスモークテスト:
autocert.Managerを一旦外して、自分でtls.X509KeyPairした固定証明書をGetCertificateで返す関数に差し替える。SNI ルーティングの動作確認だけならこちらの方が速い。
3. agent と server を立ち上げる
# Terminal 1:edge
$ go run ./cmd/mintunnel-server -domain example.test
# Terminal 2:upstream(ローカルアプリ)
$ python3 -m http.server 3000
# Terminal 3:agent
$ go run ./cmd/mintunnel-agent -server edge.example.test:7000 -local http://localhost:3000
{"publicURL":"https://abc123.example.test"}
# Terminal 4:ブラウザかわりに curl
$ curl -v https://abc123.example.test/
ハンドシェイクのログに Server name: abc123.example.test が出て、その後 200 が返ってくれば、SNI ルーティング + TLS 終端 + agent への muxado 転送のすべてが繋がっている。
ハマりどころ集
実装と検証で踏みやすい落とし穴を 4 つ。
- HTTP-01 のために 80 番が要る。
autocertは 80 番がローカルから到達できる前提なので、本番デプロイ時はファイアウォール設定で 80 を開ける必要がある。 - ワイルドカード証明書は HTTP-01 では取れない。
*.example.testを 1 枚で覆いたい場合は DNS-01 が必須。autocert ではなくgo-acme/legoに切り替えて DNS プロバイダー API(Route53 / Cloudflare 等)と連携する。 - Let’s Encrypt のレート制限:同一登録ドメイン配下で 週 50 枚 の上限がある(rate limits)。ngrok の short-lived hostname を再現したい場合、本番でやると一瞬で枯れる。実運用するなら ZeroSSL や BuyPass を併用するか、自前 CA を構える。
HostPolicyを雑に書くと SSRF 経由の証明書発行濫用を招く。任意のホスト名で証明書を取れる状態にしないこと。ホワイトリスト or サフィックス一致 を必ず実装する。本付録のコードではextractSubでサブドメイン抽出に失敗するホストは弾いている。
本物の ngrok と何が違うのか
ここまで作ったものと、本物の ngrok の HTTPS endpoint には、まだ大きな差が 3 つある。
- GSLB と anycast:本物は世界中の PoP のうち最寄りに引き寄せる(ngrok GSLB 発表)。今回は 1 ノード。
- マルチテナント規模での証明書管理:ngrok は内部で大量の short-lived 証明書を OCSP staple と一緒に捌いている。今回は autocert の素朴な DirCache。
- Traffic Policy による宣言的な切り替え:本物は
terminate_at: edge | agent | upstreamを YAML で書き換えるだけで終端位置を動かせる。今回は edge 終端固定。
それでも、SNI を読み、ホスト名で振り分け、ACME で証明書を取り、TLS を解いて muxado に流す ── この 1 連鎖の構造は、本物の ngrok と完全に同じだ。第 9 章エピローグで触れた「GSLB → エッジ → muxado → agent」のパイプラインのうち、エッジ部分の TLS 周りはこれで再現できたことになる。
章末まとめ
- エッジでの TLS 終端は
tls.Listen+tls.Config.GetCertificateの 2 つで成立するautocert.ManagerをGetCertificateに刺すだけで、SNI ごとの証明書をオンデマンドに Let’s Encrypt から取得できる- SNI ルーティングは「証明書を切り替える」のと同じ仕組みに乗っており、別途ルーターを書く必要はない
- HTTP-01 は 80 番が要る・ワイルドカードは DNS-01 が要るなどの落とし穴を踏まえれば、自作 ngrok でも HTTPS は喋れる
- 本物の ngrok との差は「規模」と「Traffic Policy による切り替え自由度」であり、コア構造は今回の 70 行と同じ