【Go】http.Clientの名前解決差し替え紀行
この記事は何
- Goの
http.Client
の名前解決方法をカスタムしたかった。 -
http.Transport
のDialContext
を差し替えればできそうだが、名前解決部分をカスタムしたいだけなのに変更必要箇所が大きくなりすぎない? - 本当に名前解決の部分だけ差し替える方法はないかしら、と調べたが
net.Resolver
で躓いた。 - 一周回って、今の所は
http.Transport
のDialContext
フィールドをカスタムするのが良さそう。net/http
やnet
のコードを読んでみて、変更は小さくできそうなことがわかった。
この記事の対象読者
- Goの
http.Client
の名前解決方法をカスタマイズしたい人。ただしこの記事中の内容は実用性は気にしていません。悪しからず。 - Goのソースコードリーディングが好きな人
以下、本文です。
背景
最近、DNS over HTTPS(DoH)について簡単に調べる機会がありました。調べてみたら、もちろん使ってみたくなりますね。DoHを利用する方法としては例えばCloudflareさんが提供しているAPIがありました。
以下のようなリクエストで手軽に名前解決が実現できるとのことです。
curl --http2 -H "accept: application/dns-json" "https://1.1.1.1/dns-query?name=cloudflare.com"
# Response:
# {"Status":0,"TC":false,"RD":true,"RA":true,"AD":true,"CD":false,"Question":[{"name":"cloudflare.com","type":1}],"Answer":[{"name":"cloudflare.com","type":1,"TTL":155,"data":"104.16.133.229"},{"name":"cloudflare.com","type":1,"TTL":155,"data":"104.16.132.229"}]}
実用的にはどうなんだろう?と思いますが、ここまで簡単にトライできると、当然Goで活用してみたくなります。(強引)
名前解決にDoHを使うためのクライアントの実装はあるのでしょうか?"golang dns over https"で検索すると、はい、何件かヒットしますね。
こちらは一例ですが、サーバーとクライアント両方を含んでいます。活発に開発もされていそうです。
他にもいくつかの例を見ましたが、http.Client
の名前解決の方法だけをピンポイントで差し替える方法は見受けられません。極力標準パッケージを活用したいな、と個人的には思うので、どうしたら実現できるのか一度確認してみよう!と思いました。
http.Clientが名前解決をするまで
まず簡単にhttp.Client
が名前解決をするまでの手順を確認してみます。特にことわりを入れない限り、http.DefaultClient
が行う手順について記載します。
http.Client
は次のような構造体ですね。リクエスト処理を行うのはhttp.RoundTripper
インタフェースです。
type Client struct {
Transport RoundTripper
CheckRedirect func(req *Request, via []*Request) error
Jar CookieJar
Timeout time.Duration
}
type RoundTripper interface {
RoundTrip(*Request) (*Response, error)
}
http.RoundTripper
の実装として*http.Transport
があります。その中でコネクションを確立するのがDialContext
関数です。
type Transport struct{
DialContext func(ctx context.Context, network, addr string) (net.Conn, error)
}
コネクション確立の過程で名前解決を行うため、DialContext
フィールドをカスタムした関数で上書きすればやりたいことはやれそうです。ただ、名前解決方法だけを差し替えたいのに、変更する範囲が広くなってしまいそうです。そこでもう一歩踏み込んでみたいと思います。
http.DefaultTransport
では、DialContxt
フィールドを次のように設定します。
var DefaultTransport RoundTripper = &Transport{
DialContext: defaultTransportDialContext(&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}),
}
defaultTransportDialContext
の実装はwasmかそれ以外かで分けられていますが、wasm以外では初期化したnet.Dialer
のDialContext
関数をそのまま返します。どうやらnet.Dialer
が鍵を握っていそうです。
net.Dialer
は次のような構造体です。
type Dialer struct {
// Resolver optionally specifies an alternate resolver to use.
Resolver *Resolver
}
func (d *Dialer) DialContext(ctx context.Context, network, address string) (Conn, error) {
// 省略
}
Resolver
フィールドにカスタムした*net.Resolver
を設定すればコネクション確立のやり方(名前解決含む)を上書きできそうに見えますが、*Dialer.DialContext
関数の中身を見ると、Resolver
のresolveAddrList
関数を呼び出しています。このプライベートメソッドの挙動を変更するにはどうしたら良いのでしょうか?
net.Resolver
を見てみるとDial
関数なるフィールドがありますが、この関数はresolveAddrList
の中からは呼ばれないので、挙動の変更には使え無さそうです。
type Resolver struct {
PreferGo bool
StrictErrors bool
Dial func(ctx context.Context, network, address string) (Conn, error)
}
resolveAddrList
関数をもう少し見てみます。
この関数の中では、指示されたアドレスを*Resolver.internetAddrList
、さらにそこから*Resolver.lookupIPAddr
を使ってIPアドレスに変換しています。ついに名前解決きた!
そしてついにピンポイントで名前解決だけを差し替える部位を発見しました。
// The underlying resolver func is lookupIP by default but it
// can be overridden by tests. This is needed by net/http, so it
// uses a context key instead of unexported variables.
resolverFunc := r.lookupIP
if alt, _ := ctx.Value(nettrace.LookupIPAltResolverKey{}).(func(context.Context, string, string) ([]IPAddr, error)); alt != nil {
resolverFunc = alt
}
なるほど、context
の中にlookupIP(ctx context.Context, network, host string) (addrs []IPAddr, err error)
と同じ型の関数を入れておけば差し替えられるんですね!
いざカスタム
ということで喜び勇んで次のようなコードを書きました。
// 適当に省略
func CustomDialContext(ctx context.Context, /*省略*/) {
ctx := context.WithValue(
ctx,
nettrace.LookupIPAltResolverKey{},
myCustomLookupIPFunc,
)
d := &net.Dialer{}
return d.DialContext(ctx, /*省略*/)
}
これをhttp.Transport
に差し込めばいける…!と確信した刹那
[gopls] go list:Error:use of internal package internal/nettrace not allowed
え、君、internal
パッケージの人なの…?
なんと言うことでしょう、この差し替えはテスト向けにしか使われていないのです。ここを変えれば変更ミニマムで既存コードの恩恵に預かれると思ったのですが…。悲しい。
代案
さあ、ではどうしようとなりますが、先ほどのコードリーディングの最中に1つ発見をしました。*Resolver.lookupIpAddr
の処理の中で、指示されたアドレスがホスト名ではなくIPアドレスの際はそのままリターンする処理がありました。
ということで、当初モチベーションにこだわってDoHしたい!のであれば、デフォルトの処理が実行される前に自力で名前解決してあげるのが良さそうです。ということでhttp.Transport
のDialContext
フィールドに、次のようなカスタム品を設定してみます。
func customDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
// addr = host:port format
addrs := strings.Split(addr, ":")
if len(addrs) != 2 {
return nil, errors.New("invalid address")
}
host := addrs[0]
port := addrs[1]
// dohLookupIPはcloudflareのdoh APIを使って名前解決をする
ip, err := dohLookupIP(host)
if err != nil {
return nil, fmt.Errorf("can not resolve host %s: %w", host, err)
}
d := &net.Dialer{}
return d.DialContext(ctx, network, ip+":"+port)
}
DNSキャッシュをクリアして、上の関数を差し込んだhttp.Transport
を使うと、確かにDNSへの問い合わせがスキップできています。遠回りしましたが、当初狙いは達成できましたね。
おわりに
途中まではnet.Resolver
の内部で行う名前解決部分だけを差し替える方法を模索しましたが、最終的にはhttp.Transport
にカスタムしたDialContext
を渡すことで狙いを達成しました。
net.Resolver
をカスタムしたい要望はありそうで、以下のようなIssueもあります。net.Resolver
をインタフェースにしてはどうかという議論もされていますが、現在のnet.Resolver
構造体は意外とメソッドも多く、やるにしても責務ごとにインタフェースを分割する必要がありそうなど、ある程度の変更が必要そうですね。
個人的には、今回のソースコードリーディングで発見したcontext
で名前解決の関数を差し込む部分を、テスト用に限らなければ、多少やりたいことはやれそうだけどな、と思いました。(トリッキーな感じは拭えませんが)
Discussion