DNS на Zig

Post Thumbnail

Это должен был быть перевод статьи "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-сервер:

  1. Разбивает имя по точкам на части-метки: "google" и "com"
  2. Перед каждой частью пишет её длину в байтах: \x06google\x03com
  3. Ставит \x00 в конце — это сигнал «имя закончилось»
  4. Дописывает 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", .{});
}

Тут делается три вещи:

  1. Выделяет буфер на 512 байт — это «чистый лист», куда будем записывать DNS-запрос.
  2. Вызывает buildHeader — заполняет первые 12 байт заголовком DNS (ID, флаги, количество вопросов).
  3. Вызывает encodeDomain — сразу после заголовка записывает домен "google.com" в DNS-формате вместе с QTYPE и QCLASS.
  4. Печатает результат — выводит все байты запроса в шестнадцатеричном виде, чтобы можно было глазами проверить, что всё легло как надо.

По сути программа собирает сырой 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;
    }
}
  1. Читает заголовок через bytesAsValue(DnsHeader, ...) — безопасный аналог C-каста
  2. Извлекает RCODE из младших 4 бит флагов через bigToNative (аналог ntohs)
  3. Проверяет ошибки: RCODE=3 → NXDOMAIN, любой другой ≠ 0 → ошибка
  4. Пропускает секцию вопроса — шагает по меткам домена до 0x00, затем +4 байта QTYPE/QCLASS
  5. Разбирает каждый ответ: пропускает имя (поддерживает 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.16
  • net.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