DNSサーバをPythonで....

この記事はPython Advent Calendar 2015の24日目の記事です。

今までDNSとかほとんどちゃんと理解せず、Name <-> IP Addressができるやつだよね。ぐらいの認識しかありませんでした。まあ仕様ははっきりしているし、すぐ書けるだろうという軽いノリでやってみました。

結構誰得感あるんですが、まあこういうのは自分が今書きたいものをかけるのが良いところってことで多めにみてください。

# 謎ルール -> DNS関連のライブラリ使わない

PyPIにはDNSのプロトコル関連のライブラリがあります。(しかも結構たくさん)
普段ならそれを幾つかピックアップして試したりしますが、今回は自分で書いてみるというモチベーションなので、それらは使わないことにしました。(使っちゃうとDNSの仕様が頭に入らないので。。。)

# 書いたもの

とりあえずGistにupしておきました。
https://gist.github.com/TakesxiSximada/802d36068f09a3393541

ちゃんとしたDNSサーバとしては機能しません。かろうじてAレコードを引けるようにはなりました。(でもそれも決め打ちだけど...)

# 使ったライブラリ -> binarize

サードパーティライブラリで唯一使ったのがbinarizeです。
https://pypi.python.org/pypi/binarize
これはバイナリデータをクラスに構造体っぽくもたせておいてpack(encode)やunpack(decode)の操作を行うためのライブラリです。標準ライブラリのstructモジュールとほぼ同じことができますが、クラス変数にデータの情報を持たせることができる感じです。

例えば先頭1byteでcodeを次の2byteでstatusを表現するヘッダがあったとします。その場合binarizeでは次のように表現できます。

```
>>> from binarize import Structure, UINT8, UINT16
>>> class Header(Structure):
...     code = UINT8()
...     status = UINT16()
...
>>>
```

データの順序は変数の定義順となります。バイトオーダーはデフォルトではビッグエンディアンです。このクラスはdecodeというクラスメソッドを持っています。それにバイト列を渡すことでデータをHeader objectとして扱うことができます。

```
>>> header = Header.decode(b'\x01\x00\x02')
>>> header.code
1
>>> header.status
2
```

headerはencode()を持っていてバイト列にシリアライズすることができます。

```
>>> header.encode()
b'\x01\x00\x02'
>>>
```

このbinarizeは実はほとんど使われていないっぽいです。Githubのスターとか全く付いてないし、PyPIのダウンロード数も全然たいしたことありませんでした(ほぼDLされていないっぽいし、若干放置気味)。ただ、割と書き込まれていて、使用感的には割と良かったです。

# binarize でDNSのリクエストヘッダを書くとこうなる

DNSのリクエストヘッダはこのようなフォーマットをしています。

```
      0  1  2  3  4  5  6  7  8  9  0  1  2  3  4  5
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                      ID                       |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |QR|   Opcode  |AA|TC|RD|RA|   Z    |   RCODE   |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                    QDCOUNT                    |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                    ANCOUNT                    |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                    NSCOUNT                    |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                    ARCOUNT                    |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
```

先頭から16bitずつ以下のような感じです。

| 名前 | 意味 |
|:-----------|------------:|
| ID | 識別子 |
| QR, Opcode, AA, TC, RD, RA, Z, RCODE | フラグ |
| QDCOUNT | 質問RR数 |
| ANCOUNT | 回答RR数 |
| NSCOUNT | 権威RR数 |
| ARCOUNT | 追加RR数 |

RRはリソースレコードです。

フラグの意味は以下のとおりです。

| 名前 | 意味 |
|:-----------|------------:|
| QR | 照会=0, 応答=1 |
| Opcode | 順引き=1, 逆引き=2, サーバステータス取得=3 |
| AA | そのDNSサーバが管理している情報である=1 |
| TC | 回答が長すぎて分割した=1 |
| RD | 再帰照会=1, 反復照会=0 |
| RA | 再帰可能=1 |
| Z | リザーブ |
| RCODE | 応答ステータス(正常終了=0, 不正フォーマット=1, サーバエラー=2, ネームエラー=3, 未実装=4, 拒否=5) |

詳しくはRFCをみるといいと思います。
http://www.ietf.org/rfc/rfc1035.txt の `4.1.1. Header section format` に記載されています。

これらのヘッダをbinarizeを使って表現すると次のようになります。

```
class Header(Structure):
    identifier = UINT16()
    flags = UINT16()
    query_count = UINT16()
    answer_count = UINT16()
    authority_rr_count = UINT16()
    addon_rr_count = UINT16()
```

https://gist.github.com/TakesxiSximada/802d36068f09a3393541#file-dns-py-L133-L139
構造体っぽい感じです。Structureはbinarize.Structureです。フラグはHeader.flagsに16bit確保しておいてそれを操作する感じにしました。
https://gist.github.com/TakesxiSximada/802d36068f09a3393541#file-dns-py-L141-L186


# binarizeで照会レコードを書くとこうなる

照会レコードは次のようなフォーマットになっています。

```
                                    1  1  1  1  1  1
      0  1  2  3  4  5  6  7  8  9  0  1  2  3  4  5
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                                               |
    /                     QNAME                     /
    /                                               /
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                     QTYPE                     |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
    |                     QCLASS                    |
    +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

```


| 名前 | 意味 |
|:-----------|------------:|
| QNAME | 照会するデータ ("文字数,ドメイン名,文字数ドメイン名,0x00" <- こんな感じ) |
| QTYPE | 照会のタイプ (今回はAレコードの照会しかしないので1が飛んでくる) |
| QCLASS| 使用している通信 (インターネット=1) |

詳しくは http://www.ietf.org/rfc/rfc1035.txt の `4.1.2. Question section format` に記載があります。

binarizeを使って表現すると次のようになります。

```
class QuestionRecord(Structure):
    qname = BYTES(size=32, fill=b'')
    qtype = UINT16()
    qclass = UINT16()
```

https://gist.github.com/TakesxiSximada/802d36068f09a3393541#file-dns-py-L188-L191

ここで気をつけなければならないのはqnameは可変長だということです。((非圧縮の)qnameは文字数とドメイン名の文字列の組み合わせで表現されていて末端が0x00です。こう言った形式はbinarizeでは解析できないので自分でdecode処理をする必要がありました。

```
    @classmethod
    def decode(cls, data):
        class _AnalyzeRecord(Structure):
            qtype = UINT16()
            qclass = UINT16()

        record = cls()
        qname_end = data.index(b'\x00') + 1
        record.qname = data[:qname_end]

        data = data[qname_end:]
        analyze_record = _AnalyzeRecord.decode(data)
        record.qtype = analyze_record.qtype
        record.qclass = analyze_record.qclass
        return record
```
https://gist.github.com/TakesxiSximada/802d36068f09a3393541#file-dns-py-L193-L207

またbinarize.BYTES()は放っておくと先頭バイトにその後のバイト数を差し込む仕様でした。

```
def pack_bytes(bytes_, size=-1, fill=b'\x00'):
    """Pack Bytes."""
    if size < 0:
        yield from pack_size(len(bytes_))
        yield bytes_
    else:
        missing = size - len(bytes_)
        if missing < 0 or (missing > 0 and fill is None):
            raise ValueError()
        yield bytes_
        yield fill * missing
```
https://github.com/socialcube/binarize/blob/4f0c71c985e22d1975dd8f5dfefdb7e8b5c01c57/binarize/primitives.py#L502

今回は手抜きをしてqname全体をバイト列で持ってしまいましたが、よく考えると割と気が利いている仕様なので、動的にfieldを追加できるようならqnameの保持もBYTES()のリストとして持てるとちょっといいかなと思いました。

# testと引いたらloopback addressを返す

ここは当然DBに保存したデータを使うべきでしょうが、若干面倒なのでちょっと直書きしちゃいます。

```
NAME_IPADDR = {
    (b'test', b''): b'\x7f\x00\x00\x01',  # 127.0.0.1
}
```

https://gist.github.com/TakesxiSximada/802d36068f09a3393541#file-dns-py-L29-L31

testという名前を引くとloopback アドレスを返します。

# digでIPアドレスを引いてみる

サーバを起動します。DNSはポート番号が53なのでsudoで実行しています。

```
$ sudo python dns.py
Password:

```

別のターミナルからdigコマンドで名前を引いてみます。@以降のアドレスは問い合わせに行くDNSのアドレスです。これがないとマジなDNSに問い合わせ行っちゃいます。今回はlocal上で動かしているだけなので127.0.0.1を指定します。

```
$ dig A test @127.0.0.1

; <<>> DiG 9.8.3-P1 <<>> A test @127.0.0.1
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 3096
;; flags: qr; QUERY: 0, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0

;; ANSWER SECTION:
test.                   9       IN      A       127.0.0.1

;; Query time: 2 msec
;; SERVER: 127.0.0.1#53(127.0.0.1)
;; WHEN: Fri Dec 25 21:43:04 2015
;; MSG SIZE  rcvd: 32

$
```

お、なんか引けたっぽいですね。ちゃんとAレコードで127.0.0.1が引けました。

# 結構苦労した...

作業中に以下のメッセージを嫌という程みました。

```
$ dig A test @127.0.0.1
;; Warning: Message parser reports malformed message packet.

```

返したデータがおかしなフォーマットだったためdigコマンドがワーニング出しているだけなんだけど、原因がわからず結構苦戦しました。大概はデータの長さがおかしいとか個数が間違っているとかいらない情報くっつけてしまっているといった結構初歩的なものでしたが...


# 最終的にはwireshark...

いくらRFCを読み込んでも、Webの情報を漁っても、間違いって気づけないときは本気で気付けないんですよね...RFCやWebの情報みながらWiresharkでデータ解析しつつやってやっと、あれなんかおかしくね?ってなることが多かったです。実際に流れているデータ見るのが一番はかどる感じでした。

# あとがき

数日前に風邪をこじらせて寝込む羽目になってしまい、1日遅れでの記事の投稿になってしまいました。まだ若干頭がぼーっとするので抗生物質とともに寝ます。さようなら。