DNSリゾルバ
実装:
network/dns// 実行:go test ./network/dns/
ドメイン名から IP アドレスを引く DNS は、読めるテキストではなく詰まったバイナリを、接続の手続きなしに1発で投げ合う(UDP)。理由は 512 バイトという上限にある。同じ名前を回答ごとに書き直すと、名前が長い環境では1つの応答に 6 件しか入らない。名前を「さっきの場所を見て」と指す形にすると回答1件が 16 バイト固定になり、同じ名前で 27 件入る。入らなければ TCP へ落ちる。
この章で作るもの
HTTP サーバは「IP アドレスが分かった後」の通信だった。 その手前で、ドメイン名から IP アドレスを引くのが DNS。example.com に アクセスする前に、必ずこの解決が走っている。
問い合わせのバイト列を手で組み立てて送り、返ってきたバイト列から IP を取り出す。 HTTP のような読めるテキストではなく、詰まったバイナリになっている。
運び方には UDP を使う。TCP のような接続の確立をせず、ひと固まりのメッセージを 1発投げて1発受け取るだけの送り方で、届く保証はない代わりに往復が最少で済む。
なぜバイナリなのかは、数字1つで説明がつく。512 バイト。UDP で1発投げて1発 受け取る、という設計を選んだ時点で、メッセージ全体がこの中に収まらなければならなく なった。フォーマットのほとんどは、この制約から出てきている。
順に作る。
- 区切り文字ではなく長さを置く:
.で切るのをやめて、ラベルごとに長さを前に置く - 同じ名前は指す: 名前をポインタで参照すると、回答1件が名前の長さによらず16バイトになる
- 入らなければ TCP へ落ちる: 512 を超えたら TC を立てて、1往復という前提そのものを諦める
① 区切り文字ではなく長さを置く
www.example.com は [3]www[7]example[3]com[0] という並びになる。 各ラベルの前に「そのラベルの長さ」を1バイト置き、最後を 0 で終える。
// encodeName はドメイン名を DNS のラベル形式にする。
// "www.example.com" → [3]www[7]example[3]com[0]。
// 各ラベルの前に「そのラベルの長さ」を1バイト置き、最後を 0 で終える。
func encodeName(name string) []byte {
var buf []byte
for _, label := range strings.Split(name, ".") {
buf = append(buf, byte(len(label)))
buf = append(buf, label...)
}
buf = append(buf, 0) // ルート(空ラベル)で終端
return buf
}
// BuildQuery は A レコードの問い合わせメッセージを組み立てる。
// 構成: 12バイトのヘッダ + 質問セクション(名前 + QTYPE + QCLASS)。
func BuildQuery(id uint16, name string) []byte {
msg := make([]byte, 12)
binary.BigEndian.PutUint16(msg[0:2], id) // ID: 応答を照合するための番号
binary.BigEndian.PutUint16(msg[2:4], 0x0100) // フラグ: RD=1(再帰的に解決してほしい)
binary.BigEndian.PutUint16(msg[4:6], 1) // QDCOUNT: 質問1つ
// ANCOUNT/NSCOUNT/ARCOUNT は 0 のまま。
msg = append(msg, encodeName(name)...)
msg = binary.BigEndian.AppendUint16(msg, 1) // QTYPE = A(IPv4 アドレス)
msg = binary.BigEndian.AppendUint16(msg, 1) // QCLASS = IN(インターネット)
return msg
}長さを前に置く形は、読む側が楽になる。. を探して走査する必要がなく、 「1バイト読んで、その数だけ飛ぶ」を繰り返せば終わる。そしてこの形にしておくと、 後から出てくるポインタ圧縮が成立する。長さバイトの上位2bitが 11 かどうかで、 「これは長さか、それとも参照か」を1バイト読んだ時点で判別できる。 区切り文字だけで組んでいたら、この分岐は入る場所がなかった。
ヘッダの先頭2バイトは ID。同時に複数の問い合わせを投げるので、 どの応答がどの問い合わせのものかを ID で照合する。次の2バイトはフラグで、 RD=1(Recursion Desired)は「あなたが再帰的に最後まで解決して、答えだけください」の意味。
② 同じ名前は指す
DNS サーバは、同じフォーマットに回答セクションを足して返す。
// ParseResponse は応答メッセージから A レコードの IP を取り出す。
// wantID は送った問い合わせの ID。一致しなければ別の問い合わせの応答なので弾く。
func ParseResponse(msg []byte, wantID uint16) ([]string, error) {
if len(msg) < 12 {
return nil, fmt.Errorf("dns: response too short (%d bytes)", len(msg))
}
id := binary.BigEndian.Uint16(msg[0:2])
if id != wantID {
return nil, fmt.Errorf("dns: id mismatch: got %#x, want %#x", id, wantID)
}
qdcount := binary.BigEndian.Uint16(msg[4:6])
ancount := binary.BigEndian.Uint16(msg[6:8])
// 質問セクションを読み飛ばす(名前 + QTYPE 2B + QCLASS 2B)。
off := 12
for i := 0; i < int(qdcount); i++ {
n, err := skipName(msg, off)
if err != nil {
return nil, err
}
off = n + 4 // QTYPE + QCLASS
}
// 回答レコードを読む。
var ips []string
for i := 0; i < int(ancount); i++ {
n, err := skipName(msg, off) // 回答の名前(たいていポインタ圧縮)
if err != nil {
return nil, err
}
off = n
if off+10 > len(msg) {
return nil, fmt.Errorf("dns: truncated answer header")
}
typ := binary.BigEndian.Uint16(msg[off : off+2])
rdlength := int(binary.BigEndian.Uint16(msg[off+8 : off+10]))
off += 10
if off+rdlength > len(msg) {
return nil, fmt.Errorf("dns: truncated rdata")
}
if typ == 1 && rdlength == 4 { // A レコード = 4バイトの IPv4
ip := msg[off : off+4]
ips = append(ips, fmt.Sprintf("%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]))
}
off += rdlength
}
return ips, nil
}
// skipName は off から名前を読み飛ばし、その次のオフセットを返す。
// DNS はメッセージを縮めるため「名前をポインタで参照する」圧縮を使う(上位2bitが 11)。
// ポインタに当たったら、そこで名前は終わり(2バイトぶん進めて返す)。
func skipName(msg []byte, off int) (int, error) {
for {
if off >= len(msg) {
return 0, fmt.Errorf("dns: name runs past end of message")
}
length := int(msg[off])
switch {
case length == 0:
return off + 1, nil // ルートラベル。名前の終わり
case length&0xc0 == 0xc0:
// 圧縮ポインタ(2バイト)。ここで名前は終端とみなす。
return off + 2, nil
default:
off += 1 + length // 通常ラベル: 長さ + 中身を飛ばす
}
}
}ここで問題になるのが、応答には質問と同じドメイン名がもう一度出てくること。 しかも回答が複数あれば、その数だけ繰り返す。そのままだと名前が長い環境で効いてくる。
DNS は「さっき出た名前は、メッセージの N バイト目を見て」という2バイトのポインタで これを潰す。skipName がその分岐を持っていて、上位2bitが 11 なら 「そこで名前は終わり」と判断して2バイト進める。
効き方を数える。回答1件は、名前のあとに TYPE 2 + CLASS 2 + TTL 4 + RDLENGTH 2 + IPv4 4 が続く形になる。名前をポインタにすると、この合計が 16 バイトで固定される。
// UDPLimit は UDP で送れる DNS メッセージの上限(RFC 1035)。
//
// この 512 という数字が、DNS のフォーマットをほぼ全部決めている。
// テキストではなくバイナリなのも、名前をポインタで指すのも、ここに収めるため。
const UDPLimit = 512
// QuerySize は問い合わせメッセージのバイト数を返す。
// ヘッダ12 + 名前 + QTYPE 2 + QCLASS 2。
func QuerySize(name string) int {
return 12 + len(encodeName(name)) + 4
}
// AnswerSize は A レコード1件のバイト数を返す。
//
// 名前のあとに TYPE 2 + CLASS 2 + TTL 4 + RDLENGTH 2 + IPv4 4 が続く。
// 圧縮すると名前が2バイトのポインタになるので、合計は 16 で固定になる。
// 名前をそのまま書くと、名前が長いほどレコードも太る。
func AnswerSize(name string, compress bool) int {
n := 2 // 圧縮ポインタ
if !compress {
n = len(encodeName(name))
}
return n + 2 + 2 + 4 + 2 + 4
}
// Capacity は 512 バイトに入る A レコードの件数を返す。
//
// 圧縮ありなら 1件 16 バイト固定なので、名前が長くなっても
// 減るのは質問セクションのぶんだけになる。
func Capacity(name string, compress bool) int {
return (UDPLimit - QuerySize(name)) / AnswerSize(name, compress)
}長さの違う名前で、512 バイトに入る件数を測った:
| 名前 | 名前のバイト数 | 名前を指す | 名前を書き直す |
|---|---|---|---|
example.com | 13 | 30 件 | 17 件 |
www.example.com | 17 | 29 件 | 15 件 |
d111111abcdef8.cloudfront.net | 31 | 29 件 | 10 件 |
ec2-203-0-113-25.ap-northeast-1.compute.amazonaws.com | 55 | 27 件 | 6 件 |
名前を4倍に伸ばしても、指すほうは 30 件から 27 件にしか減らない。減ったのは質問が 太ったぶんだけで、回答1件はずっと16バイトのままだからだ。書き直すほうは 17 件から 6 件、4割を切るところまで落ちる。テストで、この落ち方の差(片方は8割を保ち、 もう片方は4割を下回る)を固定した。
ポインタ圧縮は「少し縮む」工夫ではない。回答の大きさを名前の長さから切り離す 仕掛けで、切り離しておかないと、名前が長いというだけで載る件数が変わってしまう。 クラウド事業者が振り出す長いホスト名は、この圧縮を前提にして初めて成立している。
③ 入らなければ TCP へ落ちる
それでも入らないことはある。そのときは、入るぶんだけ載せて TC ビットを立てる。
// BuildResponse は回答つきの応答メッセージを組み立てる。
//
// compress が false なら、回答ごとに名前をそのまま書き直す。
// 512 バイトに収まらなくなったらそこで打ち切り、TC ビットを立てる。
// TC=1 は「入りきらなかったので TCP でやり直して」の合図になる。
func BuildResponse(id uint16, name string, ips [][4]byte, compress bool) []byte {
msg := make([]byte, 12)
binary.BigEndian.PutUint16(msg[0:2], id)
binary.BigEndian.PutUint16(msg[2:4], 0x8180) // 応答, RD, RA
binary.BigEndian.PutUint16(msg[4:6], 1) // QDCOUNT
// 質問の名前は必ずオフセット12から始まる。回答はここを指せばいい。
const qnameOffset = 12
qname := encodeName(name)
msg = append(msg, qname...)
msg = binary.BigEndian.AppendUint16(msg, 1) // QTYPE = A
msg = binary.BigEndian.AppendUint16(msg, 1) // QCLASS = IN
n := 0
for _, ip := range ips {
if len(msg)+AnswerSize(name, compress) > UDPLimit {
msg[2] |= 0x02 // TC = 1
break
}
if compress {
msg = binary.BigEndian.AppendUint16(msg, 0xc000|qnameOffset)
} else {
msg = append(msg, qname...)
}
msg = binary.BigEndian.AppendUint16(msg, 1) // TYPE = A
msg = binary.BigEndian.AppendUint16(msg, 1) // CLASS = IN
msg = binary.BigEndian.AppendUint32(msg, 60) // TTL
msg = binary.BigEndian.AppendUint16(msg, 4) // RDLENGTH
msg = append(msg, ip[:]...)
n++
}
binary.BigEndian.PutUint16(msg[6:8], uint16(n)) // ANCOUNT
return msg
}
// Truncated は TC ビットが立っているかを返す。
// 立っていたら、同じ問い合わせを TCP で投げ直すことになる。
func Truncated(msg []byte) bool {
return len(msg) >= 12 && msg[2]&0x02 != 0
}TC=1 は「入りきらなかったので TCP でやり直して」の合図になる。受け取った側は 同じ問い合わせを TCP で投げ直す。つまり接続確立の往復を省くという、UDP を選んだ 理由そのものが消える。
さっきの長い名前で、8件の回答を返そうとするとこうなった:
| メッセージ長 | 載った件数 | TC | |
|---|---|---|---|
| 名前を指す | 199 バイト | 8 件 | 立たない |
| 名前を書き直す | 485 バイト | 6 件 | 立つ |
8件はロードバランスでよくある数だが、書き直すほうはそこに届かない。 テストで、同じ8件が片方では収まり、片方では TC が立つことを固定した。 また、載った件数が Capacity の計算と一致することも固定した。
動かす
下のデモは、打ち込んだ名前でそのまま同じ計算をする。名前を長くすると、 バイト列の緑の部分が伸び、その下の2本の棒が離れていく。
名前を長くすると差が開く。指すほうは 1 件 16 バイトのまま動かないので、減るのは質問が太ったぶんだけ。書き直すほうは、名前がそのままレコードの大きさになる。
実際に問い合わせる
組み立てた問い合わせを UDP ソケットで送り、応答を受け取る:
// Resolver は DNS サーバに UDP で問い合わせて名前を解決する。
type Resolver struct {
// Server は問い合わせ先(例: "8.8.8.8:53" = Google Public DNS)。
Server string
Timeout time.Duration
// nextID は問い合わせIDの採番用。応答を照合するために毎回変える。
nextID uint16
}
// NewResolver は指定した DNS サーバを使うリゾルバを返す。
func NewResolver(server string) *Resolver {
return &Resolver{Server: server, Timeout: 3 * time.Second, nextID: 1}
}
// Resolve は name の A レコード(IPv4)を引く。
// 1. UDP ソケットを開く 2. 問い合わせを送る 3. 応答を受け取る 4. パースする。
func (r *Resolver) Resolve(name string) ([]string, error) {
id := r.nextID
r.nextID++
conn, err := net.Dial("udp", r.Server)
if err != nil {
return nil, fmt.Errorf("dns: dial %s: %w", r.Server, err)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(r.Timeout))
query := BuildQuery(id, name)
if _, err := conn.Write(query); err != nil {
return nil, fmt.Errorf("dns: send query: %w", err)
}
// DNS 応答は 512 バイト以内(UDP の古い上限)に収まる前提で受ける。
buf := make([]byte, 512)
n, err := conn.Read(buf)
if err != nil {
return nil, fmt.Errorf("dns: read response: %w", err)
}
return ParseResponse(buf[:n], id)
}net.Dial("udp", ...) で UDP の口を開き、問い合わせを Write、応答を Read。 TCP と違って接続確立の往復がないので、これで1往復になる。 テストではローカルの UDP サーバを立ててこの経路を検証している。8.8.8.8 に 向ければ本物の名前解決になる。
設計の観点
- 上限からフォーマットを決める: 512 という数字が先にあって、バイナリも圧縮も後から出てきた。逆順ではない
- 繰り返す部分を指せる形にしておく: 長さプレフィックスにしたおかげで、参照との判別が1バイトで済んだ
- 可変長を固定長に変える: 回答の大きさを名前から切り離すと、載る件数が入力に左右されなくなる
- 収まらない場合の道を用意する: TC と TCP フォールバックが無ければ、大きい応答は返しようがない
- 問い合わせと応答を結び付ける: UDP は誰でも投げ込めるので、ID を照合しない実装は偽の応答を受け入れる
- 壊れた入力で落ちない: 長さもオフセットも相手が書いた数字になる。境界は毎回確かめる
対照と実例
| 運び方 | 形式 | 大きさの上限 | 名前の重複 | |
|---|---|---|---|---|
| DNS(UDP) | 接続なし・1往復 | バイナリ | 512 バイト | ポインタで指す |
| DNS(TCP) | 接続あり | バイナリ | 2バイトの長さ前置 | 同じ |
| DNS over HTTPS | TCP + TLS + HTTP/2 | 同じバイナリを包む | 実質なし | 同じ |
| HTTP | 接続あり | テキスト | ヘッダは実装依存 | ヘッダ圧縮(HPACK)がある |
| gRPC | 接続あり | バイナリ(protobuf) | 実装依存 | フィールド番号で短くする |
裏どり:
- 512 の出どころ: RFC 1035 4.2.1 が「UDP メッセージは 512 バイト以下」と決めている。RFC 6891 の EDNS(0) が、問い合わせ側から「もっと大きくても受け取れる」と申告する仕組みを後から足した
- 圧縮の規定: 同じく RFC 1035 4.1.4。先頭2bitが
11のときポインタ、00のとき長さ、と決めてあるので、ラベル長は 63 が上限になる。上位2bitを分岐に使った代償がそこに出ている - ID だけでは足りなかった: 2008年の Kaminsky の指摘で、16bit の ID を推測して偽の応答を先に届ける攻撃が現実的だと分かった。RFC 5452 が送信元ポートもランダムにすることを求め、推測すべき空間を 2^16 から 2^32 へ広げている
- HTTP/2 も同じことをしている: 繰り返すヘッダを表の番号で指す(HPACK)。繰り返しを指すという手は、詰まったプロトコルではだいたい出てくる
- TCP へ落ちる頻度: DNSSEC の署名を載せると応答が大きくなるので、EDNS(0) で上限を上げる運用が普通になった。それでも足りなければ TC が立つ
簡略化したこと
- A レコードのみ: AAAA/CNAME/MX 等は読み飛ばすだけで、値は取り出さない
- 再帰リゾルバ丸投げ:
RD=1で 8.8.8.8 に任せる。ルートから辿る反復解決はしない - キャッシュ・TTL なし: 毎回問い合わせる。DNS が速いのはキャッシュのおかげなので、実物との差は大きい
- 圧縮は読み飛ばしのみ: ポインタを展開して名前を復元するところまではやらない
- TCP フォールバックなし: TC を立てるところまでで、投げ直しは実装していない
- EDNS(0)・DNSSEC・DoH/DoT なし: 上限の引き上げも、署名も、暗号化も入れていない
- 送信元ポートは OS 任せ: ID は変えているが、ポートのランダム化は明示していない
参考資料
- RFC 1035 — メッセージフォーマットと圧縮の規格
- RFC 6891 — EDNS(0)。512 の制約を後から緩める仕組み
- RFC 5452 — なりすまし対策。送信元ポートのランダム化
- Implement DNS in a weekend — 反復解決まで手で書くチュートリアル
- 実装: network/dns