Привет, Хабр!
В этой статье я хочу рассказать о своем опыте создания DNS сервера. Разрабатывал я его "чисто повеселиться", при разработке будем придерживаться спецификации RFC.
DNS сервер
Сейчас по-быстрому разберемся, в чем принцип работы DNS серверов. Чтобы сейчас читать эту статью, вы зашли на Хабр, для этого в браузере вы ввели www.habr.com, браузер же переводит этот домен в ip адрес, по типу 178.248.237.68:443, чтобы сделать запрос. Домены существуют, чтобы люди не запоминали эти сложные комбинации чисел, а запоминали только привычные нам слова. DNS сервера же переводят эти домены в нормальный для компьютера вид.
Простая аналогия, телефонная книжка. Вместо того, чтобы запоминать мобильные номера каждого человека, мы создаем контакт и ориентируемся по заданым именам в телефонной книжке.
DNS протокол
DNS протокол является прикладным протоколом, который работает поверх UDP. В данном протоколе сущетствуют только один формат, который называется "Сообщение".
То есть 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 адрес
Резюме
Ну подводя итоги, разработали минимальный по умениям рабочий DNS сервер.
Надеюсь вам понравилась эта статья!
P.S. Возможно много опечаток, не судите строго
Комментарии (9)
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 бита все таки адреса у нас ещё в байтах)
chechyotka Автор
04.12.2023 05:48спасибо за отзыв, изначально хотел сделать минимальное рабочее решение, опечатку поправил
Mexator
04.12.2023 05:48Так как мы не можем запарсить ответ
А что имеется в виду? Почему не можем? Или тут скорее про "не нужно в рамках текущей задачи"?
Можно же делать рекурсивные DNS запросы, и парсить ответы "вышестоящих" серверов
ilyazyabirov
То, что выход туториала совпал с выходом челленджа по DNS на codecrafters - совпадение?
chechyotka Автор
codecrafters украли у меня идею)
даже если идея с codecrafters, почему не рассказать, не все знают в принципе про данный сервис
ilyazyabirov
Я бы не задал этот вопрос, если бы в посте упоминался codecrafters)