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

В этой статье я хочу рассказать о своем опыте создания 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. Возможно много опечаток, не судите строго

Комментарии (9)


  1. ilyazyabirov
    04.12.2023 05:48

    То, что выход туториала совпал с выходом челленджа по DNS на codecrafters - совпадение?


    1. chechyotka Автор
      04.12.2023 05:48

      codecrafters украли у меня идею)
      даже если идея с codecrafters, почему не рассказать, не все знают в принципе про данный сервис


      1. ilyazyabirov
        04.12.2023 05:48

        Я бы не задал этот вопрос, если бы в посте упоминался codecrafters)


  1. akaddr
    04.12.2023 05:48

    Наверное парсить и создавать пакет проще с gopacket?


    1. chechyotka Автор
      04.12.2023 05:48

      хотелось реализовать без сторонних библиотек, самому потыкать


  1. Gamedef256
    04.12.2023 05:48

    Как простой DNS сервер вполне рабочее решение.

    Но в реальных DNS серверах используется ещё и message compression https://www.rfc-editor.org/rfc/rfc1035 пункт 4.1.4. В реальных DNS ответах эта структура встречается достаточно часто.

    И есть опечатка RDLENGHT - длина RDATA, так как IP адрес это 4 бита все таки адреса у нас ещё в байтах)


    1. chechyotka Автор
      04.12.2023 05:48

      спасибо за отзыв, изначально хотел сделать минимальное рабочее решение, опечатку поправил


  1. Mexator
    04.12.2023 05:48

    Так как мы не можем запарсить ответ

    А что имеется в виду? Почему не можем? Или тут скорее про "не нужно в рамках текущей задачи"?

    Можно же делать рекурсивные DNS запросы, и парсить ответы "вышестоящих" серверов


    1. chechyotka Автор
      04.12.2023 05:48

      скорее "не нужно в рамках текущей задачи"