Книга: Сетевое программирование. От основ до приложений
Назад: Глава 5. Соединение. Потоковый обмен данными. Серверный API
Дальше: Глава 7. Сокет в ядре Linux

Глава 6. Внеполосные данные. Пространства имен

Эти системы при локальном использовании имеют доступ ко всем сигналам, отправляемым пользователем, будь то обычные символы или специальные «внеполосные» сигналы, такие как отправляемые нажатием телетайпной клавиши BREAK или клавиши IBM 2741 ATTN.

RFC 854 «TELNET Protocol Specification», 1983

Введение

В этой главе мы рассмотрим концепцию Out-Of-Band, или «внеполосных», данных и углубимся в детали их использования в сетевом программировании. Это данные, которые передаются параллельно основным данным через отдельный канал связи. Мы узнаем, как их отправлять, изучим несколько вариантов приема, а также рассмотрим примеры работы с ними в различных языках программирования и на разных платформах.

Понимание работы с внеполосными данными поможет разработчикам создавать более безопасный код и выполнять некоторые специальные задачи.

Также в главе мы коснемся пространств имен — важного механизма, который позволяет ограничивать видимость системных объектов для групп процессов. Пример такой изоляции — возможность создать несколько сокетов на одном и том же порту в одной и той же системе.

Рассмотрим мы их на примере сетевых пространств имен в Linux и API, который предоставляет Linux для работы с ними.

Внеполосные данные

Некоторые протоколы дают возможность использовать отдельный канал для обмена данными, то есть передавать их вне основного потока, или вне «главной полосы». Такие данные называются внеполосными, или Out-Of-Band, OOB-данными.

Иными словами, внеполосные данные — это данные, передаваемые через поток, независимый от главного потока данных.

Они, как правило, имеют приоритет над обычными данными.

Внимание! Внеполосные данные используются крайне редко. Кроме того, в RFC 6093 «On the Implementation of the TCP Urgent Mechanism» сказано, что новые приложения не должны использовать механизм внеполосных данных в TCP по причине того, что он является устаревшим, а при обработке внеполосных данных могут возникнуть проблемы, о чем написано далее.

На прикладном уровне, например, протокол Telnet использует внеполосные данные. Для Telnet через OOB-данные реализуется команда SYNCH, которая заставляет удаленную систему отбросить данные в очереди и немедленно обработать все необработанные команды Telnet. В этом случае внеполосные данные поддерживает TCP. Отдельного физического канала для OOB-данных в нем нет.

Однако в TCP имеется «срочный режим» и показанный на рис. 6.1 «указатель срочности» — TCP Urgent Pointer, помечающий данные как внеполосные. Он позволяет отправить только один байт внеполосных данных с каждым сегментом. Этот указатель действителен, если в заголовке сегмента установлен флаг URG.

Обычно отправляющий TCP сокет за небольшое время отправляет несколько сегментов, содержащих флаг URG, с указателем срочности на один и тот же байт OOB-данных. Только первый из этих сегментов уведомляет принимающий процесс о поступлении новых данных.

Рис. 6.1. Внеполосные данные в TCP

Существует два противоречивых описания того, как работает передача внеполосных данных:

В RFC 793 «Transmission Control Protocol» говорится, что указатель срочности указывает на байт, следующий за срочными данными.

В RFC 1122 «Requirements for Internet Hosts — Communication Layers» сказано, что это указатель на последний байт срочных данных.

Такое расхождение приводит к проблемам взаимодействия, если разные узлы используют описания из разных RFC.

Глядя на рис. 6.1, вам может показаться, что у сокета есть некий единый буфер, в который, помимо основных данных, записываются внеполосные, и есть маркер «тут начинаются срочные данные». Но это не совсем так. В общем случае внеполосные данные — это именно отдельный канал вне основного канала данных. И если не установлена опция SO_OOBINLINE, описанная далее, внеполосные данные сохраняются в отдельном буфере.

Даже в случае TCP, хотя внеполосные данные и передаются в общих данных, в ядре Linux, например, они реализованы как отдельный байт данных:

struct tcp_sock

{

...

    // Сохраненный октет OOB-данных и управляющие флаги.

    u16 urg_data;

...

};

Данная структура определена в файле include/linux/tcp.h исходных кодов ядра.

Видно, что ядро Linux содержит в 16-битной переменной 8 бит флагов и 8 бит данных, то есть может вернуть OOB-данные независимо от буфера «обычных» данных.

Механизм внеполосных данных существует не только в TCP.

Пожалуй, можно сказать, что FTP использует OOB-данные. Но, в отличие от Telnet, он не использует OOB TCP. В FTP реализован отдельный канал управления, через который и передаются «внеполосные» данные — команды и ответы. А данные «в полосе» — это передаваемые файлы.

В стеке протоколов X.25 нечто подобное называют expedited data. Эти данные переносятся в пакетах прерывания. Такие пакеты содержат заголовок, состоящий из байта типа, который всегда равен XL_DAT, и байта команды. За этим заголовком могут следовать пользовательские данные. В X.25 получение внеполосных данных подтверждается отдельным сообщением.

Такие VoIP-протоколы, как H.323 и SIP, реализуют механизм, подобный механизму внеполосных данных, на прикладном уровне. Трафик реального времени они передают в UDP-дейтаграммах. А для сигнализации используется TCP-соединение.

Bluetooth в старом варианте процедуры сопряжения использует OOB-данные: обмен ключами может производиться через отдельный физический транспорт — не канал 2,4 ГГц, а, например, канал NFC.

Ну и «классический пример» — ОКС-7, система для передачи управляющей информации в сетях с коммутацией каналов, например, в обычной телефонной сети.

Отправка внеполосных данных

Для отправки внеполосных данных при вызове функции send() следует использовать флаг MSG_OOB:

send(sock_fd, "a", 1, MSG_OOB);

Если через TCP-сокет отправить буфер данных, передав функции send() флаг MSG_OOB, только последний байт передается как OOB.

В случае других протоколов такое ограничение не обязательно.

Прием внеполосных данных

При приеме внеполосных данных в структуре сокета устанавливается поле метки, то есть позиция внеполосных данных в основном потоке данных отправителя. Принимающий процесс должен определить, находится ли он на этой метке. Для этого он может использовать функцию sockatmark():

#include <sys/socket.h>

 

#if _POSIX_C_SOURCE >= 200112L

 

int sockatmark(int sockfd);

 

#endif

Функция вернет 1, если сокет находится на метке OOB-данных «в буфере», и 0 в ином случае. Пришедшие обычные данные «сдвигают» метку, и функция снова возвращает 0, но пока такие данные не пришли, она всегда будет возвращать 1.

В случае ошибки будет возвращен –1.

Внимание! Выше показан feature-макрос _POSIX_C_SOURCE. Его необходимо проверить, чтобы определить наличие функции. В книге feature-макросы показаны не везде; кроме того, именно данный макрос говорит о том, что поддерживается относительно давний стандарт.

Изучая man по функции, обращайте внимание на такие макросы.

Данная функция в ОС Linux реализована через ioctl SIOCATMARK, который присутствует и в ОС Windows, где функция недоступна.

Этот ioctl может быть использован в Python, где siocatmark() тоже нет, что может пригодиться, если потребуется реализовать кросс-платформенное чтение метки.

Работу с ioctl мы рассмотрим в главе 10 и отдельно для ОС Windows — в главе 18.

Синхронный вариант приема OOB-данных

Из-за того что внеполосные данные помещаются в приоритетную «очередь» данных, функция recv() тоже должна использовать флаг MSG_OOB, чтобы их получить. Если флаг установлен, но OOB-данных нет, функция recv() вернет ошибку EINVAL. В Unix-подобных системах она установит код в специальной переменной errno.

В ОС Windows код возвращает функция WSAGetLastError(), и он будет равен WSAEINVAL.

В этом случае метод Python socket.recv() сгенерирует исключение: «OSError: [Errno 22] Invalid argument».

Внимание! Если новые внеполосные данные поступят еще до того, как вычитаны предыдущие, данные, пришедшие первыми, будут перезаписаны и потеряны, кроме случая использования опции SO_OOBINLINE, описанной ниже.

Посмотрим, как работать с внеполосными данными на практике, реализовав сервер, принимающий такие данные от клиента.

Сначала, как обычно, создадим прослушивающий сокет, примем клиент, создадим буфер для приема данных и вспомогательные переменные:

assert(argv[1]);

auto server_sock = socket_wrapper::create_tcp_server(argv[1]);

auto client_sock = socket_wrapper::accept_client(server_sock);

// Буфер обычных данных.

std::array<char, buffer_size> data_buff;

Перед чтением данных проверим результат вызова функции sockatmark().

Если он равен 1, можно проверить флаг, сигнализирующий, что данные приняты, или воспользоваться тем фактом, что в случае отсутствия OOB-данных recv() будет возвращен код EINVAL.

Если использовать отдельный флаг, чтобы проверить, что OOB-данные уже приняты и обработаны, может возникнуть состояние гонки: флаг взведен, но будут поступать новые OOB-данные, sockatmark() так и будет возвращать 1, и поэтому флаг не будет сброшен, а новые данные не будут обработаны.

На практике ситуация несколько другая. По крайней мере в Linux-реализации TCP новые OOB-данные всегда будут перезаписывать старые, но последовательная отправка в несколько вызовов с флагом MSG_OOB приведет к тому, что принят будет только последний байт.

Если подряд отправить несколько байт OOB-данных без «обычных» данных, они придут, что видно на рис. 6.2, но приняты не будут и sockatmark() всегда будет возвращать 0.

Рис. 6.2. Несколько подряд отправленных OOB-байт

Мы реализуем этот вариант без лишнего флага, с использованием проверки EINVAL:

// Вычитываем данные в цикле.

while (true)

{

    // Сначала определим, на метке ли сокет.

    switch (sockatmark(client_sock))

    {

        case -1:

            throw std::system_error(errno, std::system_category(),

                                    "sockatmark");

        break;

        case 1:

            // Да, сокет на метке, но это еще не означает, что OOB новые.

            std::cout << "OOB data received..?" << std::endl;

            // Принять внеполосные данные.

            if (char oob_data = 0;

                -1 == recv(client_sock, &oob_data, 1, MSG_OOB))

            {

                if (EINVAL == sock_wrap.get_last_error_code())

                {

                    std::cout << "EINVAL — this is not OOB" << std::endl;

                    // Вычитать обычные данные.

                    recv_data(sock_wrap, client_sock, data_buff);

                    continue;

                }

                throw std::system_error(sock_wrap.get_last_error_code(),

                                        std::system_category(), "recv oob");

            }

            // Распечатать внеполосные данные.

            else std::cout << "OOB data = " << oob_data << std::endl;

        break;

Принимается один байт данных, поскольку это максимум, который поддерживается в TCP. В случае TCP, после того как OOB-данные приняты, sockatmark() будет возвращать 1 до момента, когда придут новые обычные данные.

То есть OOB-данные приняты, но метка внеполосных данных не сбрасывается, а повторный их «прием» просто приведет к ошибке. И если эта ошибка EINVAL, мы перезапустим чтение обычных данных.

Если sockatmark() вернула 0, принимаем данные как обычно:

        case 0:

            std::cout << "sockatmark() is 0" << std::endl;

            // Принять обычные данные в буфер.

            recv_data(sock_wrap, client_sock, data_buff);

            break;

         default:

            throw std::runtime_error("unexpected sockatmark");

    }

}

Функция приема стандартная:

void recv_data(const socket_wrapper::SocketWrapper &sock_wrap,

               const socket_wrapper::Socket &client_sock,

               std::array<char, buffer_size> &data_buff)

{

    // Принять данные.

    if (ssize_t n = recv(client_sock, data_buff.data(), data_buff.size(), 0);

        n < 0)

    {

        throw std::system_error(sock_wrap.get_last_error_code(),

                                std::system_category(), "recv data");

    }

    else if (!n)

    {

        // Если данных нет — выйти.

        std::cout << "No data, exiting..." << std::endl;

        exit(EXIT_SUCCESS);

    }

    else

    {

        std::cout

            << "Ordinary data received...\n"

            << n << " bytes was read: "

            << std::string(data_buff.begin(), data_buff.begin() + n)

            << std::endl;

    }

}

Если клиент отправляет подряд несколько байт OOB-данных, в случае TCP передан будет только последний байт с момента отправки сегмента «обычных» данных.

Так как в Python нет функции sockatmark(), необходимо либо обрабатывать исключение приема данных при установленном флаге OOB, либо реализовывать работу с OOB-данными так, как показано ниже. Кроме того, можно реализовать функцию через соответствующий ioctl.

Для отправки OOB-данных реализуем на Python скрипт:

import socket

import time

 

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP)

sock.connect(('127.0.0.1', 12345))

 

# Чтобы избежать состояния гонки при работе с асинхронным сервером.

time.sleep(0.1)

 

sock.send('abcd'.encode(), socket.MSG_OOB)

Запустим сервер, принимающий OOB-данные. После вызова кода, отправляющего OOB-данные, сервер выведет следующее:

build/bin/b01-ch06-oob-server 12345

Client from 127.0.0.1...

sockatmark() is 0

Ordinary data received...

3 bytes was read: abc

OOB data received..?

OOB data = d

OOB data received..?

EINVAL — this is not OOB

No data, exiting...

Видно, что принято три байта обычных данных, которые отправил клиент Python, и один байт OOB-данных.

Внимание! В общем случае следует исходить из того, что прочитать внеполосные данные можно только один раз, а после считывания они разрушаются. Но в TCP-сокетах для хранения внеполосных данных обычно используется однобайтная переменная. Она не переустанавливается, и внеполосные данные можно читать многократно.

Асинхронный вариант приема с использованием сигнала SIGURG

В Unix-подобных ОС возможен еще один способ работы с OOB-данными — прием в асинхронном режиме через перехват сигнала SIGURG. Процесс или группа процессов получат сигнал SIGURG, когда внеполосные данные доступны для чтения в сокете.

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

В ряде примеров, которые можно найти в Сети, данные читаются в обработчике. Это допустимо, потому что обычно в таких примерах используется статически выделенный буфер и вычитка нужна только для того, чтобы отбросить «данные перед меткой». То есть данные уже приняты и выполняется их копирование из внутреннего буфера сокета в буфер неизменного размера в пользовательском пространстве.

Ничего произойти в этом случае не должно, но это «плохой тон», и если кто-то случайно расширит обработчик в будущем, добавив туда что-либо неприменимое, могут возникнуть неожиданные побочные эффекты.

В рассмотренном далее примере этого варианта OOB-данные не принимаются в обработчике сигнала, поэтому его код очень похож на код «обычного» синхронного примера. Мы проверяем код ошибки функции recv() на EINVAL, как в примере выше.

Обработчик сигнала необходим, — SIGURG по умолчанию игнорируется, а нам требуется, чтобы он прервал блокирующий ввод-вывод. Но обработчик рудиментарный, и все, что он делает в данном случае, — это выводит сообщение:

constexpr size_t buffer_size = 255;

 

// Указатель на обработчик сигнала.

void signal_handler(int signal)

{

    // В процессе обработки SIGURG другой сигнал не может прийти.

    std::cout << "SIGURG [" << signal << "] received" << std::endl;

}

Печать сообщения тоже ввод-вывод, но достаточно предсказуемый. В основном коде, например в main(), установим обработчик:

    // Установка обработчика сигнала.

    if (SIG_ERR == std::signal(SIGURG, signal_handler))

    {

        throw std::system_error(errno, std::system_category(), "signal");

    }

Для получения этого сигнала также надо установить опцию F_SETOWN на файловый дескриптор сокета с помощью вызова fcntl():

    // Установка опции F_SETOWN в значение PID текущего процесса.

    if (-1 == fcntl(client_sock, F_SETOWN, getpid()))

    {

        throw std::system_error(errno, std::system_category(), "fcntl");

    }

Установка этой опции делает владельцем файлового дескриптора процесс или группу с указанным PID.

После установки параметра заданному процессу будут приходить сигналы SIGURG и SIGIO.

Для F_SETOWN, чтобы установить PID группы, нужно указать отрицательное значение.

Положительное значение — это PID процесса, а если приложение использует многопоточную библиотеку, поддерживающую группы потоков, — PID потока.

Этот параметр оброс достаточно сложными правилами, и в Linux, начиная с версии ядра 2.6.32, добавили новый параметр — F_SETOWN_EX. Он принимает структуру f_owner_ex:

struct f_owner_ex

{

    // F_OWNER_TID, F_OWNER_PID или F_OWNER_PGRP.

    int type;

    pid_t pid;

};

Также стоит отметить, что есть симметричные вызовы — F_GETOWN и F_GETOWN_EX, — которые возвращают идентификатор владельца дескриптора или 0 в случае его отсутствия.

Вместо fcntl() можно использовать ioctl SIOCSPGRP, который используется внутри функции, напрямую.

Между вызовом обработчика сигнала и непосредственной вычиткой принятых данных из буфера проходит какое-то время. За это время могут быть получены еще данные.

Код в цикле идентичен коду синхронного примера. Если sockatmark() возвращает 0, принимаем обычные данные, если 1 — также принимаем OOB-данные с обработкой EINVAL.

Код приема «обычных» данных почти такой же, как код для синхронного примера.

Но раз мы обрабатываем сигналы и вызов recv() может быть прерван, вводится дополнительная проверка кода ошибки на EINTR, и хотя без этой проверки сервер в большинстве случаев будет работать, лучше ее выполнить:

void recv_data(const socket_wrapper::SocketWrapper &sock_wrap,

               const socket_wrapper::Socket &client_sock,

               std::array<char, buffer_size> &data_buff)

{

    // Принять обычные данные в буфер.

    if (ssize_t n = recv(client_sock, data_buff.data(), data_buff.size(), 0);

        n < 0)

    {

        if (auto e_code = sock_wrap.get_last_error_code();

            EINTR == e_code || EAGAIN == e_code)

        {

            // Проверка на прерывание сигналом.

            std::cout << "recv was interrupted!" << std::endl;

        }

        else throw std::system_error(e_code, std::system_category(),

                                     "recv data");

    }

    else if (!n)

    {

        // Клиент отключился.

        std::cout << "No data, exiting..." << std::endl;

        exit(EXIT_SUCCESS);

    }

    else

    {

        std::cout

            << "Ordinary data received...\n"

            << n << " bytes was read: "

            << std::string(data_buff.begin(), data_buff.begin() + n)

            << std::endl;

    }

}

Работает этот сервер так же, как и синхронный:

build/bin/b01-ch06-oob-server-async 12345

Client from 127.0.0.1...

sockatmark() is 0

SIGURG [23] received

Ordinary data received...

3 bytes was read: abc

OOB data received..?

OOB data = d

OOB data received..?

EINVAL — this is not OOB

No data, exiting...

Данный вариант обработки сложный и проблемный. Если вдруг на практике вам придется использовать OOB-данные, не осуществляйте их обработку по сигналам.

Существует неочевидная проблема, которая делает использование данного метода весьма проблематичным.

Если убрать задержку в Python-клиенте, сервер может вывести следующее:

build/bin/b01-ch06-oob-server-async 12345

Client from 127.0.0.1...

sockatmark() is 0

Ordinary data received...

3 bytes was read: abc

No data, exiting...

Вероятно, это происходит из-за наличия состояния гонки. Клиент, подключившись, сразу отправляет данные, и это происходит очень быстро, так как и клиент, и сервер работают на одной машине, связываясь через интерфейс локальной петли. Но вызов fcntl(client_sock, F_SETOWN, getpid()) не успевает выполниться, и поэтому сигнал не приходит.

Эта «особенность» не позволяет выполнить быстрое подключение с отправкой данных, которое часто требуется для работы нагруженных серверов, и еще сильнее снижает целесообразность применения данного метода.

В некоторых примерах данный флаг устанавливают на прослушивающий сокет, но в Linux это не работает.

Также может возникнуть желание использовать флаг, устанавливаемый обработчиком и сбрасываемый приходом основных данных, чтобы проверять факт обработки данных, когда функция sockatmark() возвращает 1.

Но этого делать нельзя из-за того, что может быть выполнен следующий сце­нарий:

1. Мы принимаем внеполосные данные — это системный вызов, который может быть прерван сигналом.

2. Сигнал поступает, перезаписывает данные и прерывает вызов.

3. Флаг сбрасывается, и новые OOB-данные теряются.

Асинхронный вариант приема в неблокирующем режиме

В неблокирующем режиме, который будет рассмотрен в книге 2, функция select() вернет управление, если по дескриптору для исключительных ситуаций поступили OOB-данные. Так же поведут себя ее аналоги, например функция poll().

Далее можно считать OOB-данные через обычный recv() с флагом MSG_OOB. Это естественный вариант для асинхронных приложений. К тому же он кросс-платформенный.

В примере сначала также примем клиент, но кроме этого добавим маски дескрипторов:

    // Как обычно, создать новый сокет и принять клиент.

    auto server_sock = socket_wrapper::create_tcp_server(argv[1]);

    auto client_sock = socket_wrapper::accept_client(server_sock);

 

    // Установить неблокирующий режим через FIONBIO.

    set_nonblock(client_sock);

 

    // Маски дескрипторов.

    fd_set read_descriptors_set;

    // fd_set write_descriptors_set;

    fd_set err_descriptors_set;

    // Буфер обычных данных.

    std::array<char, buffer_size> data_buff;

Эти маски используются функцией select(), вызываемой для определения состояния дескрипторов в рабочем цикле:

    while (true)

    {

        timeval timeout = {.tv_sec = 1, .tv_usec = 0};

        FD_ZERO(&err_descriptors_set);

        FD_ZERO(&read_descriptors_set);

 

        // Добавление в списки дескрипторов.

        FD_SET(client_sock, &err_descriptors_set);

        FD_SET(client_sock, &read_descriptors_set);

        // Блокирующий вызов select().

        auto active_descriptors = select(client_sock + 1,

                                         &read_descriptors_set,

                                         nullptr /* &write_descriptors_set */,

                                         &err_descriptors_set,

                                         &timeout);

        // Обработка результатов select().

 

        // Ошибка.

        if (active_descriptors < 0)

        {

             throw std::system_error(errno, std::system_category(), "select");

        }

 

        if (0 == active_descriptors)

        {

            // Отсутствие событий дескрипторов – возможно, тайм-аут.

            std::cout << "No active descriptors..." << std::endl;

            continue;

        }

И в случае если в сокете произошла ошибка или были приняты OOB-данные, он будет присутствовать в множестве err_descriptors_set:

        // Внеполосные данные или ошибка.

        if (FD_ISSET(client_sock, &err_descriptors_set))

        {

            std::cout << "OOB or error" << std::endl;

            // Проверить состояние маркера OOB-данных.

            switch (sockatmark(client_sock))

            {

                case -1:

                    throw std::system_error(errno, std::system_category(),

                                            "sockatmark");

                break;

                case 1:

                    std::cout << "OOB data received..?" << std::endl;

                    // Принять OOB-данные.

                    if (char oob_data = 0; -1 == recv(client_sock,

                                                      &oob_data, 1, MSG_OOB))

                    {

                        if (EINVAL == sock_wrap.get_last_error_code())

                        {

                            // Обычные данные будут приняты на следующей

                            // итерации.

                            std::cout << "EINVAL — this is not OOB"

                                      << std::endl;

                            continue;

                        }

                        throw

                            std::system_error(sock_wrap.get_last_error_code(),

                                              std::system_category(),

                                              "recv oob");

                    }

                    else std::cout << "OOB data = " << oob_data << std::endl;

                break;

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

Если это OOB, старые данные будут перезаписаны, а если это обычные данные, возникнет ошибка EINVAL и они будут приняты на следующей итерации цикла.

То же самое произойдет, если данные были приняты чуть раньше и sockatmark() вернул 0:

                case 0:

                    // Обычные данные, будут вычитаны на следующей итерации.

                    std::cout << "sockatmark() is 0" << std::endl;

                break;

                default:

                    throw std::runtime_error("unexpected sockatmark");

            }

        }

 

        // Пришли обычные данные, вычитать.

        if (FD_ISSET(client_sock, &read_descriptors_set))

        {

            recv_data(sock_wrap, client_sock, data_buff);

            continue;

        }

Пример запустится как обычно:

build/bin/b01-ch06-oob-server-select 12345

Client from 127.0.0.1...

OOB or error

sockatmark() is 0

Ordinary data received...

3 bytes was read: abc

OOB or error

OOB data received..?

OOB data = d

No data, exiting...

Прием в буфер основных данных

Опция сокета SO_OOBINLINE дает возможность принимать данные в буфер основных данных.

Если она включена, то есть имеет значение 1, внеполосные данные помещаются в основной поток и функции recv() и recvfrom() могут получать их, не включая флаг MSG_OOB. Эта опция применима не для всех протоколов, но работает для TCP. Поэтому когда она установлена и функция sockatmark() возвращает 0, следует вычитывать обычные данные, пока указатель в потоке не окажется на метке, указывающей на OOB-данные.

В man 7 tcp при описании ioctl SIOCATMARK также явно сказано о необходимости вычитки, даже в случае получения SIGURG. Но в примерах выше при вычитке OOB мы всегда находимся на «метке» и начинаем читать OOB, только тогда, когда результат sockatmark() равен 1.

Особенностью работы с опцией в данном случае является то, что внеполосные данные записываются в основной поток вперемешку с «обычными», и они не теряются, когда приходит следующий байт OOB.

Опция работает в том числе в ОС Windows.

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

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

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

Изменения ресурса видны только процессам в пространстве имен, но невидимы для процессов вне его.

Процессы наследуют свое сетевое пространство имен от предка. Изначально процессы запускаются в глобальном пространстве имен.

Каждое пространство имен пользователя, кроме глобального, имеет родительское пространство и может содержать вложенные пространства имен, количеством обычно до 32.

Одним из применений пространств имен является реализация контейнеров.

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

Взаимодействовать друг с другом они могут через свои сетевые интерфейсы как независимые узлы.

Существует несколько типов пространств имен, которые изолируют следующие ресурсы:

Cgroup — корневой каталог контрольных групп.

• IPC — System V IPC, очереди сообщений POSIX. Предотвращает коммуникацию процессов из разных пространств имен, используя IPC.

• Network — сетевые устройства, стеки, порты и т.п.

• Mount — точки монтирования.

• PID — идентификаторы процессов. Процессы в разных пространствах могут иметь одинаковые PID. Но в глобальном пространстве все процессы имеют уникальные PID.

• Time — часы; как идущие со времени загрузки, так и монотонные.

• User — идентификаторы групп и пользователей, корневой каталог и привилегии.

UTS — изоляция вызова uname(): имени узла и доменного имени NIS.

Нас интересует прежде всего сетевое пространство имен, которое является логической копией сетевого стека хост-системы.

Каждое сетевое пространство имен имеет собственные IP-адреса, сетевые интерфейсы со своими адресами, правила брандмауэра, таблицы маршрутизации, пространство номеров портов, сокеты, каталог /sys/class/net, различные файлы в каталоге /proc/sys/net и т.д.

Физические интерфейсы хост-системы существуют в пространстве имен по умолчанию или в глобальном пространстве имен.

Рис. 6.3. Разные сетевые пространства имен

Как показано на рис. 6.3, в пространстве имен могут быть созданы виртуальные устройства — veth, используя которые можно создавать туннели между пространствами имен и мостов к физическим сетевым устройствам.

Когда пространство имен освобождается, содержащиеся в нем виртуальные устройства уничтожаются.

API пространств имен

Чтобы запустить процесс в новом пространстве имен, в системном вызове clone() нужно установить соответствующий флаг. Для сетевого пространства имен это CLONE_NEWNET, а для UTS — CLONE_NEWUTS.

Рассмотрим пример, который меняет имя узла и создает новый сокет, прослушивающий на одном и том же порту, но в разных пространствах имен.

Для функции uname() и структуры utsname, ею используемой, требуется включить заголовочный файл sys/utsname.h.

Прочие заголовочные файлы см. в примере, который содержится в репозитории книги.

Следующая функция выведет на экран имя узла:

void print_hostname()

{

   utsname uts;

 

   // Получить и вывести имя узла.

   if (-1 == uname(&uts)) exit(EXIT_FAILURE);

   std::cout << uts.nodename << std::endl;

}

Теперь реализуем функцию, которая создает прослушивающий сокет:

int make_socket(unsigned short port)

{

    // Тут мы заполним адрес напрямую исключительно для того, чтобы указать,

    // что порт задан явно.

    const sockaddr_in addr = {AF_INET, port, INADDR_ANY, 0};

 

    int sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

 

    if (-1 == sock) return -1;

    // Привязка к одному и тому же адресу и порту не должна проходить.

    if (-1 == bind(sock, reinterpret_cast<const sockaddr*>(&addr),

                   sizeof(addr)))

    {

        perror("bind");

        close(sock);

        return -1;

    }

    if (-1 == listen(sock, SOMAXCONN))

    {

        perror("listen");

        close(sock);

        return -1;

    }

 

    std::cout << "Listening on port " << port << std::endl;

 

    return sock;

}

В случае повторного запуска в одном и том же пространстве имен функция выведет ошибку: «bind: Address already in use», так как сокет без установки специальных опций не может прослушивать на одном и том же адресе и порту.

Следующая функция запускается как точка входа дочернего процесса:

static int child(void *arg)

{

    const std::string new_hostname(static_cast<char*>(arg));

 

    // Изменить имя узла в данном пространстве.

    if (-1 == sethostname(new_hostname.c_str(), new_hostname.size()))

    {

        return EXIT_FAILURE;

    }

    // И вывести на экран.

    std::cout << "uts.nodename in child: ";

    print_hostname();

    // Создать новый прослушивающий сокет на порту 8080.

    const auto sock = make_socket(8080);

    // Имитировать работу.

    sleep(1);

    close(sock);

 

    return EXIT_SUCCESS;

}

В ней открывается прослушивающий на порту 8080 сокет и печатается имя узла.

В функции main() также откроем сокет на порту 8080, прослушивающий на любом адресе:

int main(int argc, char *argv[])

{

   // Буфера размером 1 Мбайт должно хватить для адресов.

   constexpr size_t stack_size = 1024 * 1024;

   // Вывести имя узла.

   std::cout << "uts.nodename in parent before run: ";

   print_hostname();

 

   // Выделить память под стек дочернего процесса.

   char *stack = static_cast<char*>(

       mmap(nullptr, stack_size, PROT_READ | PROT_WRITE,

            MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0));

 

   if (MAP_FAILED == stack) return EXIT_FAILURE;

 

   char *stack_top = stack + stack_size;

 

   // Создать новый сокет, прослушивающий на порту 8080.

   auto sock = make_socket(8080);

 

   // Новое имя узла.

   std::string new_hostname{"new_host"};

И наконец, запустим дочерний процесс в новых пространствах имен:

   // Создать потомка, у которого свои UTS и сетевое пространства имен.

   pid_t pid = clone(child, stack_top,

                     CLONE_NEWUTS | CLONE_NEWNET | SIGCHLD,

                     new_hostname.data());

 

   if (-1 == pid)

   {

       perror("clone");

       return EXIT_FAILURE;

   }

   std::cout << "clone() returned " << pid << std::endl;

 

   sleep(1);

Видно, что флаги пространств имен можно объединять через битовое «ИЛИ».

Когда потомок изменил у себя имя узла, выведем имя узла в родителе и будем ожидать завершения потомка:

   // Вывести имя узла в пространстве имен родителя.

   std::cout << "uts.nodename in parent: ";

   print_hostname();

   // Ожидать завершения потомка.

   if (-1 == waitpid(pid, nullptr, 0)) return EXIT_FAILURE;

   std::cout << "child has terminated" << std::endl;

 

   close(sock);

 

   return EXIT_SUCCESS;

}

Запустим пример:

sudo build/bin/b01-ch06-namespaces

uts.nodename in parent before run: working-machine

Listening on port 8080

clone() returned 1790131

uts.nodename in child: new_host

Listening on port 8080

uts.nodename in parent: working-machine

child has terminated

Только процесс с возможностью CAP_SYS_ADMIN может использовать CLONE_NEWNET и CLONE_NEWUTS.

Внимание! Если вызову clone() указать nullpt  r в качестве аргумента стека, дочерний процесс будет использовать стек родительского процесса. Вряд ли это то, что вам нужно, поэтому в примере явно создается отдельный стек.

Для работы с пространствами имен существует системный вызов unshare(), который переносит работающий процесс в другие пространства имен, и одноименная функция в GLibC:

#define _GNU_SOURCE

#include <sched.h>

 

int unshare(int flags);

Интересующие нас флаги:

CLONE_NEWNET — клонировать сетевое пространство имен. Оно будет общим с родительским процессом.

CLONE_NEWUTS — клонировать пространство имен UTS.

Пространства имен, для которых не заданы флаги, в процессе, где была вызвана функция, будут новыми. В случае успеха функция вернет 0, в случае ошибки –1.

Функция setns(), использующая одноименный системный вызов, позволяет вызывающему ее потоку переходить в различные пространства имен:

#define _GNU_SOURCE

#include <sched.h>

 

int setns(int fd, int nstype);

Параметры функции setns():

fd — файловый дескриптор, по которому выбирается пространство имен для перехода. Это может быть дескриптор:

• файла одной из ссылок в каталоге /proc/pid/ns;

• возвращенный функцией pidfd_open();

• возвращенный функцией clone() с установленным флагом CLONE_PIDFD.

nstype — тип пространства имен, который аналогичен флагам unshare().

Возвращаемое значение такое же, как для unshare().

Кроме функций, доступны еще два ioctl-вызова:

NS_GET_USERNS — получить дескриптор пространства имен для некоторого дескриптора fd.

NS_GET_PARENT — в иерархии пространств имен получить дескриптор, который ссылается на родительское пространство имен для дескриптора fd.

Подробнее узнать о них можно в man 2 ioctl_ns. Об ioctl речь пойдет в главе 10.

Пространства имен в командной оболочке

В Linux пространства имен процесса можно посмотреть через специальную файловую систему ProcFS, описанную в главе 13:

ls -l /proc/$$/ns

итого 0

lrwxrwxrwx 1 artiom artiom 0 мая 24 18:52 cgroup -> 'cgroup:[4026531835]'

lrwxrwxrwx 1 artiom artiom 0 мая 24 18:52 ipc -> 'ipc:[4026531839]'

lrwxrwxrwx 1 artiom artiom 0 мая 24 18:52 mnt -> 'mnt:[4026531841]'

lrwxrwxrwx 1 artiom artiom 0 мая 24 18:52 net -> 'net:[4026531840]'

lrwxrwxrwx 1 artiom artiom 0 мая 24 18:52 pid -> 'pid:[4026531836]'

lrwxrwxrwx 1 artiom artiom 0 мая 24 18:52 pid_for_children -> 'pid:[4026531836]'

lrwxrwxrwx 1 artiom artiom 0 мая 24 18:52 time -> 'time:[4026531834]'

lrwxrwxrwx 1 artiom artiom 0 мая 24 18:52 time_for_children -> 'time:[4026531834]'

lrwxrwxrwx 1 artiom artiom 0 мая 24 18:52 user -> 'user:[4026531837]'

lrwxrwxrwx 1 artiom artiom 0 мая 24 18:52 uts -> 'uts:[4026531838]'

В более удобном виде информацию представляет команда lsns, которую можно запустить с параметром --type net, чтобы увидеть сетевые пространства имен:

lsns --type net

NS         TYPE    NPROCS PID     USER   NETNSID    NSFS    COMMAND

4026531840 net     102    177153  artiom unassigned /run/docker/netns/default /usr/bin/ark /home/artiom/Downloads/libmnl-1.0.5.tar.bz2

4026532481 net     1      1539375 artiom unassigned                           /usr/lib/firefox/firefox -contentproc -childID 1

...

Команда ip позволяет настраивать постоянные пространства имен:

ip netns help

Usage: ip netns list

       ip netns add NAME

       ip netns attach NAME PID

       ip netns set NAME NETNSID

       ip [-all] netns delete [NAME]

       ip netns identify [PID]

       ip netns pids NAME

       ip [-all] netns exec [NAME] cmd ...

       ip netns monitor

       ip netns list-id [target-nsid POSITIVE-INT] [nsid POSITIVE-INT]

Разница с временными пространствами заключается в том, что они будут существовать, даже когда в них завершится последний запущенный процесс.

Можно добавлять и удалять такие пространства:

sudo ip netns add test

ip netns list

test

sudo ip netns del test

А также запускать в них программы:

ip netns exec <namespace_name> <command>

 

Кроме того, запустить приложение в ином пространстве имен позволяет команда unshare:

sudo unshare -n nc -l -p 10000

Подключиться к данному экземпляру Netcat как к localhost не получится, так как он запущен не в глобальном пространстве имен.

Еще одна команда — nsenter. Она делает примерно то же самое, что и unshare, но позволяет запускать процессы в существующих пространствах имен, задавая через опцию -t <PID> идентификатор процесса, в пространстве имен которого должен быть запущен новый процесс:

sudo nsenter -t 1395 -n ls -1

122.pcap

a.out

...

Опция -n говорит о том, что нужно использовать сетевое пространство имен.

Дополнительную информацию о пространствах имен можно посмотреть в man 7 network_namespaces.

Резюме

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

В TCP передача осуществляется с использованием указателя срочности. В качестве указателя срочности используется флаг MSG_OOB при вызове функции send() и такой же флаг для приема функцией recv().

Прием ООВ-данных может быть осуществлен не только в синхронном, но и в асинхронном режиме с помощью сигнала SIGURG либо через неблокирующие вызовы.

Пространства имен оборачивают глобальный системный ресурс в абстракцию, что создает у группы процессов иллюзию наличия уникальной копии данного ресурса.

Существуют пространства имен, которые предоставляют абстракции сетевых ресурсов — сетевые пространства имен.

Каждое сетевое пространство имен имеет свои собственные IP-адреса, сетевые интерфейсы с индивидуальными адресами, настройки межсетевого экрана, таблицы маршрутизации, пространство номеров портов, сокеты и т.д.

Изменения, происходящие с ресурсом, могут быть замечены только процессами, находящимися в данном пространстве имен, тогда как для процессов вне его они остаются невидимыми.

Linux для создания процесса в новом пространстве имен, а также для перехода в существующее пространство предоставляет набор функций.

Вопросы и задания

1. Зачем нужны внеполосные данные?

2. Какие протоколы поддерживают передачу внеполосных данных?

3. Как передача внеполосных данных реализована в TCP?

4. Что нужно сделать, чтобы передать внеполосные данные?

5. Что делает функция sockatmark()? Всегда ли она доступна?

6. Как приложения могут использовать внеполосные данные для управления соединением?

7. Могут ли возникнуть проблемы при использовании внеполосных данных? И если да, как их можно решить?

8. Что будет со старыми внеполосными данными при получении новых? Как решить эту проблему?

9. Допустимо ли многократное чтение внеполосных данных?

10. Каков приоритет внеполосных данных по сравнению с обычными данными?

11. Какие существуют способы приема внеполосных данных? Каковы их преимущества и недостатки?

12. Каковы основные преимущества использования пространств имен?

13. Как пространство имен влияет на видимость системных ресурсов для процессов?

14. Как пространство имен связано с концепцией контейнеризации и такими технологиями, как Docker?

15. Какие функции используются для создания пространства имен, а также для перехода в него?

16. Приведите несколько примеров команд оболочки, использующих API пространств имен.

17. Дополните TCP-клиент работой с внеполосными данными согласно RFC 854 «Telnet Protocol Specification».

Назад: Глава 5. Соединение. Потоковый обмен данными. Серверный API
Дальше: Глава 7. Сокет в ядре Linux