Разработка простого 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)  
       }  
    }  
}

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

mrku9xhgphgalp8xugb8uiit5n4.gif

С помощью утилиты 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)

cbjcii2ypghntrahpeuveqeayli.png

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

Балдеж

Questions

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

xbkaa0p6jltpxvo9dx4idxbwh-w.png

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

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

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

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

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

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

  • \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 client

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, размер 16 бит

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

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

type Answer struct {
	Name string
	Type Type
	Class Class
	TTL uint32
	Length uint16
	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 сервер.
Надеюсь вам понравилась эта статья!

© Habrahabr.ru