目次を表示する

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

付録 A:TLS 終端と SNI ルーティングを実装する

付録 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 つを後付けする。

  1. エッジでの TLS 終端:edge が :443tls.Listen で受け、平文化してから内部の muxado に流す
  2. SNI ベースのサブドメイン振り分け:1 つの IP・1 つのポートで、abc123.example.testxyz789.example.test を別々に処理する
  3. 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.appxyz789.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 つある。

  1. HTTP-01 チャレンジは 80 番ポートが要る。Let’s Encrypt が http://abc123.example.test/.well-known/acme-challenge/... を叩きに来る。m.HTTPHandler(nil) を 80 番で立てて応答する。
  2. ワイルドカード証明書(*.example.test 全部を 1 枚で覆う)は HTTP-01 では取れない。Let’s Encrypt 仕様で、ワイルドカードには DNS-01 チャレンジ(DNS に TXT レコードを書く)が必要になる。autocert は DNS-01 を標準でサポートしないので、ワイルドカードを取りたい場合は lego などの別ライブラリに乗り換えるのが定石だ。本付録では「サブドメインごとに HTTP-01 で取る」方式で書く。

実装:internal/server/tls.go

第 6 章で完成した mintunnelserver.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 に流す。fallbacknil を渡すと「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 つ。

  1. HTTP-01 のために 80 番が要るautocert は 80 番がローカルから到達できる前提なので、本番デプロイ時はファイアウォール設定で 80 を開ける必要がある。
  2. ワイルドカード証明書は HTTP-01 では取れない*.example.test を 1 枚で覆いたい場合は DNS-01 が必須。autocert ではなく go-acme/lego に切り替えて DNS プロバイダー API(Route53 / Cloudflare 等)と連携する。
  3. Let’s Encrypt のレート制限:同一登録ドメイン配下で 週 50 枚 の上限がある(rate limits)。ngrok の short-lived hostname を再現したい場合、本番でやると一瞬で枯れる。実運用するなら ZeroSSL や BuyPass を併用するか、自前 CA を構える。
  4. 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.ManagerGetCertificate に刺すだけで、SNI ごとの証明書をオンデマンドに Let’s Encrypt から取得できる
  • SNI ルーティングは「証明書を切り替える」のと同じ仕組みに乗っており、別途ルーターを書く必要はない
  • HTTP-01 は 80 番が要る・ワイルドカードは DNS-01 が要るなどの落とし穴を踏まえれば、自作 ngrok でも HTTPS は喋れる
  • 本物の ngrok との差は「規模」と「Traffic Policy による切り替え自由度」であり、コア構造は今回の 70 行と同じ

参考文献