Компьютерные сети DNS: иерархия, типы записей, резолвинг, кэширование, DoH/DoT
0%

DNS: иерархия, типы записей, резолвинг, кэширование, DoH/DoT

DNS: иерархия, типы записей, резолвинг, кэширование, DoH/DoT

Каждая статья этого трека до сих пор начиналась с готового IP-адреса. Канальный уровень доставлял кадр до соседа, IP — пакет до сети, TCP и QUIC строили поверх этого надёжный поток. Откуда берётся сам адрес, мы аккуратно не спрашивали.

Отвечает на это DNS — и отвечает так, что за 40 лет система пережила рост на семь порядков без единой точки отказа и без единой глобальной транзакции. Цена, которую мы за это платим, — согласованность в конечном счёте. В DNS нет момента «запись изменена». Есть момент «авторитетный сервер начал отдавать новое значение», и есть долгий хвост чужих кэшей, которые про это ещё не знают и знать не обязаны. Почти все страшные истории про DNS — это истории про то, как инженер перепутал первое со вторым.

Вторая половина страшных историй — про то, что DNS проектировали в 1983 году, когда сеть состояла из знакомых друг с другом организаций. Аутентичности данных в протоколе не было вообще, приватности — тем более. Ответ приходил открытым текстом в одной UDP-датаграмме, и единственным доказательством подлинности были 16 бит случайного ID. Обе дыры закрывали задним числом: DNSSEC для подлинности, DoT/DoH/DoQ для приватности. Обе заплатки заметно сложнее того, что латают, и обе с собственной ценой.

Аналогия на всю статью: DNS — не телефонная книга, а система справочных бюро с делегированием полномочий. Корневое бюро не знает ни одного телефона, но точно знает, кто ведёт справочник по .com. Тот не знает ваш телефон, но знает, кто ведёт справочник по example.com. А ещё каждое бюро по дороге запоминает ответ на некоторое время — и именно поэтому справка бывает устаревшей.

Зачем понадобился DNS

До 1983 года имена хостов ARPANET жили в одном файле — HOSTS.TXT. Его вручную вёл Стэнфордский исследовательский институт (SRI-NIC), а администраторы периодически скачивали копию по FTP. Схема работала, пока хостов были сотни, и начала разваливаться на тысячах: файл рос, трафик на его раздачу рос квадратично, конфликты имён приходилось разруливать по телефону, а изменение применялось днями.

Пол Мокапетрис предложил заменить файл распределённой иерархической базой — RFC 882/883 (1983), которые через четыре года переписали в канонические RFC 1034 (концепции) и RFC 1035 (формат сообщений). Три инженерных решения из этих документов работают до сих пор:

  1. Иерархия имён — пространство имён делится на дерево, право именования внутри поддерева делегируется вниз. Конфликтов не бывает по построению.
  2. Делегирование администрирования — тот, кому делегировали зону, отвечает за неё сам. Центральной точки записи нет, и её невозможно перегрузить.
  3. Кэширование с явным TTL — автор данных сам объявляет, сколько его ответ разрешено считать актуальным. Это уводит подавляющее большинство запросов из сети вообще.

Пространство имён: дерево, зоны, делегирование

Имя читается справа налево. www.api.example.com. — это метка www внутри api внутри example внутри com внутри корня. Финальная точка — это и есть корень; имя с ней называется FQDN (fully qualified domain name), без неё имя относительное и его будут достраивать по search-доменам. Точка в конце — не педантизм: в Kubernetes она экономит по шесть лишних запросов на каждый резолв.

Жёсткие ограничения формата (RFC 1035 §2.3.4), которые стоит помнить наизусть:

  • метка — от 1 до 63 байт (в длине метки два старших бита зарезервированы под сжатие, отсюда 63, а не 255);
  • полное имя на проводе — до 255 байт вместе с байтами длин и завершающим нулём;
  • сравнение имён регистронезависимое, но регистр сохраняется при передаче — на этом построен трюк 0x20 из раздела про безопасность;
  • не-ASCII имена кодируются в punycode: почта.рф едет по сети как xn--80a1acny.xn--p1ai (IDNA, RFC 5890).

Ключевое различие, которое путают чаще всего: домен — это поддерево имён, зона — это административная единица. Зона example.com содержит все имена поддерева, кроме тех, что делегированы дальше. Делегирование выглядит как набор записей NS в родительской зоне: .com не хранит адрес www.example.com, он хранит «спрашивайте a.iana-servers.net».

Glue-записи — тонкое место. Если серверы зоны example.com называются ns1.example.com, возникает круг: чтобы узнать адрес ns1.example.com, нужно спросить ns1.example.com. Разрывается он тем, что родительская зона .com вместе с делегированием отдаёт адреса этих серверов в секции additional. Забыли обновить glue при смене IP — домен пропадает у всех, у кого истёк кэш, а dig с вашего ноутбука ещё несколько часов показывает, что всё в порядке.

Отдельный класс инцидентов — lame delegation: родитель говорит «спрашивайте ns2», а ns2 про эту зону ничего не знает и отвечает REFUSED. Резолвер честно перебирает серверы, но половина запросов тратит лишний RTT, а часть падает в SERVFAIL. Проверяется одной командой:

# Спрашиваем каждый NS напрямую, без рекурсии, и смотрим на флаг aa
$ for ns in $(dig +short NS example.com); do
    printf '%-28s ' "$ns"
    dig @"$ns" example.com SOA +norecurse +noall +comments | grep -o 'status: [A-Z]*, .*flags: [a-z ]*'
  done
a.iana-servers.net.          status: NOERROR, flags: qr aa
b.iana-servers.net.          status: NOERROR, flags: qr aa

aa (authoritative answer) обязан быть у всех перечисленных серверов. Нет aa — делегирование битое.

Типы записей: что реально бывает в зоне

Единица хранения в DNS — не запись, а RRset: все записи с одинаковыми (имя, тип, класс). Резолвер кэширует RRset целиком и целиком же его подписывает DNSSEC. Отсюда правило: у всех записей одного RRset обязан быть одинаковый TTL (RFC 2181 §5.2).

Тип Что содержит Зачем и где подвох
A IPv4-адрес базовая привязка имени к адресу
AAAA IPv6-адрес отдельный RRset с отдельным TTL — про это ниже
CNAME каноническое имя псевдоним; не может сосуществовать с другими записями того же имени
MX приоритет + хост почта; хост в MX обязан быть именем с A/AAAA, не CNAME
TXT произвольный текст SPF, DKIM, DMARC, подтверждение владения доменом
NS имя сервера зоны делегирование; живёт и в родителе, и в самой зоне
SOA параметры зоны serial, refresh, retry, expire, minimum = TTL негативного кэша
PTR имя по адресу обратные зоны in-addr.arpa и ip6.arpa; почтовики его проверяют
SRV приоритет, вес, порт, хост сервис + порт; SIP, XMPP, Kerberos, LDAP
CAA какому УЦ можно выпускать сертификат единственная запись, которую обязаны проверять все публичные УЦ
HTTPS / SVCB параметры подключения ALPN, порт, ECH-ключ, адреса — до первого пакета
DNAME подстановка поддерева переименование целой ветки
DS, DNSKEY, RRSIG, NSEC3 криптография DNSSEC

Проблема CNAME на apex ловит всех. Стандарт запрещает CNAME рядом с другими записями, а на apex домена (example.com, без поддомена) обязаны быть SOA и NS. Значит, example.com. CNAME cdn.provider.net. — невалидная конфигурация, и строгий сервер её не примет. Провайдеры обходят это нестандартными типами ALIAS/ANAME/CNAME flattening: авторитетный сервер сам резолвит цель и отдаёт наружу обычные A. Работает, но резолв цели происходит с точки зрения вашего DNS-провайдера, а не пользователя, — geo-балансировка CDN при этом промахивается. Правильное решение с 2023 года — запись HTTPS из RFC 9460, у которой есть легальный механизм AliasMode на apex.

Как это выглядит вживую:

$ dig +noall +answer cloudflare.com HTTPS
cloudflare.com.	300 IN HTTPS 1 . alpn="h3,h2" ipv4hint=104.16.132.229 ipv6hint=2606:4700::6810:84e5

$ dig +noall +answer _dmarc.example.com TXT
_dmarc.example.com. 3600 IN TXT "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; pct=100"

$ dig +noall +answer example.com MX
example.com.	3600 IN MX 0 .

Три примера — три урока. Первый: запись HTTPS сообщает браузеру, что сервер умеет HTTP/3, до установки соединения, — раньше для этого требовался круг по HTTP/1.1 с заголовком Alt-Svc (см. HTTP), а ipv4hint позволяет не ждать отдельный запрос A. Второй: политика DMARC — обычный TXT, и опечатка в нём молча выключает защиту почты. Третий: MX 0 . — это «null MX» из RFC 7505, явное заявление «домен почту не принимает»; отсутствие MX означало бы fallback на A-запись и доставку на веб-сервер.

Зона в текстовом виде (формат master file, RFC 1035 §5) — то, что реально лежит на авторитетном сервере:

$ORIGIN example.com.
$TTL 3600                       ; TTL по умолчанию для записей ниже

@   IN SOA ns1.example.com. hostmaster.example.com. (
        2026071601   ; serial — монотонно растёт, иначе вторичные не заберут зону
        7200         ; refresh — как часто вторичный опрашивает первичный
        3600         ; retry   — пауза после неудачной попытки
        1209600      ; expire  — когда вторичный признает данные мёртвыми
        3600 )       ; minimum — TTL негативного кэша (NXDOMAIN), НЕ «минимальный TTL»

@       IN NS    ns1.example.com.
@       IN NS    ns2.example.com.
@       IN A     203.0.113.10
@       IN AAAA  2001:db8::10
@       IN MX    10 mx1.example.com.
@       IN CAA   0 issue "letsencrypt.org"
www     IN CNAME @
api  60 IN A     203.0.113.20        ; собственный TTL 60 — готовим быструю перекладку
ns1     IN A     203.0.113.53        ; это и есть glue, отдаваемый родителем
_dmarc  IN TXT   "v=DMARC1; p=reject"

Поле minimum в SOA — самое неудачно названное поле во всём DNS. С 1998 года (RFC 2308) оно означает не «минимальный TTL записей», а срок кэширования отрицательных ответов. Поставили 86400 — и опечатка в имени будет отдавать NXDOMAIN сутки после того, как вы её исправите.

Как выглядит резолв: от getaddrinfo до корня

Резолв делится на две принципиально разные роли, и путаница между ними — источник половины непониманий.

Stub-резолвер живёт внутри вашего процесса (это getaddrinfo() из libc или встроенный резолвер Go/JVM). Он умеет ровно одно: задать вопрос настроенному серверу и поверить ответу. Настройки берёт из /etc/resolv.conf и /etc/nsswitch.conf.

Рекурсивный резолвер — сервер, который делает всю работу: обходит иерархию сверху вниз итеративными запросами и кэширует всё, что видит. Это 1.1.1.1, 8.8.8.8, резолвер провайдера, CoreDNS в кластере.

Ключевая деталь: рекурсивным запрос делает флаг RD, а иерархию резолвер обходит итеративно — каждый сервер отвечает не «вот адрес», а «вот кто знает больше».

Обратите внимание на шаг 4: резолвер спрашивает у корня не полное имя, а только com. Это QNAME minimisation (RFC 9156): корневому серверу незачем знать, что вы ищете secret-staging.example.com. До 2016 года полное имя уходило на каждый сервер по пути, включая корневые.

Посмотреть весь обход целиком можно dig +trace — он игнорирует ваш резолвер и идёт по цепочке сам:

$ dig +trace +nodnssec example.com A

.			518400	IN	NS	a.root-servers.net.
.			518400	IN	NS	m.root-servers.net.
;; Received 239 bytes from 127.0.0.53#53(127.0.0.53) in 1 ms

com.			172800	IN	NS	a.gtld-servers.net.
com.			172800	IN	NS	m.gtld-servers.net.
;; Received 1170 bytes from 192.5.5.241#53(f.root-servers.net) in 12 ms

example.com.		172800	IN	NS	a.iana-servers.net.
example.com.		172800	IN	NS	b.iana-servers.net.
;; Received 275 bytes from 192.55.83.30#53(m.gtld-servers.net) in 24 ms

example.com.		86400	IN	A	93.184.216.34
;; Received 56 bytes from 199.43.135.53#53(a.iana-servers.net) in 8 ms

Три сетевых обращения, ~45 мс — и это холодный путь, который случается один раз на тысячи запросов. Стоимость резолва в общем случае — O(глубина имени) обращений по RTT и O(1) по памяти клиента; вся экономика DNS построена на том, что верхние уровни дерева меняются раз в сутки и живут в кэше вечно.

Первая строка вывода важна отдельно: даже +trace начинает с вашего резолвера, чтобы узнать список корневых серверов. Настоящий рекурсивный сервер берёт его из вшитого файла root hints и при старте делает priming query — спрашивает NS . у первого попавшегося корневого сервера, чтобы получить актуальный список.

Между приложением и рекурсивным резолвером есть ещё слой, про который забывают. Вот что на самом деле делает getaddrinfo:

$ cat /etc/nsswitch.conf | grep hosts
hosts: files mdns4_minimal [NOTFOUND=return] resolve [!UNAVAIL=return] dns

$ cat /etc/resolv.conf
nameserver 127.0.0.53          # это stub systemd-resolved, а не настоящий резолвер
options edns0 trust-ad
search prod.svc.cluster.local svc.cluster.local cluster.local
options ndots:5

Порядок из nsswitch.conf объясняет классику «dig работает, приложение — нет»: dig не читает ни /etc/hosts, ни nsswitch.conf. Он говорит с сервером напрямую. Приложение идёт через libc и может получить ответ из files (то есть /etc/hosts) или от mDNS. Инструмент, который повторяет путь приложения, — getent hosts или resolvectl query:

$ getent hosts api.example.com
203.0.113.20    api.example.com

$ resolvectl query api.example.com
api.example.com: 203.0.113.20                  -- link: eth0

-- Information acquired via protocol DNS in 1.9ms.
-- Data is authenticated: no; Data was acquired via local or encrypted transport: no
-- Data from: cache network

Строка Data from: cache — прямой ответ на вопрос «почему я поменял запись, а ничего не изменилось».

Сообщение на проводе: заголовок, EDNS, TCP

Формат сообщения не менялся с 1987 года и одинаков для запроса и ответа: 12-байтовый заголовок, затем четыре секции подряд без разделителей — их длины определяются только счётчиками из заголовка.

Формат DNS-сообщения: заголовок, флаги, секции и сжатие имён

Что важно на практике:

  • ID (16 бит) плюс случайный исходный UDP-порт (ещё ~16 бит) — вся защита ответа от подделки до появления DNSSEC. Итого около 32 бит энтропии, которые атака Камински научилась обходить.
  • RCODE (4 бита) — тот самый status: в выводе dig. NOERROR (0), SERVFAIL (2), NXDOMAIN (3), REFUSED (5). Расширенные коды ошибок (RFC 8914) едут в EDNS и объясняют, почему SERVFAIL.
  • TC (truncated) — «ответ не влез». Клиент обязан повторить вопрос по TCP. DNS работает поверх TCP не как аварийный режим, а как штатный транспорт (RFC 7766); фаервол, режущий 53/tcp, ломает всё большое: DNSSEC, длинные TXT, зонные передачи.
  • AA — ответ от авторитетного сервера; RA — резолвер готов рекурсировать; AD — данные прошли DNSSEC-валидацию; CD — «не проверяй DNSSEC, отдай как есть» (бесценно при отладке).
  • EDNS(0) (RFC 6891) — псевдозапись OPT в additional. Она несёт размер UDP-буфера, флаг DO (хочу DNSSEC), DNS-cookies, NSID и ECS. Именно её видно как строку ;; OPT PSEUDOSECTION:.

Соберём и разберём сообщение руками — это лучший способ убедиться, что формат простой:

"""Минимальный DNS-клиент: собрать запрос, отправить по UDP, разобрать ответ.
Ровно то, что делает stub-резолвер внутри libc, без сторонних библиотек."""

import random
import socket
import struct

QTYPE = {"A": 1, "NS": 2, "CNAME": 5, "SOA": 6, "PTR": 12,
         "MX": 15, "TXT": 16, "AAAA": 28, "SRV": 33, "HTTPS": 65}
RCODE = {0: "NOERROR", 1: "FORMERR", 2: "SERVFAIL",
         3: "NXDOMAIN", 4: "NOTIMP", 5: "REFUSED"}


def encode_name(name: str) -> bytes:
    # "example.com" превращается в 07 'example' 03 'com' 00
    out = bytearray()
    for label in name.rstrip(".").split("."):
        raw = label.encode() if label.isascii() else label.encode("idna")
        if not 1 <= len(raw) <= 63:
            raise ValueError(f"метка {label!r} нарушает лимит 1..63 байта")
        out.append(len(raw))
        out += raw
    out.append(0)                       # корневая метка — та самая точка в конце
    if len(out) > 255:
        raise ValueError("имя длиннее 255 байт")
    return bytes(out)


def build_query(name: str, qtype: str = "A", udp_payload: int = 1232) -> tuple[int, bytes]:
    tid = random.SystemRandom().randrange(0x10000)          # 16 бит энтропии
    header = struct.pack("!HHHHHH", tid, 0x0100, 1, 0, 0, 1)  # flags: RD=1; 1 вопрос, 1 additional
    question = encode_name(name) + struct.pack("!HH", QTYPE[qtype], 1)  # QCLASS=IN
    # OPT: имя = корень, тип 41, «класс» = размер буфера, TTL = расширенные флаги, RDLEN = 0
    opt = b"\x00" + struct.pack("!HHIH", 41, udp_payload, 0, 0)
    return tid, header + question + opt


def read_name(msg: bytes, off: int, budget: int = 128) -> tuple[str, int]:
    """Читает имя с учётом сжатия. budget защищает от зацикленных указателей —
    это реальный класс уязвимостей (DNS decompression loop)."""
    labels, after_pointer = [], None
    while budget > 0:
        budget -= 1
        length = msg[off]
        if length & 0xC0 == 0xC0:                    # два старших бита 11 — указатель
            pointer = struct.unpack("!H", msg[off:off + 2])[0] & 0x3FFF
            if after_pointer is None:
                after_pointer = off + 2              # вернёмся сюда после прыжка
            off = pointer
            continue
        off += 1
        if length == 0:
            break
        labels.append(msg[off:off + length].decode("ascii", "replace"))
        off += length
    else:
        raise ValueError("подозрительное сообщение: слишком много прыжков по указателям")
    return ".".join(labels), (after_pointer if after_pointer is not None else off)


def parse(msg: bytes, expect_id: int):
    tid, flags, qd, an, _ns, _ar = struct.unpack("!HHHHHH", msg[:12])
    if tid != expect_id:
        raise ValueError("ID не совпал — так выглядит чужой или поддельный ответ")
    rcode = flags & 0x000F
    if rcode:
        raise ValueError(f"сервер ответил {RCODE.get(rcode, rcode)}")
    off = 12
    for _ in range(qd):                              # секцию вопроса пропускаем
        _, off = read_name(msg, off)
        off += 4
    records = []
    for _ in range(an):
        name, off = read_name(msg, off)
        rtype, _cls, ttl, rdlen = struct.unpack("!HHIH", msg[off:off + 10])
        off += 10
        records.append((name, rtype, ttl, msg[off:off + rdlen]))
        off += rdlen
    return bool(flags & 0x0200), records             # TC-бит и записи


def resolve(name: str, server: str = "1.1.1.1", qtype: str = "A"):
    tid, query = build_query(name, qtype)
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
        sock.settimeout(2.0)
        sock.sendto(query, (server, 53))             # исходный порт ядро выберет случайно
        reply, _ = sock.recvfrom(4096)
    truncated, records = parse(reply, tid)
    if truncated:
        raise RuntimeError("TC=1: ответ обрезан, надо повторить запрос по TCP")
    return records


if __name__ == "__main__":
    for name, rtype, ttl, rdata in resolve("example.com"):
        value = socket.inet_ntoa(rdata) if rtype == 1 else rdata.hex()
        print(f"{name:24} TTL={ttl:<6} {value}")
$ python3 mini_dns.py
example.com              TTL=86400  93.184.216.34

Разбор сообщения — O(n) по его длине и O(1) по памяти сверх самого буфера; единственное место, где наивная реализация ломается, — сжатие имён, поэтому в коде и стоит budget. Указатель, ссылающийся сам на себя, укладывает парсер без счётчика прыжков; на этом граблях спотыкались и коммерческие продукты.

Кэширование: TTL, негативный кэш и почему миграция «не применяется»

TTL — это не «через столько запись обновится». Это «столько мне разрешено считать ответ годным». Кто именно и когда возьмёт запись в кэш — вам неизвестно, поэтому реальный срок жизни старого значения складывается из TTL всех слоёв по пути.

Слои кэша DNS и то, как складываются их TTL

Убедиться, что вы смотрите в чужой кэш, а не в источник, можно за две команды: TTL в ответе резолвера убывает, у авторитетного сервера — постоянен.

$ dig @1.1.1.1 +noall +answer example.com A
example.com.		86171	IN	A	93.184.216.34
$ sleep 5 && dig @1.1.1.1 +noall +answer example.com A
example.com.		86166	IN	A	93.184.216.34

$ dig @a.iana-servers.net +noall +answer example.com A
example.com.		86400	IN	A	93.184.216.34

Убывающий TTL — прямая улика: «ответ из кэша, ему уже 229 секунд». Постоянный — «это первоисточник».

Практические следствия, каждое из которых стоило кому-то ночи:

  • A и AAAA — независимые RRset с независимыми TTL. Клиент с двойным стеком может получить новый IPv4 и старый IPv6, и половина запросов пойдёт не туда. Меняйте оба одновременно и с одинаковым TTL.
  • Негативный ответ кэшируется по SOA. Создали запись и не видите её три часа — смотрите поле minimum в SOA. Для активно меняющихся зон разумно 300–900 секунд.
  • Верхняя граница кэширования есть у резолвера. BIND по умолчанию режет TTL до max-cache-ttl (неделя), Unbound — до cache-max-ttl (86400). TTL в 30 дней не даст того, на что вы рассчитывали.
  • Приложения кэшируют мимо TTL. У JVM исторически networkaddress.cache.ttl мог означать «кэшировать навсегда»; Go кэширует ответы только внутри net.Resolver без учёта TTL; HTTP-клиенты держат keep-alive соединения к уже разрешённому IP и не перерезолвят его, пока соединение живо. Смена DNS не разрывает установленные соединения — никогда.
  • Понижать TTL нужно заранее. Понижение — тоже запись с TTL, и её увидят только после того, как истечёт старый TTL.

Главное на этой диаграмме — полоса «старый сервер держим включённым». Клиенты с кривыми кэшами, забытые cron-задачи и корпоративные форвардеры с TTL-полом будут идти на старый адрес ещё сутки после переключения. Выключать его сразу — гарантированный инцидент; правильный признак готовности — не время, а нулевой трафик на старом адресе.

Заглянуть в кэш резолвера полезно и локально:

$ resolvectl statistics
Transactions
Current Transactions: 0
  Total Transactions: 14238
Cache
  Current Cache Size: 137
          Cache Hits: 11402
        Cache Misses: 2836

# Сбросить локальный кэш при отладке (не влияет на кэш провайдера!)
$ sudo resolvectl flush-caches

# У BIND — выгрузить кэш в файл и посмотреть, что и с каким TTL там лежит
$ sudo rndc dumpdb -cache && grep -A2 'example.com' /var/cache/bind/named_dump.db

Что видно в tcpdump и Wireshark

DNS — идеальный протокол для наблюдения: один запрос, один ответ, всё в открытом виде (пока вы не включили DoT/DoH).

$ sudo tcpdump -n -i any -s0 'port 53'
10:02:11.312445 IP 192.168.1.42.51267 > 1.1.1.1.53: 51873+ [1au] A? example.com. (40)
10:02:11.344812 IP 1.1.1.1.53 > 192.168.1.42.51267: 51873 1/0/1 A 93.184.216.34 (56)

Читаем по частям: 51873 — тот самый transaction ID; + — установлен флаг RD; [1au] — одна additional-запись, то есть EDNS OPT; A? — вопрос типа A; (40) — размер DNS-сообщения. В ответе 1/0/1 — счётчики ANCOUNT/NSCOUNT/ARCOUNT. 32 миллисекунды между строками — это RTT до резолвера плюс его работа.

Как выглядит обрезанный ответ и переход на TCP:

$ sudo tcpdump -n -i any 'port 53'
10:05:03.101234 IP 192.168.1.42.39955 > 198.51.100.53.53: 4711+ [1au] ANY? example.com. (40)
10:05:03.130445 IP 198.51.100.53.53 > 192.168.1.42.39955: 4711| 0/0/0 (12)
10:05:03.131002 IP 192.168.1.42.44120 > 198.51.100.53.53: Flags [S], seq 2938471, length 0
10:05:03.160338 IP 192.168.1.42.44120 > 198.51.100.53.53: 4711+ [1au] ANY? example.com. (42)

Символ | в третьей строке — флаг TC. Клиент немедленно открывает TCP-соединение и повторяет вопрос; обратите внимание, что по TCP сообщение на два байта длиннее — там оно предваряется 16-битным полем длины. Если 53/tcp закрыт фаерволом, вы увидите SYN без ответа и SERVFAIL через 5 секунд — типичный симптом «большие ответы не работают, маленькие работают».

Как выглядит потеря пакета. У DNS нет ретрансмиссии на уровне транспорта, поэтому повтор делает сам stub-резолвер — по таймауту из resolv.conf (options timeout:5 attempts:2):

10:07:20.001122 IP 192.168.1.42.55010 > 10.0.0.53.53: 30412+ A? api.example.com. (33)
10:07:25.004531 IP 192.168.1.42.55010 > 10.0.0.53.53: 30412+ A? api.example.com. (33)
10:07:30.008874 IP 192.168.1.42.55011 > 10.0.0.53.53: 41200+ A? api.example.com. (33)

Три признака сразу: ровно 5 секунд между попытками (это не сеть тормозит, это таймер), тот же самый ID и порт при первом повторе, и смена сервера на третьей попытке. Отсюда знаменитые «ровно пять секунд» и «ровно двадцать секунд» в логах приложений — это всегда таймауты резолвера, а не медленный бэкенд.

В Wireshark добавьте столбец dns.time и включите фильтр dns:

  • Нормальный резолв — две строки, dns.flags.response == 0 и == 1, связанные dns.id. Wireshark сам считает dns.time и подсвечивает ответ ссылкой на запрос.
  • Потеря — запрос без ответа; фильтр dns.flags.response == 0 && !dns.response_in находит все неотвеченные запросы в дампе за секунду.
  • Обрезаниеdns.flags.truncated == 1, следом TCP-поток на порт 53.
  • Подозрение на подделку — два ответа с одним dns.id от разных источников; фильтр dns.id == 0x... покажет обоих. Легитимный дубль тоже бывает (анкаст с разной задержкой), но выигрывает всегда первый пришедший, и в этом суть атаки.
  • Медленный резолв в приложении — сравните dns.time с временем, которое приложение показывает как «DNS lookup». Расхождение означает, что время съел не сервер, а перебор search-доменов или ожидание AAAA.

Полезная деталь: Wireshark умеет расшифровывать DoT и DoH, если вы подложите SSLKEYLOGFILE (как это работает — в статье про TLS). Для DoH ищите HTTP/2 POST на /dns-query с content-type: application/dns-message — внутри лежит ровно тот же бинарный формат, что мы разбирали выше.

Надёжность зоны: NS, зонные передачи, anycast

Зона обслуживается несколькими серверами: один первичный (там правится зона) и несколько вторичных, которые копируют её. Синхронизация — по SOA serial: вторичный раз в refresh секунд спрашивает SOA, и если serial вырос, забирает изменения. Плюс NOTIFY от первичного, чтобы не ждать refresh.

# Инкрементальная передача зоны — только изменения с указанного serial (RFC 1995)
$ dig @ns1.example.com example.com IXFR=2026071600 +noall +answer

# Полная передача — должна быть закрыта ACL или TSIG-ключом
$ dig @ns1.example.com example.com AXFR
; Transfer failed.

Transfer failed — правильный ответ публичного сервера. Открытый AXFR отдаёт всю внутреннюю структуру компании: имена стендов, тестовых баз, VPN-шлюзов. Это до сих пор одна из первых вещей, которую проверяют при пентесте.

Корневая зона обслуживается 13 именами (am.root-servers.net) — ограничение из-за старого лимита 512 байт на UDP-ответ с priming query. Но за этими 13 именами стоят около 1900 физических площадок, разбросанных по миру: работает anycast, один и тот же IP анонсируется из множества точек, и BGP приводит вас к ближайшей (механика — в статье про IP и маршрутизацию). DNS для anycast идеален именно потому, что транзакция умещается в одну датаграмму: смена маршрута между запросом и ответом ничего не ломает, в отличие от TCP-сессии.

Проверить, на какую именно площадку вы попали, помогает NSID:

$ dig @1.1.1.1 +nsid example.com A | grep NSID
; NSID: 6c 68 72 30 31 ("lhr01")

DNS как балансировщик: что он умеет и чем платит

Несколько A-записей на одно имя — самый дешёвый способ размазать нагрузку, и самый обманчивый.

$ dig +short example-api.net A
203.0.113.10
203.0.113.11
203.0.113.12

Что здесь не так, как кажется:

  • Порядок не гарантирует распределения. Сервер ротирует записи, но клиент часто берёт первую, а libc ещё и пересортирует по RFC 6724 (предпочтение «своей» подсети). Плюс Happy Eyeballs в браузере пробует несколько адресов параллельно.
  • Гранулярность — кэш, а не запрос. Один корпоративный резолвер закэширует один порядок на весь офис на весь TTL. Пятитысячный офис уедет на один адрес.
  • Отказ не убирается TTL. Упал 203.0.113.11 — DNS об этом не знает. Даже сняв запись, вы будете ждать хвост кэшей. Поэтому DNS не является механизмом отказоустойчивости; им является health-check на балансировщике (прокси и балансировка) или anycast.
  • Geo-маршрутизация видит резолвер, а не клиента. Авторитетный сервер знает IP резолвера. Если сотрудник в Новосибирске ходит через корпоративный резолвер во Франкфурте, CDN отдаст ему франкфуртский узел.

Последнее чинится костылём EDNS Client Subnet (RFC 7871): резолвер добавляет в запрос усечённую подсеть клиента.

$ dig @8.8.8.8 +subnet=203.0.113.0/24 +noall +answer +comments cdn.example.com A | grep -A1 CLIENT-SUBNET
; CLIENT-SUBNET: 203.0.113.0/24/24
cdn.example.com.	60	IN	A	198.51.100.7

Платим приватностью (подсеть клиента уезжает всем авторитетным серверам по пути) и эффективностью кэша: ключ кэширования теперь включает подсеть, и вместо одной записи резолвер хранит тысячи. Cloudflare намеренно не поддерживает ECS на 1.1.1.1 именно из-за приватности; подробнее о географической маршрутизации — в статье про CDN и edge.

Безопасность: подмена, Камински, DNSSEC

Ответ по UDP принимается, если совпали пять вещей: адрес и порт источника, адрес и порт назначения, transaction ID. Атакующему, который может слать пакеты, но не видит трафик, нужно угадать ID (16 бит) и порт (~16 бит). В 2008 году Дэн Камински показал, что перебирать можно бесконечно: вместо атаки на популярное имя (у которого ответ уже закэширован и надо ждать TTL) атакуют несуществующие имена random123.bank.com, а в подделанном ответе кладут в секцию authority NS-запись, отравляющую всю зону bank.com. Каждая попытка занимает миллисекунды, кэш ждать не нужно.

Экстренные меры того года живы до сих пор:

  • рандомизация исходного порта — до 2008 многие резолверы использовали фиксированный порт, что оставляло 16 бит;
  • 0x20 encoding — резолвер случайно меняет регистр букв в QNAME (ExAmPle.CoM), а ответ обязан вернуть тот же регистр: ещё несколько бит энтропии бесплатно;
  • DNS cookies (RFC 7873) — лёгкий обмен маркерами, отсеивающий слепого атакующего.

Всё это — статистические заплатки. Криптографическое решение — DNSSEC (RFC 4033–4035): каждый RRset подписывается, подпись едет в записи RRSIG, ключ — в DNSKEY, а хэш ключа дочерней зоны (DS) публикуется в родительской зоне и подписан ею. Так строится цепочка от корня, чей ключ вшит в резолверы.

Проверяется одной командой — delv делает валидацию сам, не доверяя чужому флагу AD:

$ delv @1.1.1.1 cloudflare.com A
; fully validated
cloudflare.com.		300	IN	A	104.16.132.229
cloudflare.com.		300	IN	RRSIG	A 13 2 300 20260801000000 20260730000000 34505 cloudflare.com. Xk8...

$ dig +dnssec +noall +comments cloudflare.com A @1.1.1.1 | head -2
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 33871
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 1

Флаг ad — «резолвер проверил подписи». Его отсутствие само по себе не ошибка: зона может быть просто не подписана.

Чем платим за DNSSEC — честно и без агитации:

  • Операционный риск выше, чем криптографический выигрыш. Истёкшие подписи или несинхронизированный DS кладут домен целиком и в SERVFAIL — именно так в 2020 году на несколько часов пропадал slack.com, а в разные годы — целые национальные домены. Ошибка в DNSSEC не деградирует, она отключает.
  • Ответы растут в разы. RRSIG и DNSKEY толстые, растёт доля TCP-фолбэков и потенциал amplification-атак.
  • Перечисление зоны. Отрицание существования в DNSSEC требует подписать «между именем X и именем Y ничего нет» — запись NSEC, по которой зона обходится целиком. Ответ — NSEC3 с хэшами (RFC 5155), но хэши перебираются офлайн; современный ответ — NSEC3 white lies или NSEC-записи, генерируемые на лету.
  • Не защищает «последнюю милю». Между валидирующим резолвером и вашим приложением данные едут по обычному UDP. Флагу AD от чужого резолвера можно верить, только если канал до него защищён, — то есть DNSSEC и DoT решают разные задачи и не заменяют друг друга.

При отладке главный инструмент — флаг +cd (checking disabled):

$ dig broken-dnssec.example SOA @1.1.1.1
;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 4110

$ dig +cd broken-dnssec.example SOA @1.1.1.1
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 4111

Работает с +cd и не работает без него — виноват DNSSEC, а не сеть и не зона. Визуальный разбор всей цепочки даёт dnsviz.net, проверка зоны целиком — zonemaster.net.

Отдельно — ANY. Раньше dig ANY example.com отдавал всё содержимое имени; этим пользовались для amplification-атак (маленький запрос, огромный ответ на подставленный адрес жертвы). С RFC 8482 сервер имеет право ответить одной синтетической записью, а авторитетные серверы дополнительно включают RRL (response rate limiting) — ограничение числа одинаковых ответов на подсеть.

Приватность: DoT, DoH, DoQ

Классический DNS (Do53) едет открытым текстом. Провайдер, владелец Wi-Fi в кафе и любой на пути видят каждое имя, которое вы резолвите, — это полная история посещений, даже если весь трафик потом идёт по HTTPS. Шифрование HTTP закрыло содержимое, но не оставило и следа сомнений в том, куда вы ходите.

Транспорт Порт Как выглядит для наблюдателя Практика
Do53 53/udp, 53/tcp всё видно по умолчанию везде
DoT (RFC 7858) 853/tcp видно, что это DNS, содержимое скрыто системный резолвер, Android Private DNS
DoH (RFC 8484) 443/tcp неотличимо от обычного HTTPS браузеры, обход блокировок
DoQ (RFC 9250) 853/udp QUIC-трафик мобильные сети, нет head-of-line blocking
ODoH 443/tcp прокси не видит запрос, резолвер не видит клиента эксперимент Cloudflare/Apple

Включение и проверка на практике:

# Системный DoT через systemd-resolved
$ sudo resolvectl dns eth0 1.1.1.1#cloudflare-dns.com
$ sudo resolvectl dnsovertls eth0 yes
$ resolvectl query example.com
example.com: 93.184.216.34
-- Data was acquired via local or encrypted transport: yes

# Ручная проверка DoT: kdig умеет TLS и показывает сертификат
$ kdig -d @1.1.1.1 +tls-ca +tls-host=cloudflare-dns.com example.com A
;; DEBUG: TLS, imported 152 system certificates
;; DEBUG: TLS, received certificate hierarchy:
;; DEBUG:  #1, CN=cloudflare-dns.com
;; DEBUG: TLS, The certificate is trusted.
;; ANSWER SECTION:
example.com.  86400  IN  A  93.184.216.34

# Ручная проверка DoH: обычный GET, ответ — тот же бинарный формат RFC 1035
$ curl -sS -H 'accept: application/dns-message' \
    'https://cloudflare-dns.com/dns-query?dns=q80BAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB' \
    | xxd | head -3
00000000: abcd 8180 0001 0001 0000 0000 0377 7777  .............www
00000010: 0765 7861 6d70 6c65 0363 6f6d 0000 0100  .example.com....
00000020: 01c0 0c00 0100 0100 0151 8000 045d b8d8  .........Q...]..

# curl умеет резолвить через DoH сам, минуя системный резолвер
$ curl -sS --doh-url https://1.1.1.1/dns-query -o /dev/null \
    -w 'ip=%{remote_ip} dns=%{time_namelookup}s\n' https://example.com
ip=93.184.216.34 dns=0.041s

Обратите внимание на строку 01c0 0c в hexdump — это начало записи в секции ответа, и c0 0c здесь тот самый указатель сжатия на смещение 12, который мы разбирали выше. Формат сообщения внутри DoH не изменился ни на байт; поменялся только конверт.

Чем платим за шифрование DNS:

  • Централизация. DoH в браузере по умолчанию отправляет весь ваш DNS одной компании. Приватность от провайдера обменяли на приватность от вендора браузера.
  • Сломанный split-horizon. Браузер с DoH игнорирует системный резолвер, и внутренние имена перестают резолвиться — в браузере, продолжая работать в терминале. Классический симптом из статьи про NAT, фаерволы и VPN. Механизм отказа — canary-домен use-application-dns.net: если он не резолвится, Firefox выключает DoH.
  • Потеря наблюдаемости. Корпоративный DNS-лог — дешёвый и очень эффективный источник сигналов безопасности (обращения к C2-доменам, DGA). DoH его обнуляет.
  • Латентность и стоимость соединения. DoT/DoH требуют TCP + TLS-рукопожатие; на холодном соединении это лишние 1–2 RTT. Спасают долгоживущие соединения и TLS-возобновление, а DoQ снимает и head-of-line blocking.

И связка на будущее: ECH (Encrypted Client Hello) невозможен без защищённого DNS. Ключ ECH публикуется в записи HTTPS, и если её отдают открытым текстом, шифровать SNI бессмысленно. Подробности — в статье про TLS.

Отладка: чек-лист и типичные инциденты

Порядок действий, который экономит больше всего времени, — идти сверху вниз по слоям кэша, а не гадать.

# 1. Что видит приложение (libc, hosts, nsswitch, search-домены)
$ getent hosts api.example.com

# 2. Что видит системный резолвер и откуда взял ответ
$ resolvectl query api.example.com

# 3. Что отвечает публичный резолвер — и с каким остатком TTL
$ dig @1.1.1.1 api.example.com A +noall +answer +stats

# 4. Что реально настроено в зоне — минуя все кэши
$ dig +trace api.example.com A | tail -5

# 5. Что говорит каждый авторитетный сервер по отдельности
$ for ns in $(dig +short NS example.com); do
    echo "== $ns"; dig @"$ns" api.example.com A +norecurse +short
  done

# 6. Если SERVFAIL — это DNSSEC?
$ dig +cd api.example.com A @1.1.1.1

Расхождение между соседними шагами локализует слой за один проход: разошлись 1 и 2 — дело в /etc/hosts или nsswitch; 2 и 3 — местный кэш; 3 и 4 — чужой кэш или частично применённая миграция; расхождение внутри 5 — рассинхрон между авторитетными серверами.

Инциденты, которые встречаются чаще всего:

«Задеплоили, полёт нормальный, а через 40 минут 5xx у части клиентов». Меняли A, забыли AAAA. Клиенты с IPv6 идут на выключенный сервер, Happy Eyeballs даёт им таймаут в 300 мс и фолбэк на IPv4 — отсюда «медленно, но работает» у одних и «ошибка» у других. Проверка: dig +short AAAA рядом с dig +short A, всегда обе.

«Ровно 5 секунд на каждый запрос». Таймаут stub-резолвера. Причина почти всегда одна: первый сервер из resolv.conf молчит (упал, закрыт фаерволом, отвечает только на 53/udp), а libc ждёт timeout и идёт ко второму. Лечится options timeout:1 attempts:2 как обезболивающим и починкой первого сервера как лечением.

«CoreDNS в кластере на 100% CPU при обычной нагрузке». ndots:5 и абсолютные имена без точки в конце: на каждый резолв внешнего домена уходит 4 попытки × 2 семейства адресов = 8 запросов. Лечится точкой в конце имени или dnsConfig с ndots:2 (подробно — в диагностике сети).

«Создали поддомен, он не работает три часа». Негативный кэш: до создания кто-то спросил имя, получил NXDOMAIN, и резолвер запомнил его на SOA minimum. Единственное лекарство — заранее держать minimum небольшим; задним числом чужой кэш не сбросить.

«Работает через VPN, не работает без VPN» (или наоборот). Split-horizon: внутренняя зона отдаёт приватные адреса, внешняя — публичные. Смотрите resolvectl status — какой резолвер привязан к какому интерфейсу и есть ли routing-домен ~corp.internal.

«Домен пропал у всех, кроме нас». Мы смотрим в свой кэш, весь мир — в реальность. Признак: dig без указания сервера работает, dig +trace — нет. Смотрите на срок действия делегирования у регистратора, glue-записи и подписи DNSSEC.

Антипаттерны, которые стоит вычеркнуть из привычек:

  • Проверять изменения через ping. ping кэширует, не показывает TTL, не различает A и AAAA и молчит про источник ответа. Всегда dig.
  • Ставить TTL 86400 «чтобы меньше нагрузка». Экономия на запросах измеряется копейками, а стоимость невозможности быстро переключиться — часами простоя. Разумная база: 300–3600 для сервисных имён, сутки — для NS и MX.
  • Считать DNS средством отказоустойчивости. Он им не является ни в каком виде.
  • Полагаться на порядок записей. Клиент имеет полное право его переупорядочить.
  • Резолвить в горячем пути без кэша. Библиотека, дергающая getaddrinfo на каждый HTTP-запрос, добавляет к p99 всю нестабильность DNS. Держите пул соединений и локальный кэш.

Мини-итог

  • DNS — распределённая база с делегированием и кэшированием. Согласованность в ней конечная, и все практики работы с ней — следствие этого факта.
  • Домен — поддерево имён, зона — административная единица. Границу между ними задают NS-записи; забытый glue и lame delegation ломают домен незаметно и не сразу.
  • Единица хранения — RRset, а не запись. TTL общий на RRset, A и AAAA — разные RRset с разными сроками жизни.
  • TTL — это разрешение кэшировать, а не расписание обновления. Реальное время устаревания складывается из TTL всех слоёв; понижайте TTL заранее, минимум за старый TTL до переключения.
  • minimum в SOA — TTL негативного кэша. Именно из-за него «созданная запись не появляется».
  • Формат сообщения не менялся с 1987 года: 12 байт заголовка, четыре секции, имена метками с длиной, сжатие указателями. EDNS(0) добавил размер буфера, DNSSEC-флаги, cookies и ECS — не меняя базовый формат.
  • Флаг TC означает «повтори по TCP». DNS поверх TCP — штатный режим; закрытый 53/tcp ломает всё большое.
  • dig не читает /etc/hosts и nsswitch.conf — путь приложения повторяют getent hosts и resolvectl query. Половина загадок объясняется этим.
  • DNSSEC даёт подлинность данных, но не приватность и не «последнюю милю»; его главный риск — операционный: истёкшая подпись означает SERVFAIL, а не деградацию.
  • DoT/DoH/DoQ дают приватность запроса, платя централизацией, сломанным split-horizon и потерей корпоративной наблюдаемости.
  • Балансировать DNS можно грубо и по регионам; отказоустойчивость он не обеспечивает никогда.

Источники

Смежные темы на портале: сетевой стек ядра и путь пакета до сокета — в «Операционные системы», обзорная картина протоколов — в «Компьютерные науки», а что делает браузер до первого байта — в «Frontend».

Что дальше

Имя превратилось в адрес, транспорт до этого адреса мы уже умеем строить. Осталось разобраться с тем, ради чего всё затевалось, — с прикладным протоколом, который эти адреса использует. И это протокол, спроектированный вокруг посредников: кэшей, прокси и CDN, каждый из которых имеет право прочитать и переинтерпретировать ваше сообщение.

HTTP: методы, коды, заголовки, кэширование, эволюция 1.1 → 2 → 3 — про анатомию сообщения до байта, семантику методов и кодов, модель кэширования с ETag и Vary, и честное сравнение трёх версий протокола, в котором запись HTTPS из этой статьи наконец пригодится по назначению.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов