Раздел 32 · Системное программирование: Zig, ассемблер, Verilog

Клиент, сервер и устройство сети

senior~150 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Клиент, сервер и устройство сети

В прошлом уроке сокет появился в самом конце, почти мимоходом: socketpair дал два дескриптора, tr из семидесятых поработала сетевым сервисом, и всё решили два dup2. Но то был сокет на одной машине. Сегодня выходим наружу. Прежде чем открыть сокет, соединённый с компьютером на другом конце планеты, надо понять, что вообще значит “другой компьютер” для программы: как его назвать, как он выглядит в памяти, кто и как превращает example.com в число, и что такое соединение, если смотреть на него из системного вызова. Мы разберём модель клиента и сервера, соберём сеть из адаптеров, кабелей и маршрутизаторов, научимся переводить адрес IPv4 между его формами руками, пройдём путь имени через /etc/hosts и DNS и напишем на Zig hostinfo: первый шаг нового проекта, веб-сервера TINY, который к концу блока будет отдавать страницы и запускать программы.

Цели урока

  • Описать любой сетевой обмен как транзакцию из четырёх шагов и понимать, что клиент и сервер это процессы, а не машины.
  • Объяснить, из чего сделана сеть: адаптер как устройство ввода-вывода, локальная сеть, маршрутизатор, стек протоколов и инкапсуляция заголовков.
  • Держать в голове модель интернета глазами программиста: адреса как 32-битные числа, имена как отображение на адреса, соединения как пары сокетов.
  • Переводить адрес IPv4 между числом, точечной записью и байтами в памяти, понимать порядок байтов сети и писать htonl через std.mem.nativeToBig.
  • Знать, откуда берётся ответ на вопрос “какой адрес у имени”: /etc/hosts, резолвер, DNS, и почему у одного имени бывает много адресов, а у другого ни одного.
  • Пользоваться getaddrinfo и getnameinfo из libc: подсказки, флаги AI_PASSIVE, AI_ADDRCONFIG, NI_NUMERICHOST, обход списка addrinfo и его освобождение.
  • Собрать hostinfo на Zig и сравнить его с тем, что даёт std.Io.net в версии 0.16.

Идея: сеть это ещё одно устройство

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

  1. Клиенту что-то понадобилось, и он посылает серверу запрос. Браузер просит файл /index.html.
  2. Сервер получает запрос, разбирает его и работает с ресурсом. Веб-сервер находит файл на диске и читает его.
  3. Сервер посылает ответ и ждёт следующего запроса. Байты файла уходят обратно.
  4. Клиент получает ответ и что-то с ним делает. Браузер рисует страницу.

Два уточнения, на которых новички регулярно путаются. Первое: клиент и сервер это процессы, а не машины. Слово “сервер” в разговоре о железе означает большой компьютер в стойке, но в этом блоке сервер это программа, которая ждёт запросов. На твоём ноутбуке прямо сейчас их работает десяток, а в уроке 62 клиент и сервер вообще жили в двух процессах одной программы. Второе: транзакция здесь не имеет ничего общего с транзакцией базы данных. Ни атомарности, ни отката, просто обмен запросом и ответом.

Как процесс общается с другой машиной? Точно так же, как с диском. Для машины сеть это ещё одно устройство ввода-вывода: сетевой адаптер стоит на шине ввода-вывода рядом с контроллером диска, данные из сети копируются в память по DMA, а данные из памяти уходят в сеть. Из урока про системный ввод и вывод ты знаешь, как ядро прячет устройство за дескриптором. С сетью оно делает то же самое: программа получает дескриптор сокета и зовёт read и write. Все правила, которые мы выучили за блок S9, переезжают в сеть без изменений: короткие счёты, буфер в памяти процесса, общая запись после fork, SIGPIPE при записи в закрытое соединение. Только короткие счёты теперь станут нормой, а не исключением: байты приходят пакетами, когда им вздумается.

Из чего сделана сеть

Программе не нужно знать, как устроена сеть внутри, но пара картинок сильно помогает понять, почему интерфейс сокетов выглядит именно так. Сеть это иерархия, собранная по географическому принципу, и мы пройдём её снизу вверх. Сама модель OSI и уровни TCP/IP разобраны в уроке про транспорт и TLS, здесь посмотрим на то же самое глазами того, кто пишет системный код.

Локальная сеть

Самый нижний уровень это локальная сеть, LAN, и самая распространённая технология для неё это Ethernet. Кусок Ethernet в простейшем виде это несколько машин, подключённых проводами к одному концентратору (хабу). Хаб тупо повторяет каждый бит, пришедший на любой порт, на все остальные порты: каждая машина видит все биты. Порции данных называются кадрами: у кадра есть заголовок с адресом отправителя и адресом получателя и полезная нагрузка. Адрес здесь аппаратный, MAC-адрес, 48 бит, прошитый в адаптер. Все адаптеры видят кадр, но забирает его только тот, чей адрес указан как получатель.

Несколько таких кусков соединяют мостами (сегодня это коммутаторы, свитчи). Мост умнее хаба: он запоминает, за каким его портом какой адрес живёт, и пересылает кадр только туда, куда нужно. Так кадр между двумя машинами одного куска не занимает провода остальных. Собранная из мостов сеть покрывает здание или кампус, и внутри неё кадры доставляются по MAC-адресам без всякой маршрутизации.

Маршрутизаторы

На следующем уровне локальные сети соединяются маршрутизаторами. У маршрутизатора по адаптеру на каждую сеть, к которой он подключён, и его работа в том, чтобы получить пакет из одной сети и переложить в другую, ближе к цели. Маршрутизаторы соединяют между собой и локальные сети, и глобальные, так получается интернет с маленькой буквы: сеть из сетей. Интернет с большой буквы, тот самый, это самый большой её экземпляр.

Здесь появляется главная трудность. Сети внутри интернета разные: Ethernet, Wi-Fi, оптика между континентами, мобильная связь. У каждой свой формат кадра, свои адреса, свой максимальный размер. Как отправить данные через все эти несовместимые сети? Ответ: слой программного обеспечения поверх них, протокол, который работает на каждой машине и каждом маршрутизаторе и прячет различия. Он решает две задачи:

  • Единая схема имён. Каждая машина получает хотя бы один адрес, который однозначно её определяет, и этот адрес понимают все сети, независимо от их собственных MAC-адресов. Это IP-адрес.
  • Единый способ доставки. Данные упаковываются в порции одного формата, пакеты (в терминах IP их зовут дейтаграммами). У пакета заголовок с адресами отправителя и получателя и полезная нагрузка, и его может переслать любой маршрутизатор.

Инкапсуляция

Посмотрим, как байты идут от процесса на машине A к процессу на машине B, если A сидит в одной локальной сети, B в другой, а между ними один маршрутизатор. Это пересказ картинки из книги, стоит нарисовать её себе на бумаге.

  1. Клиент на машине A зовёт write на сокете. Данные копируются из памяти процесса в ядро.
  2. Сетевой код ядра A (стек протоколов) заворачивает данные в заголовок IP с адресом B, а поверх него в заголовок кадра Ethernet с MAC-адресом маршрутизатора: B в другой сети, поэтому кадр адресован не ей, а ближайшему перевалочному пункту. Отдаёт кадр адаптеру.
  3. Адаптер A кладёт кадр в провод, мост доставляет его адаптеру маршрутизатора.
  4. Маршрутизатор снимает заголовок кадра первой сети, смотрит в заголовок IP, находит по своей таблице маршрутов, что сеть B у него за вторым адаптером, и заворачивает тот же пакет в новый кадр второй сети, уже с MAC-адресом B.
  5. Кадр доходит до адаптера B, тот копирует его в память, ядро B снимает оба заголовка и кладёт данные в буфер сокета.
  6. Сервер на B зовёт read и получает ровно те байты, что отправил клиент.

Слово для этого процесса: инкапсуляция. Каждый уровень добавляет свой заголовок и не заглядывает внутрь чужих. Если на пути сто маршрутизаторов, то внешний заголовок кадра меняется сто раз, а заголовок IP едет от A до B почти нетронутым (маршрутизаторы только уменьшают в нём счётчик времени жизни). В реальной жизни слоёв ещё на один больше: между IP и данными приложения лежит заголовок TCP с портами и номерами байтов. Если посчитать, что едет по проводу в самом простом случае:

| Ethernet 14 | IPv4 20 | TCP 20 | данные приложения, до 1460 байт | CRC 4 |

Двадцать байт IP и двадцать байт TCP это минимальные размеры, без опций. 1460 байт полезной нагрузки получается из того, что Ethernet позволяет нагрузку до 1500 байт, а 40 из них съели заголовки. Отсюда практическое следствие для нас: write на 10 000 байт уйдёт в сеть семью кусками, и на той стороне read вполне может вернуть сначала 1460, потом 2920, потом остальное. Короткий счёт из урока 60 здесь становится образом жизни.

Маршрутизаторы на пути можно увидеть. traceroute шлёт пакеты с нарочно маленьким временем жизни: первый пакет умирает на первом маршрутизаторе, второй на втором, и каждый умерший пакет возвращается сообщением “время вышло” с адресом того, кто его убил.

$ traceroute -n -q 1 -w 1 -m 12 128.2.42.95
traceroute to 128.2.42.95 (128.2.42.95), 12 hops max, 40 byte packets
 1  10.199.31.196  87.500 ms
 2  242.14.244.133  88.536 ms
 3  240.5.80.1  88.848 ms
 4  99.83.70.87  89.201 ms
 5  *
 6  *
 7  *
 8  *
 9  *
10  64.125.31.220  194.828 ms
11  64.125.22.59  178.594 ms
12  64.125.22.56  179.706 ms

Снято на macOS через VPN, у тебя путь будет своим. Звёздочки это маршрутизаторы, которые пакеты пересылают, а отвечать о смерти чужих пакетов не хотят. Между 4-м и 10-м прыжком задержка выросла на сто миллисекунд: где-то там пакеты пересекли океан.

Интернет глазами программиста

Глобальная сеть, в которой живут наши программы, держится на семействе протоколов TCP/IP. IP даёт базовую доставку пакетов от машины к машине, и доставку ненадёжную: пакет может потеряться, прийти дважды или обогнать соседа. UDP чуть расширяет IP: пакет адресуется уже не машине, а процессу (появляется порт), но гарантий по-прежнему нет. TCP строится поверх IP и даёт то, что нужно программе: надёжное двунаправленное соединение, поток байтов в правильном порядке без потерь и повторов. Как именно TCP этого добивается (рукопожатие, подтверждения, повторы) разобрано в уроке про транспорт. Нам важен результат: сокет TCP ведёт себя как пайп в обе стороны, только собеседник живёт на другой машине.

Программист видит интернет через три понятия, и дальше урок идёт по ним:

  • машины это множество 32-битных IP-адресов;
  • у адресов бывают имена (доменные имена), и существует механизм, переводящий имя в адрес;
  • процесс на одной машине общается с процессом на другой через соединение.

Ты мог заметить, что речь всё время об IPv4 с 32-битным адресом. Его преемник IPv6 с адресом в 128 бит описан ещё в середине девяностых и сегодня несёт заметную часть трафика, но интерфейс сокетов для него тот же. Код урока с первого дня работает с обоими: getaddrinfo отдаёт адреса обоих семейств, а мы их одинаково печатаем. Арифметику адресов будем разбирать на IPv4, потому что 32 бита влезают в голову.

IP-адрес: 32 бита и порядок байтов

Адрес IPv4 это беззнаковое 32-битное число. В C его по историческим причинам кладут в структуру из одного поля:

struct in_addr {
    uint32_t s_addr; /* адрес в порядке байтов сети (big-endian) */
};

Структура вокруг одного числа это наследство ранних версий интерфейса, от которого все давно устали, но оно живёт во всех структурах адресов. Важнее комментарий. Машины бывают little-endian (x86-64 и почти всегда ARM) и big-endian, а по сети адрес должен ехать в одном и том же порядке, иначе машины друг друга не поймут. TCP/IP выбрал порядок байтов сети: big-endian, старший байт первым. Все числа в заголовках протоколов, и адрес в s_addr, и номер порта, хранятся в этом порядке, даже если сама машина считает иначе. Порядок байтов мы подробно разбирали в уроке про биты и целые, здесь он впервые становится не любопытным фактом, а источником ошибок.

Для перевода в libc есть четыре функции: htonl и htons переводят 32-битное и 16-битное число из порядка хоста (host) в порядок сети (network), ntohl и ntohs обратно. Для 64 бит их нет. В Zig такие функции не нужны, потому что есть std.mem.nativeToBig и std.mem.bigToNative: на little-endian машине они переставляют байты (@byteSwap), на big-endian ничего не делают. Посмотрим на адрес 128.2.194.242 в памяти:

const std = @import("std");

fn dump(label: []const u8, value: u32) void {
    const bytes = std.mem.asBytes(&value);
    std.debug.print("{s}: 0x{x:0>8}, в памяти {x:0>2} {x:0>2} {x:0>2} {x:0>2}\n", .{
        label, value, bytes[0], bytes[1], bytes[2], bytes[3],
    });
}

pub fn main() void {
    const host: u32 = 0x8002c2f2; // 128.2.194.242
    const net = std.mem.nativeToBig(u32, host);
    std.debug.print("порядок байтов этой машины: {t}\n", .{@import("builtin").cpu.arch.endian()});
    dump("host", host);
    dump("net ", net);
    const port: u16 = 80;
    std.debug.print("порт 80: host 0x{x:0>4}, net 0x{x:0>4}\n", .{ port, std.mem.nativeToBig(u16, port) });
}
$ zig run byteorder.zig
порядок байтов этой машины: little
host: 0x8002c2f2, в памяти f2 c2 02 80
net : 0xf2c20280, в памяти 80 02 c2 f2
порт 80: host 0x0050, net 0x5000

Снято на Apple Silicon, в контейнере Linux на той же машине вывод совпадает до байта. Число 0x8002c2f2 в памяти little-endian машины лежит младшим байтом вперёд: f2 c2 02 80. После nativeToBig байты в памяти идут в порядке точечной записи: 80 02 c2 f2, то есть 128, 2, 194, 242. Так адрес должен лежать в заголовке пакета и в поле sockaddr_in. А если прочитать это число как u32 обратно на нашей машине, получится бессмыслица 0xf2c20280: значение в порядке сети имеет смысл только как байты, не как число. Порт 80 тоже превращается в 0x5000, это и есть та самая ловушка, из-за которой сервер, забывший htons, слушает на порту 20480.

Людям 32 бита неудобны, поэтому адрес записывают точечной записью: каждый байт отдельным десятичным числом, старший первым. 0x8002c2f2 это 128.2.194.242. Переводят между формами функции inet_pton (из текста в число, presentation to network) и inet_ntop (обратно). Буква n в имени напоминает, что число получается в порядке сети. Для IPv6 те же функции работают со 128-битным адресом и записью из восьми групп по 16 бит в шестнадцатеричном виде, где самую длинную серию нулевых групп сокращают до ::. Так ::1 это loopback IPv6, а :: это все нули.

Поиграй с формами в виджете. Все четыре поля связаны: правишь любое, остальные пересчитываются. Переключатель порядка байтов показывает, как одно и то же число лежит в пакете и в памяти uint32 на x86. Кнопки внизу это адреса из первого упражнения книги: прежде чем нажать, переведи в уме.

На что обратить внимание:

  • в поле двоичных октетов видно, что точечная запись это нарезка 32 бит на четыре куска по восемь;
  • строка “в пакете” не зависит от переключателя, это и есть порядок сети; меняется только строка “в uint32 на x86”;
  • 0x7f000001 это 127.0.0.1: весь диапазон 127.0.0.0/8 отдан под loopback, адреса, которые никогда не покидают машину.

Особые адреса

Некоторые адреса и диапазоны зарезервированы, и в выводе программ урока они будут встречаться постоянно. Запись вида 10.0.0.0/8 означает “все адреса, у которых первые 8 бит такие же, как у 10.0.0.0”: число после косой черты это длина общего префикса, CIDR.

ДиапазонЧто это
0.0.0.0“любой адрес этой машины”, сервер слушает на нём все свои интерфейсы; в IPv6 это ::
127.0.0.0/8loopback, чаще всего 127.0.0.1; в IPv6 это ::1
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16частные сети из RFC 1918: внутри офиса или дома, в интернете не маршрутизируются
169.254.0.0/16link-local: адрес, который машина назначает себе сама, если нет DHCP; в IPv6 это fe80::/10
224.0.0.0/4multicast, рассылка группе
255.255.255.255широковещательный в пределах своей сети
192.0.2.0/24для документации, в реальной сети его нет; им пользуется виджет

Частные адреса важны вот почему. Твой ноутбук почти наверняка сидит в частной сети и имеет адрес вроде 192.168.1.23 или 10.x.y.z. В интернете с таким адресом разговаривать нельзя, их миллионы одинаковых. Маршрутизатор на выходе из твоей сети подменяет в каждом исходящем пакете частный адрес и порт на свой публичный, а в ответах меняет обратно. Этот трюк называется NAT, и чуть ниже мы увидим его следы в живом выводе.

Одна запись, разные прочтения

Точечная запись кажется однозначной, но у неё есть тёмное прошлое. Старая функция inet_aton из BSD понимала больше, чем четыре десятичных числа: 127.1 (последнее число занимает все оставшиеся байты), 2130706433 (одно число на все 32 бита), 0x7f.1 (шестнадцатеричные части) и 010.0.0.1, где ведущий ноль, как в C, означает восьмеричное число. getaddrinfo, которым мы сейчас будем пользоваться, эти формы по-прежнему принимает, а вот читает их по-разному. Мы прогнали через hostinfo из этого урока четыре строки на macOS и в Debian:

СтрокаmacOS 26Linux, glibc 2.36
127.1127.0.0.1127.0.0.1
2130706433127.0.0.1127.0.0.1
0x7f.1127.0.0.1127.0.0.1
010.0.0.110.0.0.18.0.0.1

Последняя строка это настоящая дыра. glibc прочитал 010 как восьмеричное 8, libc macOS как десятичное 10. Представь фильтр, который запрещает обращения к внутренней сети 10.0.0.0/8 и проверяет строку своим разбором, а соединение потом открывает через getaddrinfo: один и тот же адрес пройдёт проверку как один и уйдёт в сеть как другой. На таких расхождениях строятся атаки на серверы, которые по просьбе пользователя ходят по URL. Отсюда правило: разбирай адрес строго, как inet_pton в POSIX, ровно четыре десятичных числа от 0 до 255, и откажи во всём остальном. В задаче этого урока ты напишешь такой разбор, с запретом ведущих нулей.

Имена и DNS

Адреса неудобны людям, поэтому у машин есть имена: cs.cmu.edu, example.com. Имена устроены иерархически, справа налево: корень, зона первого уровня (edu, com, ru), дальше зоны, которые их владельцы делят как хотят. Отображение имён в адреса хранит DNS, распределённая база, раскиданная по миллионам серверов. Как запрос идёт от корня до авторитетного сервера, какие бывают записи и что такое TTL, подробно разобрано в уроке про транспорт, повторять не будем. Нас интересует другое: что происходит на нашей машине, когда программа спрашивает адрес по имени.

Программа не ходит в DNS сама. Она зовёт функцию libc, а та по очереди опрашивает источники. Порядок источников на Linux задаёт строка hosts: в /etc/nsswitch.conf, обычно это files dns:

  1. files это файл /etc/hosts: строки “адрес, имя, псевдонимы”. Он древнее DNS, в семидесятых весь интернет помещался в один такой файл, который рассылали по почте. Сегодня там почти всегда только localhost:

    127.0.0.1	localhost
    ::1	localhost ip6-localhost ip6-loopback

    Это начало /etc/hosts в контейнере Debian. Если имя нашлось здесь, в сеть не уходит ни один пакет.

  2. dns это запрос к рекурсивному резолверу, адрес которого лежит в /etc/resolv.conf (строка nameserver). Библиотека шлёт ему UDP-пакет на порт 53, по пакету на каждый тип записи (A для IPv4, AAAA для IPv6), и ждёт ответа. Резолвер сам проходит всю иерархию и кэширует результат.

На macOS картина та же, но конфигурацию резолверов держит системная служба: /etc/resolv.conf там есть для совместимости, а настоящий список выдаёт scutil --dns. Libc macOS не ходит в DNS из процесса, а спрашивает службу mDNSResponder.

Посмотреть на записи можно через dig, он спрашивает DNS напрямую, минуя /etc/hosts:

$ dig +noall +answer example.com A example.com AAAA cs.cmu.edu A
example.com.		126	IN	A	172.66.147.243
example.com.		126	IN	A	104.20.23.154
example.com.		218	IN	AAAA	2606:4700:10::ac42:93f3
example.com.		218	IN	AAAA	2606:4700:10::6814:179a
cs.cmu.edu.		28613	IN	A	128.2.42.95

Числа во втором столбце это оставшийся TTL в секундах. Из этого вывода видно главное: отображение имён в адреса вовсе не один к одному.

  • Одно имя, много адресов. У example.com два адреса IPv4 и два IPv6. Так распределяют нагрузку: клиенты получают список в разном порядке и идут к разным машинам. Программа обязана быть готова к списку, а не к одному адресу.
  • Много имён, один адрес. Сотни сайтов живут на одном адресе за одним балансировщиком или CDN. Отсюда Host в каждом запросе HTTP: сервер по нему понимает, какой из сайтов у него просят.
  • Имя без адреса. Бывают имена, у которых записей A и AAAA нет вовсе: зона есть, а машины с таким именем нет. getaddrinfo тогда вернёт ошибку, а не пустой список.
  • Адрес без имени. Обратное отображение (адрес в имя) хранится в DNS отдельно, в записях PTR, и заводить их никто не обязан.

Во второй вкладке виджета разрешение имени идёт по шагам: вызов getaddrinfo, /etc/hosts, /etc/resolv.conf, запрос резолверу и готовый список addrinfo. Сравни localhost (ответ из файла, ни одного пакета), www.example.net (два адреса одного семейства) и nope.invalid (ошибка). Адреса в виджете из диапазона для документации, настоящие ответы мы сейчас получим своей программой.

Соединение: пара сокетов

Третье понятие модели программиста. Клиент и сервер общаются через соединение: двунаправленный надёжный поток байтов между двумя процессами, “точка-точка”. Каждый конец соединения это сокет, и у сокета есть адрес из двух частей: IP-адрес машины и 16-битный порт. Записывают его через двоеточие: 128.2.42.95:80. Адрес доводит данные до машины, порт до процесса на ней.

Порты сервера известны заранее, иначе клиент не знал бы, куда стучаться. Веб-сервер слушает 80, защищённый веб 443, почта 25, SSH 22. Соответствие имён служб номерам лежит в /etc/services, и getaddrinfo умеет его читать: вместо "80" можно передать "http". Порт клиента, наоборот, никому заранее знать не нужно, его выбирает ядро при connect из диапазона эфемерных портов.

Соединение однозначно определяется парой сокетов, четвёркой чисел:

(адрес клиента:порт клиента, адрес сервера:порт сервера)

Отсюда сразу два следствия. Первое: сервер на одном порту 80 держит тысячи соединений одновременно, и ядро их не путает, потому что у всех разные клиентские половинки. Второе: один клиент может открыть к одному серверу много соединений сразу, каждое получит свой эфемерный порт.

Проверим всё на живой сети. Программа ниже находит адрес по имени, соединяется и спрашивает у ядра обе половины пары: getsockname возвращает адрес своего конца, getpeername адрес чужого. Сами socket и connect мы по-настоящему разберём в следующем уроке, здесь они только средство увидеть пару. В конце программа делает обратное разрешение: getnameinfo без флага NI_NUMERICHOST спрашивает у DNS имя по адресу.

const std = @import("std");
const c = std.c;

fn say(comptime fmt: []const u8, args: anytype) void {
    var buf: [512]u8 = undefined;
    const text = std.fmt.bufPrint(&buf, fmt, args) catch return;
    _ = c.write(1, text.ptr, text.len);
}

/// Адрес и порт из sockaddr текстом: getnameinfo с числовыми флагами.
fn endpoint(sa: *const c.sockaddr, len: c.socklen_t, out: *[80]u8) []const u8 {
    var host: [64]u8 = undefined;
    var serv: [8]u8 = undefined;
    const flags: c.NI = .{ .NUMERICHOST = true, .NUMERICSERV = true };
    if (@intFromEnum(c.getnameinfo(sa, len, &host, host.len, &serv, serv.len, flags)) != 0) return "?";
    const h = std.mem.sliceTo(&host, 0);
    const s = std.mem.sliceTo(&serv, 0);
    return std.fmt.bufPrint(out, "{s}:{s}", .{ h, s }) catch "?";
}

pub fn main(init: std.process.Init) !void {
    const args = try init.minimal.args.toSlice(init.arena.allocator());
    if (args.len != 3) return say("использование: pair <host> <port>\n", .{});

    var hints = std.mem.zeroes(c.addrinfo);
    hints.family = c.AF.INET;
    hints.socktype = c.SOCK.STREAM;
    var list: ?*c.addrinfo = null;
    if (@intFromEnum(c.getaddrinfo(args[1], args[2], &hints, &list)) != 0) return say("не нашёл адрес\n", .{});
    defer c.freeaddrinfo(list.?);
    const ai = list.?;

    // socket и connect подробно разберём в следующем уроке.
    const fd = c.socket(@intCast(ai.family), @intCast(ai.socktype), @intCast(ai.protocol));
    if (fd < 0 or c.connect(fd, ai.addr.?, ai.addrlen) != 0) return say("connect не удался\n", .{});
    defer _ = c.close(fd);

    var local: c.sockaddr.storage = undefined;
    var local_len: c.socklen_t = @sizeOf(c.sockaddr.storage);
    _ = c.getsockname(fd, @ptrCast(&local), &local_len);
    var peer: c.sockaddr.storage = undefined;
    var peer_len: c.socklen_t = @sizeOf(c.sockaddr.storage);
    _ = c.getpeername(fd, @ptrCast(&peer), &peer_len);

    var a: [80]u8 = undefined;
    var b: [80]u8 = undefined;
    say("соединение ({s}, {s})\n", .{ endpoint(@ptrCast(&local), local_len, &a), endpoint(@ptrCast(&peer), peer_len, &b) });

    // Обратное разрешение: getnameinfo без NI_NUMERICHOST спрашивает имя у DNS.
    var name: [256]u8 = undefined;
    const rc = c.getnameinfo(@ptrCast(&peer), peer_len, &name, name.len, null, 0, .{ .NAMEREQD = true });
    if (@intFromEnum(rc) == 0) {
        say("имя сервера по адресу: {s}\n", .{std.mem.sliceTo(&name, 0)});
    } else {
        say("имя сервера по адресу: {s}\n", .{c.gai_strerror(rc)});
    }
}
$ zig build-exe pair.zig
$ ./pair example.com 80
соединение (10.204.56.110:58947, 104.20.23.154:80)
имя сервера по адресу: nodename nor servname provided, or not known
$ ./pair example.com 80
соединение (10.204.56.110:58949, 172.66.147.243:80)
имя сервера по адресу: nodename nor servname provided, or not known
$ ./pair cs.cmu.edu 80
соединение (10.204.56.110:58951, 128.2.42.95:80)
имя сервера по адресу: scs-web-lb.andrew.cmu.edu
$ ./pair dns.google 443
соединение (10.204.56.110:58956, 8.8.4.4:443)
имя сервера по адресу: dns.google
$ sysctl net.inet.ip.portrange.first net.inet.ip.portrange.last
net.inet.ip.portrange.first: 49152
net.inet.ip.portrange.last: 65535

Снято на macOS. Что здесь видно:

  • Эфемерные порты. 58947, 58949, 58951, 58956: ядро выдаёт их почти подряд из диапазона 49152 до 65535 (пропуски это соединения других программ на машине). В контейнере Linux тот же pair получил порты 38406 и 53660, там диапазон 32768 до 60999, он лежит в /proc/sys/net/ipv4/ip_local_port_range.
  • NAT. Наш адрес 10.204.56.110 частный. Сервер example.com видит не его, а публичный адрес маршрутизатора и какой-то другой порт. Пара сокетов, которую знает наше ядро, и пара, которую знает ядро сервера, различаются клиентской половиной, и за это соответствие отвечает маршрутизатор.
  • Балансировка. Два запуска подряд соединились с разными адресами example.com: libc каждый раз вернула список в другом порядке, а программа берёт первый элемент.
  • Обратное разрешение живёт своей жизнью. У адресов example.com записей PTR нет вовсе, у адреса cs.cmu.edu имя другое, scs-web-lb.andrew.cmu.edu (балансировщик). Имя в адрес и адрес в имя это две независимые таблицы.

getaddrinfo и getnameinfo

Разрешение имён в libc прошло долгую историю. Книга Стивенса девяностых учит gethostbyname и gethostbyaddr: они возвращали указатель на статическую структуру (одна на процесс, поэтому функции нельзя звать из двух потоков) и умели только IPv4. На смену пришли getaddrinfo и getnameinfo, описанные в POSIX. Они реентерабельны, одинаково работают с IPv4 и IPv6 и отдают адреса уже упакованными в структуры, которые готовы для socket и connect. Старые функции устарели, писать на них новый код не нужно.

int getaddrinfo(const char *host, const char *service,
                const struct addrinfo *hints, struct addrinfo **result);
void freeaddrinfo(struct addrinfo *result);
const char *gai_strerror(int errcode);

struct addrinfo {
    int              ai_flags;     /* флаги подсказки */
    int              ai_family;    /* AF_INET, AF_INET6 */
    int              ai_socktype;  /* SOCK_STREAM, SOCK_DGRAM, SOCK_RAW */
    int              ai_protocol;  /* третий аргумент socket */
    socklen_t        ai_addrlen;   /* длина структуры адреса */
    struct sockaddr *ai_addr;      /* готовая структура для connect и bind */
    char            *ai_canonname; /* каноническое имя, если просили */
    struct addrinfo *ai_next;      /* следующий элемент списка */
};

getaddrinfo получает имя машины (или адрес текстом) и имя службы (или номер порта текстом) и строит список addrinfo: по элементу на каждый способ добраться до службы. Каждый элемент несёт три числа для вызова socket (ai_family, ai_socktype, ai_protocol) и готовую структуру адреса с длиной для connect или bind. Типичный клиент идёт по списку и пробует socket плюс connect на каждом элементе, пока не получится. Типичный сервер так же пробует socket плюс bind. Весь список выделяет libc, и вернуть его надо одним freeaddrinfo. Ошибку функция возвращает кодом, а не через errno, и текст к коду даёт gai_strerror.

Всё, что вызывающий хочет уточнить, передаётся через hints: это та же структура addrinfo, где заполнены только первые четыре поля, а остальные нули. Главное в ней:

  • ai_family. AF_INET только IPv4, AF_INET6 только IPv6, AF_UNSPEC (ноль) оба.
  • ai_socktype. По умолчанию (ноль) libc вернёт каждый адрес несколько раз, по разу на каждый тип сокета: поток, дейтаграммы, сырые пакеты. Клиенту TCP это не нужно, поэтому почти всегда ставят SOCK_STREAM.
  • ai_flags, битовая маска:
    • AI_ADDRCONFIG: спрашивать адреса IPv6, только если у машины есть свой адрес IPv6 (не считая loopback), и так же с IPv4. Без него машина без IPv6 получит в списке адреса, до которых всё равно не достучится, и connect будет честно ждать таймаута на каждом;
    • AI_CANONNAME: положить в ai_canonname первого элемента каноническое имя (то, куда ведут псевдонимы CNAME);
    • AI_NUMERICSERV: служба это только число, не лезть в /etc/services;
    • AI_PASSIVE: для сервера. Если host равен NULL, адреса в списке будут “любой адрес этой машины” (0.0.0.0 и ::), годные для bind. Без флага NULL означает loopback.

Обратная функция это getnameinfo: берёт структуру адреса и пишет в буферы вызывающего имя машины и имя службы.

int getnameinfo(const struct sockaddr *sa, socklen_t salen,
                char *host, size_t hostlen,
                char *service, size_t servlen, int flags);

Без флагов она делает обратный запрос в DNS (тот самый PTR) и имя службы по /etc/services, а если имени нет, печатает адрес числом. С NI_NUMERICHOST и NI_NUMERICSERV она ничего не спрашивает и только форматирует адрес и порт в текст, для обоих семейств. С NI_NAMEREQD отсутствие имени становится ошибкой, так мы и получили nodename nor servname provided в выводе pair. Буферы передаёт вызывающий, поэтому функция реентерабельна; нужный размер для имени это константа NI_MAXHOST (1025 байт), для числового адреса IPv6 хватает 46.

В Zig обе функции объявлены в std.c с типизированными флагами: hints.flags = .{ .PASSIVE = true, .ADDRCONFIG = true } вместо | констант, и c.NI для второй функции. Раскладка addrinfo у Linux и BSD отличается порядком двух полей (ai_addr и ai_canonname поменяны местами), std.c выбирает правильную по цели, а мы обращаемся к полям по именам и разницы не замечаем.

Прогоним getaddrinfo с разными подсказками и напечатаем весь список, каждый элемент строкой:

const std = @import("std");
const c = std.c;

fn say(comptime fmt: []const u8, args: anytype) void {
    var buf: [512]u8 = undefined;
    const text = std.fmt.bufPrint(&buf, fmt, args) catch return;
    _ = c.write(1, text.ptr, text.len);
}

fn familyName(family: i32) []const u8 {
    return switch (family) {
        c.AF.INET => "AF_INET ",
        c.AF.INET6 => "AF_INET6",
        else => "AF_?    ",
    };
}

fn socktypeName(socktype: i32) []const u8 {
    return switch (socktype) {
        c.SOCK.STREAM => "SOCK_STREAM",
        c.SOCK.DGRAM => "SOCK_DGRAM ",
        c.SOCK.RAW => "SOCK_RAW   ",
        else => "SOCK_?     ",
    };
}

/// Печатает весь список, который вернул getaddrinfo, по узлу на строку.
fn show(node: ?[*:0]const u8, service: ?[*:0]const u8, hints: ?*const c.addrinfo) void {
    var list: ?*c.addrinfo = null;
    const rc = c.getaddrinfo(node, service, hints, &list);
    if (@intFromEnum(rc) != 0) {
        say("  getaddrinfo: {s}\n", .{c.gai_strerror(rc)});
        return;
    }
    defer c.freeaddrinfo(list.?);

    var current = list;
    while (current) |ai| : (current = ai.next) {
        var host: [64]u8 = undefined;
        var serv: [16]u8 = undefined;
        const flags: c.NI = .{ .NUMERICHOST = true, .NUMERICSERV = true };
        if (@intFromEnum(c.getnameinfo(ai.addr.?, ai.addrlen, &host, host.len, &serv, serv.len, flags)) != 0) continue;
        const proto: u32 = @intCast(ai.protocol);
        say("  {s} {s} proto {d: >2}  addrlen {d: >2}  {s} порт {s}\n", .{
            familyName(ai.family),     socktypeName(ai.socktype), proto, ai.addrlen,
            std.mem.sliceTo(&host, 0), std.mem.sliceTo(&serv, 0),
        });
    }
}

pub fn main() void {
    say("getaddrinfo(\"localhost\", \"80\", NULL):\n", .{});
    show("localhost", "80", null);

    var hints = std.mem.zeroes(c.addrinfo);
    hints.family = c.AF.UNSPEC;
    hints.socktype = c.SOCK.STREAM;
    say("то же с socktype = SOCK_STREAM:\n", .{});
    show("localhost", "80", &hints);

    hints.flags = .{ .PASSIVE = true, .NUMERICSERV = true };
    say("сервер: getaddrinfo(NULL, \"8000\"), AI_PASSIVE:\n", .{});
    show(null, "8000", &hints);

    hints.flags = .{};
    say("getaddrinfo(\"nope.invalid\", ...):\n", .{});
    show("nope.invalid", "80", &hints);
}
$ zig run addrinfo.zig
getaddrinfo("localhost", "80", NULL):
  AF_INET6 SOCK_DGRAM  proto 17  addrlen 28  ::1 порт 80
  AF_INET6 SOCK_STREAM proto  6  addrlen 28  ::1 порт 80
  AF_INET  SOCK_DGRAM  proto 17  addrlen 16  127.0.0.1 порт 80
  AF_INET  SOCK_STREAM proto  6  addrlen 16  127.0.0.1 порт 80
то же с socktype = SOCK_STREAM:
  AF_INET6 SOCK_STREAM proto  6  addrlen 28  ::1 порт 80
  AF_INET  SOCK_STREAM proto  6  addrlen 16  127.0.0.1 порт 80
сервер: getaddrinfo(NULL, "8000"), AI_PASSIVE:
  AF_INET6 SOCK_STREAM proto  6  addrlen 28  :: порт 8000
  AF_INET  SOCK_STREAM proto  6  addrlen 16  0.0.0.0 порт 8000
getaddrinfo("nope.invalid", ...):
  getaddrinfo: nodename nor servname provided, or not known

И то же самое в контейнере Debian (zig run addrinfo.zig -lc):

getaddrinfo("localhost", "80", NULL):
  AF_INET  SOCK_STREAM proto  6  addrlen 16  127.0.0.1 порт 80
  AF_INET  SOCK_DGRAM  proto 17  addrlen 16  127.0.0.1 порт 80
  AF_INET  SOCK_RAW    proto  0  addrlen 16  127.0.0.1 порт 80
  AF_INET  SOCK_STREAM proto  6  addrlen 16  127.0.0.1 порт 80
  AF_INET  SOCK_DGRAM  proto 17  addrlen 16  127.0.0.1 порт 80
  AF_INET  SOCK_RAW    proto  0  addrlen 16  127.0.0.1 порт 80
то же с socktype = SOCK_STREAM:
  AF_INET6 SOCK_STREAM proto  6  addrlen 28  ::1 порт 80
  AF_INET  SOCK_STREAM proto  6  addrlen 16  127.0.0.1 порт 80
сервер: getaddrinfo(NULL, "8000"), AI_PASSIVE:
  AF_INET  SOCK_STREAM proto  6  addrlen 16  0.0.0.0 порт 8000
  AF_INET6 SOCK_STREAM proto  6  addrlen 28  :: порт 8000
getaddrinfo("nope.invalid", ...):
  getaddrinfo: Name or service not known

Четыре вызова, и в каждом есть на что посмотреть.

  • NULL вместо подсказок. На macOS каждый адрес пришёл дважды: для SOCK_DGRAM (протокол 17, UDP) и SOCK_STREAM (протокол 6, TCP). На Linux хуже: каждый адрес трижды, ещё и для SOCK_RAW, и адрес 127.0.0.1 дважды, итого шесть строк. Второй 127.0.0.1 это строка ::1 localhost из /etc/hosts. glibc при hints == NULL подставляет флаги по умолчанию AI_V4MAPPED | AI_ADDRCONFIG, в контейнере нет IPv6 (кроме loopback), поэтому IPv6 не спрашивается, и glibc отдаёт запись ::1 в виде IPv4. Мораль: подсказки задавай всегда.
  • SOCK_STREAM. По адресу на строку, оба семейства. Флагов нет, поэтому AI_ADDRCONFIG не действует и glibc честно отдаёт ::1.
  • AI_PASSIVE без имени. Два адреса для bind: 0.0.0.0 и ::, “все интерфейсы”. Порядок разный: macOS ставит IPv6 первым, glibc IPv4 (порядок задают правила из RFC 6724 и файл /etc/gai.conf). Сервер, который делает bind на первый элемент, на разных системах будет слушать разное.
  • Несуществующее имя. Ошибка, не пустой список; тексты gai_strerror у двух libc свои.
  • addrlen. 16 байт у sockaddr_in, 28 у sockaddr_in6. Поэтому connect принимает длину отдельным аргументом: структура адреса бывает разной.

Одна деталь про службу. Сначала программа спрашивала "http" вместо "80", и на macOS всё работало, а в контейнере Debian каждый вызов упал с Servname not supported for ai_socktype. В минимальном образе нет файла /etc/services (он приезжает пакетом netbase), и имя службы не во что превратить. Серверный код, который должен работать в контейнере, передаёт порт числом, а лучше ещё и с AI_NUMERICSERV.

Проект tiny: шаг 63, адреса и hostinfo

С этого урока в разделе появляется последняя лабораторная, tinylab. К концу блока у тебя будет TINY: маленький, но настоящий веб-сервер на Zig, который отдаёт файлы и запускает программы, а в уроке 67 он станет HTTP-фасадом песочницы zbox. Путь в четыре шага: сегодня адреса и разрешение имён, в уроке 64 сокеты и эхо-сервер, в уроке 65 разбор HTTP и CGI, в уроке 66 сам сервер. Правила те же, что у zbox: всё, что можно проверить без ядра, лежит в чистых функциях, системного кода минимум, у каждого шага свой файл тестов, и тесты ранних шагов остаются зелёными до конца.

Раскладка после сегодняшнего шага:

our-tiny/
  build.zig              граф сборки: модуль tiny, программа tiny, шаг test по шагам проекта
  build.zig.zon          описание пакета: имя, версия, отпечаток, файлы
  src/root.zig           корень модуля tiny, отсюда программа и тесты берут части
  src/main.zig           командная строка
  src/net/addr.zig       адреса IPv4 без ядра: hex2dd, dd2hex, порядок байтов, sockaddr_in
  src/net/hostinfo.zig   все адреса имени через getaddrinfo, и то же через std.Io.net
  tests/step_63.zig      тесты этого урока

Сборка

Граф сборки знакомый по zbox из урока 48: модуль библиотеки, программа поверх него и шаг test, который прогоняет файлы tests/step_NN.zig. Модуль линкуется с libc, потому что getaddrinfo живёт там.

const std = @import("std");

/// Номера уроков курса, на которых проект вырос. Каждому шагу
/// соответствует файл `tests/step_NN.zig`, и все они остаются зелёными.
const project_steps = [_]u8{63};

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    // Библиотечный код: модуль `tiny` подключают программа и тесты шагов.
    // getaddrinfo и getnameinfo идут через libc, поэтому модуль линкуется с ней.
    const tiny = b.addModule("tiny", .{
        .root_source_file = b.path("src/root.zig"),
        .target = target,
        .optimize = optimize,
        .link_libc = true,
    });

    const exe = b.addExecutable(.{
        .name = "tiny",
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/main.zig"),
            .target = target,
            .optimize = optimize,
            .imports = &.{.{ .name = "tiny", .module = tiny }},
        }),
    });
    b.installArtifact(exe);

    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| run_cmd.addArgs(args);
    const run_step = b.step("run", "Собрать и запустить tiny");
    run_step.dependOn(&run_cmd.step);

    // zig build test прогоняет все шаги, zig build test -Dstep=63 только один.
    const only_step = b.option(u8, "step", "Прогнать тесты одного шага, например -Dstep=63");
    const test_step = b.step("test", "Прогнать тесты всех шагов проекта");

    inline for (project_steps) |number| {
        if (only_step == null or only_step.? == number) {
            const path = std.fmt.comptimePrint("tests/step_{d:0>2}.zig", .{number});
            const step_tests = b.addTest(.{
                .root_module = b.createModule(.{
                    .root_source_file = b.path(path),
                    .target = target,
                    .optimize = optimize,
                    .link_libc = true,
                    .imports = &.{.{ .name = "tiny", .module = tiny }},
                }),
            });
            test_step.dependOn(&b.addRunArtifact(step_tests).step);
        }
    }
}

Здесь впервые в разделе появляется файл пакета. Он понадобится в уроке 66, когда zbox подключит TINY как зависимость по пути, но заводим его сразу, чтобы our-tiny с первого дня был пакетом.

.{
    .name = .our_tiny,
    .version = "0.1.0",
    .fingerprint = 0x5fec2ef718615bff,
    .minimum_zig_version = "0.16.0",
    .dependencies = .{},
    .paths = .{
        "build.zig",
        "build.zig.zon",
        "src",
        "cgi",
        "www",
        "tests",
        "README.md",
    },
}

Поле fingerprint у твоего пакета будет своим. Не выдумывай его: убери строку, запусти zig build, и компилятор ответит ошибкой missing top-level 'fingerprint' field; suggested value: 0x... с готовым значением. Каталоги cgi и www из списка paths появятся в следующих уроках; paths описывает, что входит в пакет при раздаче, сборке несуществующий каталог не мешает.

//! Корень модуля `tiny`. Программа и тесты шагов берут части сервера
//! отсюда. Шаг 63: `addr` и `hostinfo`.

pub const addr = @import("net/addr.zig");
pub const hostinfo = @import("net/hostinfo.zig");

addr.zig: адреса без ядра

Чистый файл: ни одного системного вызова, ни одного обращения к сети, только числа и байты. Отсюда и hex2dd с dd2hex, те самые функции, которые в книге пишутся программами на C поверх inet_pton и inet_ntop.

//! Адреса IPv4 без ядра: шестнадцатеричная и точечная записи, порядок
//! байтов сети и сборка `sockaddr_in`. Всё чистое, тесты гоняются везде.

const std = @import("std");
const c = std.c;

pub const Error = error{InvalidAddress};

/// Точечная запись адреса занимает не больше 15 байт: `255.255.255.255`.
pub const dd_max_len = 15;

/// `htons`: порядок байтов хоста в порядок байтов сети (старший байт первым).
pub fn htons(x: u16) u16 {
    return std.mem.nativeToBig(u16, x);
}

pub fn htonl(x: u32) u32 {
    return std.mem.nativeToBig(u32, x);
}

pub fn ntohs(x: u16) u16 {
    return std.mem.bigToNative(u16, x);
}

pub fn ntohl(x: u32) u32 {
    return std.mem.bigToNative(u32, x);
}

/// Адрес как число в порядке хоста в точечную запись: 0x8002c2f2 это 128.2.194.242.
/// Старший байт числа это первый октет, поэтому число сперва переводим
/// в порядок сети, а потом смотрим на его байты по порядку.
pub fn hex2dd(addr: u32, buf: *[dd_max_len]u8) []const u8 {
    const octets: [4]u8 = @bitCast(htonl(addr));
    return std.fmt.bufPrint(buf, "{d}.{d}.{d}.{d}", .{ octets[0], octets[1], octets[2], octets[3] }) catch unreachable;
}

/// Точечная запись в число в порядке хоста: 128.2.194.242 это 0x8002c2f2.
pub fn dd2hex(text: []const u8) Error!u32 {
    return ntohl(@bitCast(try parseIp4(text)));
}

/// Разбор точечной записи в четыре октета. Строгий: ровно четыре числа
/// от 0 до 255 через точку, без пробелов, без ведущих плюсов и пустых полей.
pub fn parseIp4(text: []const u8) Error![4]u8 {
    var octets: [4]u8 = undefined;
    var parts = std.mem.splitScalar(u8, text, '.');
    for (&octets) |*octet| {
        const part = parts.next() orelse return error.InvalidAddress;
        if (part.len == 0 or part.len > 3) return error.InvalidAddress;
        for (part) |ch| if (!std.ascii.isDigit(ch)) return error.InvalidAddress;
        octet.* = std.fmt.parseInt(u8, part, 10) catch return error.InvalidAddress;
    }
    if (parts.next() != null) return error.InvalidAddress;
    return octets;
}

/// Четыре октета в точечную запись.
pub fn formatIp4(octets: [4]u8, buf: *[dd_max_len]u8) []const u8 {
    return std.fmt.bufPrint(buf, "{d}.{d}.{d}.{d}", .{ octets[0], octets[1], octets[2], octets[3] }) catch unreachable;
}

/// `sockaddr_in` для адреса и порта в порядке хоста. Оба поля структуры
/// ядро читает в порядке сети, поэтому здесь `htons` и `htonl`.
/// На macOS у структуры есть ещё поле `len`, std заполняет его сама.
pub fn sockaddrIn(ip: u32, port: u16) c.sockaddr.in {
    return .{ .port = htons(port), .addr = htonl(ip) };
}

/// Адрес и порт из `sockaddr_in`, уже в порядке хоста.
pub const Endpoint = struct { ip: u32, port: u16 };

pub fn endpointOf(sa: *const c.sockaddr.in) Endpoint {
    return .{ .ip = ntohl(sa.addr), .port = ntohs(sa.port) };
}

/// Печать `host:port` для IPv4-адреса из `sockaddr`. Для других семейств
/// адрес печатает `getnameinfo` в `socket.zig`, ему нужен libc.
pub fn formatEndpoint(sa: *const c.sockaddr.in, buf: *[dd_max_len + 6]u8) []const u8 {
    var ip_buf: [dd_max_len]u8 = undefined;
    const endpoint = endpointOf(sa);
    return std.fmt.bufPrint(buf, "{s}:{d}", .{ hex2dd(endpoint.ip, &ip_buf), endpoint.port }) catch unreachable;
}

pub const loopback: u32 = 0x7f000001;
pub const any: u32 = 0;

Разберём места, на которых стоит задержаться.

hex2dd через порядок сети. Число в порядке хоста сначала переводится htonl в порядок сети, и только потом его байты разглядываются по порядку через @bitCast в [4]u8. Это ровно то, что мы видели в byteorder.zig: после nativeToBig байты в памяти идут в порядке точечной записи. Такой приём работает на машине с любым порядком байтов. Можно было бы и сдвигами (addr >> 24, (addr >> 16) & 0xff), результат тот же; @bitCast удобнее тем, что показывает, как адрес лежит в sockaddr_in.

Буфер вместо аллокатора. hex2dd пишет в *[15]u8 вызывающего и возвращает срез этого буфера. Пятнадцать байт это длина 255.255.255.255, длиннее точечная запись не бывает, поэтому bufPrint никогда не кончит место и catch unreachable честен. Так же устроены getnameinfo и inet_ntop: буфер даёт вызывающий, функция реентерабельна.

parseIp4 строже parseInt. std.fmt.parseInt(u8, ...) сама по себе примет +1 и 1_0, поэтому перед ней каждая часть проверяется на длину и на то, что в ней одни цифры. Ведущий ноль этот разбор пропускает: 010 станет десятью, как на macOS. В задаче урока ты сделаешь строже и откажешь в нём совсем, а в упражнениях вернёшься сюда.

sockaddrIn и отличие BSD. Структура sockaddr_in это то, что ядро получит в connect и bind: семейство, порт и адрес, оба в порядке сети, и восемь нулевых байт добивки до 16. На macOS у неё есть ещё первый байт len с длиной структуры, наследство BSD; std.c заполняет его значением по умолчанию, и тест ниже поэтому ищет порт через @offsetOf, а не по жёсткому смещению 2. Приводить *sockaddr_in к *sockaddr и обратно мы будем в следующем уроке, сегодня только сборка и разбор.

hostinfo.zig: все адреса имени

Программа hostinfo из книги получает имя и печатает все его адреса. Наша версия отличается в двух местах: семейство AF_UNSPEC с флагом AI_ADDRCONFIG вместо только IPv4, и печать через getnameinfo с NI_NUMERICHOST, поэтому IPv6-адреса печатаются тем же кодом, что и IPv4. Рядом lookupStd: то же самое средствами std.Io.net, к ней вернёмся в конце шага.

//! `hostinfo <имя>`: все адреса имени через `getaddrinfo` из libc, как
//! `hostinfo.c` из книги. Отличия: `AI_ADDRCONFIG` (не спрашивать IPv6,
//! если у машины нет адреса IPv6) и печать через `getnameinfo` с
//! `NI_NUMERICHOST`, поэтому IPv6-адреса печатаются тоже.
//! Рядом `lookupStd`: то же самое обёрткой `std.Io.net.HostName` из 0.16.

const std = @import("std");
const c = std.c;
const Io = std.Io;

pub const Error = error{ LookupFailed, NameTooLong, WriteFailed };

/// Печатает по адресу на строку. `AF_UNSPEC` и `SOCK_STREAM`: адреса
/// обоих семейств, но по одному на адрес, а не по одному на каждый
/// тип сокета (без `socktype` libc отдала бы каждый адрес трижды).
pub fn hostinfo(name: []const u8, out: *Io.Writer) Error!void {
    var name_buf: [256]u8 = undefined;
    const name_z = std.fmt.bufPrintZ(&name_buf, "{s}", .{name}) catch return error.NameTooLong;

    const hints: c.addrinfo = .{
        .flags = .{ .ADDRCONFIG = true },
        .family = c.AF.UNSPEC,
        .socktype = c.SOCK.STREAM,
        .protocol = 0,
        .addrlen = 0,
        .addr = null,
        .canonname = null,
        .next = null,
    };
    var list: ?*c.addrinfo = null;
    const rc = c.getaddrinfo(name_z.ptr, null, &hints, &list);
    if (@intFromEnum(rc) != 0) {
        out.print("getaddrinfo: {s}\n", .{c.gai_strerror(rc)}) catch return error.WriteFailed;
        return error.LookupFailed;
    }
    defer c.freeaddrinfo(list.?);

    var current = list;
    while (current) |ai| : (current = ai.next) {
        var host: [64]u8 = undefined;
        const name_rc = c.getnameinfo(ai.addr.?, ai.addrlen, &host, host.len, null, 0, .{ .NUMERICHOST = true });
        if (@intFromEnum(name_rc) != 0) continue;
        out.print("{s}\n", .{std.mem.sliceTo(&host, 0)}) catch return error.WriteFailed;
    }
}

/// То же средствами std 0.16: `HostName.lookup` кладёт адреса в очередь,
/// мы их вычитываем. На Linux под `Io.Threaded` это собственный резолвер
/// std (читает `/etc/hosts` и `/etc/resolv.conf`, ходит к DNS по UDP), на
/// macOS обычный `getaddrinfo`, но без `AI_ADDRCONFIG`: ответ может
/// отличаться от `hostinfo` выше.
pub fn lookupStd(io: Io, name: []const u8, out: *Io.Writer) !void {
    const host_name = try Io.net.HostName.init(name);
    var buffer: [16]Io.net.HostName.LookupResult = undefined;
    var queue: Io.Queue(Io.net.HostName.LookupResult) = .init(&buffer);
    var lookup = io.async(Io.net.HostName.lookup, .{ host_name, io, &queue, .{ .port = 0 } });
    defer _ = lookup.cancel(io) catch {};

    while (true) {
        const result = queue.getOne(io) catch break;
        switch (result) {
            .address => |address| try out.print("{f}\n", .{address}),
            .canonical_name => {},
        }
    }
    try lookup.await(io);
}

Имя приходит срезом, а getaddrinfo хочет строку с нулём на конце. bufPrintZ кладёт её в буфер на 256 байт (имя в DNS не длиннее 253 символов) и возвращает [:0]u8. Подсказки заполняются все, поля по именам: std.mem.zeroes тоже подошёл бы, но полная запись видна глазами. Флаги это packed struct, и .{ .ADDRCONFIG = true } компилятор проверит на опечатку, в отличие от | целых констант в C.

Ошибка приходит значением перечисления c.EAI, где ноль означает успех. Поэтому сравнение идёт через @intFromEnum(rc) != 0, а текст даёт gai_strerror. Сразу за успешным вызовом стоит defer c.freeaddrinfo(list.?): список выделила libc, и вернуть его надо ровно один раз, на любом пути выхода. Обход это обычный цикл по связному списку с ai.next. Если getnameinfo не смогла отформатировать какой-то элемент, мы его пропускаем, а не падаем.

Почему функция пишет в *Io.Writer, а не прямо в stdout? Ради тестов: тест подставляет Writer поверх массива и сравнивает напечатанное. Та же привычка, что в уроке про буферы: функция не знает, куда пишет, это решает вызывающий.

main.zig: подкоманда hostinfo

Программа tiny будет расти подкомандами: сегодня hostinfo, в следующем уроке echoserver и echoclient, потом сам сервер. Пока в main.zig только первая.

//! Точка входа: подкоманды `tiny`. Вся работа живёт в модуле `tiny`,
//! здесь только разбор командной строки. Шаг 63: `hostinfo`.

const std = @import("std");
const tiny = @import("tiny");

const usage =
    \\tiny, веб-сервер из главы 11 на Zig
    \\
    \\Использование:
    \\  tiny hostinfo <name>             все адреса имени через getaddrinfo
    \\  tiny hostinfo --std <name>       то же через std.Io.net.HostName
    \\
;

pub fn main(init: std.process.Init) !void {
    const arena = init.arena.allocator();
    const io = init.io;
    const args = try init.minimal.args.toSlice(arena);

    var out_buf: [4096]u8 = undefined;
    var stdout = std.Io.File.stdout().writerStreaming(io, &out_buf);
    const out = &stdout.interface;

    if (args.len < 2) return fail(out, usage);
    const command = args[1];
    const rest = args[2..];

    if (std.mem.eql(u8, command, "hostinfo")) {
        const use_std = rest.len > 0 and std.mem.eql(u8, rest[0], "--std");
        const names = if (use_std) rest[1..] else rest;
        if (names.len != 1) return fail(out, usage);
        if (use_std) {
            try tiny.hostinfo.lookupStd(io, names[0], out);
        } else {
            tiny.hostinfo.hostinfo(names[0], out) catch |err| switch (err) {
                error.LookupFailed => {
                    try out.flush();
                    std.process.exit(1);
                },
                else => return err,
            };
        }
        try out.flush();
    } else {
        return fail(out, usage);
    }
}

fn fail(out: *std.Io.Writer, text: []const u8) !void {
    try out.writeAll(text);
    try out.flush();
    std.process.exit(2);
}

Код возврата: 1, если имя не разрешилось, 2 при неправильном вызове. Перед std.process.exit стоит flush, иначе сообщение gai_strerror так и останется в буфере: exit не возвращается, и defer не сработают.

Прогон

$ zig build
$ ./zig-out/bin/tiny hostinfo localhost
::1
127.0.0.1
$ ./zig-out/bin/tiny hostinfo example.com
172.66.147.243
104.20.23.154
$ ./zig-out/bin/tiny hostinfo --std example.com
172.66.147.243:0
104.20.23.154:0
[2606:4700:10::ac42:93f3]:0
[2606:4700:10::6814:179a]:0
$ ./zig-out/bin/tiny hostinfo nosuch.invalid
getaddrinfo: nodename nor servname provided, or not known
$ echo $?
1

Снято на macOS. Сравни второй и третий вызовы: dig выше показал у example.com и A, и AAAA, но наш hostinfo напечатал только IPv4. Это работа AI_ADDRCONFIG: у машины, где снимался вывод, нет глобального адреса IPv6 (только link-local fe80:: на интерфейсах), до адресов 2606:... ей всё равно не достучаться, и libc их даже не спрашивает. --std флага не знает и отдаёт все четыре. А localhost получил ::1 несмотря на флаг: loopback IPv6 есть у любой машины, и libc macOS его учитывает.

В контейнере Debian:

$ tiny hostinfo localhost
127.0.0.1
127.0.0.1
$ tiny hostinfo --std localhost
127.0.0.1:0
[::1]:0
$ tiny hostinfo --std nope.invalid
error: NoAddressReturned

Двойной 127.0.0.1 ты уже видел в addrinfo.zig: IPv6 в контейнере нет, AI_ADDRCONFIG выключил его, и glibc вернула строку ::1 localhost в виде IPv4. Реализация --std на Linux своя, /etc/hosts она читает сама и флагов не знает, поэтому оба адреса на месте, в порядке строк файла. А несуществующее имя там даёт error.NoAddressReturned (с трассой стека, потому что main пробрасывает ошибку наверх), хотя по смыслу это error.UnknownHostName.

Что даёт std.Io.net

В Zig 0.16 сеть переехала в std.Io.net, и обёртки стоит знать, даже если TINY написан на libc. Главные типы:

  • IpAddress это union(enum) из ip4: Ip4Address и ip6: Ip6Address. У Ip4Address поля bytes: [4]u8 (в порядке сети, то есть в порядке точечной записи) и port: u16 (в порядке хоста). Никаких htons снаружи: переводом занимается реализация, когда строит sockaddr.
  • IpAddress.parse(text, port) это чистая функция, строгий разбор без ведущих нулей (ошибка NonCanonical) и без экзотических форм. IpAddress.resolve(io, text, port) делает то же, но умеет ещё IPv6 с именем интерфейса вроде fe80::1%en0: чтобы превратить имя интерфейса в номер, нужен системный вызов, поэтому нужен и io.
  • Печать через {f} даёт адрес вместе с портом: 127.0.0.1:0 и [::1]:0, отсюда вид вывода --std.
  • HostName.lookup(host_name, io, &queue, .{ .port = 0 }) разрешает имя. Результаты она не возвращает, а кладёт в очередь Io.Queue(LookupResult), по мере поступления, и закрывает очередь в конце. Поэтому lookupStd запускает её через io.async и тут же вычитывает очередь, пока та не закроется, а потом забирает итог через await.

Где обёртка 0.16 не дотягивает до libc, по исходникам lib/std/Io/Threaded.zig (функция netLookupFallible) и lib/std/Io/net/HostName.zig:

  • Разная реализация на разных системах. На Linux это собственный резолвер std: IP-литерал, /etc/hosts, особый случай localhost, дальше DNS по UDP на серверы из /etc/resolv.conf. Ни nsswitch.conf, ни systemd-resolved, ни LDAP он не знает. На macOS ветка для системы пустая (в исходнике стоит TODO), и дело кончается обычным getaddrinfo из libc. Одна и та же программа ищет имена двумя разными механизмами.
  • Нет подсказок. Из опций только порт, семейство и буфер под каноническое имя. AI_ADDRCONFIG нет, поэтому --std на машине без IPv6 отдаёт адреса, до которых не достучаться.
  • Нет служб. Порт только числом, /etc/services не читается.
  • Нет обратного разрешения. Аналога getnameinfo с запросом PTR нет вовсе.
  • Экзотика не принимается. 127.1, 0x7f.1 и 010.0.0.1 на Linux --std не разбирает как адрес и уходит с ними в DNS. Строго, но не так, как привык весь остальной Unix.

Отсюда выбор проекта: TINY зовёт libc напрямую, как книга и как весь блок S9, а std.Io.net мы держим рядом как вторую колонку, чтобы видеть, что обёртка прячет. В следующем уроке сравним так же IpAddress.listen и Server.accept с голыми bind, listen и accept.

Тесты шага

//! Шаг 63: адреса IPv4 без ядра, hex2dd и dd2hex, порядок байтов сети,
//! sockaddr_in байт в байт.

const std = @import("std");
const tiny = @import("tiny");
const addr = tiny.addr;

const testing = std.testing;

test "hex2dd: упражнения 11.1 и 11.2 книги" {
    var buf: [addr.dd_max_len]u8 = undefined;
    try testing.expectEqualStrings("0.0.0.0", addr.hex2dd(0x0, &buf));
    try testing.expectEqualStrings("255.255.255.255", addr.hex2dd(0xffffffff, &buf));
    try testing.expectEqualStrings("127.0.0.1", addr.hex2dd(0x7f000001, &buf));
    try testing.expectEqualStrings("205.188.160.121", addr.hex2dd(0xcdbca079, &buf));
    try testing.expectEqualStrings("64.12.149.13", addr.hex2dd(0x400c950d, &buf));
    try testing.expectEqualStrings("205.188.146.23", addr.hex2dd(0xcdbc9217, &buf));
    try testing.expectEqualStrings("128.2.194.242", addr.hex2dd(0x8002c2f2, &buf));
}

test "dd2hex: обратно" {
    try testing.expectEqual(0x8002c2f2, try addr.dd2hex("128.2.194.242"));
    try testing.expectEqual(0x0, try addr.dd2hex("0.0.0.0"));
    try testing.expectEqual(0xffffffff, try addr.dd2hex("255.255.255.255"));
    try testing.expectEqual(0x7f000001, try addr.dd2hex("127.0.0.1"));
}

test "dd2hex и hex2dd обратны друг другу на случайных адресах" {
    var prng = std.Random.DefaultPrng.init(63);
    const random = prng.random();
    var buf: [addr.dd_max_len]u8 = undefined;
    for (0..1000) |_| {
        const value = random.int(u32);
        try testing.expectEqual(value, try addr.dd2hex(addr.hex2dd(value, &buf)));
    }
}

test "dd2hex отказывает на кривых строках" {
    for ([_][]const u8{ "", "1.2.3", "1.2.3.4.5", "256.0.0.1", "1..2.3", "a.b.c.d", " 1.2.3.4", "1.2.3.4 ", "+1.2.3.4", "1.2.3.-4", "0001.2.3.4" }) |text| {
        try testing.expectError(error.InvalidAddress, addr.dd2hex(text));
    }
}

test "htons и htonl меняют порядок байтов на little-endian и не трогают на big-endian" {
    const native = @import("builtin").cpu.arch.endian();
    const port = addr.htons(80);
    const expected_port: u16 = if (native == .little) 0x5000 else 80;
    try testing.expectEqual(expected_port, port);
    try testing.expectEqual(80, addr.ntohs(port));
    try testing.expectEqual(0x8002c2f2, addr.ntohl(addr.htonl(0x8002c2f2)));
}

test "sockaddr_in: порт и адрес лежат в порядке сети, старший байт первым" {
    const sa = addr.sockaddrIn(0x8002c2f2, 80);
    const bytes = std.mem.asBytes(&sa);
    // Раскладка: [len на BSD] family, порт (2 байта), адрес (4 байта), нули.
    const offset = @offsetOf(std.c.sockaddr.in, "port");
    try testing.expectEqualSlices(u8, &.{ 0x00, 0x50, 128, 2, 194, 242 }, bytes[offset .. offset + 6]);
    try testing.expectEqual(std.c.AF.INET, sa.family);
    try testing.expectEqual(@as(usize, 16), @sizeOf(std.c.sockaddr.in));

    const endpoint = addr.endpointOf(&sa);
    try testing.expectEqual(80, endpoint.port);
    try testing.expectEqual(0x8002c2f2, endpoint.ip);

    var buf: [addr.dd_max_len + 6]u8 = undefined;
    try testing.expectEqualStrings("128.2.194.242:80", addr.formatEndpoint(&sa, &buf));
}

test "hostinfo: localhost разрешается хотя бы в один loopback-адрес" {
    var buf: [1024]u8 = undefined;
    var out: std.Io.Writer = .fixed(&buf);
    try tiny.hostinfo.hostinfo("localhost", &out);
    const text = out.buffered();
    try testing.expect(std.mem.indexOf(u8, text, "127.0.0.1") != null or std.mem.indexOf(u8, text, "::1") != null);
}

test "hostinfo: числовой адрес печатается как есть" {
    var buf: [256]u8 = undefined;
    var out: std.Io.Writer = .fixed(&buf);
    try tiny.hostinfo.hostinfo("128.2.194.242", &out);
    try testing.expectEqualStrings("128.2.194.242\n", out.buffered());
}

test "hostinfo: несуществующее имя это ошибка, а не пустой список" {
    var buf: [256]u8 = undefined;
    var out: std.Io.Writer = .fixed(&buf);
    try testing.expectError(error.LookupFailed, tiny.hostinfo.hostinfo("no-such-host.invalid", &out));
}

Первые шесть тестов чистые и работают где угодно, включая машину без сети. Три последних зовут настоящий getaddrinfo: localhost и числовой адрес разрешаются без DNS (из /etc/hosts и разбором строки), а .invalid это зона, которую RFC 6761 навсегда запретил, поэтому ответ “имени нет” гарантирован и тоже не требует сети. Тест на localhost принимает любой из двух адресов: мы только что видели, что на разных системах список разный.

$ zig build test -Dstep=63 --summary all
Build Summary: 3/3 steps succeeded; 9/9 tests passed
test success
+- run test 9 pass (9 total) 328ms MaxRSS:10M
   +- compile test Debug native success 1s MaxRSS:272M

Зелёные на macOS и в контейнере Debian (linux/arm64).

На macOS

Весь урок работает на macOS напрямую: getaddrinfo, getnameinfo, socket и connect там из libSystem, которая линкуется всегда. Выводы в тексте сняты на macOS 26 (Apple Silicon) и в контейнере Debian 12 (ghcr.io/bondiano/runner-zig:dev, linux/arm64); где они различаются, показаны оба. На Linux для одиночных программ добавляй -lc. Отличия, на которые ты наступишь:

  • конфигурацию резолверов показывает scutil --dns, а не /etc/resolv.conf; разрешение идёт через службу mDNSResponder, поэтому strace-подобный взгляд на UDP-пакеты из процесса ничего не покажет;
  • /etc/hosts на месте и работает так же, в /etc/nsswitch.conf нужды нет;
  • диапазон эфемерных портов 49152 до 65535 (sysctl net.inet.ip.portrange), на Linux 32768 до 60999 (/proc/sys/net/ipv4/ip_local_port_range);
  • у sockaddr_in лишний первый байт len, размер тот же, 16 байт;
  • std.Io.net.HostName.lookup на macOS это тот же getaddrinfo, на Linux свой резолвер std: сравнивать --std между системами бесполезно, сравнивай с libc на той же системе;
  • 010.0.0.1 в libc macOS это 10.0.0.1, в glibc 8.0.0.1;
  • traceroute есть в системе, на Debian его надо ставить пакетом.

Задачу урока можно решать прямо на macOS: она чистая, без сети и ядра, тесты гоняются везде.

Практика

Две функции из addr.zig, только разбор строже: hex2dd(addr, buf) и dd2hex(text), где dd2hex ведёт себя как inet_pton, а не как inet_aton. Ровно четыре десятичных числа от 0 до 255 через точку, и отказ во всём остальном, включая ведущие нули. Тесты проверяют адреса из упражнений книги, порядок октетов, буфер, таблицу кривых строк и обратимость на двух тысячах случайных чисел.

Упражнения

Первые четыре это упражнения 11.1 до 11.4 книги, пересказанные на другие числа и на Zig. Остальные уводят дальше книги.

Итоги

  • Клиент-сервер это модель, а не железо: сервер и клиент это процессы, общение складывается из транзакций “запрос, обработка, ответ, обработка ответа”.
  • Для машины сеть это ещё одно устройство ввода-вывода: адаптер на шине, данные по DMA. Для программы это дескриптор сокета с read и write и всеми правилами блока S9, короткие счёты становятся нормой.
  • Сеть собрана иерархически: локальные сети (Ethernet, кадры по MAC-адресам, мосты) соединены маршрутизаторами. Протокол IP даёт единые адреса и единый формат пакета поверх разных сетей. Каждый уровень заворачивает данные верхнего в свой заголовок: инкапсуляция. Ethernet плюс IP плюс TCP оставляют данным 1460 байт из 1500.
  • Адрес IPv4 это 32 бита в порядке байтов сети (big-endian). htonl и htons в Zig это std.mem.nativeToBig. Точечная запись это байты старшим вперёд; разбирать её надо строго, как inet_pton: экзотические формы inet_aton читаются разными libc по-разному (010.0.0.1 это 10 или 8).
  • Имя в адрес переводит libc: сначала /etc/hosts, потом резолвер из /etc/resolv.conf по DNS (на macOS через mDNSResponder). У имени бывает много адресов, у адреса много имён, обратное разрешение это отдельная таблица PTR.
  • Соединение однозначно задаётся парой сокетов (адрес:порт клиента, адрес:порт сервера). Порт сервера известен заранее, порт клиента эфемерный, его выбирает ядро.
  • getaddrinfo отдаёт список addrinfo, готовый для socket и connect или bind, освобождается freeaddrinfo, ошибки через gai_strerror. Подсказки задавай всегда: SOCK_STREAM убирает дубли, AI_ADDRCONFIG убирает недостижимые семейства, AI_PASSIVE даёт адреса для сервера. getnameinfo с NI_NUMERICHOST печатает адрес любого семейства.
  • std.Io.net в 0.16 даёт типы (IpAddress, HostName.lookup через очередь), но на Linux это свой резолвер, а на macOS getaddrinfo, без подсказок, служб и обратного разрешения. TINY поэтому работает с libc.

Дальше

Сегодня мы научились называть другую машину: адресом, именем, парой сокетов. Но ещё ни одного соединения по-настоящему не открыли, pair.zig только подсмотрел, что получается. В следующем уроке разберём сам интерфейс сокетов: socket, connect, bind, listen и accept, структуры адресов и приведение sockaddr_in к sockaddr, очередь соединений и SO_REUSEADDR. Соберём из них openClientfd и openListenfd поверх сегодняшнего getaddrinfo, напишем эхо-сервер и эхо-клиент, посмотрим на них через lsof и сравним с std.Io.net. А zbox получит первый слой настоящей изоляции: запуск без сети.

домашка

Домашка