ホスト名で振り分ける —— HTTP ルーティングと x-forwarded-* を実装する
前章までで残してきた問題
ここまでの道のりを 1 行で整理しておく。
- 第 3 章では、
net.Listenとio.Copyだけで「edge に来た TCP を、すでにつながっている 1 本の agent にそのまま流す」素朴なリバーストンネルを書いた。 - 第 4 章では、muxado を被せて「1 本の TCP の中に複数のストリームを流す」状態を作った。同時に 2 つのリクエストが来ても捌けるようになった。
ここまでで、同時性 は手に入った。だが、まだ正面玄関にひとつ穴がある。edge は、来た HTTP リクエストを「どの agent に渡せばいいか」を判断できていない。
第 4 章の時点では、edge に接続している agent は事実上 1 つだけだった。だから「来たものを唯一の agent に流す」で済んでいた。しかし本物の ngrok は、何万もの agent が同時につながっている世界で動いている。https://abc123.example.com と https://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/http の Server を public listener に被せ、httputil.ReverseProxy を使ってリクエストを agent に流す。ReverseProxy.Director を自分で書けば、Host も x-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 に転送する直前に呼ばれる関数だ。この中で Host と x-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/withSessionはcontext.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-Proto | http または 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 つ。
Host: abc123.example.testをそのまま渡す(今回の実装)- upstream のアプリケーションが「公開 URL の自分」を認識できる。Cookie の Domain や signed URL の生成で便利。
- 一方、Host ベースでルーティングしている upstream(仮想ホスト構成の Apache/Nginx など)が混乱する。
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.Mapでsub -> muxado sessionのテーブルを持つだけで HTTP ルーティングは成立するhttputil.ReverseProxyのDirectorとTransport.DialContextを組み合わせると、agent の muxado stream を「transport」として扱えるx-forwarded-for / -proto / -hostは 既存値があれば追記、無ければ新規セット が ngrok 公式の挙動。上書きしてはいけないHostヘッダーは追記ではなく置換が仕様。今回はデフォルトで元の値を保持する設計を採用した- 自作版のルーティングロジックは、第 7 章で本物の Traffic Policy として再登場する伏線
- 次章では
TypedStreamSessionで「制御」と「データ」を 1 本の接続の中で型付き分離する