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