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

Сокеты: эхо-клиент и эхо-сервер

senior~190 мин

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

Сокеты: эхо-клиент и эхо-сервер

В прошлом уроке сеть была картинкой: адреса из четырёх байт, имена, которые резолвер превращает в список адресов, соединение как пара точек адрес:порт. А в уроке про общие файлы мы увидели, что сокет для ядра это обычный дескриптор, на котором работают read, write и dup2. Сегодня соединим одно с другим. Откуда берётся дескриптор, за которым стоит машина на другом конце сети? Что делает сервер, пока ему никто не звонит? Почему accept возвращает новый дескриптор, а не тот, что слушал? И почему сервер, который ты только что остановил, минуту отказывается запускаться на том же порту? Ответ умещается в пять системных вызовов и одну неудобную структуру адреса. Мы разберём их по одному, соберём из них openClientfd и openListenfd из книги, напишем итеративный эхо-сервер и клиента, посмотрим на них снаружи через lsof, netstat, ss и strace, а потом положим рядом ту же пару на std.Io.net. В конце zbox научится отбирать у программы сеть целиком.

Цели урока

  • Назвать пять вызовов интерфейса сокетов, кто из них клиентский, кто серверный, и в каком порядке они идут.
  • Собрать sockaddr_in руками, объяснить, зачем все вызовы принимают указатель на общий sockaddr, и привести один к другому в Zig через @ptrCast и @alignCast.
  • Отличать слушающий дескриптор от присоединённого и объяснять, почему их два.
  • Написать openClientfd и openListenfd поверх getaddrinfo, с перебором списка адресов и флагами AI_PASSIVE, AI_ADDRCONFIG, AI_NUMERICSERV.
  • Объяснить TIME_WAIT и SO_REUSEADDR и воспроизвести EADDRINUSE при перезапуске сервера.
  • Написать итеративный эхо-сервер и клиента и прочитать их состояние в lsof, netstat, ss и strace.
  • Знать, что делает за тебя std.Io.net: SOCK_CLOEXEC, SO_REUSEPORT, MSG_NOSIGNAL, и где это может укусить.
  • Выключить программе сеть через unshare(CLONE_NEWNET) и объяснить, почему даже 127.0.0.1 отвечает ENETUNREACH.

Идея: пять вызовов и одна структура

В книге интерфейс сокетов рисуют двумя колонками. Клиент делает два вызова, сервер четыре, и встречаются они в середине:

        клиент                              сервер
                                      socket()      создать сокет
                                      bind()        привязать к адресу и порту
                                      listen()      сделать слушающим
socket()   создать сокет
connect()  позвонить  ─── рукопожатие ──→  accept()  забрать готовое соединение
                                           (новый дескриптор)
write() ────────── запрос ──────────────→  read()
read()  ←───────── ответ ───────────────   write()
close() ──────────  EOF  ───────────────→  read() вернул 0
                                           close()

Сокет для программы это дескриптор, для ядра запись в таблице открытых файлов из прошлого урока, только за ней стоит не файл и не пайп, а два буфера (приёма и отправки) и машина состояний протокола TCP. Отсюда главное свойство всей сегодняшней темы: как только соединение установлено, с ним работают уже знакомые read и write, с короткими счётами из урока про ввод и вывод. Новое только то, что происходит до этого момента: как два процесса на разных машинах договариваются, какие два дескриптора соединить. И каждая стрелка на картинке это обычный системный вызов: номер в регистре и инструкция syscall, как в уроке про ассемблер без libc. Никакой отдельной сетевой машинерии в процессе нет, вся она в ядре.

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

Структура адреса

Все вызовы, которым нужен адрес (connect, bind, accept, getsockname), принимают его одинаково: указатель на struct sockaddr и длину. Для IPv4 настоящая структура другая, sockaddr_in:

struct sockaddr_in {
    uint16_t       sin_family;  // AF_INET
    uint16_t       sin_port;    // порт, порядок байтов сети
    struct in_addr sin_addr;    // IPv4-адрес, порядок байтов сети
    unsigned char  sin_zero[8]; // заполнитель до размера sockaddr
};

struct sockaddr {
    uint16_t sa_family;         // семейство: какая структура на самом деле
    char     sa_data[14];       // адрес, смысл зависит от семейства
};

Почему не void *? Интерфейс сокетов появился в BSD в начале восьмидесятых, а void * в C стандартизовали позже. Общий тип sockaddr играет его роль: ядро читает первые два байта, узнаёт семейство и только потом решает, как понимать остальное. Длина вторым аргументом нужна потому, что структуры разного размера: sockaddr_in занимает 16 байт, sockaddr_in6 28, адрес Unix-сокета больше сотни. Когда адрес приходит из ядра (accept, getsockname), длина передаётся по указателю: на входе ты говоришь, сколько места дал, на выходе ядро сообщает, сколько заполнило. Под «любой адрес» заводят sockaddr_storage: 128 байт с выравниванием на 8, в неё влезает любое семейство.

В Zig эти структуры лежат в std.c.sockaddr (in, in6, storage) и повторяют раскладку C байт в байт. На macOS у sockaddr_in первым идёт ещё байт длины len, наследство BSD, но размер и смещения порта и адреса те же. Приведение к общему типу это @ptrCast, а обратно ещё и @alignCast: у sockaddr выравнивание 2, у sockaddr_in 4, и компилятор хочет, чтобы ты явно пообещал, что указатель выровнен как надо. В отладочной сборке обещание проверяется.

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

test "sockaddr_in занимает 16 байт, как общий sockaddr" {
    try std.testing.expectEqual(16, @sizeOf(c.sockaddr.in));
    try std.testing.expectEqual(@sizeOf(c.sockaddr), @sizeOf(c.sockaddr.in));
    // Порт всегда со смещением 2, адрес со смещением 4.
    try std.testing.expectEqual(2, @offsetOf(c.sockaddr.in, "port"));
    try std.testing.expectEqual(4, @offsetOf(c.sockaddr.in, "addr"));
}

test "порт и адрес лежат в порядке байтов сети" {
    const addr: c.sockaddr.in = .{
        .port = std.mem.nativeToBig(u16, 8080),
        .addr = std.mem.nativeToBig(u32, 0x7f000001), // 127.0.0.1
    };
    const bytes = std.mem.asBytes(&addr);
    try std.testing.expectEqualSlices(u8, &.{ 0x1f, 0x90 }, bytes[2..4]);
    try std.testing.expectEqualSlices(u8, &.{ 127, 0, 0, 1 }, bytes[4..8]);
}

test "из общего sockaddr обратно в sockaddr_in: @ptrCast плюс @alignCast" {
    var storage: c.sockaddr.storage = undefined;
    const in: *c.sockaddr.in = @ptrCast(&storage);
    in.* = .{ .port = std.mem.nativeToBig(u16, 15213), .addr = 0 };

    // Так ядро отдаёт адрес: указатель на общий тип, семейство в заголовке.
    const generic: *const c.sockaddr = @ptrCast(&storage);
    try std.testing.expectEqual(c.AF.INET, generic.family);
    const back: *const c.sockaddr.in = @ptrCast(@alignCast(generic));
    try std.testing.expectEqual(15213, std.mem.bigToNative(u16, back.port));
}

Второй тест стоит перечитать: 8080 это 0x1f90, и в структуре байты идут именно в таком порядке, старший первым. На x86-64 и на arm64 порядок машины обратный, поэтому забытый nativeToBig не даёт ошибки компиляции, а тихо привязывает сервер к порту 0x901f, то есть 36895. Это одна из самых частых ошибок в первом сетевом коде, и мы ещё встретим её в задаче.

Пять вызовов по одному

socket

int socket(int domain, int type, int protocol);

Создаёт сокет и возвращает дескриптор. domain это семейство адресов (AF_INET для IPv4, AF_INET6, AF_UNIX), type это вид канала (SOCK_STREAM для надёжного потока байтов, то есть TCP, SOCK_DGRAM для отдельных датаграмм, то есть UDP), protocol обычно 0: ядро само выберет единственный подходящий. Сокет после socket ещё ни с чем не связан, у него нет ни адреса, ни собеседника. Это запись в таблице открытых файлов, которая ждёт, кем ей стать: клиентом после connect или слушателем после bind и listen.

К типу можно добавить флаги через «или»: SOCK_CLOEXEC сразу ставит close-on-exec (зачем это нужно, было в уроке про общие файлы), SOCK_NONBLOCK делает сокет неблокирующим. На macOS этих флагов нет, там нужен отдельный fcntl.

connect

int connect(int clientfd, const struct sockaddr *addr, socklen_t addrlen);

Клиентский вызов: установить соединение с сервером по адресу addr. Блокирует, пока рукопожатие не завершится или не провалится. Порт клиенту назначает ядро, это эфемерный порт из прошлого урока. Ошибки, которые стоит знать в лицо: ECONNREFUSED (на этом порту никто не слушает, машина ответила сегментом RST), ETIMEDOUT (никто не ответил вовсе), ENETUNREACH (до сети адресата нет маршрута; к нему мы вернёмся в конце урока).

bind

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

Серверный вызов: привязать сокет к локальному адресу и порту. Адрес 0.0.0.0 (константа INADDR_ANY) значит «на всех интерфейсах машины», конкретный адрес значит «только на нём»: сервер на 127.0.0.1 виден только с этой же машины. Порт 0 просит ядро выбрать любой свободный; какой именно, потом скажет getsockname. Так делают тесты: два параллельных прогона не подерутся за один порт. Если порт занят, bind вернёт EADDRINUSE. Порты ниже 1024 без прав администратора не дают (на Linux это право CAP_NET_BIND_SERVICE).

listen

int listen(int sockfd, int backlog);

По умолчанию ядро считает любой сокет будущим клиентом. listen говорит: этот сокет будет принимать соединения. С этого момента ядро само отвечает на рукопожатия клиентов, не дожидаясь программы, и складывает готовые соединения в очередь. backlog это подсказка ядру о длине очереди. Книга берёт 1024, Linux молча обрезает значение до net.core.somaxconn (на ядре из этого урока 4096), macOS до kern.ipc.somaxconn (128). Когда очередь полна, новые клиенты ждут или получают отказ.

accept

int accept(int listenfd, struct sockaddr *addr, socklen_t *addrlen);

Забирает из очереди одно готовое соединение и возвращает новый дескриптор. Если очередь пуста, спит. В addr ядро пишет адрес клиента. Это центральный момент урока, поэтому два термина. Слушающий дескриптор создаётся один раз и живёт, пока работает сервер; данных через него не бывает, он только принимает звонки. Присоединённый дескриптор появляется на каждое соединение, через него идёт разговор с одним клиентом, и закрывается он, когда разговор окончен.

Зачем два? Затем, что разговоров может быть много одновременно, а порт у сервера один. Если бы accept превращал слушающий сокет в соединение, сервер на время разговора стал бы глухим к остальным клиентам. А так один процесс держит слушающий дескриптор и раздаёт присоединённые: в этом уроке по одному за раз, через четыре урока через fork дочерним процессам, потом потокам. Ядро различает соединения на одном порту сервера по паре сокетов из прошлого урока: (адрес клиента:порт клиента, адрес сервера:порт сервера). Две вкладки браузера к одному серверу отличаются эфемерным портом клиента.

Руками в виджете

Ниже клиент и сервер как две таблицы дескрипторов. Нажимай вызовы в порядке книги и смотри, какой номер получает каждый сокет, как меняется его состояние и какие сегменты уходят по сети. Подсвечены вызовы, которые сейчас пройдут; остальные тоже можно нажать, виджет ответит тем, что сказало бы ядро. Попробуй connect до listen (получишь ECONNREFUSED), accept до connect (сервер уснёт), write клиента после close сервера (EPIPE, и вспомни SIGPIPE из прошлого урока). Обрати внимание: после accept у сервера два сокета, listenfd по-прежнему LISTEN, а у connfd состояние ESTABLISHED, и закрыть можно любой из них независимо.

Одного виджет не показывает: что остаётся от соединения после того, как обе стороны сделали close. Кажется, что ничего. На самом деле кое-что остаётся, и это кое-что не даст перезапустить сервер. Но сначала соберём из пяти вызовов удобные функции.

openClientfd и openListenfd

Пять вызовов неудобны в сыром виде: адрес надо собирать руками, отдельно для IPv4 и IPv6, а имя вроде example.com сначала превращать в адреса. Книга прячет всё это в две функции, и мы сделаем то же самое. Обе опираются на getaddrinfo из прошлого урока: он принимает имя и порт строками и отдаёт связный список готовых структур addrinfo, в каждой из которых уже лежат аргументы для socket (family, socktype, protocol) и для connect или bind (addr, addrlen). Код не знает, IPv4 перед ним или IPv6: он просто перебирает список.

Это первый файл шага проекта tiny, который растёт с прошлого урока и к уроку 66 станет веб-сервером. Весь системный код идёт через libc, как в zbox: так один и тот же файл работает и на Linux, и на macOS.

//! Обёртки над интерфейсом сокетов из главы 11: `openClientfd`, `openListenfd`,
//! `accept` с именем клиента. Всё через libc (`getaddrinfo`, `socket`,
//! `bind`, `listen`, `connect`, `setsockopt`), поэтому один код работает
//! на macOS и на Linux. Рядом, в `echo_std.zig`, то же самое на `std.Io.net`.

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

pub const Error = error{
    /// `getaddrinfo` не нашёл ни одного адреса или имя не разобралось.
    LookupFailed,
    /// Ни к одному адресу подключиться не удалось.
    ConnectFailed,
    /// Ни к одному адресу привязаться не удалось.
    BindFailed,
    /// `listen` отказал (например, порт уже слушают).
    ListenFailed,
    /// `accept` вернул ошибку.
    AcceptFailed,
    /// Имя длиннее буфера.
    NameTooLong,
};

/// Длина очереди `listen`. В книге LISTENQ равна 1024.
pub const listenq: c_uint = 1024;

/// Место под `host:port` от `getnameinfo`: имя хоста, двоеточие, порт.
pub const name_max_len = 64 + 1 + 5;

/// Открывает соединение с `host:port`, возвращает дескриптор сокета.
/// `getaddrinfo` разбирает и имена, и числовые адреса, и отдаёт список:
/// у имени может быть несколько адресов (IPv4 и IPv6), пробуем по очереди.
pub fn openClientfd(host: []const u8, port: u16) Error!c.fd_t {
    var host_buf: [256]u8 = undefined;
    const host_z = std.fmt.bufPrintZ(&host_buf, "{s}", .{host}) catch return error.NameTooLong;
    var port_buf: [6]u8 = undefined;
    const port_z = std.fmt.bufPrintZ(&port_buf, "{d}", .{port}) catch unreachable;

    const hints: c.addrinfo = .{
        .flags = .{ .NUMERICSERV = true, .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;
    if (@intFromEnum(c.getaddrinfo(host_z.ptr, port_z.ptr, &hints, &list)) != 0) return error.LookupFailed;
    defer c.freeaddrinfo(list.?);

    var current = list;
    while (current) |ai| : (current = ai.next) {
        const fd = c.socket(@intCast(ai.family), @intCast(ai.socktype), @intCast(ai.protocol));
        if (fd < 0) continue;
        if (c.connect(fd, ai.addr.?, ai.addrlen) == 0) return fd;
        _ = c.close(fd);
    }
    return error.ConnectFailed;
}

/// Слушающий сокет на порту `port` (0 значит любой свободный, его номер
/// потом отдаст `localPort`). `AI_PASSIVE` без имени хоста просит адрес
/// «все интерфейсы». `SO_REUSEADDR` позволяет перезапустить сервер сразу,
/// пока старые соединения в TIME_WAIT ещё держат порт.
pub fn openListenfd(port: u16) Error!c.fd_t {
    var port_buf: [6]u8 = undefined;
    const port_z = std.fmt.bufPrintZ(&port_buf, "{d}", .{port}) catch unreachable;

    const hints: c.addrinfo = .{
        .flags = .{ .PASSIVE = true, .NUMERICSERV = true, .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;
    if (@intFromEnum(c.getaddrinfo(null, port_z.ptr, &hints, &list)) != 0) return error.LookupFailed;
    defer c.freeaddrinfo(list.?);

    // Два прохода: сначала IPv4, потом остальное. macOS отдаёт `::` первым,
    // и клиент 127.0.0.1 приходил бы как `::ffff:127.0.0.1`; книга и уроки
    // показывают адреса в точечной записи.
    for ([_]bool{ true, false }) |ipv4_only| {
        var current = list;
        while (current) |ai| : (current = ai.next) {
            if (ipv4_only != (ai.family == c.AF.INET)) continue;
            const fd = c.socket(@intCast(ai.family), @intCast(ai.socktype), @intCast(ai.protocol));
            if (fd < 0) continue;
            setReuseAddr(fd);
            if (c.bind(fd, ai.addr.?, ai.addrlen) == 0) {
                if (c.listen(fd, listenq) == 0) return fd;
                _ = c.close(fd);
                return error.ListenFailed;
            }
            _ = c.close(fd);
        }
    }
    return error.BindFailed;
}

pub fn setReuseAddr(fd: c.fd_t) void {
    const yes: c_int = 1;
    _ = c.setsockopt(fd, c.SOL.SOCKET, c.SO.REUSEADDR, &yes, @sizeOf(c_int));
}

/// Стоит ли на сокете `SO_REUSEADDR`: читаем ту же опцию обратно.
pub fn reuseAddrEnabled(fd: c.fd_t) bool {
    var value: c_int = 0;
    var len: c.socklen_t = @sizeOf(c_int);
    if (c.getsockopt(fd, c.SOL.SOCKET, c.SO.REUSEADDR, &value, &len) != 0) return false;
    return value != 0;
}

/// Порт, который ядро назначило сокету: нужен после `bind` на порт 0.
pub fn localPort(fd: c.fd_t) ?u16 {
    var storage: c.sockaddr.storage = undefined;
    var len: c.socklen_t = @sizeOf(c.sockaddr.storage);
    if (c.getsockname(fd, @ptrCast(&storage), &len) != 0) return null;
    return portOf(@ptrCast(&storage));
}

/// Порт из `sockaddr` любого из двух семейств, в порядке хоста.
pub fn portOf(sa: *const c.sockaddr) ?u16 {
    return switch (sa.family) {
        c.AF.INET => std.mem.bigToNative(u16, @as(*const c.sockaddr.in, @ptrCast(@alignCast(sa))).port),
        c.AF.INET6 => std.mem.bigToNative(u16, @as(*const c.sockaddr.in6, @ptrCast(@alignCast(sa))).port),
        else => null,
    };
}

/// Принятое соединение: дескриптор и имя клиента вида `127.0.0.1:53211`.
pub const Connection = struct {
    fd: c.fd_t,
    name: []const u8,
};

/// `accept` плюс `getnameinfo` с числовыми флагами: имя клиента для лога
/// без обратного запроса к DNS.
pub fn accept(listenfd: c.fd_t, name_buf: *[name_max_len]u8) Error!Connection {
    var storage: c.sockaddr.storage = undefined;
    var len: c.socklen_t = @sizeOf(c.sockaddr.storage);
    while (true) {
        const fd = c.accept(listenfd, @ptrCast(&storage), &len);
        if (fd >= 0) return .{ .fd = fd, .name = formatName(@ptrCast(&storage), len, name_buf) };
        if (posix.errno(fd) != .INTR) return error.AcceptFailed;
    }
}

/// `host:port` через `getnameinfo`. `NI_NUMERICHOST` и `NI_NUMERICSERV`
/// запрещают ходить в DNS: сервер не должен ждать резолвера ради лога.
pub fn formatName(sa: *const c.sockaddr, len: c.socklen_t, buf: *[name_max_len]u8) []const u8 {
    var host: [64]u8 = undefined;
    var serv: [6]u8 = undefined;
    const rc = c.getnameinfo(sa, len, &host, host.len, &serv, serv.len, .{ .NUMERICHOST = true, .NUMERICSERV = true });
    if (@intFromEnum(rc) != 0) return std.fmt.bufPrint(buf, "?", .{}) catch unreachable;
    const host_z = std.mem.sliceTo(&host, 0);
    const serv_z = std.mem.sliceTo(&serv, 0);
    return std.fmt.bufPrint(buf, "{s}:{s}", .{ host_z, serv_z }) catch unreachable;
}

pub fn close(fd: c.fd_t) void {
    _ = c.close(fd);
}

Разберём решения по порядку.

Флаги в hints. AI_NUMERICSERV говорит, что порт это число, а не имя службы вроде http, и резолверу не нужно заглядывать в /etc/services. AI_ADDRCONFIG просит не возвращать адреса IPv6, если у машины нет ни одного своего адреса IPv6 (и наоборот): незачем пробовать то, что заведомо не сработает. SOCK_STREAM в socktype нужен, чтобы каждый адрес пришёл один раз: без него libc выдала бы на каждый адрес три записи, по одной на поток, датаграммы и сырой сокет.

Перебор в openClientfd. У имени может быть несколько адресов, и первый не обязан отвечать. Живой пример прямо на машине: на macOS localhost резолвится в два адреса, и IPv6 идёт первым.

$ tiny hostinfo localhost
::1
127.0.0.1

Наш сервер ниже слушает только IPv4. Клиент, которому дали имя localhost, первым делом звонит на ::1, получает ECONNREFUSED, закрывает сокет и пробует 127.0.0.1, где его ждут. Без перебора echoclient localhost на macOS просто не работал бы. Обрати внимание на close после неудачи: сокет, который не смог соединиться, повторно использовать нельзя, на следующий адрес нужен новый.

AI_PASSIVE в openListenfd. Имя хоста null плюс этот флаг значат «адрес для bind на всех интерфейсах»: резолвер вернёт 0.0.0.0 и ::. Здесь есть тонкость, которой нет в книге. macOS отдаёт :: первым, а слушающий сокет IPv6 по умолчанию принимает и IPv4-клиентов, только видит их адреса в странном виде ::ffff:127.0.0.1. Чтобы лог выглядел как в книге, функция делает два прохода: сначала ищет в списке IPv4, потом всё остальное.

setReuseAddr до bind. Об этой строке следующий раздел целиком.

listen сразу после успешного bind. Если bind прошёл, а listen отказал, дальше по списку идти бессмысленно: порт наш, проблема в другом. Поэтому ошибка ListenFailed возвращается сразу.

localPort и portOf. После bind на порт 0 номер знает только ядро, и getsockname его возвращает. Адрес приходит в sockaddr_storage, семейство смотрим в общем заголовке, а порт достаём уже из правильной структуры: ровно то приведение, которое мы проверяли тестом выше.

accept и EINTR. accept это медленный вызов: он спит, пока нет клиентов. Если в это время процессу придёт сигнал с обработчиком, вызов вернётся с EINTR, как read в уроке про сигналы. Для сервера это не ошибка, поэтому цикл.

formatName и NI_NUMERICHOST. Книга печатает имя клиента через getnameinfo, и без флагов тот честно идёт в DNS за обратной записью адреса. Сервер, который ждёт резолвер ради одной строки лога, обслуживает клиентов со скоростью DNS. Числовые флаги запрещают этот поход.

SO_REUSEADDR и TIME_WAIT

Представь: сервер работает, к нему подключались клиенты, ты останавливаешь его, чинишь опечатку и запускаешь снова. И получаешь EADDRINUSE, хотя никакого сервера на порту уже нет. Через минуту всё проходит само. Воспроизведём это в одной программе: сервер принимает соединение, закрывает его первым, потом закрывает слушающий сокет и пробует занять порт снова. Дважды: без SO_REUSEADDR и с ним.

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

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

fn errnoName(rc: c_int) []const u8 {
    return if (rc == 0) "ok" else @tagName(std.posix.errno(rc));
}

/// Слушающий сокет на 127.0.0.1:port, SO_REUSEADDR по желанию.
/// Возвращает дескриптор или минус единицу, печатает итог bind.
fn listenOn(port: u16, reuse: bool) c.fd_t {
    const fd = c.socket(c.AF.INET, c.SOCK.STREAM, 0);
    if (reuse) {
        const yes: c_int = 1;
        _ = c.setsockopt(fd, c.SOL.SOCKET, c.SO.REUSEADDR, &yes, @sizeOf(c_int));
    }
    const addr: c.sockaddr.in = .{
        .port = std.mem.nativeToBig(u16, port),
        .addr = std.mem.nativeToBig(u32, 0x7f000001),
    };
    const rc = c.bind(fd, @ptrCast(&addr), @sizeOf(c.sockaddr.in));
    say("bind(:{d}, SO_REUSEADDR={}) -> {s}\n", .{ port, reuse, errnoName(rc) });
    if (rc != 0) {
        _ = c.close(fd);
        return -1;
    }
    _ = c.listen(fd, 16);
    return fd;
}

/// Один запуск сервера: слушаем, принимаем соединение, закрываем первыми,
/// потом пробуем занять тот же порт заново с теми же настройками.
fn restart(port: u16, reuse: bool) void {
    const listenfd = listenOn(port, reuse);
    if (listenfd < 0) return;

    // Клиент и сервер в одном процессе: connect завершает рукопожатие
    // сам, соединение ждёт в очереди listen, accept его забирает.
    const client = c.socket(c.AF.INET, c.SOCK.STREAM, 0);
    const addr: c.sockaddr.in = .{
        .port = std.mem.nativeToBig(u16, port),
        .addr = std.mem.nativeToBig(u32, 0x7f000001),
    };
    _ = c.connect(client, @ptrCast(&addr), @sizeOf(c.sockaddr.in));
    const conn = c.accept(listenfd, null, null);

    // Сервер закрывает первым: TIME_WAIT достанется его стороне
    // соединения, той, у которой наш порт. Клиент видит EOF и закрывает свою.
    _ = c.close(conn);
    var byte: [1]u8 = undefined;
    _ = c.read(client, &byte, 1);
    _ = c.close(client);
    _ = c.close(listenfd);

    const again = listenOn(port, reuse);
    if (again >= 0) _ = c.close(again);
}

pub fn main() void {
    restart(15214, false);
    restart(15215, true);
}
$ zig run reuse.zig
bind(:15214, SO_REUSEADDR=false) -> ok
bind(:15214, SO_REUSEADDR=false) -> ADDRINUSE
bind(:15215, SO_REUSEADDR=true) -> ok
bind(:15215, SO_REUSEADDR=true) -> ok
$ netstat -an -p tcp | grep 1521
tcp4       0      0  127.0.0.1.15214        127.0.0.1.58214        TIME_WAIT
tcp4       0      0  127.0.0.1.15215        127.0.0.1.58215        TIME_WAIT

Вывод снят на macOS 26, в контейнере с Linux 7.0 (Debian 12, arm64) те же четыре строки. std печатает имя ошибки без буквы E: ADDRINUSE это EADDRINUSE из <errno.h>. Если запустить программу второй раз сразу после первого, не пройдёт и первый bind: пары с порта 15214 ещё досиживают своё.

Кто держит порт, видно в netstat: два соединения в TIME_WAIT, и оба со стороны порта сервера. Сторона, которая закрыла соединение первой, после обмена сегментами FIN не забывает его сразу, а ждёт двойное максимальное время жизни сегмента в сети. На Linux это жёстко зашитые 60 секунд, на macOS net.inet.tcp.msl равен 15000 мс, то есть ожидание 30 секунд. Смысл ожидания двойной. Во-первых, последний ACK мог потеряться, и собеседник пришлёт свой FIN ещё раз: надо быть на месте, чтобы ответить. Во-вторых, в сети могут бродить опоздавшие сегменты старого соединения. Если тут же открыть новое с той же парой адресов, они попадут в него как свежие данные.

Пока жива хотя бы одна пара в TIME_WAIT с этим портом, ядро по умолчанию не даст новому сокету bind на него. SO_REUSEADDR ослабляет правило: занять порт можно, если на нём никто не слушает, а соединения в TIME_WAIT не мешают. Защита от опоздавших сегментов при этом не страдает: она про пару адресов целиком, а новое соединение почти наверняка придёт с другого эфемерного порта клиента. Флаг должен стоять и у старого сокета, и у нового, поэтому ставят его всегда, в каждом сервере, до bind. Второй сервер на занятом порту SO_REUSEADDR по-прежнему не пустит: EADDRINUSE от живого слушателя никуда не делся, и это как раз хорошо.

Почему в эхо-сервере ниже ты этой проблемы, скорее всего, не увидишь? Потому что там первым закрывает клиент, и TIME_WAIT достаётся его эфемерному порту. А веб-сервер из урока 66 закрывает соединение сам, сразу после ответа, и без SO_REUSEADDR он не переживал бы собственного перезапуска.

Эхо-сервер

Эхо это самый маленький сервер, на котором видно всё: клиент шлёт строку, сервер возвращает её как есть. Строки из сокета читаются тем же приёмом, что и в уроке про буферизованный ввод: короткие счёты на сокете это норма, а конец строки заранее неизвестен. Там мы писали свой Rio, а потом нашли тот же буфер внутри std.Io.Reader. Здесь соединим два мира: дескриптор из libc обернём в std.Io.Reader и std.Io.Writer, чтобы строки брать через takeDelimiterInclusive, а в ядро ходить своим read.

//! Дескриптор как `std.Io.Reader` и `std.Io.Writer`: буфер даёт вызывающий,
//! в ядро ходят `read` и `write` из libc. Это RIO из урока 61, только
//! в интерфейсе std: строки берутся через `takeDelimiterInclusive`, а
//! запись копится в буфере до `flush`. Сокет для ядра такой же дескриптор,
//! как файл или пайп, поэтому один адаптер обслуживает и эхо, и HTTP.

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

pub const WriteError = error{ BrokenPipe, WriteFailed };
pub const ReadError = error{ReadFailed};

/// `writen` из урока 60: дописывает циклом, пока не уйдёт всё. На сокете
/// `write` вправе принять меньше, чем дали. `EPIPE` значит, что клиент
/// уже закрыл соединение: `SIGPIPE` сервер игнорирует (см. `tiny.zig`),
/// поэтому обрыв приходит сюда ошибкой, а не смертью процесса.
pub fn writen(fd: c.fd_t, bytes: []const u8) WriteError!void {
    var rest = bytes;
    while (rest.len > 0) {
        const n = c.write(fd, rest.ptr, rest.len);
        if (n < 0) switch (posix.errno(n)) {
            .INTR => continue,
            .PIPE, .CONNRESET => return error.BrokenPipe,
            else => return error.WriteFailed,
        };
        rest = rest[@intCast(n)..];
    }
}

/// `readn`: дочитывает ровно `buf.len` байт, меньше только на конце потока.
pub fn readn(fd: c.fd_t, buf: []u8) ReadError!usize {
    var got: usize = 0;
    while (got < buf.len) {
        const n = c.read(fd, buf[got..].ptr, buf.len - got);
        if (n < 0) switch (posix.errno(n)) {
            .INTR => continue,
            else => return error.ReadFailed,
        };
        if (n == 0) break;
        got += @intCast(n);
    }
    return got;
}

/// Читатель поверх дескриптора. Всё, что выше `vtable` (строки, `peek`,
/// `take`), std делает сама над нашим буфером; от нас только один `read`.
pub const Reader = struct {
    fd: c.fd_t,
    interface: std.Io.Reader,

    pub fn init(fd: c.fd_t, buffer: []u8) Reader {
        return .{
            .fd = fd,
            .interface = .{
                .vtable = &.{ .stream = stream },
                .buffer = buffer,
                .seek = 0,
                .end = 0,
            },
        };
    }

    /// Один системный вызов: читаем сразу в буфер писателя, сколько влезет.
    /// Ноль от `read` это конец потока, и std ждёт его ошибкой `EndOfStream`.
    fn stream(io_r: *std.Io.Reader, io_w: *std.Io.Writer, limit: std.Io.Limit) std.Io.Reader.StreamError!usize {
        const r: *Reader = @alignCast(@fieldParentPtr("interface", io_r));
        const dest = limit.slice(try io_w.writableSliceGreedy(1));
        while (true) {
            const n = c.read(r.fd, dest.ptr, dest.len);
            if (n < 0) switch (posix.errno(n)) {
                .INTR => continue,
                else => return error.ReadFailed,
            };
            if (n == 0) return error.EndOfStream;
            io_w.advance(@intCast(n));
            return @intCast(n);
        }
    }
};

/// Писатель поверх дескриптора. Байты копятся в буфере, в ядро уходят
/// при переполнении и по `flush`. Забытый `flush` теряет ответ.
pub const Writer = struct {
    fd: c.fd_t,
    interface: std.Io.Writer,
    /// Причина последнего отказа: `Io.Writer` умеет сообщить только
    /// `WriteFailed`, а серверу важно отличить обрыв клиента.
    err: ?WriteError = null,

    pub fn init(fd: c.fd_t, buffer: []u8) Writer {
        return .{
            .fd = fd,
            .interface = .{
                .vtable = &.{ .drain = drain },
                .buffer = buffer,
            },
        };
    }

    /// std зовёт `drain`, когда буфер полон или по `flush`: сначала уходит
    /// буфер, потом переданные срезы, последний из них `splat` раз.
    fn drain(io_w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize {
        const w: *Writer = @alignCast(@fieldParentPtr("interface", io_w));
        w.send(io_w.buffered()) catch return error.WriteFailed;
        var sent: usize = 0;
        for (data[0 .. data.len - 1]) |chunk| {
            w.send(chunk) catch return error.WriteFailed;
            sent += chunk.len;
        }
        const last = data[data.len - 1];
        for (0..splat) |_| {
            w.send(last) catch return error.WriteFailed;
            sent += last.len;
        }
        // consume сдвигает буфер и возвращает, сколько ушло из data.
        _ = io_w.consume(io_w.end);
        return sent;
    }

    fn send(w: *Writer, bytes: []const u8) WriteError!void {
        writen(w.fd, bytes) catch |err| {
            w.err = err;
            return err;
        };
    }
};

Всё, что выше vtable, делает std: поиск разделителя, сдвиг буфера, peek, take. От нас один метод stream, который делает ровно один read прямо в буфер. writen и readn пришли из урока про короткие счёты почти без изменений; новое только то, что EPIPE и ECONNRESET превращаются в отдельную ошибку BrokenPipe: обрыв клиента это событие, а не авария. Писатель пригодится позже, в HTTP.

Теперь сам сервер и клиент:

//! Эхо-сервер и эхо-клиент из главы 11 на libc-сокетах. Сервер итеративный:
//! одно соединение за раз, строка за строкой через буферизованный читатель
//! из `fdio.zig`. Клиент шлёт строку и ждёт её обратно.

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

pub const Error = socket.Error || fdio.WriteError || fdio.ReadError;

/// Одна сессия эха: читаем строки, пока клиент не закроет соединение,
/// каждую возвращаем как есть. Отчёт о размере идёт в `log`.
pub fn echo(connfd: c.fd_t, log: *Io.Writer) (fdio.WriteError || error{ReadFailed})!void {
    var buf: [8192]u8 = undefined;
    var reader: fdio.Reader = .init(connfd, &buf);
    while (true) {
        const line = reader.interface.takeDelimiterInclusive('\n') catch |err| switch (err) {
            error.EndOfStream => return,
            error.ReadFailed => return error.ReadFailed,
            // Строка длиннее буфера: отдаём то, что есть, и идём дальше.
            error.StreamTooLong => reader.interface.buffered(),
        };
        log.print("server received {d} bytes\n", .{line.len}) catch {};
        log.flush() catch {};
        try fdio.writen(connfd, line);
        if (line.len == buf.len) reader.interface.tossBuffered();
    }
}

/// Итеративный сервер: `accept` в цикле, по одному клиенту за раз.
/// `max_clients` ограничивает число сессий (для тестов), 0 значит вечно.
pub fn serve(listenfd: c.fd_t, max_clients: usize, log: *Io.Writer) Error!void {
    var served: usize = 0;
    while (max_clients == 0 or served < max_clients) : (served += 1) {
        var name_buf: [socket.name_max_len]u8 = undefined;
        const conn = try socket.accept(listenfd, &name_buf);
        defer socket.close(conn.fd);
        log.print("Connected to ({s})\n", .{conn.name}) catch {};
        log.flush() catch {};
        echo(conn.fd, log) catch |err| {
            log.print("echo: {t}\n", .{err}) catch {};
            log.flush() catch {};
        };
    }
}

/// Клиент: каждую строку из `in` отправляет серверу и печатает ответ в `out`.
pub fn client(host: []const u8, port: u16, in: *Io.Reader, out: *Io.Writer) !void {
    const fd = try socket.openClientfd(host, port);
    defer socket.close(fd);
    var buf: [8192]u8 = undefined;
    var reader: fdio.Reader = .init(fd, &buf);
    while (in.takeDelimiterInclusive('\n')) |line| {
        try fdio.writen(fd, line);
        const reply = try reader.interface.takeDelimiterInclusive('\n');
        try out.writeAll(reply);
        try out.flush();
    } else |err| switch (err) {
        error.EndOfStream => {},
        else => return err,
    }
}

Сервер итеративный: цикл accept, разговор с одним клиентом до конца, close, следующий accept. Пока идёт разговор, второй клиент уже соединён (рукопожатие за программу выполнило ядро), но его соединение лежит в очереди listen, и его строки копятся в приёмном буфере сокета. Ответ он получит, только когда первый клиент уйдёт. Это главный недостаток итеративного сервера, и блок про конкурентность начнёт именно с него.

defer socket.close(conn.fd) стоит сразу после accept: присоединённый дескриптор закрывается, как бы ни кончился разговор. Ошибка разговора с одним клиентом не роняет сервер, она только пишется в лог. Для сервера это правило номер один: клиент, который оборвал соединение или прислал мусор, не должен уносить с собой всех остальных.

Сессия кончается, когда takeDelimiterInclusive получает EndOfStream: клиент закрыл свою сторону, наш read вернул ноль. Это тот же EOF, что на пайпе без писателей. Клиент устроен зеркально: openClientfd, потом цикл «строка из stdin в сокет, строка из сокета в stdout». out.flush() после каждой строки обязателен: без него ответ застрял бы в буфере, а клиент ушёл бы за следующей строкой.

Живой прогон

Подкоманды echoserver и echoclient появляются в main.zig этого шага (листинг ниже, в разделе про тесты). Сервер в одном терминале, клиенты в другом. macOS 26, arm64:

$ zig build run -- echoserver 15213
echoserver: listening on port 15213
Connected to (127.0.0.1:58197)
server received 14 bytes
server received 26 bytes
Connected to (127.0.0.1:58198)
server received 29 bytes
$ printf 'hello, server\nвторая строка\n' | zig-out/bin/tiny echoclient localhost 15213
hello, server
вторая строка
$ printf 'ещё один клиент\n' | zig-out/bin/tiny echoclient 127.0.0.1 15213
ещё один клиент

Вторая строка это 26 байт: 12 русских букв по два байта в UTF-8, пробел и перевод строки. Первый клиент звонил по имени localhost, и в логе сервера он всё равно 127.0.0.1: попытку через ::1 сервер не видел вовсе, её отбило ядро. Порты клиентов 58197 и 58198 выдало ядро из эфемерного диапазона, подряд.

Что видно снаружи

Сервер и соединения можно рассмотреть, не трогая код. На macOS для этого lsof и netstat. Пока сервер ждёт клиентов:

$ lsof -nP -iTCP:15213
COMMAND   PID     USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
tiny    84522 bondiano    4u  IPv4 0x27df4c3509bc74c8      0t0  TCP *:15213 (LISTEN)
$ netstat -an -p tcp | grep 15213
tcp4       0      0  *.15213                *.*                    LISTEN
tcp4       0      0  127.0.0.1.58197        127.0.0.1.15213        TIME_WAIT

Колонка FD у lsof это номер дескриптора, 4u значит «дескриптор 4, открыт на чтение и запись». *:15213 это INADDR_ANY. В netstat видна и пара первого клиента в TIME_WAIT, на этот раз со стороны клиента: первым закрыл он. Флаги -n и -P у lsof запрещают ему резолвить адреса и порты в имена, по той же причине, по которой наш сервер ставит NI_NUMERICHOST: иначе ждёшь DNS.

На Linux вместо netstat давно ss из пакета iproute2. Сервер и один клиент, который держит соединение открытым (контейнер Debian 12, ядро 7.0, arm64):

$ ss -tlnp
State  Recv-Q Send-Q Local Address:Port  Peer Address:PortProcess
LISTEN 0      1024         0.0.0.0:15213      0.0.0.0:*    users:(("tiny",pid=613,fd=3))
$ ss -tnp
State Recv-Q Send-Q Local Address:Port  Peer Address:Port Process
ESTAB 0      0          127.0.0.1:59330    127.0.0.1:15213 users:(("tiny",pid=615,fd=3))
ESTAB 0      0          127.0.0.1:15213    127.0.0.1:59330 users:(("tiny",pid=613,fd=4))

Флаги: -t только TCP, -l только слушающие, -n без имён, -p с процессами. Здесь видна вся теория раздела. У слушающего сокета в колонке Send-Q стоит длина очереди listen, та самая 1024 (у слушающих сокетов ss показывает в этих колонках очередь, а не байты). Соединение одно, а строк две: по одной на каждый конец, у клиента (процесс 615, дескриптор 3) и у сервера (процесс 613, дескриптор 4). Слушающий дескриптор 3 сервера живёт отдельно. Пары адресов зеркальные: это и есть «соединение как пара сокетов».

А вот что сервер говорит ядру. strace с фильтром по интересным вызовам, та же сессия с одной строкой hello:

$ strace -f -e trace=socket,setsockopt,bind,listen,accept,read,write,close tiny echoserver 15213
socket(AF_NETLINK, SOCK_RAW|SOCK_CLOEXEC, NETLINK_ROUTE) = 3
bind(3, {sa_family=AF_NETLINK, nl_pid=0, nl_groups=00000000}, 12) = 0
close(3)                          = 0
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 3
setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
bind(3, {sa_family=AF_INET, sin_port=htons(15213), sin_addr=inet_addr("0.0.0.0")}, 16) = 0
listen(3, 1024)                   = 0
accept(3, {sa_family=AF_INET, sin_port=htons(59330), sin_addr=inet_addr("127.0.0.1")}, [128 => 16]) = 4
read(4, "hello\n", 8192)          = 6
write(4, "hello\n", 6)            = 6
read(4, "", 8186)                 = 0
close(4)                          = 0
accept(3,

Это весь сервер на языке ядра, и в нём нет ничего, чего мы не обсудили. Три детали. Первые три строки делает getaddrinfo: чтобы выполнить AI_ADDRCONFIG, glibc открывает сокет netlink и спрашивает у ядра, какие адреса есть у машины. [128 => 16] у accept это длина адреса туда и обратно: мы дали 128 байт sockaddr_storage, ядро заполнило 16. И второй read просит 8186 байт, а не 8192: шесть байт hello\n ещё лежат в буфере читателя, и std просит только свободное место. Последняя строка оборвана: сервер снова спит в accept. Как снять такую трассу своим zt strace, было в уроке про сисколлы.

То же на std.Io.net

В Zig 0.16 сеть переехала в std.Io.net, и вызывается она, как и файлы, через явный io. Положим рядом ту же пару, эхо-сервер и клиент, на обёртке:

//! То же эхо на `std.Io.net` из 0.16: `IpAddress.listen`, `Server.accept`,
//! `Stream.reader` и `Stream.writer`. Системные вызовы те же самые
//! (`socket`, `bind`, `listen`, `accept`, `recv`, `send`), только их зовёт
//! реализация `Io`. Файл лежит рядом с `echo.zig`, чтобы урок мог положить
//! два листинга бок о бок.

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

/// Слушающий сокет на loopback. Порт 0 значит любой свободный: реальный
/// номер лежит в `server.socket.address`.
pub fn listen(io: Io, port: u16) !net.Server {
    const address: net.IpAddress = .{ .ip4 = .loopback(port) };
    return address.listen(io, .{ .reuse_address = true });
}

pub fn echo(io: Io, stream: net.Stream, log: *Io.Writer) !void {
    var in_buf: [8192]u8 = undefined;
    var out_buf: [8192]u8 = undefined;
    var reader = stream.reader(io, &in_buf);
    var writer = stream.writer(io, &out_buf);
    while (reader.interface.takeDelimiterInclusive('\n')) |line| {
        log.print("server received {d} bytes\n", .{line.len}) catch {};
        log.flush() catch {};
        try writer.interface.writeAll(line);
        try writer.interface.flush();
    } else |err| switch (err) {
        error.EndOfStream => {},
        else => return err,
    }
}

pub fn serve(io: Io, server: *net.Server, max_clients: usize, log: *Io.Writer) !void {
    var served: usize = 0;
    while (max_clients == 0 or served < max_clients) : (served += 1) {
        const stream = try server.accept(io);
        defer stream.close(io);
        log.print("Connected to ({f})\n", .{stream.socket.address}) catch {};
        log.flush() catch {};
        echo(io, stream, log) catch |err| {
            log.print("echo: {t}\n", .{err}) catch {};
            log.flush() catch {};
        };
    }
}

pub fn client(io: Io, address: net.IpAddress, in: *Io.Reader, out: *Io.Writer) !void {
    const stream = try address.connect(io, .{ .mode = .stream });
    defer stream.close(io);
    var in_buf: [8192]u8 = undefined;
    var out_buf: [8192]u8 = undefined;
    var reader = stream.reader(io, &in_buf);
    var writer = stream.writer(io, &out_buf);
    while (in.takeDelimiterInclusive('\n')) |line| {
        try writer.interface.writeAll(line);
        try writer.interface.flush();
        const reply = try reader.interface.takeDelimiterInclusive('\n');
        try out.writeAll(reply);
        try out.flush();
    } else |err| switch (err) {
        error.EndOfStream => {},
        else => return err,
    }
}

Код заметно короче: адрес это IpAddress с методами listen и connect, Server.accept отдаёт Stream, у которого есть готовые reader и writer. Своего адаптера fdio не нужно. Но интереснее не то, что видно в коде, а то, что обёртка делает молча. Трасса того же обмена со строкой hi (Linux, strace -e trace=%network,readv,writev,close, вывод в stderr вырезан):

socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC, IPPROTO_TCP) = 3
setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
setsockopt(3, SOL_SOCKET, SO_REUSEPORT, [1], 4) = 0
bind(3, {sa_family=AF_INET, sin_port=htons(15213), sin_addr=inet_addr("127.0.0.1")}, 16) = 0
listen(3, 128)                    = 0
getsockname(3, {sa_family=AF_INET, sin_port=htons(15213), sin_addr=inet_addr("127.0.0.1")}, [16]) = 0
accept4(3, {sa_family=AF_INET, sin_port=htons(43090), sin_addr=inet_addr("127.0.0.1")}, [28 => 16], SOCK_CLOEXEC) = 4
readv(4, [{iov_base="hi\n", iov_len=8192}], 1) = 3
sendmsg(4, {msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="hi\n", iov_len=3}], msg_iovlen=1, msg_controllen=0, msg_flags=0}, MSG_NOSIGNAL) = 3
readv(4, [{iov_base="", iov_len=8192}], 1) = 0
close(4)                          = 0

Системные вызовы те же пять, но с разумными умолчаниями, до которых книга доходит только в упражнениях:

  • SOCK_CLOEXEC у сокета и у каждого присоединённого дескриптора (accept4 вместо accept): ни один сокет не утечёт в программу, запущенную через execve. Наш сервер на libc этого не делает, и для TINY с его CGI из урока 66 это станет вопросом.
  • sendmsg с MSG_NOSIGNAL вместо write: запись в соединение, которое клиент уже закрыл, не пошлёт процессу SIGPIPE, а вернёт ошибку. В уроке про пайпы мы видели, что std.Io.Threaded вдобавок ставит на SIGPIPE пустой обработчик; здесь защита стоит прямо на вызове.
  • getsockname сразу после listen: так server.socket.address знает настоящий порт, даже если просили 0.
  • listen(3, 128): длина очереди по умолчанию 128, её меняет поле kernel_backlog.

И одна ловушка. Опция reuse_address = true ставит два флага: SO_REUSEADDR и SO_REUSEPORT, это написано в комментарии к ListenOptions. Второй флаг значит совсем другое: несколько сокетов могут одновременно слушать один порт, и Linux раздаёт между ними входящие соединения. Для балансировки между процессами это полезно, но защиту от случайного второго запуска он выключает:

$ tiny echoserver --std 15213 &
echoserver (std): listening on 127.0.0.1:15213
$ tiny echoserver --std 15213 &
echoserver (std): listening on 127.0.0.1:15213
$ tiny echoserver 15213
error: BindFailed
$ for i in 1 2 3 4; do printf "n$i\n" | tiny echoclient 127.0.0.1 15213; done

Второй сервер на std запустился без единой жалобы, а четыре клиента разошлись между двумя серверами поровну, по два каждому (это видно по их логам). Наш openListenfd без SO_REUSEPORT получил честный отказ. На macOS второй сервер на std тоже стартует. Если тебе нужен только быстрый перезапуск, а не общий порт, обёртка 0.16 такого варианта не даёт: придётся ставить опцию руками или жить без неё.

Чего в std.Io.net 0.16 нет совсем, так это аналога getaddrinfo с AI_PASSIVE: адрес для listen ты строишь сам, как IpAddress с конкретным семейством. Поэтому серверная пара в эталоне слушает loopback IPv4, а openListenfd остаётся на libc.

Под обёрткой те же сокеты, и это проверяется тестом: клиент на libc разговаривает с сервером на std без всяких переходников. Он ниже, последний в файле шага.

Шаг проекта: tiny учится говорить по сети

Всё, что выше было листингами, и есть новые файлы шага: src/net/socket.zig, src/net/fdio.zig, src/net/echo.zig, src/net/echo_std.zig. Осталось подключить их. В src/root.zig четыре новые строки:

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

pub const addr = @import("net/addr.zig");
pub const hostinfo = @import("net/hostinfo.zig");
pub const fdio = @import("net/fdio.zig");
pub const socket = @import("net/socket.zig");
pub const echo = @import("net/echo.zig");
pub const echo_std = @import("net/echo_std.zig");

В main.zig появились подкоманды echoserver и echoclient, у каждой есть вариант --std. Файл целиком:

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

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
    \\  tiny echoserver <port>           эхо-сервер, одно соединение за раз
    \\  tiny echoserver --std <port>     то же на std.Io.net
    \\  tiny echoclient <host> <port>    эхо-клиент: строки из stdin туда и обратно
    \\
;

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;
    var err_buf: [4096]u8 = undefined;
    var stderr = std.Io.File.stderr().writerStreaming(io, &err_buf);
    const log = &stderr.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 if (std.mem.eql(u8, command, "echoserver")) {
        const use_std = rest.len > 0 and std.mem.eql(u8, rest[0], "--std");
        const ports = if (use_std) rest[1..] else rest;
        if (ports.len != 1) return fail(out, usage);
        const port = std.fmt.parseInt(u16, ports[0], 10) catch return fail(out, "tiny: порт это число от 0 до 65535\n");
        if (use_std) {
            var server = try tiny.echo_std.listen(io, port);
            defer server.deinit(io);
            try log.print("echoserver (std): listening on {f}\n", .{server.socket.address});
            try log.flush();
            try tiny.echo_std.serve(io, &server, 0, log);
        } else {
            const listenfd = try tiny.socket.openListenfd(port);
            defer tiny.socket.close(listenfd);
            try log.print("echoserver: listening on port {d}\n", .{tiny.socket.localPort(listenfd) orelse port});
            try log.flush();
            try tiny.echo.serve(listenfd, 0, log);
        }
    } else if (std.mem.eql(u8, command, "echoclient")) {
        if (rest.len != 2) return fail(out, usage);
        const port = std.fmt.parseInt(u16, rest[1], 10) catch return fail(out, "tiny: порт это число от 0 до 65535\n");
        var in_buf: [8192]u8 = undefined;
        var stdin = std.Io.File.stdin().readerStreaming(io, &in_buf);
        try tiny.echo.client(rest[0], port, &stdin.interface, out);
    } 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);
}

Порт разбирается как u16: parseInt сам откажет на 70000 и на отрицательном числе. Сервер пишет в лог, какой порт ему достался, через localPort: при echoserver 0 это единственный способ узнать номер.

В build.zig добавь шаг в список: const project_steps = [_]u8{ 63, 64 };.

Тесты шага

Сетевые тесты обычно боятся двух вещей: занятых портов и зависаний. Здесь от первого спасает порт 0 плюс localPort, от второго то, что сервер в каждом тесте обслуживает ровно одного клиента (max_clients = 1) и живёт в потоке этого же процесса, который тест дожидается через join.

//! Шаг 64: openListenfd, openClientfd, accept с именем клиента, эхо
//! на libc-сокетах и на std.Io.net. Сервер живёт в потоке этого же процесса.

const std = @import("std");
const tiny = @import("tiny");
const socket = tiny.socket;
const fdio = tiny.fdio;

const testing = std.testing;

test "openListenfd на порту 0: ядро выбирает порт, SO_REUSEADDR стоит" {
    const listenfd = try socket.openListenfd(0);
    defer socket.close(listenfd);
    const port = socket.localPort(listenfd) orelse return error.NoPort;
    try testing.expect(port != 0);
    try testing.expect(socket.reuseAddrEnabled(listenfd));
}

test "openClientfd подключается, accept отдаёт соединение и имя клиента" {
    const listenfd = try socket.openListenfd(0);
    defer socket.close(listenfd);
    const port = socket.localPort(listenfd).?;

    const clientfd = try socket.openClientfd("127.0.0.1", port);
    defer socket.close(clientfd);

    var name_buf: [socket.name_max_len]u8 = undefined;
    const conn = try socket.accept(listenfd, &name_buf);
    defer socket.close(conn.fd);
    try testing.expect(std.mem.startsWith(u8, conn.name, "127.0.0.1:"));

    try fdio.writen(clientfd, "ping\n");
    var buf: [16]u8 = undefined;
    const n = try fdio.readn(conn.fd, buf[0..5]);
    try testing.expectEqualStrings("ping\n", buf[0..n]);
}

test "openClientfd на закрытый порт это ConnectFailed, на кривое имя LookupFailed" {
    const listenfd = try socket.openListenfd(0);
    const port = socket.localPort(listenfd).?;
    socket.close(listenfd);
    try testing.expectError(error.ConnectFailed, socket.openClientfd("127.0.0.1", port));
    try testing.expectError(error.LookupFailed, socket.openClientfd("no-such-host.invalid", 80));
}

fn echoOne(listenfd: std.c.fd_t) void {
    var log_buf: [256]u8 = undefined;
    var log: std.Io.Writer = .fixed(&log_buf);
    tiny.echo.serve(listenfd, 1, &log) catch |err| std.debug.print("echo.serve: {t}\n", .{err});
}

test "эхо на libc: строки возвращаются по одной, сервер считает байты" {
    const listenfd = try socket.openListenfd(0);
    defer socket.close(listenfd);
    const port = socket.localPort(listenfd).?;
    const thread = try std.Thread.spawn(.{}, echoOne, .{listenfd});

    const fd = try socket.openClientfd("localhost", port);
    var buf: [64]u8 = undefined;
    var reader: fdio.Reader = .init(fd, &buf);
    try fdio.writen(fd, "раз\n");
    try testing.expectEqualStrings("раз\n", try reader.interface.takeDelimiterInclusive('\n'));
    try fdio.writen(fd, "два\nтри\n");
    try testing.expectEqualStrings("два\n", try reader.interface.takeDelimiterInclusive('\n'));
    try testing.expectEqualStrings("три\n", try reader.interface.takeDelimiterInclusive('\n'));
    socket.close(fd);
    thread.join();
}

test "эхо-клиент: stdin уходит на сервер, ответы попадают в stdout" {
    const listenfd = try socket.openListenfd(0);
    defer socket.close(listenfd);
    const port = socket.localPort(listenfd).?;
    const thread = try std.Thread.spawn(.{}, echoOne, .{listenfd});

    var in: std.Io.Reader = .fixed("hello\nworld\n");
    var out_buf: [64]u8 = undefined;
    var out: std.Io.Writer = .fixed(&out_buf);
    try tiny.echo.client("127.0.0.1", port, &in, &out);
    try testing.expectEqualStrings("hello\nworld\n", out.buffered());
    thread.join();
}

fn echoOneStd(server: *std.Io.net.Server) void {
    var log_buf: [256]u8 = undefined;
    var log: std.Io.Writer = .fixed(&log_buf);
    tiny.echo_std.serve(testing.io, server, 1, &log) catch |err| std.debug.print("echo_std.serve: {t}\n", .{err});
}

test "эхо на std.Io.net: тот же обмен через IpAddress.listen и Stream" {
    const io = testing.io;
    var server = try tiny.echo_std.listen(io, 0);
    defer server.deinit(io);
    const port = server.socket.address.getPort();
    try testing.expect(port != 0);
    const thread = try std.Thread.spawn(.{}, echoOneStd, .{&server});

    var in: std.Io.Reader = .fixed("std\nnet\n");
    var out_buf: [64]u8 = undefined;
    var out: std.Io.Writer = .fixed(&out_buf);
    try tiny.echo_std.client(io, .{ .ip4 = .loopback(port) }, &in, &out);
    try testing.expectEqualStrings("std\nnet\n", out.buffered());
    thread.join();
}

test "libc-клиент говорит со std-сервером: под обёрткой те же сокеты" {
    const io = testing.io;
    var server = try tiny.echo_std.listen(io, 0);
    defer server.deinit(io);
    const thread = try std.Thread.spawn(.{}, echoOneStd, .{&server});

    const fd = try socket.openClientfd("127.0.0.1", server.socket.address.getPort());
    var buf: [64]u8 = undefined;
    var reader: fdio.Reader = .init(fd, &buf);
    try fdio.writen(fd, "mixed\n");
    try testing.expectEqualStrings("mixed\n", try reader.interface.takeDelimiterInclusive('\n'));
    socket.close(fd);
    thread.join();
}

Что закрепляет каждый тест:

  • «порт 0» проверяет, что ядро выбрало порт и что SO_REUSEADDR стоит: ту же опцию читаем обратно через getsockopt.
  • «accept отдаёт соединение» проходит весь путь без потоков: connect завершается сам, потому что рукопожатие выполняет ядро сервера, а соединение ждёт в очереди, пока тест не сделает accept. Имя клиента обязано начинаться с 127.0.0.1:, это проверка двух проходов в openListenfd.
  • «закрытый порт» и «кривое имя» различают две ошибки: имя вида no-such-host.invalid не резолвится никогда, домен .invalid зарезервирован именно для этого.
  • эхо на libc шлёт две строки одним write и ждёт две строки ответа: сервер обязан разрезать поток по \n, а не по границам read.
  • эхо на std.Io.net и смешанный тест показывают, что обёртка и libc говорят на одном протоколе.
$ zig build test --summary all
Build Summary: 5/5 steps succeeded; 16/16 tests passed
test success
+- run test 9 pass (9 total)
+- run test 7 pass (7 total)

Девять тестов шага 63 и семь тестов шага 64, на macOS 26 и в контейнере Linux (arm64) одинаково.

Шаг проекта: zbox без сети

Вернёмся к zbox. После урока про буферы он запускает программу с лимитами времени, памяти и вывода. Но сеть у программы по-прежнему вся, какая есть у машины: студенческий код в песочнице мог бы скачать решение, постучаться во внутренний сервис или стать участником чужой атаки. Раннер курса закрывает это флагом docker run --network none. Сегодня мы сделаем то же самое одним системным вызовом.

Сетевое пространство имён

В Linux сеть это не свойство машины, а свойство пространства имён. Интерфейсы, адреса, таблица маршрутов, открытые сокеты и порты принадлежат сетевому пространству, и каждый процесс живёт в одном из них. После загрузки пространство одно на всех. Вызов unshare(CLONE_NEWNET) выводит текущий процесс в новое, пустое пространство, а его потомки наследуют его через fork. Пустое значит буквально пустое: в нём есть только интерфейс lo, и тот выключен. Ни адресов, ни маршрутов.

Что увидит программа? socket работает: создать сокет можно в любом пространстве. А вот connect куда угодно, даже на 127.0.0.1, вернёт ENETUNREACH: маршрут до loopback появляется вместе с поднятым lo, а он не поднят. Сервер хозяина на 127.0.0.1 тоже недосягаем, это другой lo в другом пространстве. Поднять lo можно, но это отдельная работа через netlink или ioctl, и мы её не делаем: сеть выключена целиком (включить обратно предлагает третья домашняя задача). Вот где наш zbox расходится с Docker: docker run --network none оставляет в контейнере поднятый lo с адресом 127.0.0.1. В контейнере раннера это легко проверить:

$ docker run --rm --network none ghcr.io/bondiano/runner-zig:dev cat /sys/class/net/lo/flags
0x9

0x9 это IFF_UP плюс IFF_LOOPBACK: интерфейс поднят. Поэтому в песочнице курса задачи с сокетами работают: сеть наружу закрыта, а loopback внутри контейнера открыт, и тест может и слушать, и звонить сам себе. Сегодняшняя код-задача на этом и держится.

Вызов требует права CAP_SYS_ADMIN, то есть root. Обычному пользователю ядро ответит EPERM:

$ id
uid=10001(runner) gid=10001(runner) groups=10001(runner)
$ unshare -n true
unshare: unshare failed: Operation not permitted

Но есть обходной путь, на котором стоят все контейнеры без root: пространство пользователей. unshare(CLONE_NEWUSER) обычному пользователю разрешён. В новом пространстве пользователей процесс получает полный набор capabilities, но действуют они только на ресурсы, принадлежащие этому пространству. Новое пустое сетевое пространство, созданное изнутри, ему и принадлежит, поэтому следующий unshare(CLONE_NEWNET) проходит. Чтобы процесс внутри не был «никем» (uid 65534), надо записать отображение uid и gid в /proc/self/uid_map и gid_map, а перед gid_map ядро требует записать deny в /proc/self/setgroups.

netns.zig

Новый файл шага, целиком. Пространство пользователей нам ещё понадобится в уроке 67, поэтому unshare с отображением uid вынесен в отдельную функцию unshareUser(flags): сегодня она получает один флаг CLONE_NEWUSER, а в уроке 67 к нему добавятся остальные пространства.

//! Сеть выключена: `unshare(CLONE_NEWNET)` в ребёнке между `fork` и `execvp`.
//!
//! Новое сетевое пространство имён пустое: в нём один интерфейс `lo`, и тот
//! выключен. Ни маршрутов, ни адресов. Любой `connect` наружу отвечает
//! `ENETUNREACH`, и даже на `127.0.0.1` тоже: маршрут до loopback появляется
//! только вместе с поднятым `lo`. Программа может открыть сокет, но никуда
//! по нему не дозвонится. `docker run --network none` заводит такое же
//! пространство, но ещё поднимает в нём `lo`.
//!
//! Право на `CLONE_NEWNET` даёт `CAP_SYS_ADMIN`. Без него (обычный пользователь)
//! путь в обход: сначала `unshare(CLONE_NEWUSER)`. В новом пространстве
//! пользователей процесс получает все capabilities, но только над тем, что
//! в это пространство входит. Пустая сеть входит, и следующий `unshare`
//! проходит. Так работают контейнеры без root (rootless podman, bubblewrap).
//!
//! Всё здесь зовётся в ребёнке, поэтому только async-signal-safe вызовы:
//! сырые системные вызовы из `std.os.linux`, `open`, `write`, `close`.
//! Строки для `/proc/self/*_map` собирает `bufPrint`: он пишет в буфер на
//! стеке и ничего не выделяет.

const std = @import("std");
const builtin = @import("builtin");
const linux = std.os.linux;

pub const supported = builtin.os.tag == .linux;

/// Пробует отобрать сеть у текущего процесса. Возвращает false, если ядро
/// отказало и по прямому пути, и через пространство пользователей.
pub fn dropNetwork() bool {
    if (!supported) return false;
    // Прямой путь: есть CAP_SYS_ADMIN (root, контейнер с --privileged).
    if (linux.errno(linux.unshare(linux.CLONE.NEWNET)) == .SUCCESS) return true;
    // Обходной: сначала своё пространство пользователей, в нём мы root.
    if (!becomeRootInUserNamespace()) return false;
    return linux.errno(linux.unshare(linux.CLONE.NEWNET)) == .SUCCESS;
}

/// `unshare(CLONE_NEWUSER)` плюс отображение своего uid и gid в 0.
///
/// Без записи в `uid_map` процесс внутри виден как «никто» (65534), и
/// `execvp` по-прежнему работает, но файлы, созданные программой, получают
/// чужого владельца. Отобразить можно только собственный uid, и ровно одну
/// строку: на большее нужен `newuidmap` с setuid-битом. `gid_map` ядро даёт
/// записать только после `echo deny > /proc/self/setgroups`, иначе через
/// `setgroups` можно было бы сбросить группу, которая запрещает доступ.
///
/// Тот же вызов нужен шагу 67: там к `CLONE_NEWUSER` добавляются остальные
/// пространства, но отображение делается точно так же.
pub fn becomeRootInUserNamespace() bool {
    return unshareUser(linux.CLONE.NEWUSER);
}

/// `unshare` с любым набором флагов, в котором есть `CLONE_NEWUSER`, и
/// отображение uid/gid. Флаги передаются одним вызовом: ядро сначала
/// заводит пространство пользователей, а уже в нём остальные.
pub fn unshareUser(flags: usize) bool {
    if (!supported) return false;
    const uid = linux.getuid();
    const gid = linux.getgid();
    if (linux.errno(linux.unshare(flags)) != .SUCCESS) return false;

    var buf: [64]u8 = undefined;
    if (!writeProcSelf("/proc/self/setgroups", "deny")) return false;
    const uid_map = std.fmt.bufPrint(&buf, "0 {d} 1", .{uid}) catch unreachable;
    if (!writeProcSelf("/proc/self/uid_map", uid_map)) return false;
    const gid_map = std.fmt.bufPrint(&buf, "0 {d} 1", .{gid}) catch unreachable;
    return writeProcSelf("/proc/self/gid_map", gid_map);
}

/// Файлы `/proc/self/*_map` принимают ровно одну запись целиком: пишем одним
/// `write` и не повторяем, второй раз ядро ответит EPERM.
fn writeProcSelf(path: [*:0]const u8, text: []const u8) bool {
    const rc = linux.open(path, .{ .ACCMODE = .WRONLY }, 0);
    if (linux.errno(rc) != .SUCCESS) return false;
    const fd: i32 = @intCast(rc);
    defer _ = linux.close(fd);
    return linux.write(fd, text.ptr, text.len) == text.len;
}

dropNetwork зовётся в ребёнке, между fork и execvp. Там разрешены только async-signal-safe вызовы (правило из урока про процессы), поэтому никаких std.Io: сырые open, write, close из std.os.linux и bufPrint в буфер на стеке. Каждый файл *_map принимает ровно одну запись целиком, второй write получит EPERM, поэтому строка собирается заранее и уходит одним вызовом.

Правки в остальных файлах

Новый флаг --no-net в разборе командной строки. У него нет значения, поэтому он проверяется до общего правила «флаг плюс слово»:

@@
     /// Лимит захваченного вывода, килобайты, на stdout и stderr по отдельности.
     out_kb: u32 = 64,
+    /// Программа получает пустое сетевое пространство имён. Только Linux.
+    no_net: bool = false,
     /// Программа и её аргументы: то, что уйдёт в `execvp`.
     argv: []const []const u8,
@@
 /// `args` это командная строка без имени самого zbox:
 /// `run [--time-ms N] [--as-mb N] [--mem-mb N] [--cgroup-root DIR]
-/// [--stdin FILE] [--out-kb N] <cmd> [args...]`.
+/// [--stdin FILE] [--out-kb N] [--no-net] <cmd> [args...]`.
 pub fn parse(args: []const []const u8) Error!Command {
     if (args.len < 2) return error.BadUsage;
@@
     // ей самой: `zbox run ls --time-ms` передаст `--time-ms` в ls.
     while (rest.len > 0 and std.mem.startsWith(u8, rest[0], "--")) {
+        // Переключатели без значения идут первыми: у них нет второго слова.
+        if (std.mem.eql(u8, rest[0], "--no-net")) {
+            command.no_net = true;
+            rest = rest[1..];
+            continue;
+        }
         if (rest.len < 2) return error.BadUsage;
         const flag = rest[0];

Поле net в ответе, чтобы по JSON было видно, какая сеть досталась программе:

@@
 const memory = @import("memory.zig");
 
+pub const Net = enum { host, none };
+
 pub const Outcome = struct {
     /// Код возврата, если программа завершилась сама (`exit` или `return` из `main`).
@@
     stdout_truncated: bool = false,
     stderr_truncated: bool = false,
+    /// Какая сеть досталась программе: хозяйская или пустое пространство имён.
+    net: Net = .host,
 
     pub fn reason(outcome: Outcome) memory.Reason {
@@
     try out.writeAll(",\"stderr\":");
     try writeJsonString(out, outcome.stderr);
-    try out.writeAll("}\n");
+    try out.print(",\"net\":\"{t}\"}}\n", .{outcome.net});
 }
 

В run.zig три изменения. На системе без пространств имён zbox отказывает сразу, до fork, чтобы ответить словами. В ребёнке сеть отбирается после входа в группу cgroups и до лимита адресов. И childFail теперь принимает сообщение: причин не запустить программу стало три, и в stderr ответа должно быть видно, какая именно.

@@
 const capture_mod = @import("capture.zig");
 const memlimit = @import("memlimit.zig");
+const netns = @import("netns.zig");
 const report = @import("report.zig");
 const watchdog = @import("watchdog.zig");
@@
 extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
 
-pub const Error = error{ ForkFailed, WaitFailed, OutOfMemory } || memlimit.CgroupError || capture_mod.Error;
+pub const Error = error{ ForkFailed, WaitFailed, OutOfMemory, NetnsUnsupported } || memlimit.CgroupError || capture_mod.Error;
 
 /// Код возврата ребёнка, если `execvp` не удался. Так же поступает оболочка:
 /// 127 значит, что команда не найдена.
 pub const exec_failed_code = 127;
-/// Ребёнок не смог поставить себе лимит памяти. Запускать программу
-/// без заказанного лимита нельзя, поэтому до `execvp` дело не доходит.
+/// Ребёнок не смог поставить себе лимит памяти или отобрать у себя сеть.
+/// Запускать программу без заказанной изоляции нельзя, поэтому до `execvp`
+/// дело не доходит.
 pub const limit_failed_code = 126;
 
 pub fn run(arena: std.mem.Allocator, command: args.Command) Error!report.Outcome {
     const argv = command.argv;
+    // Пространства имён есть только в Linux: на другой системе отказываем
+    // сразу, а не после fork, чтобы ответить словами, а не кодом 126.
+    if (command.no_net and !netns.supported) return error.NetnsUnsupported;
     // Массив для execvp собираем до fork. После fork в ребёнке безопасны
     // только async-signal-safe функции, а аллокатор к ним не относится.
@@
         // Сначала группа, потом execvp: память программы с первой страницы
         // считается уже в группе с лимитом.
-        if (command.mem_mb != null and !memlimit.Cgroup.joinSelf(cgroup.procsPath())) childFail(limit_failed_code);
+        if (command.mem_mb != null and !memlimit.Cgroup.joinSelf(cgroup.procsPath())) {
+            childFail(limit_failed_code, "zbox: не удалось поставить лимит памяти\n");
+        }
+        // Сеть отбираем до лимита адресов: обходной путь через пространство
+        // пользователей пишет в /proc, и делать это лучше без лишних оков.
+        if (command.no_net and !netns.dropNetwork()) {
+            childFail(limit_failed_code, "zbox: не удалось выключить сеть, unshare отказал\n");
+        }
         if (command.as_mb) |as_mb| {
-            if (!memlimit.limitAddressSpace(as_mb)) childFail(limit_failed_code);
+            if (!memlimit.limitAddressSpace(as_mb)) childFail(limit_failed_code, "zbox: не удалось поставить лимит памяти\n");
         }
         // Мы в ребёнке. Успешный execvp не возвращается: образ процесса
         // заменён, этого кода в памяти больше нет.
         _ = execvp(c_argv[0].?, c_argv.ptr);
-        childFail(exec_failed_code);
+        childFail(exec_failed_code, "zbox: не удалось запустить программу\n");
     }
 
@@
     // Лимит превышен, только если её действительно убил наш SIGKILL.
     outcome.timed_out = alarm_fired and outcome.signal == @intFromEnum(std.posix.SIG.KILL);
+    outcome.net = if (command.no_net) .none else .host;
     return outcome;
 }
@@
 
 /// Выход из ребёнка с сообщением. Только async-signal-safe вызовы.
-fn childFail(code: u8) noreturn {
-    const message: []const u8 = if (code == exec_failed_code)
-        "zbox: не удалось запустить программу\n"
-    else
-        "zbox: не удалось поставить лимит памяти\n";
+fn childFail(code: u8, message: []const u8) noreturn {
     _ = c.write(2, message.ptr, message.len);
     // Именно _exit, а не exit: обычный exit сбросил бы буферы stdio

В main.zig строка справки и ответ с кодом 3, как у недоступных cgroups:

@@
     \\  --out-kb N         лимит на stdout и на stderr, по умолчанию 64 КБ на каждый;
     \\                     программу, которая его превысила, zbox убивает
+    \\  --no-net           пустое сетевое пространство имён, unshare(CLONE_NEWNET) (только Linux)
     \\
     \\Ответ: одна строка JSON на stdout. Код программы лежит в поле exit_code,
@@
             std.process.exit(3);
         },
+        error.NetnsUnsupported => {
+            try out.writeAll("zbox: --no-net работает только в Linux: сетевые пространства имён есть лишь там\n");
+            try out.flush();
+            std.process.exit(3);
+        },
         else => return err,
     };

В src/root.zig добавь pub const netns = @import("box/netns.zig");. В build.zig шаг 64 и новая подопытная программа:

@@
 /// Номера уроков курса, на которых проект вырос. Каждому шагу
 /// соответствует файл `tests/step_NN.zig`, и все они зелёные на финале.
-const project_steps = [_]u8{ 48, 49, 54, 61 };
+const project_steps = [_]u8{ 48, 49, 54, 61, 64 };
 
 pub fn build(b: *std.Build) void {
@@
     options.addOptionPath("hog_exe", hog.getEmittedBin());
 
+    // Подопытная программа для теста выключенной сети: один `connect`
+    // и слово в ответ. Сокеты берёт из libc, как и сам zbox.
+    const netcheck = b.addExecutable(.{
+        .name = "netcheck",
+        .root_module = b.createModule(.{
+            .root_source_file = b.path("tests/netcheck.zig"),
+            .target = target,
+            .optimize = optimize,
+            .link_libc = true,
+        }),
+    });
+    options.addOptionPath("netcheck_exe", netcheck.getEmittedBin());
+
     // zig build test прогоняет все шаги, zig build test -Dstep=48 только один.
     const only_step = b.option(u8, "step", "Прогнать тесты одного шага, например -Dstep=48");

Подопытная программа netcheck

Проверять сеть будем не curl, а своей программой в семьдесят строк: она делает один connect и печатает одно слово. Неблокирующий connect плюс poll с таймаутом нужны на случай адреса, который молча глотает пакеты: без них тест мог бы висеть минутами.

//! Подопытная программа для теста выключенной сети.
//!
//!   netcheck <ipv4> <порт>
//!
//! Пробует `connect` к адресу и печатает одно слово: `ok`, если соединение
//! установлено, иначе имя errno (`ENETUNREACH`, `ECONNREFUSED`, ...).
//! Если не удался сам `socket` (под seccomp он запрещён), печатает
//! `socket EPERM`. Ждёт не дольше полусекунды: неблокирующий сокет плюс
//! `poll`, чтобы адрес, который молча глотает пакеты, не подвесил тест.

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

const wait_ms = 500;

pub fn main(init: std.process.Init) !void {
    const args = try init.minimal.args.toSlice(init.arena.allocator());
    if (args.len != 3) std.process.exit(2);
    const address = parseIpv4(args[1]) orelse std.process.exit(2);
    const port = try std.fmt.parseInt(u16, args[2], 10);

    var buf: [64]u8 = undefined;
    var stdout = std.Io.File.stdout().writerStreaming(init.io, &buf);
    try stdout.interface.print("{s}\n", .{verdict(address, port)});
    try stdout.interface.flush();
}

fn verdict(address: u32, port: u16) []const u8 {
    const fd = c.socket(c.AF.INET, c.SOCK.STREAM, 0);
    if (fd < 0) return if (std.posix.errno(fd) == .PERM) "socket EPERM" else "socket failed";
    defer _ = c.close(fd);

    const flags = c.fcntl(fd, c.F.GETFL);
    const nonblock: c_int = @bitCast(@as(u32, @bitCast(c.O{ .NONBLOCK = true })));
    _ = c.fcntl(fd, c.F.SETFL, flags | nonblock);

    const addr: c.sockaddr.in = .{ .port = std.mem.nativeToBig(u16, port), .addr = address };
    const rc = c.connect(fd, @ptrCast(&addr), @sizeOf(c.sockaddr.in));
    if (rc == 0) return "ok";
    switch (std.posix.errno(rc)) {
        .INPROGRESS => {},
        else => |err| return errnoName(err),
    }

    // Неблокирующий connect уходит в фон; итог сообщает POLLOUT плюс SO_ERROR.
    var fds = [_]c.pollfd{.{ .fd = fd, .events = c.POLL.OUT, .revents = 0 }};
    if (c.poll(&fds, 1, wait_ms) == 0) return "ETIMEDOUT";
    var so_error: c_int = 0;
    var len: c.socklen_t = @sizeOf(c_int);
    _ = c.getsockopt(fd, c.SOL.SOCKET, c.SO.ERROR, &so_error, &len);
    if (so_error == 0) return "ok";
    return errnoName(@enumFromInt(so_error));
}

fn errnoName(err: std.posix.E) []const u8 {
    // Имя в std без буквы E: NETUNREACH. Печатаем как в <errno.h>.
    return switch (err) {
        .NETUNREACH => "ENETUNREACH",
        .CONNREFUSED => "ECONNREFUSED",
        .HOSTUNREACH => "EHOSTUNREACH",
        .TIMEDOUT => "ETIMEDOUT",
        .ADDRNOTAVAIL => "EADDRNOTAVAIL",
        else => @tagName(err),
    };
}

/// `a.b.c.d` в четыре байта в сетевом порядке, как их хранит `sockaddr_in`.
fn parseIpv4(text: []const u8) ?u32 {
    var bytes: [4]u8 = undefined;
    var parts = std.mem.splitScalar(u8, text, '.');
    for (&bytes) |*byte| byte.* = std.fmt.parseInt(u8, parts.next() orelse return null, 10) catch return null;
    if (parts.next() != null) return null;
    return @bitCast(bytes);
}

Неблокирующий connect почти никогда не завершается сразу: он возвращает EINPROGRESS, а итог рукопожатия приходит позже. Узнают его так: poll ждёт готовности к записи, потом getsockopt(SO_ERROR) отдаёт код ошибки соединения, ноль значит успех. А вот ENETUNREACH приходит сразу из connect: маршрута нет, ядру даже не нужно ничего отправлять. Ветка socket EPERM сегодня не срабатывает, она для урока 67: под seccomp-фильтром отказать может уже сам socket.

Прогон

Контейнер runner-zig с --privileged, Linux 7.0, arm64. Сначала от root, где работает прямой путь:

$ zbox run netcheck 1.1.1.1 80
{"exit_code":0,...,"stdout":"ok\n","stderr":"","net":"host"}
$ zbox run --no-net netcheck 1.1.1.1 80
{"exit_code":0,...,"stdout":"ENETUNREACH\n","stderr":"","net":"none"}
$ zbox run sh -c 'cat /proc/net/dev | tail -n +3 | cut -c1-12'
{"exit_code":0,...,"stdout":"    lo:     \n  eth0:    1\n","stderr":"","net":"host"}
$ zbox run --no-net sh -c 'cat /proc/net/dev | tail -n +3 | cut -c1-12; cat /proc/self/uid_map'
{"exit_code":0,...,"stdout":"    lo:     \n         0          0 4294967295\n","stderr":"","net":"none"}

Без флага программе видны оба интерфейса контейнера, lo и eth0, и 1.1.1.1 отвечает. С флагом остаётся один lo, и connect сразу получает ENETUNREACH. Отображение uid тождественное на все четыре миллиарда номеров: root пошёл прямым путём, пространства пользователей не создавалось.

Теперь от пользователя 10001, от которого песочница курса гоняет задачи и которому unshare -n только что отказал:

$ setpriv --reuid=10001 --regid=10001 --clear-groups \
    zbox run --no-net sh -c 'cat /proc/net/dev | tail -n +3 | cut -c1-12; id; cat /proc/self/uid_map'
{"exit_code":0,...,"stdout":"    lo:     \nuid=0(root) gid=0(root) groups=0(root)\n         0      10001          1\n","stderr":"","net":"none"}

Обходной путь сработал: программа внутри считает себя root, а строка 0 10001 1 в uid_map говорит правду. Нулевой uid внутри это 10001 снаружи, и отображена ровно одна запись. Никаких прав на машине у этого «root» не прибавилось.

Тесты шага

Вспомогательный модуль получил поле net в Reply и путь к netcheck:

@@
     stdout_truncated: bool = false,
     stderr_truncated: bool = false,
+    net: []const u8 = "",
 };
 
@@
 /// Путь к подопытной программе `hog`, которая занимает память по заказу.
 pub const hog_exe = build_options.hog_exe;
+/// Путь к подопытной программе `netcheck`, которая пробует `connect`.
+pub const netcheck_exe = build_options.netcheck_exe;
 
 /// Сырой запуск zbox: для тестов, которым важен код возврата самого zbox.
//! Шаг 64: сеть выключена, `unshare(CLONE_NEWNET)` в ребёнке.

const std = @import("std");
const builtin = @import("builtin");
const zbox = @import("zbox");
const support = @import("support.zig");

const testing = std.testing;
const c = std.c;

test "parse: --no-net это переключатель без значения" {
    const command = try zbox.args.parse(&.{ "run", "--no-net", "--time-ms", "100", "true" });
    try testing.expect(command.no_net);
    try testing.expectEqual(100, command.time_ms);
    try testing.expectEqualStrings("true", command.argv[0]);

    const plain = try zbox.args.parse(&.{ "run", "true" });
    try testing.expect(!plain.no_net);

    // Флаг без программы это по-прежнему ошибка.
    try testing.expectError(error.BadUsage, zbox.args.parse(&.{ "run", "--no-net" }));
}

test "writeJson печатает net" {
    var buf: [1024]u8 = undefined;
    var out: std.Io.Writer = .fixed(&buf);
    try zbox.report.writeJson(&out, .{ .exit_code = 0, .net = .none });
    try testing.expect(std.mem.endsWith(u8, out.buffered(), ",\"net\":\"none\"}\n"));

    out = .fixed(&buf);
    try zbox.report.writeJson(&out, .{ .exit_code = 0 });
    try testing.expect(std.mem.endsWith(u8, out.buffered(), ",\"net\":\"host\"}\n"));
}

test "без флага в ответе net: host" {
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();

    const run = try support.zbox(arena_state.allocator(), &.{ "run", "true" });
    try testing.expectEqualStrings("host", run.reply.net);
}

test "не Linux: --no-net даёт код 3 и объяснение" {
    if (builtin.os.tag == .linux) return error.SkipZigTest;
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();

    const raw = try support.zboxRaw(arena_state.allocator(), &.{ "run", "--no-net", "true" });
    try testing.expectEqual(std.process.Child.Term{ .exited = 3 }, raw.term);
    try testing.expect(std.mem.indexOf(u8, raw.stdout, "только в Linux") != null);
}

/// Запуск под `--no-net`, который пропускает тест, если ядро не даёт
/// пространств имён: тогда ребёнок выходит с кодом 126 и объяснением.
fn zboxNoNet(arena: std.mem.Allocator, argv: []const []const u8) !support.Run {
    const full = try std.mem.concat(arena, []const u8, &.{ &.{ "run", "--no-net" }, argv });
    const run = try support.zbox(arena, full);
    if (run.reply.exit_code == zbox.run.limit_failed_code) {
        std.debug.print("пропуск: {s}", .{run.reply.stderr});
        return error.SkipZigTest;
    }
    return run;
}

test "linux: наружу под --no-net дороги нет" {
    if (builtin.os.tag != .linux) return error.SkipZigTest;
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();

    const run = try zboxNoNet(arena_state.allocator(), &.{ support.netcheck_exe, "1.1.1.1", "80" });
    try testing.expectEqual(0, run.reply.exit_code);
    try testing.expectEqualStrings("ENETUNREACH\n", run.reply.stdout);
    try testing.expectEqualStrings("none", run.reply.net);
}

test "linux: loopback-сервер родителя виден без флага и не виден с ним" {
    if (builtin.os.tag != .linux) return error.SkipZigTest;
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    // Слушатель в самом тесте. Принимать соединения не нужно: ядро
    // завершает рукопожатие само и кладёт соединение в очередь listen.
    const listener = c.socket(c.AF.INET, c.SOCK.STREAM, 0);
    try testing.expect(listener >= 0);
    defer _ = c.close(listener);
    var addr: c.sockaddr.in = .{ .port = 0, .addr = std.mem.nativeToBig(u32, 0x7f000001) };
    try testing.expectEqual(0, c.bind(listener, @ptrCast(&addr), @sizeOf(c.sockaddr.in)));
    try testing.expectEqual(0, c.listen(listener, 4));
    var len: c.socklen_t = @sizeOf(c.sockaddr.in);
    try testing.expectEqual(0, c.getsockname(listener, @ptrCast(&addr), &len));
    const port = try std.fmt.allocPrint(arena, "{d}", .{std.mem.bigToNative(u16, addr.port)});

    const open = try support.zbox(arena, &.{ "run", support.netcheck_exe, "127.0.0.1", port });
    try testing.expectEqualStrings("ok\n", open.reply.stdout);

    // В новом пространстве свой lo, и он выключен: до 127.0.0.1 нет маршрута.
    const closed = try zboxNoNet(arena, &.{ support.netcheck_exe, "127.0.0.1", port });
    try testing.expectEqualStrings("ENETUNREACH\n", closed.reply.stdout);
}

Первые три теста работают где угодно. Четвёртый проверяет честный отказ на macOS. Два последних только для Linux, и самый показательный из них последний: тест поднимает слушающий сокет на 127.0.0.1, не принимая соединений (рукопожатие выполнит ядро), и зовёт netcheck дважды. Без флага ok, с флагом ENETUNREACH: loopback песочницы это не loopback хозяина. Если ядро не даёт пространств имён даже через пространство пользователей (так бывает в контейнере без --privileged, где seccomp Docker запрещает unshare), ребёнок выходит с кодом 126, и тест пропускает себя с объяснением, а не падает.

$ zig build test --summary all            # macOS 26
Build Summary: 15/15 steps succeeded; 50/55 tests passed (5 skipped)
$ zig build test --summary all            # Linux, контейнер --privileged, root
Build Summary: 15/15 steps succeeded; 54/55 tests passed (1 skipped)
$ zig build test -Dstep=64 --summary all  # Linux, uid 10001
Build Summary: 7/7 steps succeeded; 5/6 tests passed (1 skipped)

На macOS пропущены три теста cgroups из урока 54 и два Linux-теста шага 64. В контейнере пропущен единственный тест, который проверяет поведение на macOS. От пользователя 10001 шаг 64 проходит целиком через пространство пользователей.

На macOS

Весь сетевой код урока работает на macOS напрямую: socket.zig, эхо на libc и на std.Io.net, reuse.zig, тесты шага tiny. Выводы в тексте сняты на macOS 26 (Apple Silicon) и перепроверены в Debian 12 под Docker. Отличия, которые стоит знать. ss на macOS нет, его заменяют netstat -an -p tcp и lsof -nP -iTCP. Флагов SOCK_CLOEXEC и SOCK_NONBLOCK у socket нет, как и accept4: close-on-exec и неблокирующий режим ставятся отдельным fcntl. Для записи без SIGPIPE там есть и флаг MSG_NOSIGNAL, и опция сокета SO_NOSIGPIPE, которая действует сразу на все записи в сокет. Очередь listen обрезается до kern.ipc.somaxconn, это 128. TIME_WAIT длится 30 секунд, а не 60. Эфемерные порты берутся из диапазона 49152 до 65535, на Linux 32768 до 60999. И у sockaddr_in первым полем идёт байт длины, так что структуры, собранные руками для Linux, на macOS не подойдут: пользуйся типами из std.c, они правильные на каждой системе.

Шаг zbox на macOS не проверить: сетевых пространств имён там нет, и zbox run --no-net честно отвечает zbox: --no-net работает только в Linux: сетевые пространства имён есть лишь там с кодом 3. Тесты шага гоняются в контейнере runner-zig с --privileged, рецепт в README эталона zbox и в уроке про память. Без --privileged профиль seccomp самого Docker запрещает unshare, и два Linux-теста пропустят себя. strace и ss в образе нет, ставятся через apt-get install strace iproute2 в контейнере с сетью.

Практика

openListenfd из урока стоит на libc и getaddrinfo. В задаче libc нет, есть только сисколлы из std.os.linux, и адрес ты собираешь сам: sockaddr_in с нулевым адресом и портом в порядке байтов сети. Вторая функция, localPort, достаёт порт обратно через getsockname. Тесты подключаются к твоему сокету через loopback песочницы (сеть наружу закрыта, но lo поднят, как мы выяснили выше) и проверяют: порт 0, SO_REUSEADDR, адрес 0.0.0.0, порядок байтов порта, connect и accept с обменом байтами в обе стороны, двух клиентов в очереди, AddressInUse на занятом порту без утечки дескриптора и, главное, повторный запуск на порту, где соединение досиживает в TIME_WAIT. Без SO_REUSEADDR последний тест падает с EADDRINUSE ровно как reuse.zig.

Упражнения

Итоги

  • Интерфейс сокетов это пять вызовов. Клиент: socket, connect. Сервер: socket, bind, listen, accept. После установки соединения работают обычные read и write с короткими счётами.
  • Все вызовы принимают адрес как указатель на общий sockaddr и длину. Для IPv4 настоящая структура sockaddr_in: 16 байт, порт и адрес в порядке байтов сети. В Zig приведение к общему типу это @ptrCast, обратно @ptrCast плюс @alignCast. Забытый nativeToBig не ломает сборку, а меняет порт.
  • listen превращает сокет в слушающий: ядро само проводит рукопожатия и складывает готовые соединения в очередь длиной не больше somaxconn. accept забирает одно соединение и возвращает новый, присоединённый дескриптор; слушающий живёт дальше. Соединения различаются парой сокетов.
  • openClientfd и openListenfd строятся поверх getaddrinfo и перебирают список адресов: у имени их может быть несколько, и первый не обязан ответить. AI_PASSIVE даёт адрес для всех интерфейсов, AI_ADDRCONFIG отсекает недоступные семейства, NI_NUMERICHOST спасает сервер от ожидания DNS.
  • Сторона, закрывшая соединение первой, держит пару адресов в TIME_WAIT (60 секунд на Linux, 30 на macOS). Без SO_REUSEADDR это EADDRINUSE при перезапуске сервера. Флаг ставят в каждом сервере до bind.
  • Итеративный сервер обслуживает одного клиента за раз, остальные ждут в очереди. Ошибка одного клиента не должна ронять сервер.
  • std.Io.net делает те же вызовы с разумными умолчаниями: SOCK_CLOEXEC, accept4, MSG_NOSIGNAL. Но reuse_address включает ещё и SO_REUSEPORT, и два сервера молча делят один порт.
  • unshare(CLONE_NEWNET) даёт процессу пустое сетевое пространство имён: один выключенный lo, и connect куда угодно, даже на 127.0.0.1, получает ENETUNREACH. Без root тот же результат даёт CLONE_NEWUSER с отображением uid и gid. Docker в режиме --network none поднимает lo, поэтому задачи с сокетами в песочнице курса работают.

Дальше

Теперь у тебя есть дескриптор, за которым стоит другая машина, и сервер, который принимает звонки. Но эхо это не протокол: сервер просто возвращает байты, не понимая их. В следующем уроке поверх того же соединения появится язык, на котором говорит весь веб. HTTP окажется набором текстовых строк: строка запроса, заголовки, пустая строка, тело. Мы прочитаем его руками через nc и curl -v, разберём, чем статический контент отличается от динамического, и увидим, как CGI отдаёт вывод программы прямо в сокет через тот самый dup2. А openListenfd и fdio.Reader из сегодняшнего урока станут фундаментом веб-сервера TINY.

домашка

Домашка