🎃

Pythonで名前解決する/DNSクエリを送る

2024/08/10に公開
import dns.resolver

resolver = dns.resolver.Resolver()
answers = resolver.resolve('www.google.com', 'a')
for rdata in answers:
    print(rdata.address)

基本的にはこれだけなんですが、もう少し細かいことをやりたい場合の例をいくつかご紹介します。

問い合わせ先リゾルバなどを指定

resolver = dns.resolver.Resolver()
resolver.nameservers = ['8.8.8.8', '8.8.4.4']
resolver.port = 53
resolver.timeout = 5

NXDOMAINを拾う

resolver = dns.resolver.Resolver()
try:
    answers = resolver.resolve('存在しないFQDN', 'a')
    for rdata in answers:
        print(rdata.address)
except dns.resolver.NXDOMAIN:
    print('no record')

MXレコードを取得

import dns.resolver

resolver = dns.resolver.Resolver()
answers = resolver.resolve('gmail.com', 'mx')
for rdata in answers:
    print(f'Preference: {rdata.preference}')
    print(f'Server: {rdata.exchange.to_text(omit_final_dot=True)}')

rdata には dns.rdtypes.ANY.MX.MX 型が返ります。Preferench値とメールサーバーのホスト名はそれぞれ上記のように取得できます。

rdata.exhangedns.name.Name 型が返ります。dns.name.Name 型はdnspythonにおいて広く汎用的に使われる型で、to_text() メソッドで簡単にstr型に変換できる優れものです。ただし、(リソースレコードが実際にそうだから仕方がないのですが、)末尾に"."がついてきて、邪魔です。そこで omit_final_dot=True としておくと、この末尾の"."を除外した形でstr型へ変換してくれます。

TXTレコードを取得

resolver = dns.resolver.Resolver()
answers = resolver.resolve('gmail.com', 'txt')
for rdata in answers:
    tuple = rdata.strings
    for item in tuple:
        print(item.decode('utf-8'))

TXTレコードは .strings で取得できますが、この返値はbyte型のtupleになるため注意が必要です。これは、TXTレコードが

exmaple.com. 999 IN TXT "hogehoge" "foobar"

のようにダブルクオーテーションマークで囲まれる形で複数列挙できるからです。

CNAMEレコードを取得

resolver = dns.resolver.Resolver()
try:
    answers = resolver.resolve('www.example.com', 'cname')
    print(answers[0].target)
except dns.resolver.NoAnswer:
    print('no cname target')

RFCの規定上CNAMEレコードは必ず1つだけしか応答がりませんので、リストではないものが返って来てほしいのですが、ドキュメントによるとそうではないようです。

Note: although CNAME is officially a singleton type, dnspython allows non-singleton CNAME rdatasets because such sets have been commonly used by BIND and other nameservers for load balancing.
参考: https://dnspython.readthedocs.io/en/latest/rdata-subclasses.html#dns.rdtypes.ANY.CNAME.CNAME

でも、現実的にそんな応答をする権威ネームサーバーをみたことはないので、通常は雑にanswers[0]で良いはずです。

併せて、CNAMEが登録されていない場合、dns.resolver.NoAnswer エラーが発生します。通常はこのエラー処理がセットで必要になると思います。

SoAレコードを取得

import dns.resolver
import dns.name

def get_soa_record(domain):

    resolver = dns.resolver.Resolver()
    try:
        answers = resolver.resolve(domain, 'soa')
        rdata = answers[0]
        print('Zone name: ' + domain)
        print(f'Serial: {rdata.serial}, Refresh: {rdata.refresh}, Retry: {rdata.retry}, Min: {rdata.minimum}')
        print('Primary NS: ' + rdata.mname.to_text(omit_final_dot=True))
        print('Admin email: ' + rdata.rname.to_text(omit_final_dot=True))
    except dns.resolver.NoAnswer:
        print('No SOA')

        # 必要ならば
        parent = dns.name.from_text(domain).parent().to_text(omit_final_dot=True)
        print('try to check at parent...' + parent)
        get_soa_record(parent)

get_soa_record('www.google.com')
% python resolve.py
No SOA
try to check at parent...google.com
Serial: 660761116, Refresh: 900, Retry: 900, Min: 60
Primary NS: ns1.google.com
Admin email: dns-admin.google.com

だいたい他のレコードと一緒ですが、再帰処理があります。これは、ゾーン頂点ではないドメインに対してSoAレコードを問い合わせたとしても、あくまでゾーン情報を取りたいケースに役立ちます。具体的には、 dns.resolver.NoAnswer 例外の応答があったときに、一つ上の階層でSoAレコードを問い合わせるようにしています。

str型に格納されているドメインを from_text() で dns.name.Name 型に変換し、parent() メソッドにより親のレコードを取得しています。一方、この関数の入力はstr型を期待しているので、再度 to_text() で変換しています。

ちなみにこの実装だと、SoAレコードはゾーン頂点ドメインにしか設定できないため、結果的にゾーン名を調べることができます(NSレコードを使って調査してもよいと思います)。本当はリゾルバに対し再帰的問い合わせをオフにして問い合わせるほうが筋なのでしょうが、面倒なので現実的にはこれでいいんじゃないかと思います。

EDNS

resolver = dns.resolver.Resolver()
resolver.edns = True

これだけでEDNSが有効になります。

残念ながら、Client Subnetで任意のサブネット長を埋め込む方法は私には理解ができませんでした・・・。以下のリポジトリを使うといけそうなのですが、すでにメンテナンスされていないようで、悩み中です。

https://github.com/opendns/dnspython-clientsubnetoption

スタブリゾルバを使わずにより低レベルなDNS処理を実装

これまでは dns.resolver を用いて問い合わせ結果を得ていました。一方、より低レベルな処理をしたい、よりリクエストを厳密に定義したいケースもあります。その場合、

  1. dns.message.Message をクエリを作成
  2. dns.query.udp() でクエリを送信
  3. 得られた dns.message.QueryMessage を解析

という手順で実装することもできます。実際には、これまで見てきた dns.resolver は、上記処理をラップするものであるようです。以後、dns.resolver のことをスタブリゾルバと呼び、区別したいと思います。

まず、例えば単にAレコードを取得する場合、以下のような実装となります。

import dns.name
import dns.message
import dns.query
import dns.rdatatype

# 1. クエリ作成
request = dns.message.make_query(dns.name.from_text('www.google.com'), dns.rdatatype.A)
# 2. クエリ送信
response = dns.query.udp(request, '8.8.8.8') #=> dns.message.QueryMessage
# 3. 得られたQueryMessageを解析
for rrset in response.answer:
    for rdata in rrset:
        print(rdata.address)

ここで、rdatadns.rdtypes.IN.A.A 型で、後の処理はこれまでと同じです。すなわち、 address で IPアドレスの文字列にアクセスできますね。

ただ、これだけではあまり意味がありませんので、いくつかの例を見ていきたいと思います。

転送レイヤー

例えば、dns.query.udpdns.query.tcp に置き換えることで、DNSクエリをTCPで送信することもできます。そのほか、DoH(DNS-over-https)であれば dns.query.https が、 DoT(DNS-over-TLS)であれば dns.query.tls が利用できます。

https://dnspython.readthedocs.io/en/latest/query.html

非再帰問い合わせ

例えば反復問い合わせを自前で実装したい場合、権威ネームサーバーへ直接問い合わせをする際には非再帰問い合わせをしたいですね。dnspythonでは dns.flags によりフラグを制御することができ、デフォルトで dns.flags.RD (Recursion Desired)のみ設定されています。よって、明示的にこのフラグを立てないようにすればよいことになります。具体的には、 make_query する際に flags=0 とするだけで大丈夫です。

ただし、ルートネームサーバーに .com の権威を問い合わせたところ、UDPではサイズが大きすぎて完全なレスポンスが得られませんでした。そのためTCPでリクエストを送っています。

応答の処理としては、サーバーが権威を持っていた場合、応答に dns.flags.AA (Authoritative Answer)が含まれますので、この有無によって分岐させるとよいと思います。

import dns.name
import dns.message
import dns.query
import dns.rdatatype
import dns.flags

domain = 'www.google.com'
server = '198.41.0.4' # A.ROOT-SERVERS.NET
#server = '192.5.6.30' # a.gtld-servers.net
#server = '216.239.32.10' # ns1.google.com

# Non-recursive request
print('Request A record of ' + domain + ' to ' + server)
query = dns.message.make_query(dns.name.from_text(domain), dns.rdatatype.A, flags=0)
response = dns.query.tcp(query, server)

if response.flags & dns.flags.AA:
    print('Authoritative Answer')
    
    for rrset in response.answer:
        for rdata in rrset:
            print('A record: ' + rdata.address)
else:
    print('Non-Authoritive Answer')

    authority_servers = []
    for rrset in response.authority:
        for rdata in rrset:
            authority_servers.append(rdata.target.to_text(omit_final_dot=True))

    for rrset in response.additional:
        name_server = rrset.name.to_text(omit_final_dot=True)
        if name_server in authority_servers:
            address_list = []
            for rdata in rrset:
                address_list.append(rdata.address)
            print('Name server: ' + name_server + ', Address: ' + '|'.join(address_list))

おわりに

dnspythonは、さくっと名前解決したい場合にも、クエリや応答の処理を自前で実装したい場合にも使える便利なライブラリだなと思いました。
ただ、いつも応答がどんな形式で返ってくるのか忘れてしまうので、自分でも見直せる記事が書けてよかったです。以上です。

Discussion