計算機網絡——DNS協議的學習與實現

1. 主要內容

不說廢話,直接進入正題。先說說本文本文的主要內容,好讓你決定是否看下去:

  1. 介紹DNS是幹什麼的;
  2. 介紹DNS是如何工作的;
  3. 介紹DNS請求與響應的消息格式;
  4. 編程實現一個簡單的DNS服務器;

2. DNS是啥

關於DNS是啥,想必學過計算機網絡的應該都知道,它是Domain Name System的簡寫,中文翻譯過來就是域名系統,是用來將主機名轉換爲ip的。事實上,除了進行主機名到IP地址的轉換外,DNS通常還提供主機名到以下幾項的轉換服務:

  1. 主機命名(host aloasing)。有着複雜規範主機名(canonical hostname)的主機可能有一個或多個別名,通常規範主機名較複雜,而別名讓人更容易記憶。應用程序可以調用DNS來獲得主機別名對應的規範主機名,以及主機的ip地址。
  2. 郵件服務器別名(mail server aliasing)。DNS也能完成郵件服務器別名到其規範主機名以及ip地址的轉換。
  3. 負載均衡(load distribution)。DNS可用於冗餘的服務器之間進行負載均衡。一個繁忙的站點,如abc.com,可能被冗餘部署在多臺具有不同ip的服務器上。在該情況下,在DNS數據庫中,該主機名可能對應着一個ip集合,但應用程序調用DNS來獲取該主機名對應的ip時,DNS通過某種算法從該主機名對應的ip集合中,挑選出某一ip進行響應。

問:爲什麼會有DNS,或者說爲什麼要弄出兩種方式(主機名和IP地址)來標識一臺主機呢?

答:這是因爲主機名便於人的記憶,而IP地址便於計算機網絡設備的處理,於是需要DNS來做前者到後者的轉換。

3. DNS工作原理

DNS實際上是由一個分層的DNS服務器實現的分佈式數據庫和一個讓主機能夠查詢分佈式數據庫的應用層協議組成。因此,要了解DNS的工作原理,需要從以上兩個方便入手。

3.1 DNS的分佈式架構

先來了解DNS的分佈式架構。

DNS服務器根據域名命名空間(domian name space)組織成如下圖所示的樹形結構(當然,只給出部分DNS服務器,只爲顯示出DNS服務器的層次結構):

在圖中,根節點代表的是根DNS服務器,因特網上共有13臺,編號從A到M;根DNS服務器之下的一層被稱爲頂級DNS服務器;再往下一層被稱爲權威DNS服務器。

當一個應用要通過DNS來查詢某個主機名,比如www.google.com的ip時,粗略地說,查詢過程是這樣的:它先與根服務器之一聯繫,根服務器根據頂級域名com,會響應命名空間爲com的頂級域服務器的ip;於是該應用接着向com頂級域服務器發出請求,com頂級域服務器會響應命名空間爲google.com的權威DNS服務器的ip地址;最後該應用將請求命名空間爲google.com的權威DNS服務器,該權威DNS服務器會響應主機名爲www.google.com的ip。

實際上,除了上圖層次結構中所展示的DNS外,還有一類與我們接觸更爲密切的DNS服務器,它們是本地DNS服務器,我們經常在電腦上配置的DNS服務器通常就是此類。它們一般由某公司,某大學,或某居民區提供,比如Google提供的DNS服務器8.8.8.8;比如常被人詬病的114.114.114.114等。

加入了本地DNS的查詢過程跟之前的查詢過程基本上是一致的,查詢流程如下圖所示:

在實際工作中,DNS服務器是帶緩存的。即DNS服務器在每次收到DNS請求時,都會先查詢自身數據庫包括緩存中有無要查詢的主機名的ip,若有且沒有過期,則直接響應該ip,否則纔會按上圖流程進行查詢;而服務器在每次收到響應信息後,都會將響應信息緩存起來;

3.2 DNS應用層協議

3.2.1 DNS資源記錄

在介紹DNS層協議之前,先了解一下DNS服務器存儲的資源記錄(Resource Records,RRs),一條資源記錄(RR)記載着一個映射關係。每條RR通常包含如下表所示的一些信息:

字段 含義
NAME 名字
TYPE 類型
CLASS
TTL 生存時間
RDLENGTH RDATA所佔的字節數
RDATA 數據

NAME和RDATA表示的含義根據TYPE的取值不同而不同,常見的:

  1. 若TYPE=A,則name是主機名,value是其對應的ip;
  2. 若TYPE=NS,則name是一個域,value是一個權威DNS服務器的主機名。該記錄表示name域的域名解析將由value主機名對應的DNS服務器來做;
  3. 若TYPE=CNAME,則value是別名爲name的主機對應的規範主機名;
  4. 若TYPE=MX,則value是別名爲name的郵件服務器的規範主機名;
  5. ……

TYPE實際上還有其他類型,所有可能的type及其約定的數值表示如下:

TYPE value meaning
A 1 a host address
NS 2 an authoritative name server
MD 3 a mail destination (Obsolete - use MX)
MF 4 a mail forwarder (Obsolete - use MX)
CNAME 5 the canonical name for an alias
SOA 6 marks the start of a zone of authority
MB 7 a mailbox domain name (EXPERIMENTAL)
MG 8 a mail group member (EXPERIMENTAL)
MR 9 a mail rename domain name (EXPERIMENTAL)
NULL 10 a null RR (EXPERIMENTAL)
WKS 11 a well known service description
PTR 12 a domain name pointer
HINFO 13 host information
MINFO 14 mailbox or mail list information
MX 15 mail exchange
TXT 16 text strings

3.2.2 整體及Header部分

下面介紹第二個方面,DNS協議。

DNS請求與響應的格式是一致的,其整體分爲Header、Question、Answer、Authority、Additional5部分,如下圖所示:

Header部分是一定有的,長度固定爲12個字節;其餘4部分可能有也可能沒有,並且長度也不一定,這個在Header部分中有指明。Header的結構如下:

下面說明一下各個字段的含義:

  1. ID:佔16位。該值由發出DNS請求的程序生成,DNS服務器在響應時會使用該ID,這樣便於請求程序區分不同的DNS響應。
  2. QR:佔1位。指示該消息是請求還是響應。0表示請求;1表示響應。
  3. OPCODE:佔4位。指示請求的類型,有請求發起者設定,響應消息中複用該值。0表示標準查詢;1表示反轉查詢;2表示服務器狀態查詢。3~15目前保留,以備將來使用。
  4. AA(Authoritative Answer,權威應答):佔1位。表示響應的服務器是否是權威DNS服務器。只在響應消息中有效。
  5. TC(TrunCation,截斷):佔1位。指示消息是否因爲傳輸大小限制而被截斷。
  6. RD(Recursion Desired,期望遞歸):佔1位。該值在請求消息中被設置,響應消息複用該值。如果被設置,表示希望服務器遞歸查詢。但服務器不一定支持遞歸查詢。
  7. RA(Recursion Available,遞歸可用性):佔1位。該值在響應消息中被設置或被清除,以表明服務器是否支持遞歸查詢。
  8. Z:佔3位。保留備用。
  9. RCODE(Response code):佔4位。該值在響應消息中被設置。取值及含義如下:
    • 0:No error condition,沒有錯誤條件;
    • 1:Format error,請求格式有誤,服務器無法解析請求;
    • 2:Server failure,服務器出錯。
    • 3:Name Error,只在權威DNS服務器的響應中有意義,表示請求中的域名不存在。
    • 4:Not Implemented,服務器不支持該請求類型。
    • 5:Refused,服務器拒絕執行請求操作。
    • 6~15:保留備用。
  10. QDCOUNT:佔16位(無符號)。指明Question部分的包含的實體數量。
  11. ANCOUNT:佔16位(無符號)。指明Answer部分的包含的RR(Resource Record)數量。
  12. NSCOUNT:佔16位(無符號)。指明Authority部分的包含的RR(Resource Record)數量。
  13. ARCOUNT:佔16位(無符號)。指明Additional部分的包含的RR(Resource Record)數量。

3.2.3 Question部分

Question部分的每一個實體的格式如下圖所示:

  1. QNAME:字節數不定,以0x00作爲結束符。表示查詢的主機名。注意:衆所周知,主機名被"."號分割成了多段標籤。在QNAME中,每段標籤前面加一個數字,表示接下來標籤的長度。比如:api.sina.com.cn表示成QNAME時,會在"api"前面加上一個字節0x03,"sina"前面加上一個字節0x04,"com"前面加上一個字節0x03,而"cn"前面加上一個字節0x02;
  2. QTYPE:佔2個字節。表示RR類型,見以上RR介紹;
  3. QCLASS:佔2個字節。表示RR分類,見以上RR介紹。

3.2.4 Answer、Authority、Additional部分

Answer、Authority、Additional部分格式一致,每部分都由若干實體組成,每個實體即爲一條RR,之前有過介紹,格式如下圖所示:

  1. NAME:長度不定,可能是真正的數據,也有可能是指針(其值表示的是真正的數據在整個數據中的字節索引數),還有可能是二者的混合(以指針結尾)。若是真正的數據,會以0x00結尾;若是指針,指針佔2個字節,第一個字節的高2位爲11。
  2. TYPE:佔2個字節。表示RR的類型,如A、CNAME、NS等,見以上RR介紹;
  3. CLASS:佔2個字節。表示RR的分類,見以上RR介紹;
  4. TTL:佔4個字節。表示RR生命週期,即RR緩存時長,單位是秒;
  5. RDLENGTH:佔2個字節。指定RDATA字段的字節數;
  6. RDATA:即之前介紹的value,含義與TYPE有關,見以上RR介紹。

DNS協議是工作在應用層的,運輸層依賴的是UDP協議。下面嘗試使用Python3.6來實現一個簡單的DNS服務器。

在此之前先用Wireshark抓一下DNS包,驗證一下上面的DNS協議的格式,也便於之後的實現。Wireshark的用法就不做介紹了,相信裝好隨便點點就知道怎麼用了。先打開監聽,添加過濾條件,然後用nslookup命令發送一個DNS包,比如我們嘗試查詢www.baidu.com的ip:

nslookup www.baidu.com

然後可以在Wireshark中看到如下圖所示的請求數據包:

響應數據如下圖所示:

4. 實現一個簡單的DNS服務器

下面用Python來實現一個非常簡單的DNS服務器。

4.1 代理功能

首先,它應該具有最基本的“代理”功能,即我們的DNS服務器在接到DNS請求後,直接將請求轉發到某DNS服務器(如114.114.114.114)上,然後再將那臺DNS的響應結果返回給DNS客戶端:

import threading
import socket
import socketserver


class Handler(socketserver.BaseRequestHandler):
    def handle(self):
        request_data = self.request[0]
        # 將請求轉發到 114 DNS
        redirect_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        redirect_socket.sendto(request_data, ('114.114.114.114', 53))
        response_data, address = redirect_socket.recvfrom(1024)
    
        # 將114響應響應給客戶
        client_socket = self.request[1]
        client_socket.sendto(response_data, self.client_address)


class Server(socketserver.ThreadingMixIn, socketserver.UDPServer):
    pass


if __name__ == "__main__":
    # 一下ip需換成自己電腦的ip
    server = Server(('172.16.42.254', 53), Handler)
    with server:
        server_thread = threading.Thread(target=server.serve_forever)
        server_thread.daemon = True
        server_thread.start()
        print('The DNS server is running at 172.16.42.254...')
        server_thread.join()

現在我們的DNS服務器就可以進行轉發工作了。運行以上程序(需root權限),然後用nsloop命令,向我們的服務器發送DNS請求,一切OK:

$ nslookup baidu.com 172.16.42.254
Server:     172.16.42.254
Address:    172.16.42.254#53

Non-authoritative answer:
Name:   baidu.com
Address: 123.125.114.144
Name:   baidu.com
Address: 180.149.132.47
Name:   baidu.com
Address: 111.13.101.208
Name:   baidu.com
Address: 220.181.57.217

4.2 緩存功能

如果僅僅做一下代理轉發,那也太無聊了。現在我們再加上緩存功能,即它能夠將其他DNS服務器的響應結果緩存起來。當收到請求時,若請求主機號在緩存中,且沒有過期,則直接響應緩存結果;否則進行上一功能中的操作。這一功能的關鍵在於對DNS消息的解析,代碼如下:

class Message:
    u"""All communications inside of the domain protocol are carried in a single format called a message"""

    def __init__(self, header, question=None, answer=None, authority=None, additional=None):
        self.header = header
        self.question = question
        self.answer = answer
        self.authority = authority
        self.additional = additional

    @classmethod
    def from_bytes(cls, data):
        scanner = Scanner(data)
        # 讀取header
        header = dict()
        header['ID'] = scanner.next_bytes(2)
        header['QR'] = scanner.next_bits(1)
        header['OPCODE'] = scanner.next_bits(4)
        header['AA'] = scanner.next_bits(1)
        header['TC'] = scanner.next_bits(1)
        header['RD'] = scanner.next_bits(1)
        header['RA'] = scanner.next_bits(1)
        header['Z'] = scanner.next_bits(3)
        header['RCODE'] = scanner.next_bits(4)
        header['QDCOUNT'] = scanner.next_bytes(2)
        header['ANCOUNT'] = scanner.next_bytes(2)
        header['NSCOUNT'] = scanner.next_bytes(2)
        header['ARCOUNT'] = scanner.next_bytes(2)
        print('header:', header)
        # 讀取question
        questions = list()
        for _ in range(header['QDCOUNT']):
            question = dict()
            question['QNAME'] = scanner.next_bytes_until(lambda current, _: current == 0)
            scanner.next_bytes(1)  # 跳過0
            question['QTYPE'] = scanner.next_bytes(2)
            question['QCLASS'] = scanner.next_bytes(2)
            questions.append(question)
        print('questions:', questions)
        message = Message(header)
        # 讀取answer、authority、additional
        rrs = list()
        for i in range(header['ANCOUNT'] + header['NSCOUNT'] + header['ARCOUNT']):
            rr = dict()
            rr['NAME'] = cls.handle_compression(scanner)
            rr['TYPE'] = scanner.next_bytes(2)
            rr['CLASS'] = scanner.next_bytes(2)
            rr['TTL'] = scanner.next_bytes(4)
            rr['RDLENGTH'] = scanner.next_bytes(2)
            # 處理data
            if rr['TYPE'] == 1:  # A記錄
                r_data = scanner.next_bytes(rr['RDLENGTH'], False)
                rr['RDATA'] = reduce(lambda x, y: y if (len(x) == 0) else x + '.' + y,
                                     map(lambda num: str(num), r_data))
            elif rr['TYPE'] == 2 or rr['TYPE'] == 5:  # NS與CNAME記錄
                rr['RDATA'] = cls.handle_compression(scanner, rr['RDLENGTH'])
            rrs.append(rr)
        answer, authority, additional = list(), list(), list()
        for i, rr in enumerate(rrs):
            if i < header['ANCOUNT']:
                answer.append(rr)
            elif i < header['ANCOUNT'] + header['NSCOUNT']:
                authority.append(rr)
            else:
                additional.append(rr)
        print('answer:', answer)
        print('authority:', authority)
        print('additional:', additional)
        return message

    @classmethod
    def handle_compression(cls, scanner, length=float("inf")):
        """
        The compression scheme allows a domain name in a message to be represented as either:
            - a pointer
            - a sequence of labels ending in a zero octet
            - a sequence of labels ending with a pointer
        """
        byte = scanner.next_bytes()
        if byte >> 6 == 3:  # a pointer
            pointer = (byte & 0x3F << 8) + scanner.next_bytes()
            return cls.handle_compression(Scanner(scanner.data, pointer))
        data = scanner.next_bytes_until(lambda current, offset: current == 0 or current >> 6 == 3 or offset > length)
        if scanner.next_bytes(move=False) == 0:  # a sequence of labels ending in a zero octet
            scanner.next_bytes()
            return data
        # a sequence of labels ending with a pointer
        result = data + '.' + cls.handle_compression(Scanner(scanner.data, *scanner.position()))
        scanner.next_bytes(2)  # 跳過2個字節的指針
        return result

其中用到了一個自定義的Scanner類,用來幫助我們從bytes中按字節或位讀取數據,其定義如下:

class Scanner:
    """scan bytes"""
    __mark_offset_byte, __mark_offset_bit = 0, 0

    def __init__(self, data: bytes, offset_byte=0, offset_bit=0):
        self.data = data
        self.__offset_byte = offset_byte
        self.__offset_bit = offset_bit

    def next_bits(self, n=1):
        if n > (len(self.data) - self.__offset_byte) * 8 - self.__offset_bit:
            raise RuntimeError('剩餘數據不足{}位'.format(n))
        if n > 8 - self.__offset_bit:
            raise RuntimeError('不能跨字節讀取讀取位')
        result = self.data[self.__offset_byte] >> 8 - self.__offset_bit - n & (1 << n) - 1
        self.__offset_bit += n
        if self.__offset_bit == 8:
            self.__offset_bit = 0
            self.__offset_byte += 1
        return result

    def next_bytes(self, n=1, convert=True, move=True):
        if not self.__offset_bit == 0:
            raise RuntimeError('當前字節不完整,請先讀取完當前字節的所有位')
        if n > len(self.data) - self.__offset_byte:
            raise RuntimeError('剩餘數據不足{}字節'.format(n))
        result = self.data[self.__offset_byte: self.__offset_byte + n]
        if move:
            self.__offset_byte += n
        if convert:
            result = int.from_bytes(result, 'big')
        return result

    def next_bytes_until(self, stop, convert=True):
        if not self.__offset_bit == 0:
            raise RuntimeError('當前字節不完整,請先讀取完當前字節的所有位')
        end = self.__offset_byte
        while not stop(self.data[end], end - self.__offset_byte):
            end += 1
        result = self.data[self.__offset_byte: end]
        self.__offset_byte = end
        if convert:
            if result:
                result = reduce(lambda x, y: y if (x == '.') else x + y,
                                map(lambda x: chr(x) if (31 < x < 127) else '.', result))
            else:
                result = ''
        return result

然後需要做的是當收到114 DNS服務器的響應消息後,將消息緩存起來:

# 緩存響應結果
message = Message.from_bytes(response_data)
message.save()

以上save方法就是將message中包含的各條RR保存起來,可以直接用一個集合來保存,也可以保存在一些專業的緩存設施中,比如redis。需要注意的是TTL的處理,若用redis緩存,它自帶了TTL功能,可以直接使用。若是自己實現的,需要在保存的時候記錄當前的時間,以便取出的時候能夠判斷是否過期。這些應該很容易實現,但是本人比較懶,這裏就不寫了……

4.3 添加記錄功能

最後,它需要具備能夠讀取我們自定義的記錄,並將記錄加入緩存。。這個也不想寫了……

另外,Message類還應該有一個to_bytes方法,它能將一個Message對象轉換爲bytes對象,用於將 從緩存中取出的數據(即RR記錄)轉換爲bytes,返回給用戶。這個其實就是from_bytes的逆過程,但實現起來應該比from_bytes簡單許多,因爲你可以不使用指針來壓縮數據,這樣處理起來就沒什麼難度了。同樣不想寫了……

最後稍微做一下測試,算是做個結束:

if __name__ == "__main__":
    server = Server('172.16.42.254')
    server.start()

使用nsloop發送DNS請求到我們自己寫的服務器上,響應結果如下:

$ nslookup api.sina.com.cn 172.16.42.254
Server:     172.16.42.254
Address:    172.16.42.254#53

Non-authoritative answer:
api.sina.com.cn canonical name = common6.dpool.sina.com.cn.
Name:   common6.dpool.sina.com.cn
Address: 123.126.56.253

在運行的控制檯中,打印出了從114 DNS返回的數據的解析結果:

$ sudo python3 dns.py 
The DNS server is running at 172.16.42.254...
header: {'ID': 25835, 'QR': 1, 'OPCODE': 0, 'AA': 0, 'TC': 0, 'RD': 1, 'RA': 1, 'Z': 0, 'RCODE': 0, 'QDCOUNT': 1, 'ANCOUNT': 2, 'NSCOUNT': 4, 'ARCOUNT': 4}
questions: [{'QNAME': 'api.sina.com.cn', 'QTYPE': 1, 'QCLASS': 1}]
answer: [{'NAME': 'api.sina.com.cn', 'TYPE': 5, 'CLASS': 1, 'TTL': 56, 'RDLENGTH': 16, 'RDATA': 'common6.dpool.sina.com.cn'}, {'NAME': 'common6.dpool.sina.com.cn', 'TYPE': 1, 'CLASS': 1, 'TTL': 34, 'RDLENGTH': 4, 'RDATA': '123.126.56.253'}]
authority: [{'NAME': 'dpool.sina.com.cn', 'TYPE': 2, 'CLASS': 1, 'TTL': 26753, 'RDLENGTH': 6, 'RDATA': 'ns1.sina.com.cn'}, {'NAME': 'dpool.sina.com.cn', 'TYPE': 2, 'CLASS': 1, 'TTL': 26753, 'RDLENGTH': 6, 'RDATA': 'ns3.sina.com.cn'}, {'NAME': 'dpool.sina.com.cn', 'TYPE': 2, 'CLASS': 1, 'TTL': 26753, 'RDLENGTH': 6, 'RDATA': 'ns2.sina.com.cn'}, {'NAME': 'dpool.sina.com.cn', 'TYPE': 2, 'CLASS': 1, 'TTL': 26753, 'RDLENGTH': 6, 'RDATA': 'ns4.sina.com.cn'}]
additional: [{'NAME': 'ns1.sina.com.cn', 'TYPE': 1, 'CLASS': 1, 'TTL': 26674, 'RDLENGTH': 4, 'RDATA': '202.106.184.166'}, {'NAME': 'ns2.sina.com.cn', 'TYPE': 1, 'CLASS': 1, 'TTL': 26652, 'RDLENGTH': 4, 'RDATA': '61.172.201.254'}, {'NAME': 'ns3.sina.com.cn', 'TYPE': 1, 'CLASS': 1, 'TTL': 26509, 'RDLENGTH': 4, 'RDATA': '123.125.29.99'}, {'NAME': 'ns4.sina.com.cn', 'TYPE': 1, 'CLASS': 1, 'TTL': 26497, 'RDLENGTH': 4, 'RDATA': '121.14.1.22'}]

以上完整代碼,見這裏

5. 參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章