目次を表示する

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

ホスト名で振り分ける —— HTTP ルーティングと `x-forwarded-*` を実装する

ホスト名で振り分ける —— HTTP ルーティングと x-forwarded-* を実装する

前章までで残してきた問題

ここまでの道のりを 1 行で整理しておく。

  • 第 3 章では、net.Listenio.Copy だけで「edge に来た TCP を、すでにつながっている 1 本の agent にそのまま流す」素朴なリバーストンネルを書いた。
  • 第 4 章では、muxado を被せて「1 本の TCP の中に複数のストリームを流す」状態を作った。同時に 2 つのリクエストが来ても捌けるようになった。

ここまでで、同時性 は手に入った。だが、まだ正面玄関にひとつ穴がある。edge は、来た HTTP リクエストを「どの agent に渡せばいいか」を判断できていない。

第 4 章の時点では、edge に接続している agent は事実上 1 つだけだった。だから「来たものを唯一の agent に流す」で済んでいた。しかし本物の ngrok は、何万もの agent が同時につながっている世界で動いている。https://abc123.example.comhttps://xyz789.example.com が同じ PoP に着地したとき、edge は HTTP の Host ヘッダーを見て、対応する agent を引き当て ている。

この章で実装するのは、まさにそれだ。さらに、ngrok のエッジが本当にやっている x-forwarded-* 系ヘッダーの付与 ── upstream(agent の向こうにある localhost:3000)に「本当はどこから来たリクエストか」を伝えるための定型処理 ── も同じ章で組み込む。

新規コードはおよそ 40 行。internal/server/router.go 1 ファイルを新設するだけで、edge は急に「HTTP リバースプロキシ」らしい顔つきになる。

設計:公開 URL の形と、ルーティングのキー

仕様書(spec.md §3)に従って、公開 URL は次の形で払い出すことにする。

https://<sub>.<Server.Domain>

たとえば Server.Domain = "example.test" のとき、ある agent が接続してきたら edge は abc123.example.test のような ID を 1 つ生成して agent に返す。以後、外から Host: abc123.example.test で来た HTTP リクエストを、その agent のセッションに送り込む。

これを データ構造としてどう持つか。今回は 1 つの map で十分だ。

// Server に既存のフィールドに加えて、ルーティングテーブルを足す
type Server struct {
    PublicAddr string
    AgentAddr  string
    Domain     string

    // sub(ホスト名の最左ラベル) -> agent の muxado セッション
    routes sync.Map // map[string]*muxado.TypedStreamSession
}

sync.Map を選んだのは、リクエストごとに「読み」が走り、新しい agent 接続のたびに「書き」が走る、という read-heavy なアクセスパターンに合うからだ。registerAgent(第 6 章で詳述)で routes.Store(sub, sess)、agent が切断したら routes.Delete(sub)

ホスト名から sub を取り出すロジックも素朴でいい。最左のドットまでを sub と呼ぶ。

func extractSub(host, domain string) (string, bool) {
    // host は "abc123.example.test" 形式(ポートが付いていれば剥がす)
    if i := strings.Index(host, ":"); i >= 0 {
        host = host[:i]
    }
    suffix := "." + domain
    if !strings.HasSuffix(host, suffix) {
        return "", false
    }
    return strings.TrimSuffix(host, suffix), true
}

ルーティングのデータフロー

文章で書くと込み入って見えるが、実際に流れるデータはシンプルな往復だ。

sequenceDiagram
    autonumber
    participant C as curl / Browser
    participant E as Edge (mintunnel-server)
    participant A as Agent (mintunnel-agent)
    participant U as Upstream (localhost:3000)

    C->>E: GET / Host: abc123.example.test
    Note over E: routes.Load("abc123") -> Session
    E->>A: Session.Open() で新しい proxy stream
    Note over E: x-forwarded-for/proto/host を付与
    E->>A: HTTP リクエストを stream に書き込み
    A->>U: そのまま http://localhost:3000/ に転送
    U->>A: HTTP レスポンス
    A->>E: stream にレスポンスを返す
    E->>C: HTTP レスポンス

ポイントは、edge と agent の間で 追加の TCP コネクションを張らない ことだ。第 4 章で導入した muxado のセッションの上に、リクエストごとに新しいストリームを Session.Open() するだけ。これは本物の ngrok の「オンデマンド多重化」モデルとそのまま重なる(research §1(d))。

実装:internal/server/router.go

ここからが本題。net/httpServer を public listener に被せ、httputil.ReverseProxy を使ってリクエストを agent に流す。ReverseProxy.Director を自分で書けば、Hostx-forwarded-* もすべてその中で完結する。

package server

import (
    "io"
    "net"
    "net/http"
    "net/http/httputil"
    "strings"

    "golang.ngrok.com/muxado/v2"
)

// newHTTPRouter は public listener 用の http.Handler を返す。
// ホスト名から agent を引き、ReverseProxy で muxado stream に流す。
func (s *Server) newHTTPRouter() http.Handler {
    proxy := &httputil.ReverseProxy{
        Director: s.director,
        // 各リクエストごとに、対応する agent の muxado stream をダイヤルする
        Transport: &http.Transport{
            DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
                sess, ok := s.sessionFromContext(ctx)
                if !ok {
                    return nil, http.ErrAbortHandler
                }
                return sess.Open() // muxado stream を 1 本開く
            },
        },
    }
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        sub, ok := extractSub(r.Host, s.Domain)
        if !ok {
            http.Error(w, "invalid host", http.StatusNotFound)
            return
        }
        v, ok := s.routes.Load(sub)
        if !ok {
            http.Error(w, "tunnel not found", http.StatusNotFound)
            return
        }
        sess := v.(*muxado.TypedStreamSession)
        // Transport.DialContext から取り出せるように context に詰める
        ctx := withSession(r.Context(), sess)
        proxy.ServeHTTP(w, r.WithContext(ctx))
    })
}

Director は、リクエストを agent に転送する直前に呼ばれる関数だ。この中で Hostx-forwarded-* を整える

func (s *Server) director(r *http.Request) {
    // upstream の URL は agent の向こう側で解釈されるので、ここでは形だけ整える。
    // 実際の宛先は Transport.DialContext で muxado stream に置き換わる
    r.URL.Scheme = "http"
    r.URL.Host = r.Host

    // ngrok 互換のヘッダー付与(research/01 §3(e) に従う)
    addForwarded(r, "X-Forwarded-For", clientIP(r))
    addForwarded(r, "X-Forwarded-Proto", scheme(r))
    addForwarded(r, "X-Forwarded-Host", r.Host)
}

// addForwarded は既存値があれば末尾にカンマ区切りで追加、無ければ新規セットする
func addForwarded(r *http.Request, key, value string) {
    if existing := r.Header.Get(key); existing != "" {
        r.Header.Set(key, existing+", "+value)
        return
    }
    r.Header.Set(key, value)
}

func clientIP(r *http.Request) string {
    host, _, err := net.SplitHostPort(r.RemoteAddr)
    if err != nil {
        return r.RemoteAddr
    }
    return host
}

func scheme(r *http.Request) string {
    if r.TLS != nil {
        return "https"
    }
    return "http"
}

ここまでで合計 40 行強。server.Run() の中で、public listener を受けたら http.Serve(ln, s.newHTTPRouter()) を回せば、edge は HTTP リバースプロキシとして動き始める。

メモsessionFromContext / withSessioncontext.WithValue を使った 4 行ほどの薄いラッパで、ここでは省略している。context.Context に独自キーで *muxado.TypedStreamSession を出し入れするだけだ。

x-forwarded-* を「追記方式」で実装した理由

公式ドキュメントは、3 つのヘッダーの挙動を次のように定めている(research/01 §3(e)、出典:ngrok docs — HTTP/S Agent Endpoints)。

ヘッダー既存値が来ていたら
X-Forwarded-Forクライアント IP追記(カンマ区切りで末尾に足す)
X-Forwarded-Protohttp または https追記
X-Forwarded-Hostクライアント側の Host ヘッダー追記(無ければリクエストの Host の値)

注意点として、ngrok 公式は「いずれも追記方式なので、application 側は 最後の値 を読むべき」と明記している。これは、多段プロキシが前段にいる構成(CDN → ngrok edge → agent → upstream)でも、各層の情報を失わずに記録するための定石だ。

実装の良し悪しは、まさにここで分かれる。

// ❌ 悪い例:毎回上書きしてしまう
r.Header.Set("X-Forwarded-For", clientIP(r))
// → CDN や上流プロキシが既に付けていた情報を消してしまう。
//    多段構成だと「本当の発信元」を見失う
// ✅ 良い例:既存値があれば追記する(ngrok 公式と同じ挙動)
if existing := r.Header.Get("X-Forwarded-For"); existing != "" {
    r.Header.Set("X-Forwarded-For", existing+", "+clientIP(r))
} else {
    r.Header.Set("X-Forwarded-For", clientIP(r))
}

差は 4 行だが、意味は大きい。ngrok を CDN の裏に置いた瞬間、 版は debug が極めて辛くなる。

x-forwarded-* 付与の処理を 1 枚の図で整理しておく。

flowchart TD
    Start([Director がリクエストを受ける]) --> Loop[3 ヘッダー each]
    Loop --> Check{既存値がある?}
    Check -- Yes --> Append["既存値 + ', ' + 新しい値<br/>を Set"]
    Check -- No --> Set[新しい値だけを Set]
    Append --> Next{次のヘッダー?}
    Set --> Next
    Next -- Yes --> Loop
    Next -- No --> Done([upstream へ転送])

「3 つのヘッダーすべてで同じ追記ロジックを使う」と覚えれば、コードに迷いがなくなる。

Host ヘッダーをどう扱うか —— 設計判断のポイント

ヘッダーまわりでもう一つ、地味だが本質的な論点がある。agent の向こうの localhost:3000 に届く Host ヘッダーを何にするか、だ。

選択肢は 2 つ。

  1. Host: abc123.example.test をそのまま渡す(今回の実装)
    • upstream のアプリケーションが「公開 URL の自分」を認識できる。Cookie の Domain や signed URL の生成で便利。
    • 一方、Host ベースでルーティングしている upstream(仮想ホスト構成の Apache/Nginx など)が混乱する。
  2. Host: localhost:3000 に書き換える
    • upstream は「自分はずっと localhost で動いている」つもりで動ける。
    • 公開 URL を知りたいアプリは X-Forwarded-Host を読む必要がある。

ngrok の本物は デフォルトでは元の Host を保持 し、add-headers アクション(Traffic Policy の機能)を使えば任意に書き換えられる。Host は他のヘッダーと違って 特例的に追記ではなく置換 されるのが仕様だ(research/01 §3(e) 末尾)。

今回の自作版では、デフォルトを ngrok と同じ「元の Host を保持」に揃える。書き換えが必要なら、Director の中に 1 行足すだけで済む。

// オプション:upstream で localhost を期待しているなら有効化
// r.Host = "localhost:3000"

ここで重要なのは、「ヘッダーを書き換えるための小さなロジックを、リクエストパイプラインのどこに置くか」という設計判断 だ。今回は Director の中に直接書いた。本物の ngrok は、これを Traffic Policy という宣言的な言語に外出ししている。第 7 章で、その姿を答え合わせとして見る。

動かしてみる

実装ができたら、Server.Domain = "example.test" で edge を起動し、agent を 2 つ別々の port で立てる。手元でドメインの DNS は使えないので、curl-H "Host: ..." で代用する。

# agent 1: upstream は :3001
$ ./mintunnel-agent --server localhost:7000 --local http://localhost:3001 &
# 出力: assigned: https://abc123.example.test

# agent 2: upstream は :3002
$ ./mintunnel-agent --server localhost:7000 --local http://localhost:3002 &
# 出力: assigned: https://xyz789.example.test

# それぞれ別の upstream に振り分けられることを確認
$ curl -H "Host: abc123.example.test" http://localhost:8080/
# → :3001 で動いているサーバーのレスポンス

$ curl -H "Host: xyz789.example.test" http://localhost:8080/
# → :3002 で動いているサーバーのレスポンス

upstream 側で x-forwarded-* が見えていることも確認しよう。最も手軽なのは、nc -l で受けるか、Python ワンライナーでヘッダーをそのまま echo するサーバーを立てることだ。

$ python3 -m http.server 3001 &
$ curl -H "Host: abc123.example.test" http://localhost:8080/
# サーバー側ログに:
#   ::1 - - "GET / HTTP/1.1" 200 -
#   x-forwarded-for: 127.0.0.1
#   x-forwarded-proto: http
#   x-forwarded-host: abc123.example.test

さらに、前段に CDN がいる状況を模擬してみる。

$ curl -H "Host: abc123.example.test" \
       -H "X-Forwarded-For: 198.51.100.10" \
       http://localhost:8080/
# upstream には:
#   x-forwarded-for: 198.51.100.10, 127.0.0.1
# のようにカンマ区切りで「前段の IP + 今回の IP」が並ぶ

版(上書き)なら、ここで 198.51.100.10 は跡形もなく消えていた。 版(追記)なら、原典の IP が残る。

ここまでで edge が手に入れた力

40 行で、自作 edge は次のことができるようになった。

  • 公開 URL の ホスト名(の sub 部分) で agent を引き当てる
  • agent との muxado セッションの上に、リクエストごとに 新しい proxy stream を開く
  • ReverseProxy がそのストリームを HTTP の transport として扱う
  • upstream に x-forwarded-for / -proto / -host を ngrok 公式と同じ追記方式で渡す

ここまでくると、edge は外から見ると本物の ngrok エッジと同じ顔をしている。少なくとも、HTTP リクエストを 1 本受けて upstream に届くまでの「ヘッダーの世界」では、挙動が一致している。

次章への伏線:粗いルーターは、いずれ Traffic Policy になる

ただし、自作版のルーティングは現時点ではまだ「サブドメインで引いて、固定の x-forwarded-* を付ける」だけのものだ。本物の ngrok では、エッジでの振る舞いは Traffic Policy という宣言的な設定言語に外出しされていて、add-headers でヘッダーを書き換え、rate-limit で流量を絞り、jwt-validation で認可をかけ、terminate-tls で TLS を終端する位置すら選べる(research/01 §4)。

今回 Director 関数の中にベタ書きしたルーティングロジックは、第 7 章で「本物の ngrok ではこれがどう書かれているか」として再登場する。具体的には、

  • 今回の addForwarded の 4 行 → Traffic Policy の add-headers アクション
  • 今回の「sub から sess を引く」1 行 → Cloud Endpoint と Agent Endpoint の関係
  • 今回の r.URL.Scheme = "http"terminate-tls アクションの裏側

として、もう一度、別の言葉で語り直すことになる。「自分で書いたら 40 行、宣言的に書いたら YAML 数十行」── この対比が、第 7 章の主題の一つだ。

次の第 6 章では、まだ目をつぶってきたもう一つの課題に手をつける。第 4 章から第 5 章までで「複数のストリームを流す」ことはできるようになったが、そのストリームが「制御メッセージ」と「データ」のどちらなのか、agent 側はどう判別するのか という問題だ。muxado の TypedStreamSession で、コントロールプレーンとデータプレーンを 1 本の接続の中でくっきり分けにいく。


章末まとめ

  • 公開 URL は <sub>.<Server.Domain> 形式。sync.Mapsub -> muxado session のテーブルを持つだけで HTTP ルーティングは成立する
  • httputil.ReverseProxyDirectorTransport.DialContext を組み合わせると、agent の muxado stream を「transport」として扱える
  • x-forwarded-for / -proto / -host既存値があれば追記、無ければ新規セット が ngrok 公式の挙動。上書きしてはいけない
  • Host ヘッダーは追記ではなく置換が仕様。今回はデフォルトで元の値を保持する設計を採用した
  • 自作版のルーティングロジックは、第 7 章で本物の Traffic Policy として再登場する伏線
  • 次章では TypedStreamSession で「制御」と「データ」を 1 本の接続の中で型付き分離する