Это должен был быть перевод статьи "From Log Reader to Packet Crafter — Building DNS from Scratch in C". Но автор довольно фигово написал исходную статью и не удосужился показать весь написанный код, Поэтому я решил пойти дальше, реализовать очень маленький инструмент для просмотра DNS ответов и в вольном стиле перевести исходную статью. Поэтому не удивляйтесь, что весь код будет заменен на zig и будет значительно больше объяснений
Что такое DNS на самом деле
Когда вы вводите google.com, ваш компьютер сначала проверяет локальный кэш. Если там ничего нет, он проверяет файл hosts — да, тот самый /etc/hosts (в Linux) файл, с которым вы наверняка уже что-то делали. По-прежнему ничего? Запрос отправляется на ваш преобразователь — обычно это DNS-сервер вашего роутера или интернет-провайдера.
Вот тут-то и начинается самое интересное. Решатель не знает ответ по волшебству. Он отправляется в путешествие:
Сначала он запрашивает корневой сервер: «Где мне найти google.com?» Корневой сервер не знает IP-адрес. Там просто написано "обратитесь к серверу TLD .com". Сервер TLD тоже не знает — он говорит "обратитесь к авторитетному серверу имен Google". Наконец, авторитетный сервер имен Google сообщает: "вот IP: 142.250.x.x.". Этот ответ возвращается на ваш компьютер и кэшируется с TTL, так что все путешествие некоторое время не повторяется.
Резолвер выполняет всю подготовительную работу. Ваш компьютер просто ждет.
Схема как все устроено:

Теория структуры DNS-пакетов
Заголовок — всегда 12 байт
Каждый DNS-пакет начинается с фиксированного 12-байтового заголовка. В Wireshark это первое, что выделяется при нажатии на DNS-запрос.
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| Transaction ID | 2 bytes
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| Flags | 2 bytes
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| Questions | 2 bytes
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| Answer RRs | 2 bytes
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| Authority RRs | 2 bytes
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| Additional RRs | 2 bytes
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| Поле | Значение | Что означает |
|---|---|---|
| Идентификатор транзакции | 0xAAAA (произвольный) | Эхо-ответ, чтобы ваша ОС соответствовала запросу |
| Флаги | 0x0100 | Установлен бит RD — требуется рекурсия |
| Вопросы (Questions) | 0x0001 | Задается один вопрос |
| Ответьте на RRs | 0x0000 | Нулевое значение означает, что это вопрос, а не ответ |
| Авторитет (Authority) RRs | 0x0000 | Ноль |
| Дополнительные RRs | 0x0000 | Ноль |
Что показывает Wireshark
Давайте разберемся с устройством DNS пакета, что он содержит и как устроен
Поле Flags — 0x0100
В запросе вы отправляете 0x0100. Символ 1 в старшем байте — это бит RD (Recursion Desired, «требуется рекурсия»), который сообщает резолверу: «Найди полный ответ, а не просто скажи мне то, что знаешь локально».
В ответ 8.8.8.8 отправляет 0x8180:
- Бит 15 = 1 — это Response
- 0x80 в младшем байте — подтверждено наличие рекурсии
Раздел Question
Следует сразу за 12-байтовым заголовком. Содержит доменное имя в сетевом формате, а также QTYPE и QCLASS.
Сетевой формат — кодировка меток: DNS не передает точки. Вместо них используются метки с префиксом длины:
google.com → 06 67 6f 6f 67 6c 65 03 63 6f 6d 00
^ ^ ^
6, "google" 3, "com" NULL
Каждая метка имеет вид: [1 byte length][label bytes]. Имя завершается байтом 0x00.
После имени:
- QTYPE 0x0001 — запись (IPv4-адрес)
- QCLASS 0x0001 — IN (Интернет)
Раздел Answer
Каждая запись ресурса (ЗР) в разделе ответов:
[name: 2 bytes] — usually 0xc00c compression pointer
[type: 2 bytes] — 0x0001 = A record
[class: 2 bytes] — 0x0001 = IN
[TTL: 4 bytes] — seconds to cache this record
[rdlength: 2 bytes] — byte length of the data that follows
[rdata: N bytes] — the actual IP address (4 bytes for A record)
Указатель сжатия 0xc00c: DNS позволяет избежать повторения доменных имен. Два верхних бита 0xC0 сигнализируют о том, что «это указатель». Второй байт 0x0c = смещение 12 — указывает на имя в разделе вопроса.
RCODE — код ответа
Нижние 4 бита поля flags в ответе. Маска с & 0x000F.
| RCODE | Значение |
|---|---|
| 0 | Ошибки нет |
| 1 | Ошибка форматирования |
| 2 | Сбой сервера |
| 3 | NXDOMAIN — имя не существует |
| 5 | Отказано |
Реализация простого DNS-преобразователя
Как я уже говорил, реализовать логику работы с DNS мы будем на zig
Наш инструмент будет уметь три вещи: собрать DNS-запрос в бинарном виде, отправить его через UDP на DNS-сервер (например, 8.8.8.8), получить ответ и разобрать его — извлечь IP-адрес и TTL. Мы пройдём этот путь шаг за шагом: от структуры заголовка до готового сетевого вызова.
Ключевая концепция — порядок следования байтов очень важен. DNS — это сетевой протокол, который передает целые числа в порядке следования байтов от старшего к младшему (сначала самый значимый байт). В x86 Linux используется порядок следования байтов от младшего к старшему. Всегда используйте:
htons()— хост в сеть, 2-байтовые целые числа (заголовки, порты)htonl()— хост в сеть, 4-байтовые целые числа (TTL)ntohs() / ntohl()— обратный порядок, для чтения ответов
Разобравшись с порядком байтов, переходим к сердцу DNS-пакета — заголовку. Он всегда идёт первым и имеет фиксированную длину 12 байт. Именно по заголовку сервер понимает, запрос это или ответ, сколько в нём вопросов и записей. Для работы с таким бинарным форматом Zig позволяет описать структуру, которая ляжет на байты «как есть» — без выравнивания и дополнительных полей.
Структура DnsHeader:
pub const DnsHeader = extern struct {
id: u16, // Transaction ID
flags: u16, // Query flags
qdcount: u16, // Number of questions
ancount: u16, // Number of answers
nscount: u16, // Number of authority records
arcount: u16, // Number of additional records
};
Каждое поле заголовка отвечает за свою часть протокола:
- id — идентификатор транзакции. Клиент генерирует произвольное число, сервер копирует его в ответ. Так клиент понимает, какому запросу соответствует пришедший ответ.
- flags — битовое поле. В старших битах хранятся флаги типа «запрос или ответ», в младших — код возврата (RCODE). Мы будем устанавливать бит RD (Recursion Desired).
- qdcount — количество вопросов в пакете. Обычно 1.
- ancount — количество ответных записей. В запросе 0, сервер заполняет в ответе.
- nscount и arcount — количество полномочных и дополнительных записей. В простых запросах не используются.
Тип u16 гарантирует ровно 2 байта независимо от платформы.
Давайте напишем функцию buildHeader(), которая будет подготавливать заголовок в нужном виде:
pub fn buildHeader(buf: []u8) void {
const h = std.mem.bytesAsValue(DnsHeader, buf[0..@sizeOf(DnsHeader)]);
h.id = std.mem.nativeToBig(u16, 0xAAAA); // arbitrary transaction ID
h.flags = std.mem.nativeToBig(u16, 0x0100); // standard query, recursion desired
h.qdcount = std.mem.nativeToBig(u16, 1); // one question
h.ancount = 0;
h.nscount = 0;
h.arcount = 0;
}
Разберем, что делает buildHeader. Функция получает сырой буфер и заполняет первые 12 байт по шаблону DNS-заголовка. bytesAsValue накладывает структуру DnsHeader на байты — теперь можно обращаться к полям как к обычным переменным, а все изменения сразу попадают в буфер.
id = 0xAAAA— произвольный идентификатор, сервер вернёт такое же число в ответеflags = 0x0100— стандартный запрос с флагом «требуется рекурсия» (RD=1)qdcount = 1— задаём ровно один вопросancount,nscount,arcount = 0— в запросе ответов нет, эти поля сервер заполнит сам
Обратите внимание: все числа пропускаются через nativeToBig, чтобы на x86-машине байты легли в сетевом порядке (старший байт первым). Если этого не сделать, сервер не поймёт заголовок.
htons()из сишной реализации в оригинальной статье заменен наstd.mem.nativeToBig(u16, value)— работает на любой платформе, включая macOS(struct dns_header *)bufзаменен наstd.mem.bytesAsValue(DnsHeader, buf[0..@sizeOf(DnsHeader)])— безопасное переосмысление байтов без копирования
Реализация encodeDomain():
Преобразует "google.com" в формат DNS wire. Обходит строку, записывает байт длины перед каждой меткой, записывает 0x00 в конце, а затем добавляет QTYPE и QCLASS.
pub fn encodeDomain(buf: []u8, offset: usize, domain: []const u8) usize {
var pos = offset;
var label_len_pos = pos;
var label_len: u8 = 0;
pos += 1; // reserve space for first label length
for (domain) |ch| {
if (ch == '.') {
buf[label_len_pos] = label_len;
label_len = 0;
label_len_pos = pos;
pos += 1; // reserve space for next label length
} else {
buf[pos] = ch;
pos += 1;
label_len += 1;
}
}
buf[label_len_pos] = label_len; // fill in final label length
buf[pos] = 0x00; // null terminator
pos += 1;
// QTYPE: A record (0x0001)
std.mem.writeInt(u16, buf[pos..][0..2], 1, .big);
pos += 2;
// QCLASS: IN (0x0001)
std.mem.writeInt(u16, buf[pos..][0..2], 1, .big);
pos += 2;
return pos;
}
Функция превращает читаемое имя "google.com" в двоичный формат, который понимает DNS-сервер:
- Разбивает имя по точкам на части-метки: "google" и "com"
- Перед каждой частью пишет её длину в байтах: \x06google\x03com
- Ставит \x00 в конце — это сигнал «имя закончилось»
- Дописывает QTYPE и QCLASS — два числа по 2 байта, которые говорят «запрашиваем A-запись (IPv4) в классе IN (интернет)»
Итог для "google.com":
06 67 6f 6f 67 6c 65 03 63 6f 6d 00 00 01 00 01
^ ^ ^ ^ ^
6, "google" 3, "com" NUL A IN
Хитрость в том, что функция не знает длину метки заранее — она резервирует место под байт длины, потом считает буквы, и только когда встречает . или конец строки — вписывает посчитанную длину в зарезервированную позицию. Это работает за один проход, без временных массивов.
Теперь, когда заголовок и доменное имя готовы к записи, соберём их вместе и посмотрим на результат.
Пример сборки пакета
const std = @import("std");
const dnss = @import("dnss");
pub fn main() !void {
var buf: [512]u8 = undefined;
// Build DNS header at the beginning of the buffer
dnss.buildHeader(buf[0..]);
const header_end = @sizeOf(dnss.DnsHeader);
// Encode domain "google.com" immediately after the header
const question_end = dnss.encodeDomain(buf[header_end..], 0, "google.com");
const total_len = header_end + question_end;
// Print the raw DNS query bytes
std.debug.print("DNS query ({d} bytes):\n", .{total_len});
for (buf[0..total_len], 0..) |byte, i| {
if (i % 16 == 0) std.debug.print(" ", .{});
std.debug.print("{X:0>2} ", .{byte});
if (i % 16 == 15) std.debug.print("\n", .{});
}
std.debug.print("\n", .{});
}
Тут делается три вещи:
- Выделяет буфер на 512 байт — это «чистый лист», куда будем записывать DNS-запрос.
- Вызывает buildHeader — заполняет первые 12 байт заголовком DNS (ID, флаги, количество вопросов).
- Вызывает encodeDomain — сразу после заголовка записывает домен "google.com" в DNS-формате вместе с QTYPE и QCLASS.
- Печатает результат — выводит все байты запроса в шестнадцатеричном виде, чтобы можно было глазами проверить, что всё легло как надо.
По сути программа собирает сырой DNS-пакет и показывает его на экране — ничего не отправляет в сеть, просто демонстрация работы двух функций.
Разбор DNS-ответа
Отправив запрос, мы получим от сервера бинарный ответ. В нем — тот же заголовок только с флагом Response, секция вопроса в которой находится наш вопрос и, если домен существует, секция ответов с IP-адресами. Задача парсера — пройти по этим байтам и извлечь полезную информацию: IP, TTL и код ошибки.
Разбор ответа:
pub fn parseResponse(response: []const u8) void {
if (response.len < @sizeOf(DnsHeader)) return;
const h = std.mem.bytesAsValue(DnsHeader, response[0..@sizeOf(DnsHeader)]);
const rcode: u8 = @intCast(std.mem.bigToNative(u16, h.flags) & 0x000F);
if (rcode == 3) {
std.debug.print("NXDOMAIN - domain does not exist.\n", .{});
return;
}
if (rcode != 0) {
std.debug.print("DNS ERROR - RCODE {d}\n", .{rcode});
return;
}
const answers = std.mem.bigToNative(u16, h.ancount);
// Skip question section — walk past the name and QTYPE/QCLASS
var offset: usize = @sizeOf(DnsHeader);
while (offset < response.len and response[offset] != 0x00) {
offset += @as(usize, response[offset]) + 1;
}
if (offset >= response.len) {
return;
}
offset += 1; // skip null terminator
offset += 4; // skip QTYPE + QCLASS
for (0..answers) |_| {
if (offset >= response.len) {
return;
}
// Skip name — handles 2-byte compression pointer (top bits 0b11)
if (response[offset] & 0xC0 == 0xC0) {
offset += 2;
} else {
while (offset < response.len and response[offset] != 0x00) {
offset += @as(usize, response[offset]) + 1;
}
if (offset >= response.len) return;
offset += 1;
}
if (offset + 10 > response.len) return;
const rtype = std.mem.readInt(u16, response[offset..][0..2], .big);
offset += 2;
const rclass = std.mem.readInt(u16, response[offset..][0..2], .big);
_ = rclass;
offset += 2;
const ttl = std.mem.readInt(u32, response[offset..][0..4], .big);
offset += 4;
const rdlength = std.mem.readInt(u16, response[offset..][0..2], .big);
offset += 2;
if (rtype == 1 and rdlength == 4) {
if (offset + 4 > response.len) return;
const ip = response[offset..][0..4];
std.debug.print("Resolved IP: {d}.{d}.{d}.{d}\n", .{ ip[0], ip[1], ip[2], ip[3] });
std.debug.print("TTL : {d} seconds\n", .{ttl});
}
offset += rdlength;
}
}
- Читает заголовок через
bytesAsValue(DnsHeader, ...)— безопасный аналог C-каста - Извлекает RCODE из младших 4 бит флагов через
bigToNative(аналог ntohs) - Проверяет ошибки:
RCODE=3 → NXDOMAIN, любой другой ≠ 0 → ошибка - Пропускает секцию вопроса — шагает по меткам домена до 0x00, затем +4 байта QTYPE/QCLASS
- Разбирает каждый ответ: пропускает имя (поддерживает compression pointers
0xC0xx), читаетtype/class/TTL/rdlengthчерезreadInt(.big)(аналогntohs/ntohlна месте), и если это A-запись (type=1, rdlength=4) — форматирует IPv4 вида {d}.{d}.{d}.{d} и TTL
Отдельного внимания заслуживает обработка compression pointers. DNS-серверы не повторяют доменное имя в каждом ответе — вместо этого они ставят указатель из двух байт, где старшие два бита равны 1 (маска 0xC0), а оставшиеся 14 бит указывают смещение в пакете, где уже лежит полное имя. Наш парсер проверяет: если встретил 0xC0 — пропускает 2 байта, иначе идет по меткам до 0x00, как в секции вопроса.
Отправка запроса и получение ответа
Теперь объединим сборку пакета, сетевой ввод-вывод и разбор ответа в одной программе.
собираем все вместе
const std = @import("std");
const dnss = @import("dnss");
const net = std.Io.net;
pub fn main(init: std.process.Init) !void {
var buf: [512]u8 = undefined;
// Build DNS query
dnss.buildHeader(buf[0..]);
const header_len = @sizeOf(dnss.DnsHeader);
const question_len = dnss.encodeDomain(buf[header_len..], 0, "google.com");
const total_len = header_len + question_len;
// Bind UDP socket (ephemeral port on all interfaces)
const local = try net.IpAddress.parseIp4("0.0.0.0", 0);
const socket = try net.IpAddress.bind(&local, init.io, .{ .mode = .dgram });
defer socket.close(init.io);
// Google DNS server
const server = try net.IpAddress.parseIp4("8.8.8.8", 53);
// Send query
try socket.send(init.io, &server, buf[0..total_len]);
std.debug.print("Sent {d} bytes to 8.8.8.8:53\n\n", .{total_len});
// Receive response
var recv_buf: [2048]u8 = undefined;
const msg = try socket.receive(init.io, recv_buf[0..]);
std.debug.print("Received {d} bytes\n\n", .{msg.data.len});
// Parse and display response
dnss.parseResponse(msg.data);
}
init.io— объектIoприходит через параметрmain(init: std.process.Init), это стандартный способ получения I/O в Zig 0.16net.IpAddress.parseIp4("0.0.0.0", 0)— создаём локальный адрес на произвольном портуnet.IpAddress.bind(&local, init.io, .{ .mode = .dgram })— открываем UDP-сокетsocket.send()/socket.receive()— отправка и получение дейтаграмм через асинхронный Io
Обратите внимание на размеры буферов: для запроса достаточно 512 байт, это стандартный минимальный размер DNS-пакета. А для ответа мы выделяем 2048 байт — ответы могут быть длиннее за счёт большого количества записей или DNSSEC-подписей. В продакшене стоило бы использовать буфер в 4096 байт с поддержкой EDNS0.
Использование
Исходники можно посмотреть на github: github.com/akovardin/dnss
Пример запроса за существующим доменом:
$ ./zig-out/bin/dnss kodikapusta.ru
Query: kodikapusta.ru (32 bytes) -> 8.8.8.8:53
Received 48 bytes
Resolved IP: 5.53.125.59
TTL : 3600 seconds
Недопустимый домен:
$ ./zig-out/bin/dnss dddfffggfg
Query: dddfffggfg (28 bytes) -> 8.8.8.8:53
Received 103 bytes
NXDOMAIN - domain does not exist.
Осталось только переписать на Rust