Выберите posix вызовы для работы с сокетами
Тема сетевого программирования является для разработчиков одной из важнейших в современном цифровом мире. Правда, надо признать, что большая часть сетевого программирования сосредоточена в области написания скриптов исполнения для web-серверов на языках PHP, Python и им подобных. Как следствие - по тематике взаимодействия клиент-сервер при работе с web-серверами написаны терабайты текстов в Интернете. Однако когда я решил посмотреть, что же имеется в Интернете по вопросу программирования сетевых приложений с использованием голых сокетов, то обнаружил интересную вещь: да, такие примеры конечно же есть, но подавляющее большинство написано под *nix-системы с использованием стандартных библиотек (что понятно – в области сетевого программирования Microsoft играет роль сильно отстающего и менее надежного «собрата» *nix-ов). Другими словами все эти примеры просто не будут работать под Windows. При определенных танцах с бубнами код сетевого приложения под Linux можно запустить и под Windows, однако это еще более запутает начинающего программиста, на которого и нацелены большинство статей в Интернете с примерами использования сокетов.
вообще не заработает, т.к. полю Service.sin_addr.s_addr невозможно присвоить значение целого типа, которое возвращает функция inet_addr (возвращает unsigned long). То есть это ни много, ни мало - ошибка! Можно себе представить, сколько пытливых бойцов полегло на этом месте кода.
Сразу оговорюсь, что статья рассчитана на начинающих программистов, которые только входят в сетевое программирование под Windows. Необходимые навыки – базовое знание С++, а также теоретическая подготовка по теме сетевых сокетов и стека технологии TCP/IP.
Этап 1: Инициализация сокетных интерфейсов Win32API
Прежде чем непосредственно создать объект сокет, необходимо «запустить» программные интерфейсы для работы с ними. Под Windows это делается в два шага следующим образом:
Нужно определить с какой версией сокетов мы работаем (какую версию понимает наша ОС) и
Запустить программный интерфейс сокетов в Win32API. Ну либо расстроить пользователя тем, что ему не удастся поработать с сокетами до обновления системных библиотек
Первый шаг делается с помощью создания структуры типа WSADATA , в которую автоматически в момент создания загружаются данные о версии сокетов, используемых ОС, а также иная связанная системная информация: WSADATA wsData;
Второй шаг – непосредственный вызов функции запуска сокетов с помощью WSAStartup() . Упрощённый прототип данной функции выглядит так:
Первый аргумент функции – указание диапазона версий реализации сокетов, которые мы хотим использовать и которые должны быть типа WORD . Этот тип данных является внутренним типом Win32API и представляет собой двухбайтовое слово (аналог в С++: unsigned short ). Функция WSAStartup() просит вас передать ей именно WORD , а она уже разложит значение переменной внутри по следующему алгоритму: функция считает, что в старшем байте слова указана минимальная версия реализации сокетов, которую хочет использовать пользователь, а в младшем – максимальная. По состоянию на дату написания этой статьи (октябрь 2021 г.) актуальная версия реализации сокетов в Windows – 2. Соответственно, желательно передать и в старшем, и в младшем байте число 2. Для того, чтобы создать такую переменную типа WORD и передать в её старший и младший байты число 2, можно воспользоваться Win32API функцией MAKEWORD(2,2) .
Второй аргумент функции – просто указатель на структуру WSADATA , которую мы создали ранее и в которую подгрузилась информация о текущей версии реализации сокетов на данной машине.
WSAStartup() в случае успеха возвращает 0, а в случае каких-то проблем возвращает код ошибки, который можно расшифровать последующим вызовом функции WSAGetLastError() .
Важное замечание: поскольку сетевые каналы связи и протоколы в теории считаются ненадежными (это отдельный большой разговор), то критически важно для сетевого приложения анализировать все возможные ошибки, которые возникают в процессе вызовов сокетных функций. По этой причине каждый вызов таких функций мы будем анализировать на ошибки и в случае их обнаружения завершать сетевые сеансы и закрывать открытые сокеты. Используем для этого переменную erStat типа int .
Также важно после работы приложения обязательно закрыть использовавшиеся сокеты с помощью функции closesocket(SOCKET ) и деинициализировать сокеты Win32API через вызов метода WSACleanup() .
Итого код Этапа 1 следующий:
Да, кода мало, а описания много. Так обычно и бывает, когда хочешь глубоко в чем-то разобраться. Так что на лабе будешь в первых рядах.
Этап 5 (только для Сервера). Подтверждение подключения
После начала прослушивания (вызов функции listen() ) следующей функцией должна идти функция accept() , которую будет искать программа после того, как установится соединение с Клиентом. Прототип функции accept() :
Если подключение подтверждено, то вся информация по текущему соединению передаётся на новый сокет, который будет отвечать со стороны Сервера за конкретное соединение с конкретным Клиентом. Перед вызовом accept() нам надо создать пустую структуру типа sockaddr_in , куда запишутся данные подключившегося Клиента после вызова accept() . Пример кода:
Всё, соединение между Клиентом и Сервером установлено! Самое время попробовать передать информацию от Клиента к Серверу и обратно. Как мы в начале и договорились, мы будет реализовывать простейший чат между ними.
Теория сокетов за 30 секунд для "dummies"
Начну всё-таки немного с теории в стиле «for dummies». В любой современной операционной системе, все процессы инкапсулируются, т.е. скрываются друг от друга, и не имеют доступа к ресурсам друг друга. Однако существуют специальные разрешенные способы взаимодействия процессов между собой. Все эти способы взаимодействия процессов можно разделить на 3 группы: (1) сигнальные, (2) канальные и (3) разделяемая память.
Когда мы говорим про работу сетевого приложения, то всегда подразумеваем взаимодействие процессов: процесс 1 (клиент) пытается что-то послать или получить от Процесса 2 (сервер). Наиболее простым и понятным способом организации сетевого взаимодействия процессов является построение канала между этими процессами. Именно таким путём и пошли разработчики первых сетевых протоколов. Получившийся способ взаимодействия сетевых процессов в итоге оказался многоуровневым: основной программный уровень - стек сетевой технологии TCP/IP, который позволяет организовать эффективную доставку пакетов информации между различными машинами в сети, а уже на прикладном уровне тот самый «сокет» позволяет разобраться какой пакет какому процессу доставить на конкретной машине.
Иными словами «сокет» - это «розетка» конкретного процесса, в которую надо подключиться, чтобы этому процессу передать какую-либо информацию. Договорились, что эта «розетка» в Сети описывается двумя параметрами – IP-адресом (для нахождения машины в сети) и Портом подключения (для нахождения процесса-адресата на конкретной машине).
Для того, чтобы сокеты заработали под Windows, необходимо при написании программы пройти следующие Этапы:
Инициализация сокетных интерфейсов Win32API.
Инициализация сокета, т.е. создание специальной структуры данных и её инициализация вызовом функции.
«Привязка» созданного сокета к конкретной паре IP-адрес/Порт – с этого момента данный сокет (его имя) будет ассоциироваться с конкретным процессом, который «висит» по указанному адресу и порту.
Для серверной части приложения: запуск процедуры «прослушки» подключений на привязанный сокет.
Для клиентской части приложения: запуск процедуры подключения к серверному сокету (должны знать его IP-адрес/Порт).
Акцепт / Подтверждение подключения (обычно на стороне сервера).
Обмен данными между процессами через установленное сокетное соединение.
Закрытие сокетного соединения.
Итак, попытаемся реализовать последовательность Этапов, указанных выше, для организации простейшего чата между клиентом и сервером. Запускаем Visual Studio, выбираем создание консольного проекта на С++ и поехали.
Этап 0: Подключение всех необходимых библиотек Win32API для работы с сокетами
Сокеты не являются «стандартными» инструментами разработки, поэтому для их активизации необходимо подключить ряд библиотек через заголовочные файлы, а именно:
WinSock2.h – заголовочный файл, содержащий актуальные реализации функций для работы с сокетами.
WS2tcpip.h – заголовочный файл, который содержит различные программные интерфейсы, связанные с работой протокола TCP/IP (переводы различных данных в формат, понимаемый протоколом и т.д.).
Ну и в конце Этапа 0 подключаем стандартные заголовочные файлы iostream и stdio.h
Итого по завершению Этапа 0 в Серверной и Клиентской частях приложения имеем:
Обратите внимание: имя системной библиотеки ws2_32.lib именно такое, как это указано выше. В Сети есть различные варианты написания имени данной библиотеки, что, возможно, связано иным написанием в более ранних версиях ОС Windows. Если вы используете Windows 10, то данная библиотека называется именно ws2_32.lib и находится в стандартной папке ОС: C:/Windows/System32 (проверьте наличие библиотеки у себя, заменив расширение с “lib” на “dll”).
netcat
Оригинальная утилита появилась 25 лет назад, больше не поддерживается. На cегодняшний день существуют порты, которые поддерживаются различными дистрибутивами: Debian, Ubuntu, FreeBSD, MacOS. В операционной системе утилиту можно вызвать с помощью команды nc, nc.traditional или ncat в зависимости от ОС. Утилита позволяет "из коробки" работать с сокетами, которые используют в качестве транспорта TCP и UDP протоколы. Примеры сценариев использования, которые, по мнению автора, наиболее интересны:
перенаправление входящих/исходящих запросов;
трансляция данных на экран в шестнадцатеричном формате.
Введем команду на открытие порта на машине Destination: nc -ulvvp 7878
Запускаем соединение из машины Source: nc 10.0.2.4 4545
В итоге получаем возможность читать данные от машины Source:
В машине Destination:
Пример с трансляцией данных в шестнадцатеричном формате можно провести так же, но заменить команду на Destination или добавить еще один пайп на Repeater:
nc -l -p 4545 -o file
В результате будет создан файл, в котором можно будет обнаружить передаваемые данные в шестнадцатеричном формате:
Как видно из тестового сценария использования, netcat не дает контролировать практически ничего, кроме направления данных. Нет ни разграничения доступа к ресурсам, которые пересылаются, ни возможности без дополнительных ухищрений работать с двумя сокетами, ни возможности контролировать действия сокета. Протестируем socat.
Этап 4 (для сервера): «Прослушивание» привязанного порта для идентификации подключений
Серверная часть готова к прослушке подключающихся «Клиентов». Для того, чтобы реализовать данный этап, нужно вызвать функцию listen() , прототип которой:
Второй аргумент: максимально возможное число подключений устанавливается через передачу параметр SOMAXCONN (рекомендуется). Если нужно установить ограничения на количество подключений – нужно указать SOMAXCONN_HINT(N) , где N – кол-во подключений. Если будет подключаться больше пользователей, то они будут сброшены.
После вызова данной функции исполнение программы приостанавливается до тех пор, пока не будет соединения с Клиентом, либо пока не будет возвращена ошибка прослушивания порта. Код Этапа 4 для Сервера:
Этап 4 (для Клиента). Организация подключения к серверу
Код для Клиента до текущего этапа выглядит даже проще: необходимо исполнение Этапов 0, 1 и 2. Привязка сокета к конкретному процессу ( bind() ) не требуется, т.к. сокет будет привязан к серверному Адресу и Порту через вызов функции connect() (по сути аналог bind() для Клиента). Собственно, после создания и инициализации сокета на клиентской стороне, нужно вызвать указанную функцию connect() . Её прототип:
Функция возвращает 0 в случае успешного подключения и код ошибки в ином случае.
Процедура по добавлению данных в структуру sockaddr аналогична тому, как это делалось на Этапе 3 для Сервера при вызове функции bind() . Принципиально важный момент – в эту структуру для клиента должна заноситься информация о сервере, т.е. IPv4-адрес сервера и номер «слушающего» порта на сервере.
Этап 3: Привязка сокета к паре IP-адрес/Порт
Сокет уже существует, но еще неполноценный, т.к. ему не назначен внешний адрес, по которому его будут находить транспортные протоколы по заданию подключающихся процессов, а также не назначен порт, по которому эти подключающиеся процессы будут идентифицировать процесс-получатель.
Такое назначение делается с помощью функции bind() , имеющей следующий прототип:
Функция bind() возвращает 0 , если удалось успешно привязать сокет к адресу и порту, и код ошибки в ином случае, который можно расшифровать вызовом WSAGetLastError() - см. итоговый код Этапа 3 далее.
Тут надо немножно притормозить и разобраться в том, что за такая структура типа sockaddr передается вторым аргументом в функцию bind() . Она очень важна, но достаточно запутанная.
Итак, если посмотреть в её внутренности, то выглядят они очень просто: в ней всего два поля – (1) первое поле хранит семейство адресов, с которыми мы уже встречались выше при инициализации сокета, а (2) второе поле хранит некие упакованные последовательно и упорядоченные данные в размере 14-ти байт. Бессмысленно разбираться детально как именно эти данные упакованы, достаточно лишь понимать, что в этих 14-ти байтах указан и адрес, и порт, а также дополнительная служебная информация для других системных функций Win32API .
Но как же явно указать адрес и порт для привязки сокета? Для этого нужно воспользоваться другой структурой, родственной sockaddr , которая легко приводится к этому типу - структурой типа sockaddr_in .
В ней уже более понятные пользователю поля, а именно:
Семейство адресов - опять оно ( sin_family )
Вложенная структура типа in_addr , в которой будет храниться сам сетевой адрес ( sin_addr )
Технический массив на 8 байт ( sin_zero[8] )
При приведении типа sockaddr_in к нужному нам типу sockaddr для использования в функции bind() поля Порт (2 байта), Сетевой адрес (4 байта) и Технический массив (8 байт) как раз в сумме дают нам 14 байт, помещающихся в 14 байт, находящихся во втором поле структуры sockaddr . Первые поля у указанных типов совпадают – это семейство адресов сокетов (указываем AF_INET ). Из этого видно, что структуры данных типа sockaddr и sockaddr_in тождественны, содержат одну и ту же информацию, но в разной форме для разных целей.
Соответственно, ввод данных для структуры типа sockaddr_in выглядит следующим образом:
Создание структуры типа sockaddr_in : sockaddr_in servInfo;
Заполнение полей созданной структуры servInfo
servInfo.sin_port = htons(); порт всегда указывается через вызов функции htons() , которая переупаковывает привычное цифровое значение порта типа unsigned short в побайтовый порядок понятный для протокола TCP/IP (протоколом установлен порядок указания портов от старшего к младшему байту или «big-endian»).
Далее нам надо указать сетевой адрес для сокета. Тип этого поля – структура типа in_addr , которая по своей сути представляет просто особый «удобный» системным функциям вид обычного строчного IPv4 адреса. Таким образом, чтобы указать этому полю обычный IPv4 адрес, его нужно сначала преобразовать в особый числовой вид и поместить в структуру типа in_addr .
Благо существует функция, которая переводит обычную строку типа char[] , содержащую IPv4 адрес в привычном виде с точками-разделителями в структуру типа in_addr – функция inet_pton() . Прототип функции следующий:
В случае ошибки функция возвращает значение меньше 0.
Соответственно, если мы хотим привязать сокет к локальному серверу, то наш код по преобразованию IPv4 адреса будет выглядеть так:
erStat = inet_pton(AF_INET, “127.0.0.1”, &ip_to_num);
Результат перевода IP-адреса содержится в структуре ip_to_num. И далее мы передаем уже в нашу переменную типа sockaddr_in значение преобразованного адреса:
Вся нужная информация для привязки сокета теперь у нас есть, и она хранится в структуре servInfo . Можно смело вызывать функцию bind() , не забыв при этом привести servInfo из типа sockaddr_in в требуемый функции sockaddr* . Тогда итоговый код Этапа 3 (слава богу закончили) выглядит так:
Этап 6: Передача данных между Клиентом и Сервером
Принимать информацию на любой стороне можно с помощью функции recv() , которая при своём вызове блокирует исполнение кода программы до того момента, пока она не получит информацию от другой стороны, либо пока не произойдет ошибка в передаче или соединении.
Отправлять информацию с любой стороны можно с помощью функции send() . При вызове данной функции обычно никакого ожидания и блокировки не происходит, а переданные в неё данные сразу же отправляются другой стороне.
Рассмотрим прототипы функций recv() и send() :
Флаги в большинстве случаев игнорируются – передается значение 0.
Функции возвращают количество переданных/полученных по факту байт.
Как видно из прототипов, по своей структуре и параметрам эти функции совершенно одинаковые. Что важно знать:
и та, и другая функции не гарантируют целостности отправленной/полученной информации. Это значит, что при реализации прикладных задач по взаимодействию Клиента и Сервера с их использованием требуется принимать дополнительные меры для контроля того, что все посланные байты действительно посланы и, что еще более важно, получены в том же объеме на другой стороне
предельно внимательно надо относиться к параметру "размер буфера". Он должен в точности равняться реальному количеству передаваемых байт. Если он будет отличаться, то есть риск потери части информации или «замусориванию» отправляемой порции данных, что ведет к автоматической поломке данных в процессе отправки/приёма. И совсем замечательно будет, если размер буфера по итогу работы функции равен возвращаемому значению функции – размеру принятых/отправленных байт.
В качестве буфера рекомендую использовать не классические массивы в С-стиле, а стандартный класс С++ типа char, т.к. он показал себя как более надежный и гибкий механизм при передаче данных, в особенности при передаче текстовых строк, где важен терминальный символ и «чистота» передаваемого массива.
Сама по себе упаковка и отправка данных делается элементарным использованием функций чтения всей строки до нажатия кнопки Ввода - fgets() с последующим вызовом функции send() , а на другой стороне - приёмом информации через recv() и выводом буфера на экран через cout
Процесс непрерывного перехода от send() к recv() и обратно реализуется через бесконечный цикл, из которого совершается выход по вводу особой комбинации клавиш. Пример блока кода для Серверной части:
Пришло время показать итоговый рабочий код для Сервера и Клиента. Чтобы не загромождать и так большой текст дополнительным кодом, даю ссылки на код на GitHub:
Несколько важных финальных замечаний:
В итоговом коде я не использую проверку на точное получение отосланной информации, т.к. при единичной (не циклической) отсылке небольшого пакета информации накладные расходы на проверку его получения и отправку ответа будут выше, чем выгоды от такой проверки. Иными словами – такие пакеты теряются редко, а проверять их целостность и факт доставки очень долго.
В последующих статьях я покажу реализацию полноценного чата между двумя сторонами (поможет разобраться в понятии «нити процесса»), а также покажу полноценную реализацию прикладного протокола по копированию файлов с Сервера на Клиент.
В ф-ии select есть сразу две проблемы.
Первая — это параметр nfds (первый параметр ф-ии select). Для Windows этот параметр не учитывается, для Unix это важный параметр который должен быть равен: максимальный дескриптор (из трех возможных fdset) плюс 1.
Вторая — второй, третий и четвертый параметры — fd_set.
Что представляет собой fd_set в Windows:
Это просто массив и счетчик элементов в нем. В этот массив, как и в любой другой можно положить все что угодно. Даже то, что сокетом не является. В этом случае select просто вернет ошибку.
Что собой представляет fd_set в Linux и FreeBSD:
Т.е. это всего лишь массив. Заполняется он так:
Чем грозит такое отличие ? Например, под Windows можно написать следующий код:
Дальше можно делать так:
И это будет работать.
В Linux и FreeBSD такой код сразу даст segmentation fault.
INVALID_SOCKET определен как -1, т.е. 0xFFFFFFFF
Например в FreeBSD NFDBITS отпределен как 32 ( 4*8 ). И мы пытаемся прочесть позицию 0x07FFFFFF в fds_bits[] из 32'ух возможных ((1024 + 31)/32).
Поэтому, тот же код с минимальными исправлениями для Linux и FreeBSD должен выглядеть так:
Еще в Windows FD_ISSET определен через ф-ию:
И что-то подсказывает, что эта ф-ия защищена от сбоев по памяти и прочих неприятных вещей.
Кстати, INVALID_SOCKET для Linux и FreeBSD надо определять самому.
Первая проблема — это SIGPIPE. Сигнал, который посылается Unix системой приложению, если то пытается послать данные в сокет, соединение которого уже разорвано. В Windows в таком случае будет возвращена одна из ошибок.
Есть два метода борьбы с этим сигналом.
Первый — пригодный для Linux — установка флага (четвертый параметр ф-ии send() ) в MSG_NOSIGNAL.
Второй, пригодный для Linux и FreeBSD — установка обработчика сигнала, для SIGPIPE. Сам обработчик ничего не делает, просто при выходе из него программа продолжается дальше, а по ошибке, возвращаемой send, можно судить о разрыве соединения.
Вторая проблема — невозможность узнать, были ли реально отправлены данные. Теоретически это проблема не кросс-платформенного кода. В MSDN сказано: The successful completion of a send does not indicate that the data was successfully delivered.
Но практически, я ни разу не сталкивался с таким под Windows, и сразу столкнулся делая порт под FreeBSD.
Поэтому опишу проблему и решение здесь.
Разрыв соединения о котором не знают обе(или одна) стороны. Например, ваша программа под FreeBSD послала данные, ей вернулась ошибка 13 icmp — сокет будет принимать следующие данные для отправки, еще в течении некоторого времени. Они будут буфферизированы в исходящей очереди, и в конце концов утеряны, при закрытии соединения системой.
Один из способов борьбы — это поставить сокет в select(), во второй параметр (readfds). При возврате положительного числа попробовать прочесть 1 байт. Если recv вернет 0 — значит соединение было разорвано. (если вы не хотите читать из сокета, вызовите recv с флагом MSG_PEEK).
3. shutdown(), close() и closesocket()
В Windows принято, что после вызова closesocket() соединение закрывается. Так же, соединения закрываются при закрытии программы.
Система знает, какому процессу соответствуют сокеты, и закрывает их при смерти приложения. В Linix и FreeBSD это не так.
Необходимо явно сказать shutdown (посылка FIN) и close (разъединение дескриптора и сокета). Если этого не сделать, то после закрытия приложения сокеты еще будут висеть в системе некоторое время, в течение которого bind на "занятые" порты будет возвращать ошибку.
Такая же ситуация возникает если вы сделаете на серверной стороне приложения shutdown и close, закроете приложение, а на клиентской стороне эти ф-ии вызваны не будут. После этого Вы не сможете запустить серверное приложение в течении некоторого времени.
Методы борьбы — не известны. Только ждать.
В Windows есть только один механизм потоков, и он реализван в ядре. В Unix реализации потоков могут быть различны. Некоторые реализованны в пространстве ядра (posix thread для Linux и FreeBSD), другие могут быть в пространстве пользователя (у Рихтера они названы fibers).
Необходимо четко представлять чем Вы пользуетесь.
Потоки, реализованные в пространстве пользователя, имеют одну неприятную особенность — система о них не знает. Значит планировщик системы никогда не передаст управление другому потоку. Только процессу. Если же такой поток в процессе завис, то зависло и все приложение.
Простейший тест для определения в каком пространстве реализованы потоки (использован pthread, используйте другие ф-ии из выбранной вами библиотеки, аналогичные приведенным):
Если Вы видите надпись TH2. менее чем 20 раз — значит потоки реализованы в пространстве пользователя.
Методы борьбы: переходить на pthread или не допускать зависания потоков.
В Windows, mutex созданый по умолчанию ведет себя рекурсивно. Т.е. сколько раз один и тот же поток вызвал для него lock, столько же раз должен быть вызван unlock.
pthread_mitex, по умолчанию, ведет себя иначе. Для того что бы его поведение соответствовало Windows mutex (по умолчанию) необходимо создать его следующим образом:
6. Исключения C++
В Windows системные исключения и исключения C++ смешаны (это точно, если вы пользуетесь MS VC). Например, сбой по памяти, попытка деления на 0, и пр. ловятся с помощью catch(. ). В Unix это не так (точно, если использовать gcc). В таких случаях приложению посылаются сигналы.
В большинстве случаев, после такого сигнала приложение можно только закрыть.
Windows код:
Методы борьбы: писать приложение так, что бы не допустить подобных ситуаций.
P.S. Все что касается сокетов — относится к TCP соединениям. Причем считается, что все используется по умолчанию. Т.е. без SO_LINGER и т.д.
int socket(int domain , int type , int protocol );
ОПИСАНИЕ
Параметр domain задает домен соединения: выбирает набор протоколов, которые будут использоваться для создания соединения. Такие наборы описаны в . В настоящее время распознаются такие форматы:
Некоторые типы сокетов могут быть не включены в определенные наборы протоколов; например, SOCK_SEQPACKET не включен в набор AF_INET .
Параметр protocol задает конкретный протокол, который работает с сокетом. Обычно существует только один протокол, задающий конкретный тип сокета в определенном семействе протоколов, в этом случае protocol может быть определено, как 0. Однако, возможно существование нескольких таких протоколов (в этом случае и используется данный параметр). Номер протокола зависит от используемого ``домена коммуникации'', см.~ protocols (5). См. getprotoent (3), где описано, как соотносить имена протоколов с их номерами.
Сокеты типа SOCK_STREAM являются соединениями полнодуплексных байтовых потоков, похожими на каналы. Они не сохраняют границы записей. Потоковый сокет должен быть в состоянии соединения перед тем, как из него можно будет отсылать данные или принимать их в нем. Соединение с другим сокетом создается с помощью системного вызова connect (2). После соединения данные можно передавать, с помощью системных вызовов read (2) , write (2) или одного из вариантов следующих системных вызовов: send (2) , recv (2). Когда сеанс закончен, выполняется команда close (2). Внепоточные данные могут передаваться, как описано в send (2), а приниматься, как описано в recv (2).
Сокеты SOCK_DGRAM и SOCK_RAW позволяют посылать датаграммы принимающей стороне, заданной при вызове send (2). Датаграммы обычно принимаются с помощью вызова recvfrom (2), который возвращает следующую датаграмму с соответствующим обратным адресом.
SOCK_PACKET - это устаревший тип сокета, позволявший получать необработанные пакеты прямо от драйвера устройства. Используйте вместо него packet (7).
Системный вызов fcntl (2) с аргументом F_SETOWN может использоваться для задания группы процессов, которая будет получать сигнал SIGURG , когда прибывают внепоточные данные; или сигнал SIGPIPE , когда соединение типа SOCK_STREAM неожиданно обрывается. Этот вызов также можно использовать, чтобы задать процесс или группу процессов, которые получают асинхронные уведомления о событиях ввода-вывода с помощью SIGIO. Использование F_SETOWN эквивалентно использованию вызова ioctl (2) с аргументом FIOSETOWN или SIOCSPGRP .
Операции сокетов контролируются их параметрами . Эти параметры описаны в . Функции setsockopt (2) и getsockopt (2) используются, чтобы установить и получить необходимые параметры соответственно.
ВОЗВРАЩАЕМЫЕ ЗНАЧЕНИЯ
НАЙДЕННЫЕ ОШИБКИ
EPROTONOSUPPORT Тип протокола или указанный протокол не поддерживаются в этом домене. EAFNOSUPPORT Реализация не поддерживает указанное семейства адресов. ENFILE Недостаточно памяти, чтобы создать новый сокет. EMFILE Переполнение таблицы с файлами процесса. EACCES Нет доступа к созданию сокета указанного типа и/или протокола. ENOBUFS или ENOMEM Недостаточно памяти для создания сокета. EINVAL Неизвестный протокол или недоступный набор протоколов.
Другие ошибки могут быть созданы модулями
протоколов более низкого уровня.
СООТВЕТСТВИЕ СТАНДАРТАМ
4.4BSD (системный вызов socket появился в 4.2BSD). Обычно переносим в не-BSD системы и с них (включая варианты System V).
ЗАМЕЧАНИЯ
В наборах протоколов BSD 4.* используются константы PF_UNIX, PF_INET и т.д., тогда как AF_UNIX и т.п. используется для указания семейства адресов. Однако, в странице руководства BSD записано: "Вообще, набор протоколов совпадает с семейством адресов", и во всех последующих стандартах используется AF_*.
НАЙДЕННЫЕ ОШИБКИ
СМ. ТАКЖЕ
lqВводное Руководство по межпроцессной коммуникации в 4.3 BSDrq (lqAn Introductory 4.3 BSD Interprocess Communication Tutorialrq) перепечатано и называется Дополнительные документы для программиста UNIX, Том~1 , ( UNIX Programmer's Supplementary Documents Volume~1 ).
lqРуководство по межпроцессной коммуникации в BSDrq перепечатано и называется Дополнительные документы для программиста UNIX, Том~1 , ( UNIX Programmer's Supplementary Documents Volume~1 ).
В данной статье будет рассмотрено понятие сокета в операционной системе Linux: основные структуры данных, как они работают и можно ли управлять состоянием сокета с помощью приложения. В качестве практики будут рассмотрены инструменты netcat и socat.
Что такое сокет?
Сокет - это абстракция сетевого взаимодействия в операционной системе Linux. Каждому сокету соответствует пара IP-адрес + номер порта. Это стандартное определение, к которому привыкли все, спасибо вики. Хотя нет, вот здесь лучше описано. Поскольку сокет является только лишь абстракцией, то связка IP-адрес + номер порта - это уже имплементация в ОС. Верное название этой имплементации - "Интернет сокет". Абстракция используется для того, чтобы операционная система могла работать с любым типом канала передачи данных. Именно поэтому в ОС Linux Интернет сокет - это дескриптор, с которым система работает как с файлом. Типов сокетов, конечно же, намного больше. В ядре ОС Linux сокеты представлены тремя основными структурами:
struct socket - представление сокета BSD, того вида сокета, который стал основой для современных "Интернет сокетов";
struct sock - собственная оболочка, которая в Linux называется "INET socket";
struct sk_buff - "хранилище" данных, которые передает или получает сокет;
Как видно по исходным кодам, все структуры достаточно объемны. Работа с ними возможна при использовании языка программирования или специальных оберток и написания приложения. Для эффективного управления этими структурами нужно знать, какие типы операций над сокетами существуют и когда их применять. Для сокетов существует набор стандартных действий:
socket - создание сокета;
bind - действие используется на стороне сервера. В стандартных терминах - это открытие порта на прослушивание, используя указанный интерфейс;
listen - используется для перевода сокета в прослушивающее состояние. Применяется к серверному сокету;
connect - используется для инициализации соединения;
accept - используется сервером, создает новое соединение для клиента;
send/recv - используется для работы с отправкой/приемом данных;
close - разрыв соединения, уничтожение сокета.
Если о структурах, которые описаны выше, заботится ядро операционной системы, то в случае команд по управлению соединением ответственность берет на себя приложение, которое хочет пересылать данные по сети. Попробуем использовать знания о сокетах для работы с приложениями netcat и socat.
Этап 2: Создание сокета и его инициализация
Сокет в С++ – это структура данных (не класс) типа SOCKET. Её инициализация проводится через вызов функции socket() , которая привязывает созданный сокет к заданной параметрами транспортной инфраструктуре сети. Выглядит прототип данной функции следующим образом:
Семейство адресов: сокеты могут работать с большим семейством адресов. Наиболее частое семейство – IPv4. Указывается как AF_INET .
Тип сокета: обычно задается тип транспортного протокола TCP ( SOCK_STREAM ) или UDP ( SOCK_DGRAM ). Но бывают и так называемые "сырые" сокеты, функционал которых сам программист определяет в процессе использования. Тип обозначается SOCK_RAW
Тип протокола: необязательный параметр, если тип сокета указан как TCP или UDP – можно передать значение 0. Тут более детально останавливаться не будем, т.к. в 95% случаев используются типы сокетов TCP/UDP.
При необходимости подробно почитать про функцию socket() можно здесь.
Код Этапа 2 будет выглядеть так:
socat
STDIO -> TCP Socket;
FILE -> TCP Socket;
TCP Socket -> Custom Application;
UDP Socket -> Custom Application;
Для повседневного использования достаточно опций, но если понадобится когда-то работать напрямую с серийным портом или виртуальным терминалом, то socat тоже умеет это делать. Полный перечень опций можно вызвать с помощью команды:
Помимо редиректов socat также можно использовать как универсальный сервер для расшаривания ресурсов, через него можно как через chroot ограничивать привилегии и доступ к директориям системы.
Чтобы комфортно пользоваться этим инструментом, нужно запомнить шаблон командной строки, который ожидает socat:
socat additionalOptions addr1 addr2
additionalOptions - опции, которые могут добавлять возможности логирования информации, управления направлением передачи данных;
addr1 - источник данных или приемник (влияет использование флага U или u), это может быть сокет, файл, пайп или виртуальный терминал;
addr2 - источник данных или приемник (влияет использование флага U или u), это может быть сокет, файл, пайп или виртуальный терминал;
Попробуем провести трансляцию данных из сокета в сокет. Будем использовать для этого 1 машину. Перед началом эксперимента стоит отметить, что особенностью socat является то, что для его корректной работы нужно обязательно писать 2 адреса. Причем адрес не обязательно должен быть адресом, это может быть и приложение, и стандартный вывод на экран.
Например, чтобы использовать socat как netcat в качестве TCP сервера, можно запустить вот такую команду:
socat TCP-LISTEN:4545, STDOUT
Для коннекта можно использовать netcat:
nc localhost 4545
Настроим более тонко наш сервер, добавив новые опции через запятую после используемого действия:
socat TCP-LISTEN:4545,reuseaddr,keepalive,fork STDOUT
Дополнительные параметры распространяются на те действия, которые socat может выполнять по отношению к адресу. Полный список опций можно найти здесь в разделе "SOCKET option group".
Таким образом socat дает практически полный контроль над состоянием сокетов и расшариваемых ресурсов.
Статья написана в преддверии старта курса Network engineer. Basic. Всех, кто желает подробнее узнать о курсе и карьерных перспективах, приглашаем записаться на день открытых дверей, который пройдет уже 4 февраля.
Предыдущую часть обсуждения мы завершили на такой вот оптимистической ноте: «Подобным образом мы можем изменить поведение любого системного вызова Linux». И тут я слукавил — любого… да не любого. Исключение составляют (могут составлять) группа сетевых системных вызовов, работающих с BSD сокетами. Когда сталкиваешься с этим артефактом в первый раз — это изрядно озадачивает.
Для прояснения картины воспользуемся заметками одного из непосредственных разработчиков сетевой подсистемы Linux:
Network systems calls on Linux (2008 год). Я коротко перескажу её основное содержание (в интересующей нас части), кому это не интересно может воспользоваться оригиналом.
Когда поддержка BSD сокетов были добавлена в ядро Linux, разработчики решили добавить их единовременно все 17 (на сегодня 20) сокетных вызовов, и добавили для этих вызовов один дополнительный уровень косвенности. Для всей группы этих вызовов введен один новый, редко упоминаемый, системный вызов (см. man socketcall(2)):
где:
— call — численный номер сетевого вызова (SYS_CONNECT, SYS_ACCEPT… мы их увидим вскоре);
— args — указатель 6-ти элементного массива (блок параметров), в который последовательно упакованы все параметры любого из системных вызовов этой группы (сетевой), без различения их типа (приведенные к unsigned long);
А вот такой макрос в ядре (), в котором «зашито» сколько фактически параметров должен использовать каждый из сокетных вызовов в зависимости от его номера (в диапазоне от 1 до 20):
(Причём, narg[ 0 ] вообще не используется, потому размерность его и 21.)
Номер сокетного вызова в пространство ядра (int 0x80 или sysenter) передаётся в регистре eax. Значения самих этих констант мы можем подсмотреть в заголовках пространства пользователя ():
Собственно, схема обработки к этому моменту уже должна быть понятна:
— необходимое число параметров системного вызова пакуется в массив unsigned long, наибольшее число параметров (6) для SYS_SENDTO=11 (nargs[ 11 ]):
— адрес сформированного массива передаётся 2-м параметром системного вызова, первым параметром передаётся номер сокетного вызова (например SYS_SENDTO);
— все сокетные вызовы обрабатываются единственным обработчиком ядра sys_socketcall() (__NR_socketcall = 102);
— обработчик сначала копирует из пространства пользователя массив значений-параметров, а далее, в зависимости от eax, копирует из пространства пользователя вослед и области данных, указываемые (возможно) значениями указателей из этого массива параметров.
Некоторые новые архитектуры (так в оригинале) не используют такой непрямой способ вызова, а используют для этих вызовов такую же реализацию, как и для всех остальных системных вызовов. Так это реализовано, в частности, для X86_64 и ARM. Таким образом, даже 64-битовые и 32-битовые (эмулируемые в системе X86_64) приложения будут выполняться по разной схеме. Но не станем на это пока отвлекаться…
Удостовериться в том, что обслуживание сокетных вызовов в 32 и 64 битовых системах осуществляется принципиально по-разному, можно если в каталоге приложений пространства пользователя (заголовочные файлы библиотек языка C, ) рассмотреть, для сравнения, определения набора системных вызовов для 32 и 64 битовых режимов:
В 32-бит системе присутствует вызов sys_socketcall(), но отсутствуют вызовы для каждого из 20 сокетых вызовов. И напротив, в 64-бит системе отсутствует такой системный вызов как sys_socketcall(), но присутствует весь полный набор системных вызовов для каждого из 20-ти сокетных вызовов.
Сам же автор заметки в завершение, в качестве оценки, пишет следующее: Данная методика кажется довольно уродливой (rather ugly) на первый взгляд, при сравнении с современными методами объектно-ориентированного программирования, но есть и определенная простота в нем. Он, также, хранит данные компактно, что улучшает попадание в кэши. Единственная проблема заключается в том, что выборка должна быть выполнена вручную, а это означает, что здесь легко выстрелить себе в ногу.
Возможность перехвата сетевых системных вызовов будем иллюстрировать на макете распределённого файервола (максимально его упростив). Одно время с этой идеей очень сильно носились, в качестве реализации файервола для больших и сверхбольших сетей (особенно в окружении Cisco). Существует много публикаций на эту тему, например, две из них, дающие полное представление о том, что понимается как распределённый файервол: Implementing a Distributed Firewall и
Automated Implementation of Stateful Firewalls in Linux.
Предложение состоит в том, чтобы контролировать не весь TCP/IP трафик на уровне IP пакетов, а осуществлять регламент на каждом хосте сверхбольшой сети только для протокола TCP и только в момент установления соединения. Под контроль попадают только 2 системных вызова: accept() и connect(). Более глубокое обсуждение распределённого файервола увело бы нас очень далеко от наших целей … рассмотрим только то как мы могли бы контролировать эти сетевые сетевые вызовы.
В качестве иллюстрации реализации перехвата сокетных вызовов был реализован модуль такого сетевого фильтра я ядре для вызовов accept() и connect(). Сделан этот модуль в максимально упрощенной (усечённой) реализации: в качестве параметров при загрузке модуль получает IP адрес (параметр deny) и TCP порт (параметр port), соединения с которыми должны быть запрещены (и ещё один дополнительный параметр debug — уровень диагностического вывода).
Примечание: В тестируемом варианте запрещённые IP адреса и TCP порты допускались множественными, хранились в циклическом списке типа struct list_head (как это и принято повсеместно в ядре), а помещались (или удалялись) они туда отдельным приложением — демоном политики в пространстве пользователя. Фильтр в ядре и должен функционировать некоторым подобным образом, но это слишком громоздко для статьи, описывающей принцип, тем более, что не принцип файервола, а принцип работы с сетевыми системными вызовами. При всех упрощениях код всё ещё великоват, поэтому я помеаю его под спойлер.
Код максимально упрощён, такие вещи, как макросы диагностики LOG(), ERR() уже показывались, отчасти, в предыдущих частях. Функция find() тоже уже обсуждалась. Для записи в защищённую от записи область таблицы sys_call_table существует, как минимум, 3-4 альтернативных варианта, все они назывались и давались ссылками в обсуждениях предыдущей части. Защита от выгрузки модуля на время обслуживания системных вызовов, путём инкремента счётчика ссылок модуля, тоже не показана (называлось в предыдущей части). Все эти подробности присутствуют в кодах прилагаемого архива. Кроме того, коды в архиве обильно пересыпаны комментариями, содержащими выдержки из исходников ядра, с указанием файлов в дереве кодов ядра — это подсказывает требуемые структуры данных.
И всё же при всех упрощениях код остаётся достаточно громоздким (не сложным, а громоздким). Но можно и не вникать в собственно код, последовательность обработки модифицированных сетевых системных вызовов следующая:
Естественно, для того, чтобы наблюдать работу сетевого фильтра ядра в действии, нам необходимы TCP клиент и сервер (например, ncat). Но для детального тестирования были подготовлены специальные ретранслирующий сервер (tcpserv) и клиент (tcpcli). Не считая некоторых мелочей, заточенных под эту работу, они ничего особенного не представляют и рассматриваться здесь не будут (но они есть в прилагаемом архиве).
Вот как будут выглядеть некоторые из попыток установления запрещённых TCP соединений:
— Запуск сервера, прослушивающего запрещённый порт:
— Попытка подключения клиента к запрещённому порту:
Ну и так далее — задача предоставляет широкое и увлекательное поле для экспериментирования…
Важно то, что после выгрузки модуля работа системы восстанавливается в исходное состояние:
Вот так, несколько с выдумкой, осуществляется в Linux обработка сетевых системных вызовов … по крайней мере, в 32 бит реализации. При первом столкновении с этими системными вызовами способ их работы несколько обескураживает.
Эта часть обсуждения получилась затянутой и скучной, но такой артефакт, как вот такая работа системных вызовов — его нужно знать и учитывать.
Маленький архив кода (и обширный журнал тестирования) для экспериментов можно взять здесь или здесь.
Читайте также: