Разработка простого DNS сервера на Go, согласно RFC

от автора

Привет, Хабр!

В этой статье я хочу рассказать о своем опыте создания DNS сервера. Разрабатывал я его «чисто повеселиться», при разработке будем придерживаться спецификации RFC.

DNS сервер

Сейчас по-быстрому разберемся, в чем принцип работы DNS серверов. Чтобы сейчас читать эту статью, вы зашли на Хабр, для этого в браузере вы ввели www.habr.com, браузер же переводит этот домен в ip адрес, по типу 178.248.237.68:443, чтобы сделать запрос. Домены существуют, чтобы люди не запоминали эти сложные комбинации чисел, а запоминали только привычные нам слова. DNS сервера же переводят эти домены в нормальный для компьютера вид.
Простая аналогия, телефонная книжка. Вместо того, чтобы запоминать мобильные номера каждого человека, мы создаем контакт и ориентируемся по заданым именам в телефонной книжке.

DNS протокол

DNS протокол является прикладным протоколом, который работает поверх UDP. В данном протоколе сущетствуют только один формат, который называется «Сообщение».

структура DNS сообщения

структура DNS сообщения

То есть DNS-запрос и DNS-ответ имеют одинаковый формат. Размер сообщения — 512 байт, согласно спецификации. Структуру сообщения разберем позже и по порядку.

Начало разработки

Для начала поднимем сервер, принимающий UDP запросы и отдающий пустые ответы, чтобы удостовериться будем просто логировать их.

Код сервера

package main      import (       "fmt"       "log"         "net" )      const Address = "127.0.0.1:2053"      func main() {       udpAddr, err := net.ResolveUDPAddr("udp", Address)       if err != nil {          log.Fatal("failed to resolve udp address", err)       }          udpConn, err := net.ListenUDP("udp", udpAddr)       if err != nil {          log.Fatal("failed to to bind to address", err)       }       defer udpConn.Close()          log.Printf("started server on %s", Address)    // размер бафенра 512 байт согласно спецификации     buf := make([]byte, 512)       for {          size, source, err := udpConn.ReadFromUDP(buf)          if err != nil {             log.Println("failed to receive data", err)             break          }             data := string(buf[:size])             log.Printf("received %d bytes from %s: %s", size, source.String(), data)             response := []byte{} // пустой ответ          _, err = udpConn.WriteToUDP(response, source)          if err != nil {             fmt.Println("Failed to send response:", err)          }       }   } 

Результат кода

С помощью утилиты nc подключились к UDP серверу и отправили запрос. Про утилиту подробнее можно узнать здесь

Заголовок сообщения

Как я указывал выше, в сообщении есть 5 секций, сейчас разберем Header (Заголовок)

заголовок

заголовок

Размер заголовка в любом сообщении ВСЕГДА 12 байт, а числа закодированы в формате Big-Endian. Эта информация нам понадобится когда придется парсить и составлять заголовок. Также можно увидеть множество полей в заголовке, но обратим внимание на важные, по-моему мнению:

  • ID, 16 битное значение, ID ответа всегда равен ID запроса

  • QR, значение 1 для ответа и 0 для запроса

  • RCODE, статус ответа, 0 (no error)

  • QDCOUNT, количество запросов/вопросов в секции Questions в сообщении

  • ANCOUNT, количество ответов в секции Answers в ответе

В Go можем заимплементировать заголовок таким образом:

type Header struct {       PacketID uint16       QR       uint16       OPCODE   uint16       AA       uint16       TC       uint16       RD       uint16       RA       uint16       Z        uint16       RCode    uint16       QDCount  uint16       ANCount  uint16       NSCount  uint16       ARCount  uint16   }

Теперь, когда к нам приходит запрос, нужно распарсить заголовок и перенести данные из заголовка запроса в заголовок ответа. Для этого можем написать функцию для чтения первых 12 байт запроса:

func ReadHeader(buf []byte) Header {       h := Header{          ID:      uint16(buf[0])<<8 | uint16(buf[1]),          QR:      1,  // установили 1, потому что это ответ        OPCODE:  uint16((buf[2] << 1) >> 4),          AA:      uint16((buf[2] << 5) >> 7),          TC:      uint16((buf[2] << 6) >> 7),          RD:      uint16((buf[2] << 7) >> 7),          RA:      uint16(buf[3] >> 7),          Z:       uint16((buf[3] << 1) >> 5),          QDCOUNT: uint16(buf[4])<<8 | uint16(buf[5]),          ANCOUNT: uint16(buf[5])<<8 | uint16(buf[7]),          NSCOUNT: uint16(buf[8])<<8 | uint16(buf[9]),          ARCOUNT: uint16(buf[10])<<8 | uint16(buf[11]),       }      // если в запросе OPCODE не равен нулю, то отправим ответ с кодом ошибки 4     if h.OPCODE == 0 {          h.RCODE = 0       } else {          h.RCODE = 4       }          return h   }

Как видно по коду, приходиться использовать побайтовые сдвиги. Все данные для полей фетчим из заголовка запроса.

Но нам также надо и закодировать сообщение в байты. Ответное сообщение кодируется сверху вниз, то есть сначала кодируем заголовок потом другие секции. Вот функция для кодировки заголовка:

func (h Header) Encode() []byte { dnsHeader := make([]byte, 12)  var flags uint16 = 0 flags = h.QR<<15 | h.OPCODE<<11 | h.AA<<10 | h.TC<<9 | h.RD<<8 | h.RA<<7 | h.Z<<4 | h.RCode  binary.BigEndian.PutUint16(dnsHeader[0:2], h.PacketID) binary.BigEndian.PutUint16(dnsHeader[2:4], flags) binary.BigEndian.PutUint16(dnsHeader[4:6], h.QDCount) binary.BigEndian.PutUint16(dnsHeader[6:8], h.ANCount) binary.BigEndian.PutUint16(dnsHeader[8:10], h.NSCount) binary.BigEndian.PutUint16(dnsHeader[10:12], h.ARCount)  return dnsHeader }

Битовые сдвиги наше все!
Для того, чтобы не запутаться, можно вернуться к этой картинке, где указаны размеры в байтах каждого поля в загаловке

заголовок

заголовок
header := ReadHeader(buf[:12]) log.Printf("ID: %d; QR: %d; QDCount: %d\n", header.PacketID, header.QR, header.QDCount)  response := header.Encode() _, err = udpConn.WriteToUDP(response, source)

После того, как мы распарсили заголовок запроса и закодировали его для ответа, надо как-то протестить то, что мы реализовали. Для этого есть маленький DNS клиент на Python

import socket   def build_dns_query():     header = bytearray([         0x00, 0x01,  # Transaction ID         0x00, 0x00,  # Flags: Standard query         0x00, 0x01,  # Questions         0x00, 0x00,  # Answer RRs         0x00, 0x00,  # Authority RRs         0x00, 0x00   # Additional RRs     ])      return header   def send_dns_query(query, server, port=2053):     with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:         s.sendto(query, (server, port))         response, _ = s.recvfrom(1024)     return response   if __name__ == "__main__":     dns_server = "127.0.0.1"     dns_query = build_dns_query()     dns_response = send_dns_query(dns_query, dns_server)

После запуска скрипта и сервера в логах можно увидеть следующее.
Дебагер, ну чтобы точно удостовериться)

Балдеж

Questions

Запросы (вопросы), как вам удобно, второе поле в каждом DNS запросе, чаще всего количество запросов равно 1, но бывает и несколько запросов/вопросов. Структура запроса имеет куда меньше полей.

Сейчас размеберем каждую в подробности

  • QName, доменное имя, представленное в виде лейблов, например для habr.com будет два лейбла: habr и com.

  • QType, 16 битное число, которое показывает, что мы хотим получить. Для нашего сервера дефолтом будет значение А, потому что А — адрес хоста, полный список типов тут

  • QClass, 16 битное число, которое показывает класс запроса, например, для нашего сервера дефолтом будет значение IN, потому что IN — the Internet полный список классов тут

Но в запросе доменное имя отправляется не сплошным текстом, а кодируется в виде последовательности лейблов <length><label>, где

  • <length> — это один байт, указывающий длину последующего лейбла

  • <label> — сам лейбл

  • \x00 — байт, который указывает на конец последовательности лейблов

Пример, habr.com будет выглядить так

\x04habr\x03com\x00

Теперь можно приступить к имплементации

Для начала создадим тип для QClass и QType. Конечно можно было задать просто две единицы, но мне такой вариант ближе

type Class uint16  const ( _ Class = iota IN CS CH HS )  type Type uint16  const ( _ Type = iota A NS MD MF CNAME SOA MB MG MR NULL WKS PTR HINFO MINFO MX TXT )  type Question struct { QName  string QType  Type QClass Class }

Как и с заголовком нам нужно распарсить запрос и закодировать его для ответа

func ReadQuestion(buf []byte) Question { start := 0 var nameParts []string  for len := buf[start]; len != 0; len = buf[start] { start++ nameParts = append(nameParts, string(buf[start:start+int(len)])) start += int(len) } questionName := strings.Join(nameParts, ".") start++  questionType := binary.BigEndian.Uint16(buf[start : start+2]) questionClass := binary.BigEndian.Uint16(buf[start+2 : start+4])  q := Question{ QName:  questionName, QType:  Type(questionType), QClass: Class(questionClass), }  return q }  func (q Question) Encode() []byte { domain := q.QName parts := strings.Split(domain, ".")  var buf bytes.Buffer  for _, label := range parts { if len(label) > 0 { buf.WriteByte(byte(len(label))) buf.WriteString(label) } } buf.WriteByte(0x00) buf.Write(intToBytes(uint16(q.QType))) buf.Write(intToBytes(uint16(q.QClass)))  return buf.Bytes() }

А также видоизменим отправку ответа в main функции

header := ReadHeader(buf[:12]) log.Printf("ID: %d; QR: %d; QDCount: %d\n", header.PacketID, header.QR, header.QDCount)  question := ReadQuestion(buf[12:])  var res bytes.Buffer res.Write(header.Encode()) res.Write(question.Encode())  _, err = udpConn.WriteToUDP(res.Bytes(), source)

И чуток видоизменим python клиент

import socket   def build_dns_query(domain: str):     header = bytearray([         0x00, 0x01,  # Transaction ID         0x00, 0x00,  # Flags: Standard query         0x00, 0x01,  # Questions         0x00, 0x00,  # Answer RRs         0x00, 0x00,  # Authority RRs         0x00, 0x00   # Additional RRs     ])      question = bytearray()     labels = domain.split('.')     for label in labels:         question.append(len(label))         question.extend(label.encode('utf-8'))     question.extend([0x00, 0x00, 0x01, 0x00, 0x01])  # QTYPE and QCLASS (A record, Internet)      return header + question   def send_dns_query(query, server, port=2053):     with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:         s.sendto(query, (server, port))         response, _ = s.recvfrom(1024)     return response   def parse_dns_response(response):     print(response)     print(response.hex())   if __name__ == "__main__":     dns_server = "127.0.0.1"     domain = "habr.com"     dns_query = build_dns_query(domain)     dns_response = send_dns_query(dns_query, dns_server)     parse_dns_response(dns_response) 

После запуска скрипта и сервера можно снова удостовериться в работе

Распрасенный запрос

Распрасенный запрос

Answers

Ответ — последнее поле, которое разберем, и очень важное, потому что именно тут будет возвращаться IP адрес хоста.

Структура ответа

Структура ответа

В ответе мы встречаем знакомые поля, но из новых тут

  • TTL — time-to-live, период времени в секундах, на которое может закеширироваться на сервере, размер 32 бита

  • RDLENGHT — длина RDATA, так как IP адрес это 4 байта, то будет равно 4, размер 32 бит

  • RDATA — значение, которое является ответом на запрос, в нашем случа IP адрес, к примеру 8.8.8.8

Пример имплементации ответа и, само собой, метод для кодировки

type Answer struct { Name string Type Type Class Class TTL uint32 Length uint32 Data [4]uint8 }  func (a Answer) Encode() []byte { var rrBytes []byte  domain := a.Name parts := strings.Split(domain, ".")  for _, label := range parts { if len(label) > 0 { rrBytes = append(rrBytes, byte(len(label))) rrBytes = append(rrBytes, []byte(label)...) } } rrBytes = append(rrBytes, 0x00)  rrBytes = append(rrBytes, intToBytes(uint16(a.Type))...) rrBytes = append(rrBytes, intToBytes(uint16(a.Class))...)  time := make([]byte, 4) binary.BigEndian.PutUint32(time, a.TTL)  rrBytes = append(rrBytes, time...) rrBytes = append(rrBytes, intToBytes(a.Length)...)  ipBytes, err := net.IPv4(a.Data[0], a.Data[1], a.Data[2], a.Data[3]).MarshalText() if err != nil { return nil }  rrBytes = append(rrBytes, ipBytes...)  return rrBytes } 

Так как мы не можем запарсить ответ, то мы просто прокинем создание структуры, а также создадим мапу, где будем хранить соотношение домена к его IP

айпишники

айпишники
answer := Answer{     Name:   question.QName,     Type:   A,     Class:  IN,     TTL:    0,     Length: net.IPv4len,     Data:   nameToIP[question.QName], }  var res bytes.Buffer res.Write(header.Encode()) res.Write(question.Encode()) res.Write(answer.Encode())  _, err = udpConn.WriteToUDP(res.Bytes(), source)

После запуска Python скрипта можно увидеть наш полученный IP адрес

ip

ip

Резюме

Ну подводя итоги, разработали минимальный по умениям рабочий DNS сервер.
Надеюсь вам понравилась эта статья!

P.S. Возможно много опечаток, не судите строго


ссылка на оригинал статьи https://habr.com/ru/articles/777996/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *