Разработка простого DNS сервера на Go, согласно RFC
Привет, Хабр!
В этой статье я хочу рассказать о своем опыте создания DNS сервера. Разрабатывал я его «чисто повеселиться», при разработке будем придерживаться спецификации RFC.
DNS сервер
Сейчас по-быстрому разберемся, в чем принцип работы DNS серверов. Чтобы сейчас читать эту статью, вы зашли на Хабр, для этого в браузере вы ввели www.habr.com, браузер же переводит этот домен в ip адрес, по типу 178.248.237.68:443, чтобы сделать запрос. Домены существуют, чтобы люди не запоминали эти сложные комбинации чисел, а запоминали только привычные нам слова. DNS сервера же переводят эти домены в нормальный для компьютера вид.
Простая аналогия, телефонная книжка. Вместо того, чтобы запоминать мобильные номера каждого человека, мы создаем контакт и ориентируемся по заданым именам в телефонной книжке.
DNS протокол
DNS протокол является прикладным протоколом, который работает поверх UDP. В данном протоколе сущетствуют только один формат, который называется «Сообщение».
структура 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 полный список классов тут
Но в запросе доменное имя отправляется не сплошным текстом, а кодируется в виде последовательности лейблов
— это один байт, указывающий длину последующего лейбла \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
Резюме
Ну подводя итоги, разработали минимальный по умениям рабочий DNS сервер.
Надеюсь вам понравилась эта статья!