GolangでDNS権威サーバを書いてfly.ioで動かす

任意のプラベートIPを返すような動的なDNS権威サーバが欲しくなったので調べたところ github.com/miekg/dns というライブラリでサクッとかけそうだったので書いてみた。

net/http を模したライブラリで、DNSサーバを簡単に書くことができる。

github.com

package main

import (
    "log"
    "net"

    "github.com/miekg/dns"
)

func main() {
    dns.HandleFunc("example.com", func(w dns.ResponseWriter, r *dns.Msg) {
        msg := &dns.Msg{}
        msg.SetReply(r)
        msg.Authoritative = true

        for _, q := range msg.Question {
            if q.Name == "hello.example.com." && q.Qtype == dns.TypeA {
                rr := &dns.A{
                    Hdr: dns.RR_Header{Name: q.Name, Rrtype: q.Qtype, Class: q.Qclass, Ttl: 300},
                    A:   net.ParseIP("127.0.0.1"),
                }
                msg.Answer = append(msg.Answer, rr)
            }
        }

        w.WriteMsg(msg)
    })

    s := &dns.Server{Addr: ":53", Net: "udp"}
    err := s.ListenAndServe()

    if err != nil {
        log.Fatal(err)
    }
}
$ dig @127.0.0.1 +short hello.example.com
127.0.0.1

ゾーンファイルのパースもできる。

func main() {
    buf := strings.NewReader(`
www.example.com.  300 IN A 127.0.0.1
www2.example.com. 300 IN A 127.0.0.2
`)
    z := dns.NewZoneParser(buf, "", "")

    for rr, ok := z.Next(); ok; rr, ok = z.Next() {
        fmt.Println(rr.String())
    }

    fmt.Println(z.Err())
}
$ go run main.go
www.example.com.    300    IN  A   127.0.0.1
www2.example.com.   300    IN  A   127.0.0.2
<nil>

作ったDNSサーバがこちら。

github.com

$ kodama example.com &
[1] 25270
$ dig @127.0.0.1 +short 127-0-0-1.example.com
127.0.0.1
$ dig @127.0.0.1 +short web-192-168-10-1.example.com
192.168.10.1

fly.ioでDNSサーバを動かす

作ったDNSサーバをどこかで動かしたい…と思っていたらなんと fly.io がUDP/TCPに対応していた。

fly.io

IPv4をアロケートして、以下のような fly.toml でアプリをデプロイしたら普通に動いた。

app = 'kodama'
primary_region = 'nrt'

[build]
  image = 'ghcr.io/winebarrel/kodama:v1.0.0'

[env]
  KODAMA_DOMAIN = 'foo.example.com'
  KODAMA_ZONE_DATA = """
; ...
bar.foo.example.com. 3600 IN NS xxx.xxx.xxx.xxx
"""

[[services]]
  protocol = 'udp'
  internal_port = 53

  [[services.ports]]
    port = 53

[[services]]
  protocol = 'tcp'
  internal_port = 53

  [[services.ports]]
    port = 53

[[vm]]
  memory = '1gb'
  cpu_kind = 'shared'
  cpus = 1

おまけ: Google Public DNSの挙動

せっかくなので動かしたDNSサーバでLet's Encryptの証明書を取得しようとしてみた。 以下のようなDNSチャレンジ用のTXTレコードを設定してcertbotを動かしたが、なぜかチャレンジに失敗する。

_acme-challenge.foo.example.com. 900 IN TXT xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

手元のdigでは値が返るのになぜ…と思ってログを仕込んでみたら、@8.8.8.8 @8.8.4.4 のリゾルバからは大文字小文字がランダムになったホスト名が問い合わせされてきた。

2025-12-XXTXX:XX:XXZ app[...] nrt [info]2025-12-XXTXX:XX:XX DEBUG NOT FOUND question=";_AcMe-chALlenGE.foo.eXamPle.com.\tIN\t A"

それで大文字の問い合わせに対応していないバグに気がついたが、なんでそうなっているのが調べたところセキュリティ対策とのこと。

developers.google.com

キャッシュポイズニングで不正なパケットが返されたときに、問い合わせのホスト名と応答のホスト名が違っていれば、キャッシュが汚染されたことがわかる、ということらしい。

ちなみに 1.1.1.1 はそのような挙動にはなっていなかった。

参考リンク